mirror of
https://github.com/rr-/pq-cli.git
synced 2026-11-12 22:40:36 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4dd2cdf1ad | ||
|
|
56a954e6c5 | ||
|
|
cee0626827 | ||
|
|
804b0d8b38 | ||
|
|
612b223a59 | ||
|
|
0beb550548 | ||
|
|
fced929d77 | ||
|
|
ed5c5cbdc3 | ||
|
|
fd4a7d151d | ||
|
|
00794aca37 | ||
|
|
9155ef418b | ||
|
|
0c1d5185a4 | ||
|
|
9ad3bee59b | ||
|
|
0a4358e2b8 | ||
|
|
ada8d5b6ed | ||
|
|
4d620bc384 | ||
|
|
9c44d69633 | ||
|
|
f1cdff8140 | ||
|
|
c25f9dc057 | ||
|
|
48f48aec58 | ||
|
|
fa31045052 | ||
|
|
0f1100d4be | ||
|
|
9942091af7 | ||
|
|
17e1b91a18 | ||
|
|
ab179c1eb0 | ||
|
|
bec6171483 | ||
|
|
f03394f680 | ||
|
|
b69231f321 | ||
|
|
1a6cd720ac | ||
|
|
c0c6c8aeba | ||
|
|
5337d94f82 | ||
|
|
da60c6f792 | ||
|
|
696fad78b3 | ||
|
|
7426924170 | ||
|
|
9609e9a3f2 | ||
|
|
7e52a74065 | ||
|
|
4d5b070949 | ||
|
|
326b9297c1 | ||
|
|
7790e52a6d | ||
|
|
e6d18352c5 | ||
|
|
4122e936c8 | ||
|
|
803eba416e | ||
|
|
07cae1a4e8 | ||
|
|
9eaf4397df | ||
|
|
077016f7a0 | ||
|
|
e51a14c081 | ||
|
|
77391e7888 | ||
|
|
69ad5e7299 | ||
|
|
04e0611d45 | ||
|
|
c0395e9447 | ||
|
|
3b2d9db2e2 | ||
|
|
bbe3842a2f | ||
|
|
1ad563ad93 | ||
|
|
29207c1d4c | ||
|
|
67c3888f82 | ||
|
|
d4675c747a | ||
|
|
c5f0c60ec8 | ||
|
|
fc4dcff9e3 | ||
|
|
f8a030a648 | ||
|
|
82c8d21395 | ||
|
|
746ca551f3 | ||
|
|
5d47a0f44c | ||
|
|
555f386b24 | ||
|
|
5f34a85c80 | ||
|
|
97552a711a | ||
|
|
561f28bd95 | ||
|
|
0bd4583c73 | ||
|
|
adc3c7914d | ||
|
|
d2c06b08bb | ||
|
|
4dc674816b | ||
|
|
8c6b1a2504 | ||
|
|
8d385523ad | ||
|
|
05c9bcd0e6 | ||
|
|
2c3da6484b | ||
|
|
d4f9326759 | ||
|
|
e85a8d5826 | ||
|
|
2da46a44a4 | ||
|
|
0ecfc2c1fd | ||
|
|
ffe5734433 | ||
|
|
ae99f1f1a4 | ||
|
|
a47d6233be | ||
|
|
1980049c6f | ||
|
|
9fc078ad94 | ||
|
|
7a835c615e | ||
|
|
9412a03da7 | ||
|
|
9012363437 | ||
|
|
021d7eb602 | ||
|
|
3d8034b285 | ||
|
|
bd3f196f84 | ||
|
|
9a90b7b7b4 | ||
|
|
e3ffe51979 | ||
|
|
0133802570 | ||
|
|
b20c4b4b34 | ||
|
|
5bdfc2ed25 | ||
|
|
8b38eeecac | ||
|
|
ed9e513271 | ||
|
|
97d958d2e7 | ||
|
|
9db61f7191 | ||
|
|
4230c57e73 | ||
|
|
871144bf0c | ||
|
|
3fdc2459ca | ||
|
|
e008fc6048 | ||
|
|
ca7ab192c5 | ||
|
|
81f6dbede2 | ||
|
|
478075347c | ||
|
|
595538cdca | ||
|
|
e27f3470a4 |
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
dist/
|
||||||
|
build/
|
||||||
|
__pycache__
|
||||||
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
default_install_hook_types: [pre-commit, commit-msg]
|
||||||
|
default_stages: [pre-commit]
|
||||||
|
|
||||||
|
repos:
|
||||||
|
|
||||||
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
|
rev: v0.17.1
|
||||||
|
hooks:
|
||||||
|
- id: ruff-check
|
||||||
|
args: [--fix]
|
||||||
|
- id: ruff-format
|
||||||
|
|
||||||
|
- repo: local
|
||||||
|
hooks:
|
||||||
|
- id: ty
|
||||||
|
name: ty
|
||||||
|
entry: uv run ty check
|
||||||
|
language: system
|
||||||
|
pass_filenames: false
|
||||||
|
types: [python]
|
||||||
|
- id: commit-msg-50-72
|
||||||
|
name: Commit message 50/72 rule
|
||||||
|
entry: python3 .prek/check_commit_msg
|
||||||
|
language: system
|
||||||
|
stages: [commit-msg]
|
||||||
Executable
+95
@@ -0,0 +1,95 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Fail the commit if its message breaks the 50/72 rule.
|
||||||
|
|
||||||
|
Subject line <= 50 chars, a blank line, then body wrapped at <= 72 chars.
|
||||||
|
The subject also has to open with a module prefix. Wired in as a
|
||||||
|
prek/pre-commit commit-msg hook so it is enforced mechanically instead of by
|
||||||
|
memory.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
MAX_SUBJECT = 50
|
||||||
|
MAX_BODY = 72
|
||||||
|
|
||||||
|
# `module-prefix: description`, the prefix at the very start of the subject.
|
||||||
|
# A prefix names a file, so it spells the name as the file does: underscores
|
||||||
|
# within a name, `/` between folders, `+` between two modules.
|
||||||
|
SUBJECT_RE = re.compile(r"^[a-z0-9][a-z0-9_+./]*: \S")
|
||||||
|
|
||||||
|
# Subjects git or a rebase writes itself, which the rule is not about.
|
||||||
|
EXEMPT_PREFIXES = ("Merge ", "Revert ", "fixup! ", "squash! ", "amend! ")
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: Sequence[str]) -> int:
|
||||||
|
if len(argv) != 2:
|
||||||
|
sys.stderr.write(f"Usage: {argv[0]} COMMIT_MSG_FILE\n")
|
||||||
|
return 2
|
||||||
|
|
||||||
|
message_path = Path(argv[1])
|
||||||
|
with message_path.open(encoding="utf-8") as handle:
|
||||||
|
raw_lines = handle.read().splitlines()
|
||||||
|
|
||||||
|
# `git commit -v` appends a diff below a scissors line; ignore it.
|
||||||
|
cut = next(
|
||||||
|
(
|
||||||
|
i
|
||||||
|
for i, line in enumerate(raw_lines)
|
||||||
|
if ">8" in line and line[:1] == "#"
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
if cut is not None:
|
||||||
|
raw_lines = raw_lines[:cut]
|
||||||
|
|
||||||
|
lines = [line for line in raw_lines if not line.startswith("#")]
|
||||||
|
while lines and not lines[-1].strip():
|
||||||
|
lines.pop()
|
||||||
|
if not lines:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
subject = lines[0]
|
||||||
|
if not subject.startswith(EXEMPT_PREFIXES):
|
||||||
|
if len(subject) > MAX_SUBJECT:
|
||||||
|
errors.append(
|
||||||
|
f"subject is {len(subject)} chars (max {MAX_SUBJECT})"
|
||||||
|
)
|
||||||
|
if not SUBJECT_RE.match(subject):
|
||||||
|
head = subject.split(":")[0]
|
||||||
|
if "," in head:
|
||||||
|
errors.append(
|
||||||
|
f"prefix `{head}` separates modules with a comma; join"
|
||||||
|
" them with `+`"
|
||||||
|
)
|
||||||
|
elif "-" in head:
|
||||||
|
errors.append(
|
||||||
|
f"prefix `{head}` uses a dash; spell the module as its"
|
||||||
|
f" file does (`{head.replace('-', '_')}`)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
errors.append(
|
||||||
|
"subject must open with a lowercase module prefix, as in"
|
||||||
|
" `mechanic: shorten quests`"
|
||||||
|
)
|
||||||
|
if len(lines) > 1 and lines[1].strip():
|
||||||
|
errors.append("second line must be blank (subject, blank, body)")
|
||||||
|
for number, line in enumerate(lines[2:], start=3):
|
||||||
|
if len(line) > MAX_BODY:
|
||||||
|
errors.append(
|
||||||
|
f"body line {number} is {len(line)} chars (max {MAX_BODY})"
|
||||||
|
)
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
sys.stderr.write("Commit message breaks the 50/72 rule:\n")
|
||||||
|
for error in errors:
|
||||||
|
sys.stderr.write(f" - {error}\n")
|
||||||
|
return 1
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv))
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
## [Unreleased](https://github.com/rr-/pq-cli/compare/1.1.0...main) - ××××-××-××
|
||||||
|
|
||||||
|
## [1.1.0](https://github.com/rr-/pq-cli/compare/1.0.4...1.1.0) - 2026-10-17
|
||||||
|
|
||||||
|
**Gameplay**
|
||||||
|
- Changed the game mechanics to match the current web version of Progress Quest, which pqcli now plays exactly like, random rolls included (#21):
|
||||||
|
- Changed the time needed to level up to grow exponentially: levels come much faster early on and much slower past level 49
|
||||||
|
- Changed quests to be much shorter
|
||||||
|
- Changed stat gains that favor the best stat to only consider the six prime stats
|
||||||
|
- Changed item rewards on inventories past 250 kinds of items to sometimes add one more of something already carried
|
||||||
|
- Changed impressive characters in cinematics to be named in the new styles, such as "the Queen of the Half Halflings"
|
||||||
|
- Changed the cinematic texts to match the web version
|
||||||
|
- Changed acts to begin as their loading task starts
|
||||||
|
- Added the Chancellor, Baroness and Inquistor titles
|
||||||
|
- Changed the random number generator to the one the web version uses, stored with each character; existing characters get a new one on first load
|
||||||
|
- Changed roman numerals past 4000 to use the web version's T and A numerals
|
||||||
|
- Fixed the plot bar staying empty during the Prologue
|
||||||
|
- Fixed the quest log keeping 101 quests instead of 100
|
||||||
|
- Fixed "an" not being used before words starting with Ü
|
||||||
|
|
||||||
|
**Customization**
|
||||||
|
- Added data files for all word lists (monsters, spells, equipment, races, classes, titles and more), which can be replaced in `$XDG_CONFIG_HOME/pqcli/data/` or a directory given with `--data-dir` (#3)
|
||||||
|
- Added customizable combat descriptions (`combat_verbs.json`), which say "Executing" by default (#3)
|
||||||
|
- Added `--dump-data DIR` to copy the built-in word lists out for editing (#3)
|
||||||
|
|
||||||
|
**Saves**
|
||||||
|
- Added importing and exporting characters as `.pqw` files, the format of the web version, from both interfaces and with `--import-save` and `--export-save` (#21)
|
||||||
|
|
||||||
|
**Installation**
|
||||||
|
- Added a `Dockerfile` and `docker-compose.yml` to run the game in a container, with saves kept in a persistent volume
|
||||||
|
- Added character creation on first run when there are no saved characters yet
|
||||||
|
|
||||||
|
**Development**
|
||||||
|
- Added tests, including gameplay traces and a comparison with the web version's own code (`just test`)
|
||||||
|
- Added a `justfile`
|
||||||
|
- Changed the hooks to prek, with ruff in place of black and isort, ty for type checking, and a 50/72 commit message check
|
||||||
|
- Removed `poetry.lock`
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir .
|
||||||
|
|
||||||
|
CMD ["pqcli", "--basic"]
|
||||||
@@ -10,27 +10,168 @@ Relive the great adventure… this time in the terminal realm!
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat`
|
|
||||||
- Clean terminal interface
|
|
||||||
- Smooth progress bars using UTF-8 block characters
|
|
||||||
- Scrollable list boxes
|
|
||||||
- Faithful port of the game logic
|
- Faithful port of the game logic
|
||||||
|
- Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat`
|
||||||
|
- Terminal interface that comes in 2 flavors:
|
||||||
|
- Rich and colorful (`--curses`, default)
|
||||||
|
- Minimal, suitable for raw grind (`--basic`)
|
||||||
- Ideal to run on your server
|
- Ideal to run on your server
|
||||||
|
|
||||||
## How it looks like
|
## How it looks like
|
||||||
|
|
||||||

|
Curses interface:
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
Basic interface:
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
## How to install
|
## How to install
|
||||||
|
|
||||||
If you have Python 3.7, just run `pip install --user pqcli` and you're good to go!
|
If you have Python 3.10 or newer, just run `pip install --user pqcli` and you're good to go!
|
||||||
Then type `pqcli` to run the game.
|
Then type `pqcli` to run the game.
|
||||||
|
|
||||||
In case if you want to use the git version, the process is just a little bit
|
In case if you want to use the git version, the process is just a bit more complex:
|
||||||
more complex:
|
|
||||||
|
|
||||||
```console
|
```console
|
||||||
$ git clone https://github.com/rr-/pq-cli.git
|
$ git clone https://github.com/rr-/pq-cli.git
|
||||||
$ cd pq-cli
|
$ cd pq-cli
|
||||||
$ pip install --user .
|
$ pip install --user .
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Docker / Docker Compose
|
||||||
|
|
||||||
|
The repository includes a `Dockerfile` and `docker-compose.yml` that run the
|
||||||
|
game with `--basic` and keep save data in a persistent named volume.
|
||||||
|
|
||||||
|
There is no registry pipeline required for this setup: build locally, then run
|
||||||
|
with Compose.
|
||||||
|
|
||||||
|
```console
|
||||||
|
# Build image locally from the Dockerfile
|
||||||
|
docker compose build
|
||||||
|
|
||||||
|
# First run (interactive): create your character in the shared save volume
|
||||||
|
docker compose run --rm pqcli-init
|
||||||
|
|
||||||
|
# Later runs on a server (detached, default save slot 1):
|
||||||
|
docker compose up -d pqcli
|
||||||
|
|
||||||
|
# List existing saves in the same volume:
|
||||||
|
docker compose run --rm pqcli-init pqcli --basic --list-saves
|
||||||
|
|
||||||
|
# Change detached slot if needed (example: slot 2):
|
||||||
|
PQCLI_SAVE_SLOT=2 docker compose up -d pqcli
|
||||||
|
|
||||||
|
# Follow logs / stop detached container:
|
||||||
|
docker compose logs -f pqcli
|
||||||
|
docker compose stop pqcli
|
||||||
|
```
|
||||||
|
|
||||||
|
On first run, if no save data exists in the mounted volume, `pqcli` will
|
||||||
|
automatically launch an interactive character-creation bootstrap in CLI mode
|
||||||
|
before starting the normal interface.
|
||||||
|
|
||||||
|
For server usage, run first-time setup without a forced slot (`pqcli-init`),
|
||||||
|
then run the long-lived detached service (`pqcli`) that loads slot `1` by
|
||||||
|
default.
|
||||||
|
|
||||||
|
## Customizing the adventure
|
||||||
|
|
||||||
|
All word lists (monsters, spells, equipment, races, classes, titles and so on)
|
||||||
|
live in JSON files. To change them, copy the built-in files somewhere and edit
|
||||||
|
them:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ pqcli --dump-data ~/.config/pqcli/data
|
||||||
|
```
|
||||||
|
|
||||||
|
On startup, pqcli reads `$XDG_CONFIG_HOME/pqcli/data/` (or the directory
|
||||||
|
given with `--data-dir DIR`). Each `NAME.json` there replaces the built-in
|
||||||
|
list of the same name. Files you leave out fall back to the built-in lists,
|
||||||
|
so you can delete the copies you don't change. If a file is malformed, pqcli
|
||||||
|
prints what is wrong and refuses to start.
|
||||||
|
|
||||||
|
The available files are `spells`, `offense_attrib`, `defense_attrib`,
|
||||||
|
`offense_bad`, `defense_bad`, `shields`, `armors`, `weapons`, `specials`,
|
||||||
|
`item_attrib`, `item_ofs`, `boring_items`, `monsters`, `races`, `classes`,
|
||||||
|
`titles`, `impressive_titles` and `combat_verbs`.
|
||||||
|
|
||||||
|
For example, to vary the combat descriptions, create `combat_verbs.json`.
|
||||||
|
`{target}` is replaced with the enemy:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
"Executing {target}",
|
||||||
|
"In a fierce battle to thwart {target}",
|
||||||
|
"Fighting bravely against {target}",
|
||||||
|
"Kicking the butt of {target}"
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
A few things to keep in mind:
|
||||||
|
|
||||||
|
- Order matters in `spells.json`: spells are learned roughly from the top
|
||||||
|
down, so keep weaker spells first.
|
||||||
|
- Titles near the top of `titles.json` show up more often.
|
||||||
|
- Stats in `races.json` and `classes.json` are written as `STR`, `CON`,
|
||||||
|
`DEX`, `INT`, `WIS`, `CHA`, `HP Max` or `MP Max`.
|
||||||
|
- Existing characters keep their race and class even if you remove them from
|
||||||
|
the lists.
|
||||||
|
|
||||||
|
## Moving characters to and from the web version
|
||||||
|
|
||||||
|
pqcli reads and writes the `.pqw` files that the
|
||||||
|
[web version](http://progressquest.com/play/)'s roster page saves and
|
||||||
|
loads. A character keeps its random number generator state either way, so
|
||||||
|
it carries on exactly as it would have where it came from.
|
||||||
|
|
||||||
|
- In either interface, pick *Import* or *Export* from the main menu.
|
||||||
|
- From the command line:
|
||||||
|
|
||||||
|
```console
|
||||||
|
$ pqcli --import-save Grumdrig.pqw
|
||||||
|
$ pqcli --list-saves
|
||||||
|
1. Grumdrig
|
||||||
|
$ pqcli --export-save 1 Grumdrig.pqw
|
||||||
|
```
|
||||||
|
|
||||||
|
Export never overwrites an existing file. pqcli plays offline only, so
|
||||||
|
online realm, guild and motto settings are dropped on import, with a
|
||||||
|
warning.
|
||||||
|
|
||||||
|
## Contributing
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Clone the repository:
|
||||||
|
git clone https://github.com/rr-/pq-cli.git
|
||||||
|
cd pq-cli
|
||||||
|
|
||||||
|
# Install to a local venv:
|
||||||
|
uv sync
|
||||||
|
|
||||||
|
# Install the git hooks (formatting, linting, commit messages):
|
||||||
|
uv run prek install
|
||||||
|
|
||||||
|
# Run the game and the tests:
|
||||||
|
just run
|
||||||
|
just test
|
||||||
|
```
|
||||||
|
|
||||||
|
This project uses [uv](https://docs.astral.sh/uv/) for packaging.
|
||||||
|
Install instructions are available at [uv#installation](https://docs.astral.sh/uv/getting-started/installation/).
|
||||||
|
The `justfile` needs [just](https://github.com/casey/just); without it, use
|
||||||
|
`uv run pqcli` and `uv run pytest`.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### `_curses.error: init_pair() returned ERR`
|
||||||
|
|
||||||
|
If running on Linux and you get the error `_curses.error: init_pair() returned ERR`,
|
||||||
|
try making sure that your `$TERM` variable is set to a value which supports
|
||||||
|
256 colors, such as via the following:
|
||||||
|
|
||||||
|
TERM=xterm-256color pqcli
|
||||||
|
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
services:
|
||||||
|
pqcli-init:
|
||||||
|
build: .
|
||||||
|
image: ghcr.io/${GITHUB_REPOSITORY_OWNER:-local}/pq-cli:latest
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
command: ["pqcli", "--basic"]
|
||||||
|
environment:
|
||||||
|
XDG_CONFIG_HOME: /data
|
||||||
|
volumes:
|
||||||
|
- pqcli-data:/data
|
||||||
|
|
||||||
|
pqcli:
|
||||||
|
build: .
|
||||||
|
stdin_open: true
|
||||||
|
tty: true
|
||||||
|
stop_signal: SIGINT
|
||||||
|
stop_grace_period: 30s
|
||||||
|
command: ["sh", "-c", "pqcli --basic --load-save ${PQCLI_SAVE_SLOT:-1}"]
|
||||||
|
environment:
|
||||||
|
XDG_CONFIG_HOME: /data
|
||||||
|
volumes:
|
||||||
|
- pqcli-data:/data
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pqcli-data:
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
default: run
|
||||||
|
|
||||||
|
# Run the game; extra arguments go to pqcli
|
||||||
|
run *args:
|
||||||
|
uv run pqcli {{args}}
|
||||||
|
|
||||||
|
# Run with the curses interface
|
||||||
|
curses *args:
|
||||||
|
uv run pqcli --curses {{args}}
|
||||||
|
|
||||||
|
# Run with the basic interface
|
||||||
|
basic *args:
|
||||||
|
uv run pqcli --basic {{args}}
|
||||||
|
|
||||||
|
# Run the formatters and linters on all files
|
||||||
|
lint:
|
||||||
|
uv run prek run -a
|
||||||
|
|
||||||
|
# Download Progress Quest web's game logic for the upstream tests
|
||||||
|
fetch-upstream:
|
||||||
|
uv run python tests/upstream/fetch.py
|
||||||
|
|
||||||
|
# Run the test suite; extra arguments go to pytest
|
||||||
|
test *args: fetch-upstream
|
||||||
|
uv run pytest {{args}}
|
||||||
|
|
||||||
|
# Re-record the gameplay parity fixtures (only when gameplay should change)
|
||||||
|
record-traces:
|
||||||
|
uv run python tests/game_trace.py
|
||||||
+183
-13
@@ -1,25 +1,53 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import TextIO
|
||||||
|
|
||||||
import xdg
|
from xdg_base_dirs import xdg_config_home
|
||||||
|
|
||||||
|
from pqcli import pqw
|
||||||
|
from pqcli.game_data import (
|
||||||
|
DataError,
|
||||||
|
GameData,
|
||||||
|
dump_builtin_data,
|
||||||
|
load_game_data,
|
||||||
|
)
|
||||||
|
from pqcli.mechanic import Player
|
||||||
from pqcli.roster import Roster
|
from pqcli.roster import Roster
|
||||||
from pqcli.ui import Ui
|
from pqcli.ui.basic import BasicUserInterface
|
||||||
|
from pqcli.ui.curses import CursesUserInterface
|
||||||
|
|
||||||
SAVE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "save.dat"
|
SAVE_PATH = Path(xdg_config_home()) / "pqcli" / "save.dat"
|
||||||
|
DATA_PATH = Path(xdg_config_home()) / "pqcli" / "data"
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
def parse_args() -> argparse.Namespace:
|
||||||
parser = argparse.ArgumentParser(prog="pqcli")
|
parser = argparse.ArgumentParser(prog="pqcli")
|
||||||
parser.add_argument(
|
|
||||||
"--no-config",
|
group = parser.add_mutually_exclusive_group()
|
||||||
dest="use_config",
|
group.set_defaults(ui=CursesUserInterface)
|
||||||
action="store_false",
|
group.add_argument(
|
||||||
help=(
|
"--basic",
|
||||||
"Don't load or save configuration files from $XDG_CONFIG_HOME "
|
dest="ui",
|
||||||
"(player data will still be saved)"
|
action="store_const",
|
||||||
),
|
const=BasicUserInterface,
|
||||||
|
help="Use basic user interface (very crude, but uses least CPU)",
|
||||||
)
|
)
|
||||||
|
group.add_argument(
|
||||||
|
"--curses",
|
||||||
|
dest="ui",
|
||||||
|
action="store_const",
|
||||||
|
const=CursesUserInterface,
|
||||||
|
help="Use curses user interface (fast, but no colors output)",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--no-colors",
|
||||||
|
dest="colors",
|
||||||
|
action="store_false",
|
||||||
|
help="Disable color highlighting in curses interface",
|
||||||
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-save",
|
"--no-save",
|
||||||
dest="use_saves",
|
dest="use_saves",
|
||||||
@@ -27,15 +55,157 @@ def parse_args() -> argparse.Namespace:
|
|||||||
help=argparse.SUPPRESS,
|
help=argparse.SUPPRESS,
|
||||||
)
|
)
|
||||||
parser.add_argument("--cheats", action="store_true", help="???")
|
parser.add_argument("--cheats", action="store_true", help="???")
|
||||||
|
parser.add_argument(
|
||||||
|
"--list-saves",
|
||||||
|
action="store_true",
|
||||||
|
help="list saved characters and exit",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--load-save",
|
||||||
|
type=int,
|
||||||
|
metavar="NUM",
|
||||||
|
help="play chosen character",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--data-dir",
|
||||||
|
type=Path,
|
||||||
|
default=DATA_PATH,
|
||||||
|
metavar="DIR",
|
||||||
|
help=(
|
||||||
|
"directory with custom word lists; each NAME.json replaces "
|
||||||
|
f"the built-in list of that name (default: {DATA_PATH})"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--dump-data",
|
||||||
|
type=Path,
|
||||||
|
metavar="DIR",
|
||||||
|
help="copy the built-in word lists to DIR for editing and exit",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--import-save",
|
||||||
|
type=Path,
|
||||||
|
action="append",
|
||||||
|
metavar="FILE",
|
||||||
|
help=(
|
||||||
|
"add the character in a Progress Quest web save (.pqw) to the "
|
||||||
|
"roster and exit; can be given more than once"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--export-save",
|
||||||
|
nargs=2,
|
||||||
|
metavar=("NUM", "FILE"),
|
||||||
|
help=(
|
||||||
|
"write the chosen character to a Progress Quest web save "
|
||||||
|
"(.pqw) and exit"
|
||||||
|
),
|
||||||
|
)
|
||||||
return parser.parse_args()
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def list_players(roster: Roster, file: TextIO | None = None) -> None:
|
||||||
|
for i, player in enumerate(roster.players, start=1):
|
||||||
|
print(f"{i}. {player.name}", file=file)
|
||||||
|
|
||||||
|
|
||||||
|
def bootstrap_first_run(
|
||||||
|
roster: Roster, args: argparse.Namespace, data: GameData
|
||||||
|
) -> None:
|
||||||
|
if roster.players or args.list_saves or args.load_save:
|
||||||
|
return
|
||||||
|
|
||||||
|
print("No saved characters found. Starting first-run character creation.")
|
||||||
|
ui = BasicUserInterface(roster, None, args, data)
|
||||||
|
while not roster.players:
|
||||||
|
player = ui.create_player(auto_play=False)
|
||||||
|
if player:
|
||||||
|
return
|
||||||
|
if not ui.confirm("No character created. Do you want to try again?"):
|
||||||
|
print("A character is required for first run.", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
def import_saves(
|
||||||
|
roster: Roster, data: GameData, paths: list[Path], use_saves: bool
|
||||||
|
) -> None:
|
||||||
|
imported = []
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
player, warnings = pqw.read_file(path, data)
|
||||||
|
except pqw.PqwError as ex:
|
||||||
|
print(f"pqcli: {ex}", file=sys.stderr)
|
||||||
|
exit(1)
|
||||||
|
for warning in warnings:
|
||||||
|
print(f"pqcli: {path}: {warning}", file=sys.stderr)
|
||||||
|
imported.append(player)
|
||||||
|
roster.players.extend(imported)
|
||||||
|
if use_saves:
|
||||||
|
roster.save()
|
||||||
|
for player in imported:
|
||||||
|
print(f"Imported {player.name}")
|
||||||
|
|
||||||
|
|
||||||
|
def export_save(roster: Roster, data: GameData, num: str, file: str) -> None:
|
||||||
|
if not num.isdigit() or not 1 <= int(num) <= len(roster.players):
|
||||||
|
print("Invalid player. Available players:", file=sys.stderr)
|
||||||
|
list_players(roster, file=sys.stderr)
|
||||||
|
exit(1)
|
||||||
|
player = roster.players[int(num) - 1]
|
||||||
|
try:
|
||||||
|
pqw.write_file(player, Path(file), data)
|
||||||
|
except pqw.PqwError as ex:
|
||||||
|
print(f"pqcli: {ex}", file=sys.stderr)
|
||||||
|
exit(1)
|
||||||
|
print(f"Exported {player.name} to {file}")
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
def main() -> None:
|
||||||
args = parse_args()
|
args = parse_args()
|
||||||
roster = Roster.load(SAVE_PATH)
|
|
||||||
|
if args.dump_data:
|
||||||
|
try:
|
||||||
|
paths = dump_builtin_data(args.dump_data)
|
||||||
|
except DataError as ex:
|
||||||
|
print(f"pqcli: {ex}", file=sys.stderr)
|
||||||
|
exit(1)
|
||||||
|
print(f"Wrote {len(paths)} files to {args.dump_data}")
|
||||||
|
exit(0)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
Ui(roster, args).run()
|
data = load_game_data(args.data_dir)
|
||||||
|
except DataError as ex:
|
||||||
|
print(f"pqcli: {ex}", file=sys.stderr)
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
roster = Roster.load(SAVE_PATH)
|
||||||
|
|
||||||
|
if args.list_saves:
|
||||||
|
list_players(roster)
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
if args.import_save:
|
||||||
|
import_saves(roster, data, args.import_save, args.use_saves)
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
if args.export_save:
|
||||||
|
export_save(roster, data, *args.export_save)
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
player: Player | None = None
|
||||||
|
if args.load_save:
|
||||||
|
try:
|
||||||
|
player = list(roster.players)[args.load_save - 1]
|
||||||
|
except IndexError:
|
||||||
|
print("Invalid player. Available players:", file=sys.stderr)
|
||||||
|
list_players(roster, file=sys.stderr)
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
bootstrap_first_run(roster, args, data)
|
||||||
|
|
||||||
|
try:
|
||||||
|
ui = args.ui(roster, player, args, data)
|
||||||
|
ui.run()
|
||||||
finally:
|
finally:
|
||||||
if args.use_saves:
|
if args.use_saves:
|
||||||
roster.save()
|
roster.save()
|
||||||
|
|||||||
+6
-680
@@ -1,5 +1,4 @@
|
|||||||
import enum
|
import enum
|
||||||
import typing as T
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
|
||||||
@@ -39,56 +38,9 @@ PRIME_STATS = [
|
|||||||
|
|
||||||
ALL_STATS = PRIME_STATS + [StatType.hp_max, StatType.mp_max]
|
ALL_STATS = PRIME_STATS + [StatType.hp_max, StatType.mp_max]
|
||||||
|
|
||||||
|
# The word lists live in pqcli/data/*.json (see pqcli.game_data).
|
||||||
SPELLS = [
|
# Saves pickle Monster, Race and Class instances by import path, so these
|
||||||
"Slime Finger",
|
# classes must stay in this module.
|
||||||
"Rabbit Punch",
|
|
||||||
"Hastiness",
|
|
||||||
"Good Move",
|
|
||||||
"Sadness",
|
|
||||||
"Seasick",
|
|
||||||
"Shoelaces",
|
|
||||||
"Innoculate",
|
|
||||||
"Cone of Annoyance",
|
|
||||||
"Magnetic Orb",
|
|
||||||
"Invisible Hands",
|
|
||||||
"Revolting Cloud",
|
|
||||||
"Aqueous Humor",
|
|
||||||
"Spectral Miasma",
|
|
||||||
"Clever Fellow",
|
|
||||||
"Lockjaw",
|
|
||||||
"History Lesson",
|
|
||||||
"Hydrophobia",
|
|
||||||
"Big Sister",
|
|
||||||
"Cone of Paste",
|
|
||||||
"Mulligan",
|
|
||||||
"Nestor's Bright Idea",
|
|
||||||
"Holy Batpole",
|
|
||||||
"Tumor (Benign)",
|
|
||||||
"Braingate",
|
|
||||||
"Summon a Bitch",
|
|
||||||
"Nonplus",
|
|
||||||
"Animate Nightstand",
|
|
||||||
"Eye of the Troglodyte",
|
|
||||||
"Curse Name",
|
|
||||||
"Dropsy",
|
|
||||||
"Vitreous Humor",
|
|
||||||
"Roger's Grand Illusion",
|
|
||||||
"Covet",
|
|
||||||
"Black Idaho",
|
|
||||||
"Astral Miasma",
|
|
||||||
"Spectral Oyster",
|
|
||||||
"Acrid Hands",
|
|
||||||
"Angioplasty",
|
|
||||||
"Grognor's Big Day Off",
|
|
||||||
"Tumor (Malignant)",
|
|
||||||
"Animate Tunic",
|
|
||||||
"Ursine Armor",
|
|
||||||
"Holy Roller",
|
|
||||||
"Tonsilectomy",
|
|
||||||
"Curse Family",
|
|
||||||
"Infinite Confusion",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -97,652 +49,26 @@ class Modifier:
|
|||||||
quality: int
|
quality: int
|
||||||
|
|
||||||
|
|
||||||
OFFENSE_ATTRIB = [
|
|
||||||
Modifier("Polished", 1),
|
|
||||||
Modifier("Serrated", 1),
|
|
||||||
Modifier("Heavy", 1),
|
|
||||||
Modifier("Pronged", 2),
|
|
||||||
Modifier("Steely", 2),
|
|
||||||
Modifier("Vicious", 3),
|
|
||||||
Modifier("Venomed", 4),
|
|
||||||
Modifier("Stabbity", 4),
|
|
||||||
Modifier("Dancing", 5),
|
|
||||||
Modifier("Invisible", 6),
|
|
||||||
Modifier("Vorpal", 7),
|
|
||||||
]
|
|
||||||
|
|
||||||
DEFENSE_ATTRIB = [
|
|
||||||
Modifier("Studded", 1),
|
|
||||||
Modifier("Banded", 2),
|
|
||||||
Modifier("Gilded", 2),
|
|
||||||
Modifier("Festooned", 3),
|
|
||||||
Modifier("Holy", 4),
|
|
||||||
Modifier("Cambric", 1),
|
|
||||||
Modifier("Fine", 4),
|
|
||||||
Modifier("Impressive", 5),
|
|
||||||
Modifier("Custom", 3),
|
|
||||||
]
|
|
||||||
|
|
||||||
OFFENSE_BAD = [
|
|
||||||
Modifier("Dull", -2),
|
|
||||||
Modifier("Tarnished", -1),
|
|
||||||
Modifier("Rusty", -3),
|
|
||||||
Modifier("Padded", -5),
|
|
||||||
Modifier("Bent", -4),
|
|
||||||
Modifier("Mini", -4),
|
|
||||||
Modifier("Rubber", -6),
|
|
||||||
Modifier("Nerf", -7),
|
|
||||||
Modifier("Unbalanced", -2),
|
|
||||||
]
|
|
||||||
|
|
||||||
DEFENSE_BAD = [
|
|
||||||
Modifier("Holey", -1),
|
|
||||||
Modifier("Patched", -1),
|
|
||||||
Modifier("Threadbare", -2),
|
|
||||||
Modifier("Faded", -1),
|
|
||||||
Modifier("Rusty", -3),
|
|
||||||
Modifier("Motheaten", -3),
|
|
||||||
Modifier("Mildewed", -2),
|
|
||||||
Modifier("Torn", -3),
|
|
||||||
Modifier("Dented", -3),
|
|
||||||
Modifier("Cursed", -5),
|
|
||||||
Modifier("Plastic", -4),
|
|
||||||
Modifier("Cracked", -4),
|
|
||||||
Modifier("Warped", -3),
|
|
||||||
Modifier("Corroded", -3),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class EquipmentPreset:
|
class EquipmentPreset:
|
||||||
name: str
|
name: str
|
||||||
quality: int
|
quality: int
|
||||||
|
|
||||||
|
|
||||||
SHIELDS = [
|
|
||||||
EquipmentPreset("Parasol", 0),
|
|
||||||
EquipmentPreset("Pie Plate", 1),
|
|
||||||
EquipmentPreset("Garbage Can Lid", 2),
|
|
||||||
EquipmentPreset("Buckler", 3),
|
|
||||||
EquipmentPreset("Plexiglass", 4),
|
|
||||||
EquipmentPreset("Fender", 4),
|
|
||||||
EquipmentPreset("Round Shield", 5),
|
|
||||||
EquipmentPreset("Carapace", 5),
|
|
||||||
EquipmentPreset("Scutum", 6),
|
|
||||||
EquipmentPreset("Propugner", 6),
|
|
||||||
EquipmentPreset("Kite Shield", 7),
|
|
||||||
EquipmentPreset("Pavise", 8),
|
|
||||||
EquipmentPreset("Tower Shield", 9),
|
|
||||||
EquipmentPreset("Baroque Shield", 11),
|
|
||||||
EquipmentPreset("Aegis", 12),
|
|
||||||
EquipmentPreset("Magnetic Field", 18),
|
|
||||||
]
|
|
||||||
|
|
||||||
ARMORS = [
|
|
||||||
EquipmentPreset("Lace", 1),
|
|
||||||
EquipmentPreset("Macrame", 2),
|
|
||||||
EquipmentPreset("Burlap", 3),
|
|
||||||
EquipmentPreset("Canvas", 4),
|
|
||||||
EquipmentPreset("Flannel", 5),
|
|
||||||
EquipmentPreset("Chamois", 6),
|
|
||||||
EquipmentPreset("Pleathers", 7),
|
|
||||||
EquipmentPreset("Leathers", 8),
|
|
||||||
EquipmentPreset("Bearskin", 9),
|
|
||||||
EquipmentPreset("Ringmail", 10),
|
|
||||||
EquipmentPreset("Scale Mail", 12),
|
|
||||||
EquipmentPreset("Chainmail", 14),
|
|
||||||
EquipmentPreset("Splint Mail", 15),
|
|
||||||
EquipmentPreset("Platemail", 16),
|
|
||||||
EquipmentPreset("ABS", 17),
|
|
||||||
EquipmentPreset("Kevlar", 18),
|
|
||||||
EquipmentPreset("Titanium", 19),
|
|
||||||
EquipmentPreset("Mithril Mail", 20),
|
|
||||||
EquipmentPreset("Diamond Mail", 25),
|
|
||||||
EquipmentPreset("Plasma", 30),
|
|
||||||
]
|
|
||||||
|
|
||||||
WEAPONS = [
|
|
||||||
EquipmentPreset("Stick", 0),
|
|
||||||
EquipmentPreset("Broken Bottle", 1),
|
|
||||||
EquipmentPreset("Shiv", 1),
|
|
||||||
EquipmentPreset("Sprig", 1),
|
|
||||||
EquipmentPreset("Oxgoad", 1),
|
|
||||||
EquipmentPreset("Eelspear", 2),
|
|
||||||
EquipmentPreset("Bowie Knife", 2),
|
|
||||||
EquipmentPreset("Claw Hammer", 2),
|
|
||||||
EquipmentPreset("Handpeen", 2),
|
|
||||||
EquipmentPreset("Andiron", 3),
|
|
||||||
EquipmentPreset("Hatchet", 3),
|
|
||||||
EquipmentPreset("Tomahawk", 3),
|
|
||||||
EquipmentPreset("Hackbarm", 3),
|
|
||||||
EquipmentPreset("Crowbar", 4),
|
|
||||||
EquipmentPreset("Mace", 4),
|
|
||||||
EquipmentPreset("Battleadze", 4),
|
|
||||||
EquipmentPreset("Leafmace", 5),
|
|
||||||
EquipmentPreset("Shortsword", 5),
|
|
||||||
EquipmentPreset("Longiron", 5),
|
|
||||||
EquipmentPreset("Poachard", 5),
|
|
||||||
EquipmentPreset("Baselard", 5),
|
|
||||||
EquipmentPreset("Whinyard", 6),
|
|
||||||
EquipmentPreset("Blunderbuss", 6),
|
|
||||||
EquipmentPreset("Longsword", 6),
|
|
||||||
EquipmentPreset("Crankbow", 6),
|
|
||||||
EquipmentPreset("Blibo", 7),
|
|
||||||
EquipmentPreset("Broadsword", 7),
|
|
||||||
EquipmentPreset("Kreen", 7),
|
|
||||||
EquipmentPreset("Warhammer", 7),
|
|
||||||
EquipmentPreset("Morning Star", 8),
|
|
||||||
EquipmentPreset("Pole-adze", 8),
|
|
||||||
EquipmentPreset("Spontoon", 8),
|
|
||||||
EquipmentPreset("Bastard Sword", 9),
|
|
||||||
EquipmentPreset("Peen-arm", 9),
|
|
||||||
EquipmentPreset("Culverin", 10),
|
|
||||||
EquipmentPreset("Lance", 10),
|
|
||||||
EquipmentPreset("Halberd", 11),
|
|
||||||
EquipmentPreset("Poleax", 12),
|
|
||||||
EquipmentPreset("Bandyclef", 15),
|
|
||||||
]
|
|
||||||
|
|
||||||
SPECIALS = [
|
|
||||||
"Diadem",
|
|
||||||
"Festoon",
|
|
||||||
"Gemstone",
|
|
||||||
"Phial",
|
|
||||||
"Tiara",
|
|
||||||
"Scabbard",
|
|
||||||
"Arrow",
|
|
||||||
"Lens",
|
|
||||||
"Lamp",
|
|
||||||
"Hymnal",
|
|
||||||
"Fleece",
|
|
||||||
"Laurel",
|
|
||||||
"Brooch",
|
|
||||||
"Gimlet",
|
|
||||||
"Cobble",
|
|
||||||
"Albatross",
|
|
||||||
"Brazier",
|
|
||||||
"Bandolier",
|
|
||||||
"Tome",
|
|
||||||
"Garnet",
|
|
||||||
"Amethyst",
|
|
||||||
"Candelabra",
|
|
||||||
"Corset",
|
|
||||||
"Sphere",
|
|
||||||
"Sceptre",
|
|
||||||
"Ankh",
|
|
||||||
"Talisman",
|
|
||||||
"Orb",
|
|
||||||
"Gammel",
|
|
||||||
"Ornament",
|
|
||||||
"Brocade",
|
|
||||||
"Galoon",
|
|
||||||
"Bijou",
|
|
||||||
"Spangle",
|
|
||||||
"Gimcrack",
|
|
||||||
"Hood",
|
|
||||||
"Vulpeculum",
|
|
||||||
]
|
|
||||||
|
|
||||||
ITEM_ATTRIB = [
|
|
||||||
"Golden",
|
|
||||||
"Gilded",
|
|
||||||
"Spectral",
|
|
||||||
"Astral",
|
|
||||||
"Garlanded",
|
|
||||||
"Precious",
|
|
||||||
"Crafted",
|
|
||||||
"Dual",
|
|
||||||
"Filigreed",
|
|
||||||
"Cruciate",
|
|
||||||
"Arcane",
|
|
||||||
"Blessed",
|
|
||||||
"Reverential",
|
|
||||||
"Lucky",
|
|
||||||
"Enchanted",
|
|
||||||
"Gleaming",
|
|
||||||
"Grandiose",
|
|
||||||
"Sacred",
|
|
||||||
"Legendary",
|
|
||||||
"Mythic",
|
|
||||||
"Crystalline",
|
|
||||||
"Austere",
|
|
||||||
"Ostentatious",
|
|
||||||
"One True",
|
|
||||||
"Proverbial",
|
|
||||||
"Fearsome",
|
|
||||||
"Deadly",
|
|
||||||
"Benevolent",
|
|
||||||
"Unearthly",
|
|
||||||
"Magnificent",
|
|
||||||
"Iron",
|
|
||||||
"Ormolu",
|
|
||||||
"Puissant",
|
|
||||||
]
|
|
||||||
|
|
||||||
ITEM_OFS = [
|
|
||||||
"Foreboding",
|
|
||||||
"Foreshadowing",
|
|
||||||
"Nervousness",
|
|
||||||
"Happiness",
|
|
||||||
"Torpor",
|
|
||||||
"Danger",
|
|
||||||
"Craft",
|
|
||||||
"Silence",
|
|
||||||
"Invisibility",
|
|
||||||
"Rapidity",
|
|
||||||
"Pleasure",
|
|
||||||
"Practicality",
|
|
||||||
"Hurting",
|
|
||||||
"Joy",
|
|
||||||
"Petulance",
|
|
||||||
"Intrusion",
|
|
||||||
"Chaos",
|
|
||||||
"Suffering",
|
|
||||||
"Extroversion",
|
|
||||||
"Frenzy",
|
|
||||||
"Sisu",
|
|
||||||
"Solitude",
|
|
||||||
"Punctuality",
|
|
||||||
"Efficiency",
|
|
||||||
"Comfort",
|
|
||||||
"Patience",
|
|
||||||
"Internment",
|
|
||||||
"Incarceration",
|
|
||||||
"Misapprehension",
|
|
||||||
"Loyalty",
|
|
||||||
"Envy",
|
|
||||||
"Acrimony",
|
|
||||||
"Worry",
|
|
||||||
"Fear",
|
|
||||||
"Awe",
|
|
||||||
"Guile",
|
|
||||||
"Prurience",
|
|
||||||
"Fortune",
|
|
||||||
"Perspicacity",
|
|
||||||
"Domination",
|
|
||||||
"Submission",
|
|
||||||
"Fealty",
|
|
||||||
"Hunger",
|
|
||||||
"Despair",
|
|
||||||
"Cruelty",
|
|
||||||
"Grob",
|
|
||||||
"Dignard",
|
|
||||||
"Ra",
|
|
||||||
"the Bone",
|
|
||||||
"Diamonique",
|
|
||||||
"Electrum",
|
|
||||||
"Hydragyrum",
|
|
||||||
]
|
|
||||||
|
|
||||||
BORING_ITEMS = [
|
|
||||||
"nail",
|
|
||||||
"lunchpail",
|
|
||||||
"sock",
|
|
||||||
"I.O.U.",
|
|
||||||
"cookie",
|
|
||||||
"pint",
|
|
||||||
"toothpick",
|
|
||||||
"writ",
|
|
||||||
"newspaper",
|
|
||||||
"letter",
|
|
||||||
"plank",
|
|
||||||
"hat",
|
|
||||||
"egg",
|
|
||||||
"coin",
|
|
||||||
"needle",
|
|
||||||
"bucket",
|
|
||||||
"ladder",
|
|
||||||
"chicken",
|
|
||||||
"twig",
|
|
||||||
"dirtclod",
|
|
||||||
"counterpane",
|
|
||||||
"vest",
|
|
||||||
"teratoma",
|
|
||||||
"bunny",
|
|
||||||
"rock",
|
|
||||||
"pole",
|
|
||||||
"carrot",
|
|
||||||
"canoe",
|
|
||||||
"inkwell",
|
|
||||||
"hoe",
|
|
||||||
"bandage",
|
|
||||||
"trowel",
|
|
||||||
"towel",
|
|
||||||
"planter box",
|
|
||||||
"anvil",
|
|
||||||
"axle",
|
|
||||||
"tuppence",
|
|
||||||
"casket",
|
|
||||||
"nosegay",
|
|
||||||
"trinket",
|
|
||||||
"credenza",
|
|
||||||
"writ",
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Monster:
|
class Monster:
|
||||||
name: str
|
name: str
|
||||||
level: int
|
level: int
|
||||||
item: T.Optional[str]
|
item: str | None
|
||||||
|
|
||||||
|
|
||||||
MONSTERS = [
|
|
||||||
Monster("Anhkheg", 6, "chitin"),
|
|
||||||
Monster("Ant", 0, "antenna"),
|
|
||||||
Monster("Ape", 4, "ass"),
|
|
||||||
Monster("Baluchitherium", 14, "ear"),
|
|
||||||
Monster("Beholder", 10, "eyestalk"),
|
|
||||||
Monster("Black Pudding", 10, "saliva"),
|
|
||||||
Monster("Blink Dog", 4, "eyelid"),
|
|
||||||
Monster("Cub Scout", 1, "neckerchief"),
|
|
||||||
Monster("Girl Scout", 2, "cookie"),
|
|
||||||
Monster("Boy Scout", 3, "merit badge"),
|
|
||||||
Monster("Eagle Scout", 4, "merit badge"),
|
|
||||||
Monster("Bugbear", 3, "skin"),
|
|
||||||
Monster("Bugboar", 3, "tusk"),
|
|
||||||
Monster("Boogie", 3, "slime"),
|
|
||||||
Monster("Camel", 2, "hump"),
|
|
||||||
Monster("Carrion Crawler", 3, "egg"),
|
|
||||||
Monster("Catoblepas", 6, "neck"),
|
|
||||||
Monster("Centaur", 4, "rib"),
|
|
||||||
Monster("Centipede", 0, "leg"),
|
|
||||||
Monster("Cockatrice", 5, "wattle"),
|
|
||||||
Monster("Couatl", 9, "wing"),
|
|
||||||
Monster("Crayfish", 0, "antenna"),
|
|
||||||
Monster("Demogorgon", 53, "tentacle"),
|
|
||||||
Monster("Jubilex", 17, "gel"),
|
|
||||||
Monster("Manes", 1, "tooth"),
|
|
||||||
Monster("Orcus", 27, "wand"),
|
|
||||||
Monster("Succubus", 6, "bra"),
|
|
||||||
Monster("Vrock", 8, "neck"),
|
|
||||||
Monster("Hezrou", 9, "leg"),
|
|
||||||
Monster("Glabrezu", 10, "collar"),
|
|
||||||
Monster("Nalfeshnee", 11, "tusk"),
|
|
||||||
Monster("Marilith", 7, "arm"),
|
|
||||||
Monster("Balor", 8, "whip"),
|
|
||||||
Monster("Yeenoghu", 25, "flail"),
|
|
||||||
Monster("Asmodeus", 52, "leathers"),
|
|
||||||
Monster("Baalzebul", 43, "pants"),
|
|
||||||
Monster("Barbed Devil", 8, "flame"),
|
|
||||||
Monster("Bone Devil", 9, "hook"),
|
|
||||||
Monster("Dispater", 30, "matches"),
|
|
||||||
Monster("Erinyes", 6, "thong"),
|
|
||||||
Monster("Geryon", 30, "cornucopia"),
|
|
||||||
Monster("Malebranche", 5, "fork"),
|
|
||||||
Monster("Ice Devil", 11, "snow"),
|
|
||||||
Monster("Lemure", 3, "blob"),
|
|
||||||
Monster("Pit Fiend", 13, "seed"),
|
|
||||||
Monster("Ankylosaurus", 9, "tail"),
|
|
||||||
Monster("Brontosaurus", 30, "brain"),
|
|
||||||
Monster("Diplodocus", 24, "fin"),
|
|
||||||
Monster("Elasmosaurus", 15, "neck"),
|
|
||||||
Monster("Gorgosaurus", 13, "arm"),
|
|
||||||
Monster("Iguanadon", 6, "thumb"),
|
|
||||||
Monster("Megalosaurus", 12, "jaw"),
|
|
||||||
Monster("Monoclonius", 8, "horn"),
|
|
||||||
Monster("Pentasaurus", 12, "head"),
|
|
||||||
Monster("Stegosaurus", 18, "plate"),
|
|
||||||
Monster("Triceratops", 16, "horn"),
|
|
||||||
Monster("Tyranosauraus Rex", 18, "forearm"),
|
|
||||||
Monster("Djinn", 7, "lamp"),
|
|
||||||
Monster("Doppleganger", 4, "face"),
|
|
||||||
Monster("Black Dragon", 7, None),
|
|
||||||
Monster("Plaid Dragon", 7, "sporrin"),
|
|
||||||
Monster("Blue Dragon", 9, None),
|
|
||||||
Monster("Beige Dragon", 9, None),
|
|
||||||
Monster("Brass Dragon", 7, "pole"),
|
|
||||||
Monster("Tin Dragon", 8, None),
|
|
||||||
Monster("Bronze Dragon", 9, "medal"),
|
|
||||||
Monster("Chromatic Dragon", 16, "scale"),
|
|
||||||
Monster("Copper Dragon", 8, "loafer"),
|
|
||||||
Monster("Gold Dragon", 8, "filling"),
|
|
||||||
Monster("Green Dragon", 8, None),
|
|
||||||
Monster("Platinum Dragon", 21, None),
|
|
||||||
Monster("Red Dragon", 10, "cocktail"),
|
|
||||||
Monster("Silver Dragon", 10, None),
|
|
||||||
Monster("White Dragon", 6, "tooth"),
|
|
||||||
Monster("Dragon Turtle", 13, "shell"),
|
|
||||||
Monster("Dryad", 2, "acorn"),
|
|
||||||
Monster("Dwarf", 1, "drawers"),
|
|
||||||
Monster("Eel", 2, "sashimi"),
|
|
||||||
Monster("Efreet", 10, "cinder"),
|
|
||||||
Monster("Sand Elemental", 8, "glass"),
|
|
||||||
Monster("Bacon Elemental", 10, "bit"),
|
|
||||||
Monster("Porn Elemental", 12, "lube"),
|
|
||||||
Monster("Cheese Elemental", 14, "curd"),
|
|
||||||
Monster("Hair Elemental", 16, "follicle"),
|
|
||||||
Monster("Swamp Elf", 1, "lilypad"),
|
|
||||||
Monster("Brown Elf", 1, "tusk"),
|
|
||||||
Monster("Sea Elf", 1, "jerkin"),
|
|
||||||
Monster("Ettin", 10, "fur"),
|
|
||||||
Monster("Frog", 0, "leg"),
|
|
||||||
Monster("Violet Fungi", 3, "spore"),
|
|
||||||
Monster("Gargoyle", 4, "gravel"),
|
|
||||||
Monster("Gelatinous Cube", 4, "jam"),
|
|
||||||
Monster("Ghast", 4, "vomit"),
|
|
||||||
Monster("Ghost", 10, None),
|
|
||||||
Monster("Ghoul", 2, "muscle"),
|
|
||||||
Monster("Humidity Giant", 12, "drops"),
|
|
||||||
Monster("Beef Giant", 11, "steak"),
|
|
||||||
Monster("Quartz Giant", 10, "crystal"),
|
|
||||||
Monster("Porcelain Giant", 9, "fixture"),
|
|
||||||
Monster("Rice Giant", 8, "grain"),
|
|
||||||
Monster("Cloud Giant", 12, "condensation"),
|
|
||||||
Monster("Fire Giant", 11, "cigarettes"),
|
|
||||||
Monster("Frost Giant", 10, "snowman"),
|
|
||||||
Monster("Hill Giant", 8, "corpse"),
|
|
||||||
Monster("Stone Giant", 9, "hatchling"),
|
|
||||||
Monster("Storm Giant", 15, "barometer"),
|
|
||||||
Monster("Mini Giant", 4, "pompadour"),
|
|
||||||
Monster("Gnoll", 2, "collar"),
|
|
||||||
Monster("Gnome", 1, "hat"),
|
|
||||||
Monster("Goblin", 1, "ear"),
|
|
||||||
Monster("Grid Bug", 1, "carapace"),
|
|
||||||
Monster("Jellyrock", 9, "seedling"),
|
|
||||||
Monster("Beer Golem", 15, "foam"),
|
|
||||||
Monster("Oxygen Golem", 17, "platelet"),
|
|
||||||
Monster("Cardboard Golem", 14, "recycling"),
|
|
||||||
Monster("Rubber Golem", 16, "ball"),
|
|
||||||
Monster("Leather Golem", 15, "fob"),
|
|
||||||
Monster("Gorgon", 8, "testicle"),
|
|
||||||
Monster("Gray Ooze", 3, "gravy"),
|
|
||||||
Monster("Green Slime", 2, "sample"),
|
|
||||||
Monster("Griffon", 7, "nest"),
|
|
||||||
Monster("Banshee", 7, "larynx"),
|
|
||||||
Monster("Harpy", 3, "mascara"),
|
|
||||||
Monster("Hell Hound", 5, "tongue"),
|
|
||||||
Monster("Hippocampus", 4, "mane"),
|
|
||||||
Monster("Hippogriff", 3, "egg"),
|
|
||||||
Monster("Hobgoblin", 1, "patella"),
|
|
||||||
Monster("Homonculus", 2, "fluid"),
|
|
||||||
Monster("Hydra", 8, "gyrum"),
|
|
||||||
Monster("Imp", 2, "tail"),
|
|
||||||
Monster("Invisible Stalker", 8, None),
|
|
||||||
Monster("Iron Peasant", 3, "chaff"),
|
|
||||||
Monster("Jumpskin", 3, "shin"),
|
|
||||||
Monster("Kobold", 1, "penis"),
|
|
||||||
Monster("Leprechaun", 1, "wallet"),
|
|
||||||
Monster("Leucrotta", 6, "hoof"),
|
|
||||||
Monster("Lich", 11, "crown"),
|
|
||||||
Monster("Lizard Man", 2, "tail"),
|
|
||||||
Monster("Lurker", 10, "sac"),
|
|
||||||
Monster("Manticore", 6, "spike"),
|
|
||||||
Monster("Mastodon", 12, "tusk"),
|
|
||||||
Monster("Medusa", 6, "eye"),
|
|
||||||
Monster("Multicell", 2, "dendrite"),
|
|
||||||
Monster("Pirate", 1, "booty"),
|
|
||||||
Monster("Berserker", 1, "shirt"),
|
|
||||||
Monster("Caveman", 2, "club"),
|
|
||||||
Monster("Dervish", 1, "robe"),
|
|
||||||
Monster("Merman", 1, "trident"),
|
|
||||||
Monster("Mermaid", 1, "gills"),
|
|
||||||
Monster("Mimic", 9, "hinge"),
|
|
||||||
Monster("Mind Flayer", 8, "tentacle"),
|
|
||||||
Monster("Minotaur", 6, "map"),
|
|
||||||
Monster("Yellow Mold", 1, "spore"),
|
|
||||||
Monster("Morkoth", 7, "teeth"),
|
|
||||||
Monster("Mummy", 6, "gauze"),
|
|
||||||
Monster("Naga", 9, "rattle"),
|
|
||||||
Monster("Nebbish", 1, "belly"),
|
|
||||||
Monster("Neo-Otyugh", 11, "organ "),
|
|
||||||
Monster("Nixie", 1, "webbing"),
|
|
||||||
Monster("Nymph", 3, "hanky"),
|
|
||||||
Monster("Ochre Jelly", 6, "nucleus"),
|
|
||||||
Monster("Octopus", 2, "beak"),
|
|
||||||
Monster("Ogre", 4, "talon"),
|
|
||||||
Monster("Ogre Mage", 5, "apparel"),
|
|
||||||
Monster("Orc", 1, "snout"),
|
|
||||||
Monster("Otyugh", 7, "organ"),
|
|
||||||
Monster("Owlbear", 5, "feather"),
|
|
||||||
Monster("Pegasus", 4, "aileron"),
|
|
||||||
Monster("Peryton", 4, "antler"),
|
|
||||||
Monster("Piercer", 3, "tip"),
|
|
||||||
Monster("Pixie", 1, "dust"),
|
|
||||||
Monster("Man-o-war", 3, "tentacle"),
|
|
||||||
Monster("Purple Worm", 15, "dung"),
|
|
||||||
Monster("Quasit", 3, "tail"),
|
|
||||||
Monster("Rakshasa", 7, "pajamas"),
|
|
||||||
Monster("Rat", 0, "tail"),
|
|
||||||
Monster("Remorhaz", 11, "protrusion"),
|
|
||||||
Monster("Roc", 18, "wing"),
|
|
||||||
Monster("Roper", 11, "twine"),
|
|
||||||
Monster("Rot Grub", 1, "eggsac"),
|
|
||||||
Monster("Rust Monster", 5, "shavings"),
|
|
||||||
Monster("Satyr", 5, "hoof"),
|
|
||||||
Monster("Sea Hag", 3, "wart"),
|
|
||||||
Monster("Silkie", 3, "fur"),
|
|
||||||
Monster("Shadow", 3, "silhouette"),
|
|
||||||
Monster("Shambling Mound", 10, "mulch"),
|
|
||||||
Monster("Shedu", 9, "hoof"),
|
|
||||||
Monster("Shrieker", 3, "stalk"),
|
|
||||||
Monster("Skeleton", 1, "clavicle"),
|
|
||||||
Monster("Spectre", 7, "vestige"),
|
|
||||||
Monster("Sphinx", 10, "paw"),
|
|
||||||
Monster("Spider", 0, "web"),
|
|
||||||
Monster("Sprite", 1, "can"),
|
|
||||||
Monster("Stirge", 1, "proboscis"),
|
|
||||||
Monster("Stun Bear", 5, "tooth"),
|
|
||||||
Monster("Stun Worm", 2, "trode"),
|
|
||||||
Monster("Su-monster", 5, "tail"),
|
|
||||||
Monster("Sylph", 3, "thigh"),
|
|
||||||
Monster("Titan", 20, "sandal"),
|
|
||||||
Monster("Trapper", 12, "shag"),
|
|
||||||
Monster("Treant", 10, "acorn"),
|
|
||||||
Monster("Triton", 3, "scale"),
|
|
||||||
Monster("Troglodyte", 2, "tail"),
|
|
||||||
Monster("Troll", 6, "hide"),
|
|
||||||
Monster("Umber Hulk", 8, "claw"),
|
|
||||||
Monster("Unicorn", 4, "blood"),
|
|
||||||
Monster("Vampire", 8, "pancreas"),
|
|
||||||
Monster("Wight", 4, "lung"),
|
|
||||||
Monster("Will-o'-the-Wisp", 9, "wisp"),
|
|
||||||
Monster("Wraith", 5, "finger"),
|
|
||||||
Monster("Wyvern", 7, "wing"),
|
|
||||||
Monster("Xorn", 7, "jaw"),
|
|
||||||
Monster("Yeti", 4, "fur"),
|
|
||||||
Monster("Zombie", 2, "forehead"),
|
|
||||||
Monster("Wasp", 0, "stinger"),
|
|
||||||
Monster("Rat", 1, "tail"),
|
|
||||||
Monster("Bunny", 0, "ear"),
|
|
||||||
Monster("Moth", 0, "dust"),
|
|
||||||
Monster("Beagle", 0, "collar"),
|
|
||||||
Monster("Midge", 0, "corpse"),
|
|
||||||
Monster("Ostrich", 1, "beak"),
|
|
||||||
Monster("Billy Goat", 1, "beard"),
|
|
||||||
Monster("Bat", 1, "wing"),
|
|
||||||
Monster("Koala", 2, "heart"),
|
|
||||||
Monster("Wolf", 2, "paw"),
|
|
||||||
Monster("Whippet", 2, "collar"),
|
|
||||||
Monster("Uruk", 2, "boot"),
|
|
||||||
Monster("Poroid", 4, "node"),
|
|
||||||
Monster("Moakum", 8, "frenum"),
|
|
||||||
Monster("Fly", 0, None),
|
|
||||||
Monster("Hogbird", 3, "curl"),
|
|
||||||
Monster("Wolog", 4, "lemma"),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Race:
|
class Race:
|
||||||
name: str
|
name: str
|
||||||
attr: T.List[StatType]
|
attr: list[StatType]
|
||||||
|
|
||||||
|
|
||||||
RACES = [
|
|
||||||
Race("Half Orc", [StatType.hp_max]),
|
|
||||||
Race("Half Man", [StatType.charisma]),
|
|
||||||
Race("Half Halfling", [StatType.dexterity]),
|
|
||||||
Race("Double Hobbit", [StatType.strength]),
|
|
||||||
Race("Hob-Hobbit", [StatType.dexterity, StatType.condition]),
|
|
||||||
Race("Low Elf", [StatType.condition]),
|
|
||||||
Race("Dung Elf", [StatType.wisdom]),
|
|
||||||
Race("Talking Pony", [StatType.mp_max, StatType.intelligence]),
|
|
||||||
Race("Gyrognome", [StatType.dexterity]),
|
|
||||||
Race("Lesser Dwarf", [StatType.condition]),
|
|
||||||
Race("Crested Dwarf", [StatType.charisma]),
|
|
||||||
Race("Eel Man", [StatType.dexterity]),
|
|
||||||
Race("Panda Man", [StatType.condition, StatType.strength]),
|
|
||||||
Race("Trans-Kobold", [StatType.wisdom]),
|
|
||||||
Race("Enchanted Motorcycle", [StatType.mp_max]),
|
|
||||||
Race("Will o' the Wisp", [StatType.wisdom]),
|
|
||||||
Race("Battle-Finch", [StatType.dexterity, StatType.intelligence]),
|
|
||||||
Race("Double Wookiee", [StatType.strength]),
|
|
||||||
Race("Skraeling", [StatType.wisdom]),
|
|
||||||
Race("Demicanadian", [StatType.condition]),
|
|
||||||
Race("Land Squid", [StatType.strength, StatType.hp_max]),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class Class:
|
class Class:
|
||||||
name: str
|
name: str
|
||||||
attr: T.List[StatType]
|
attr: list[StatType]
|
||||||
|
|
||||||
|
|
||||||
CLASSES = [
|
|
||||||
Class("Ur-Paladin", [StatType.wisdom, StatType.condition]),
|
|
||||||
Class("Voodoo Princess", [StatType.intelligence, StatType.charisma]),
|
|
||||||
Class("Robot Monk", [StatType.strength]),
|
|
||||||
Class("Mu-Fu Monk", [StatType.dexterity]),
|
|
||||||
Class("Mage Illusioner", [StatType.intelligence, StatType.mp_max]),
|
|
||||||
Class("Shiv-Knight", [StatType.dexterity]),
|
|
||||||
Class("Inner Mason", [StatType.condition]),
|
|
||||||
Class("Fighter/Organist", [StatType.charisma, StatType.strength]),
|
|
||||||
Class("Puma Burgular", [StatType.dexterity]),
|
|
||||||
Class("Runeloremaster", [StatType.wisdom]),
|
|
||||||
Class("Hunter Strangler", [StatType.dexterity, StatType.intelligence]),
|
|
||||||
Class("Battle-Felon", [StatType.strength]),
|
|
||||||
Class("Tickle-Mimic", [StatType.wisdom, StatType.intelligence]),
|
|
||||||
Class("Slow Poisoner", [StatType.condition]),
|
|
||||||
Class("Bastard Lunatic", [StatType.condition]),
|
|
||||||
Class("Lowling", [StatType.wisdom]),
|
|
||||||
Class("Birdrider", [StatType.wisdom]),
|
|
||||||
Class("Vermineer", [StatType.intelligence]),
|
|
||||||
]
|
|
||||||
|
|
||||||
TITLES = [
|
|
||||||
"Mr.",
|
|
||||||
"Mrs.",
|
|
||||||
"Sir",
|
|
||||||
"Sgt.",
|
|
||||||
"Ms.",
|
|
||||||
"Captain",
|
|
||||||
"Chief",
|
|
||||||
"Admiral",
|
|
||||||
"Saint",
|
|
||||||
]
|
|
||||||
|
|
||||||
IMPRESSIVE_TITLES = [
|
|
||||||
"King",
|
|
||||||
"Queen",
|
|
||||||
"Lord",
|
|
||||||
"Lady",
|
|
||||||
"Viceroy",
|
|
||||||
"Mayor",
|
|
||||||
"Prince",
|
|
||||||
"Princess",
|
|
||||||
"Chief",
|
|
||||||
"Boss",
|
|
||||||
"Archbishop",
|
|
||||||
]
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
[
|
||||||
|
{"name": "Lace", "quality": 1},
|
||||||
|
{"name": "Macrame", "quality": 2},
|
||||||
|
{"name": "Burlap", "quality": 3},
|
||||||
|
{"name": "Canvas", "quality": 4},
|
||||||
|
{"name": "Flannel", "quality": 5},
|
||||||
|
{"name": "Chamois", "quality": 6},
|
||||||
|
{"name": "Pleathers", "quality": 7},
|
||||||
|
{"name": "Leathers", "quality": 8},
|
||||||
|
{"name": "Bearskin", "quality": 9},
|
||||||
|
{"name": "Ringmail", "quality": 10},
|
||||||
|
{"name": "Scale Mail", "quality": 12},
|
||||||
|
{"name": "Chainmail", "quality": 14},
|
||||||
|
{"name": "Splint Mail", "quality": 15},
|
||||||
|
{"name": "Platemail", "quality": 16},
|
||||||
|
{"name": "ABS", "quality": 17},
|
||||||
|
{"name": "Kevlar", "quality": 18},
|
||||||
|
{"name": "Titanium", "quality": 19},
|
||||||
|
{"name": "Mithril Mail", "quality": 20},
|
||||||
|
{"name": "Diamond Mail", "quality": 25},
|
||||||
|
{"name": "Plasma", "quality": 30}
|
||||||
|
]
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
[
|
||||||
|
"nail",
|
||||||
|
"lunchpail",
|
||||||
|
"sock",
|
||||||
|
"I.O.U.",
|
||||||
|
"cookie",
|
||||||
|
"pint",
|
||||||
|
"toothpick",
|
||||||
|
"writ",
|
||||||
|
"newspaper",
|
||||||
|
"letter",
|
||||||
|
"plank",
|
||||||
|
"hat",
|
||||||
|
"egg",
|
||||||
|
"coin",
|
||||||
|
"needle",
|
||||||
|
"bucket",
|
||||||
|
"ladder",
|
||||||
|
"chicken",
|
||||||
|
"twig",
|
||||||
|
"dirtclod",
|
||||||
|
"counterpane",
|
||||||
|
"vest",
|
||||||
|
"teratoma",
|
||||||
|
"bunny",
|
||||||
|
"rock",
|
||||||
|
"pole",
|
||||||
|
"carrot",
|
||||||
|
"canoe",
|
||||||
|
"inkwell",
|
||||||
|
"hoe",
|
||||||
|
"bandage",
|
||||||
|
"trowel",
|
||||||
|
"towel",
|
||||||
|
"planter box",
|
||||||
|
"anvil",
|
||||||
|
"axle",
|
||||||
|
"tuppence",
|
||||||
|
"casket",
|
||||||
|
"nosegay",
|
||||||
|
"trinket",
|
||||||
|
"credenza",
|
||||||
|
"writ"
|
||||||
|
]
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[
|
||||||
|
{"name": "Ur-Paladin", "attr": ["WIS", "CON"]},
|
||||||
|
{"name": "Voodoo Princess", "attr": ["INT", "CHA"]},
|
||||||
|
{"name": "Robot Monk", "attr": ["STR"]},
|
||||||
|
{"name": "Mu-Fu Monk", "attr": ["DEX"]},
|
||||||
|
{"name": "Mage Illusioner", "attr": ["INT", "MP Max"]},
|
||||||
|
{"name": "Shiv-Knight", "attr": ["DEX"]},
|
||||||
|
{"name": "Inner Mason", "attr": ["CON"]},
|
||||||
|
{"name": "Fighter/Organist", "attr": ["CHA", "STR"]},
|
||||||
|
{"name": "Puma Burgular", "attr": ["DEX"]},
|
||||||
|
{"name": "Runeloremaster", "attr": ["WIS"]},
|
||||||
|
{"name": "Hunter Strangler", "attr": ["DEX", "INT"]},
|
||||||
|
{"name": "Battle-Felon", "attr": ["STR"]},
|
||||||
|
{"name": "Tickle-Mimic", "attr": ["WIS", "INT"]},
|
||||||
|
{"name": "Slow Poisoner", "attr": ["CON"]},
|
||||||
|
{"name": "Bastard Lunatic", "attr": ["CON"]},
|
||||||
|
{"name": "Lowling", "attr": ["WIS"]},
|
||||||
|
{"name": "Birdrider", "attr": ["WIS"]},
|
||||||
|
{"name": "Vermineer", "attr": ["INT"]}
|
||||||
|
]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[
|
||||||
|
"Executing {target}"
|
||||||
|
]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{"name": "Studded", "quality": 1},
|
||||||
|
{"name": "Banded", "quality": 2},
|
||||||
|
{"name": "Gilded", "quality": 2},
|
||||||
|
{"name": "Festooned", "quality": 3},
|
||||||
|
{"name": "Holy", "quality": 4},
|
||||||
|
{"name": "Cambric", "quality": 1},
|
||||||
|
{"name": "Fine", "quality": 4},
|
||||||
|
{"name": "Impressive", "quality": 5},
|
||||||
|
{"name": "Custom", "quality": 3}
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{"name": "Holey", "quality": -1},
|
||||||
|
{"name": "Patched", "quality": -1},
|
||||||
|
{"name": "Threadbare", "quality": -2},
|
||||||
|
{"name": "Faded", "quality": -1},
|
||||||
|
{"name": "Rusty", "quality": -3},
|
||||||
|
{"name": "Motheaten", "quality": -3},
|
||||||
|
{"name": "Mildewed", "quality": -2},
|
||||||
|
{"name": "Torn", "quality": -3},
|
||||||
|
{"name": "Dented", "quality": -3},
|
||||||
|
{"name": "Cursed", "quality": -5},
|
||||||
|
{"name": "Plastic", "quality": -4},
|
||||||
|
{"name": "Cracked", "quality": -4},
|
||||||
|
{"name": "Warped", "quality": -3},
|
||||||
|
{"name": "Corroded", "quality": -3}
|
||||||
|
]
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
"King",
|
||||||
|
"Queen",
|
||||||
|
"Lord",
|
||||||
|
"Lady",
|
||||||
|
"Viceroy",
|
||||||
|
"Mayor",
|
||||||
|
"Prince",
|
||||||
|
"Princess",
|
||||||
|
"Chief",
|
||||||
|
"Boss",
|
||||||
|
"Archbishop",
|
||||||
|
"Chancellor",
|
||||||
|
"Baroness",
|
||||||
|
"Inquistor"
|
||||||
|
]
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
[
|
||||||
|
"Golden",
|
||||||
|
"Gilded",
|
||||||
|
"Spectral",
|
||||||
|
"Astral",
|
||||||
|
"Garlanded",
|
||||||
|
"Precious",
|
||||||
|
"Crafted",
|
||||||
|
"Dual",
|
||||||
|
"Filigreed",
|
||||||
|
"Cruciate",
|
||||||
|
"Arcane",
|
||||||
|
"Blessed",
|
||||||
|
"Reverential",
|
||||||
|
"Lucky",
|
||||||
|
"Enchanted",
|
||||||
|
"Gleaming",
|
||||||
|
"Grandiose",
|
||||||
|
"Sacred",
|
||||||
|
"Legendary",
|
||||||
|
"Mythic",
|
||||||
|
"Crystalline",
|
||||||
|
"Austere",
|
||||||
|
"Ostentatious",
|
||||||
|
"One True",
|
||||||
|
"Proverbial",
|
||||||
|
"Fearsome",
|
||||||
|
"Deadly",
|
||||||
|
"Benevolent",
|
||||||
|
"Unearthly",
|
||||||
|
"Magnificent",
|
||||||
|
"Iron",
|
||||||
|
"Ormolu",
|
||||||
|
"Puissant"
|
||||||
|
]
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
[
|
||||||
|
"Foreboding",
|
||||||
|
"Foreshadowing",
|
||||||
|
"Nervousness",
|
||||||
|
"Happiness",
|
||||||
|
"Torpor",
|
||||||
|
"Danger",
|
||||||
|
"Craft",
|
||||||
|
"Silence",
|
||||||
|
"Invisibility",
|
||||||
|
"Rapidity",
|
||||||
|
"Pleasure",
|
||||||
|
"Practicality",
|
||||||
|
"Hurting",
|
||||||
|
"Joy",
|
||||||
|
"Petulance",
|
||||||
|
"Intrusion",
|
||||||
|
"Chaos",
|
||||||
|
"Suffering",
|
||||||
|
"Extroversion",
|
||||||
|
"Frenzy",
|
||||||
|
"Sisu",
|
||||||
|
"Solitude",
|
||||||
|
"Punctuality",
|
||||||
|
"Efficiency",
|
||||||
|
"Comfort",
|
||||||
|
"Patience",
|
||||||
|
"Internment",
|
||||||
|
"Incarceration",
|
||||||
|
"Misapprehension",
|
||||||
|
"Loyalty",
|
||||||
|
"Envy",
|
||||||
|
"Acrimony",
|
||||||
|
"Worry",
|
||||||
|
"Fear",
|
||||||
|
"Awe",
|
||||||
|
"Guile",
|
||||||
|
"Prurience",
|
||||||
|
"Fortune",
|
||||||
|
"Perspicacity",
|
||||||
|
"Domination",
|
||||||
|
"Submission",
|
||||||
|
"Fealty",
|
||||||
|
"Hunger",
|
||||||
|
"Despair",
|
||||||
|
"Cruelty",
|
||||||
|
"Grob",
|
||||||
|
"Dignard",
|
||||||
|
"Ra",
|
||||||
|
"the Bone",
|
||||||
|
"Diamonique",
|
||||||
|
"Electrum",
|
||||||
|
"Hydragyrum"
|
||||||
|
]
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
[
|
||||||
|
{"name": "Anhkheg", "level": 6, "item": "chitin"},
|
||||||
|
{"name": "Ant", "level": 0, "item": "antenna"},
|
||||||
|
{"name": "Ape", "level": 4, "item": "ass"},
|
||||||
|
{"name": "Baluchitherium", "level": 14, "item": "ear"},
|
||||||
|
{"name": "Beholder", "level": 10, "item": "eyestalk"},
|
||||||
|
{"name": "Black Pudding", "level": 10, "item": "saliva"},
|
||||||
|
{"name": "Blink Dog", "level": 4, "item": "eyelid"},
|
||||||
|
{"name": "Cub Scout", "level": 1, "item": "neckerchief"},
|
||||||
|
{"name": "Girl Scout", "level": 2, "item": "cookie"},
|
||||||
|
{"name": "Boy Scout", "level": 3, "item": "merit badge"},
|
||||||
|
{"name": "Eagle Scout", "level": 4, "item": "merit badge"},
|
||||||
|
{"name": "Bugbear", "level": 3, "item": "skin"},
|
||||||
|
{"name": "Bugboar", "level": 3, "item": "tusk"},
|
||||||
|
{"name": "Boogie", "level": 3, "item": "slime"},
|
||||||
|
{"name": "Camel", "level": 2, "item": "hump"},
|
||||||
|
{"name": "Carrion Crawler", "level": 3, "item": "egg"},
|
||||||
|
{"name": "Catoblepas", "level": 6, "item": "neck"},
|
||||||
|
{"name": "Centaur", "level": 4, "item": "rib"},
|
||||||
|
{"name": "Centipede", "level": 0, "item": "leg"},
|
||||||
|
{"name": "Cockatrice", "level": 5, "item": "wattle"},
|
||||||
|
{"name": "Couatl", "level": 9, "item": "wing"},
|
||||||
|
{"name": "Crayfish", "level": 0, "item": "antenna"},
|
||||||
|
{"name": "Demogorgon", "level": 53, "item": "tentacle"},
|
||||||
|
{"name": "Jubilex", "level": 17, "item": "gel"},
|
||||||
|
{"name": "Manes", "level": 1, "item": "tooth"},
|
||||||
|
{"name": "Orcus", "level": 27, "item": "wand"},
|
||||||
|
{"name": "Succubus", "level": 6, "item": "bra"},
|
||||||
|
{"name": "Vrock", "level": 8, "item": "neck"},
|
||||||
|
{"name": "Hezrou", "level": 9, "item": "leg"},
|
||||||
|
{"name": "Glabrezu", "level": 10, "item": "collar"},
|
||||||
|
{"name": "Nalfeshnee", "level": 11, "item": "tusk"},
|
||||||
|
{"name": "Marilith", "level": 7, "item": "arm"},
|
||||||
|
{"name": "Balor", "level": 8, "item": "whip"},
|
||||||
|
{"name": "Yeenoghu", "level": 25, "item": "flail"},
|
||||||
|
{"name": "Asmodeus", "level": 52, "item": "leathers"},
|
||||||
|
{"name": "Baalzebul", "level": 43, "item": "pants"},
|
||||||
|
{"name": "Barbed Devil", "level": 8, "item": "flame"},
|
||||||
|
{"name": "Bone Devil", "level": 9, "item": "hook"},
|
||||||
|
{"name": "Dispater", "level": 30, "item": "matches"},
|
||||||
|
{"name": "Erinyes", "level": 6, "item": "thong"},
|
||||||
|
{"name": "Geryon", "level": 30, "item": "cornucopia"},
|
||||||
|
{"name": "Malebranche", "level": 5, "item": "fork"},
|
||||||
|
{"name": "Ice Devil", "level": 11, "item": "snow"},
|
||||||
|
{"name": "Lemure", "level": 3, "item": "blob"},
|
||||||
|
{"name": "Pit Fiend", "level": 13, "item": "seed"},
|
||||||
|
{"name": "Ankylosaurus", "level": 9, "item": "tail"},
|
||||||
|
{"name": "Brontosaurus", "level": 30, "item": "brain"},
|
||||||
|
{"name": "Diplodocus", "level": 24, "item": "fin"},
|
||||||
|
{"name": "Elasmosaurus", "level": 15, "item": "neck"},
|
||||||
|
{"name": "Gorgosaurus", "level": 13, "item": "arm"},
|
||||||
|
{"name": "Iguanadon", "level": 6, "item": "thumb"},
|
||||||
|
{"name": "Megalosaurus", "level": 12, "item": "jaw"},
|
||||||
|
{"name": "Monoclonius", "level": 8, "item": "horn"},
|
||||||
|
{"name": "Pentasaurus", "level": 12, "item": "head"},
|
||||||
|
{"name": "Stegosaurus", "level": 18, "item": "plate"},
|
||||||
|
{"name": "Triceratops", "level": 16, "item": "horn"},
|
||||||
|
{"name": "Tyrannosaurus Rex", "level": 18, "item": "forearm"},
|
||||||
|
{"name": "Djinn", "level": 7, "item": "lamp"},
|
||||||
|
{"name": "Doppelganger", "level": 4, "item": "face"},
|
||||||
|
{"name": "Black Dragon", "level": 7, "item": null},
|
||||||
|
{"name": "Plaid Dragon", "level": 7, "item": "sporrin"},
|
||||||
|
{"name": "Blue Dragon", "level": 9, "item": null},
|
||||||
|
{"name": "Beige Dragon", "level": 9, "item": null},
|
||||||
|
{"name": "Brass Dragon", "level": 7, "item": "pole"},
|
||||||
|
{"name": "Tin Dragon", "level": 8, "item": null},
|
||||||
|
{"name": "Bronze Dragon", "level": 9, "item": "medal"},
|
||||||
|
{"name": "Chromatic Dragon", "level": 16, "item": "scale"},
|
||||||
|
{"name": "Copper Dragon", "level": 8, "item": "loafer"},
|
||||||
|
{"name": "Gold Dragon", "level": 8, "item": "filling"},
|
||||||
|
{"name": "Green Dragon", "level": 8, "item": null},
|
||||||
|
{"name": "Platinum Dragon", "level": 21, "item": null},
|
||||||
|
{"name": "Red Dragon", "level": 10, "item": "cocktail"},
|
||||||
|
{"name": "Silver Dragon", "level": 10, "item": null},
|
||||||
|
{"name": "White Dragon", "level": 6, "item": "tooth"},
|
||||||
|
{"name": "Dragon Turtle", "level": 13, "item": "shell"},
|
||||||
|
{"name": "Dryad", "level": 2, "item": "acorn"},
|
||||||
|
{"name": "Dwarf", "level": 1, "item": "drawers"},
|
||||||
|
{"name": "Eel", "level": 2, "item": "sashimi"},
|
||||||
|
{"name": "Efreet", "level": 10, "item": "cinder"},
|
||||||
|
{"name": "Sand Elemental", "level": 8, "item": "glass"},
|
||||||
|
{"name": "Bacon Elemental", "level": 10, "item": "bit"},
|
||||||
|
{"name": "Porn Elemental", "level": 12, "item": "lube"},
|
||||||
|
{"name": "Cheese Elemental", "level": 14, "item": "curd"},
|
||||||
|
{"name": "Hair Elemental", "level": 16, "item": "follicle"},
|
||||||
|
{"name": "Swamp Elf", "level": 1, "item": "lilypad"},
|
||||||
|
{"name": "Brown Elf", "level": 1, "item": "tusk"},
|
||||||
|
{"name": "Sea Elf", "level": 1, "item": "jerkin"},
|
||||||
|
{"name": "Ettin", "level": 10, "item": "fur"},
|
||||||
|
{"name": "Frog", "level": 0, "item": "leg"},
|
||||||
|
{"name": "Violet Fungi", "level": 3, "item": "spore"},
|
||||||
|
{"name": "Gargoyle", "level": 4, "item": "gravel"},
|
||||||
|
{"name": "Gelatinous Cube", "level": 4, "item": "jam"},
|
||||||
|
{"name": "Ghast", "level": 4, "item": "vomit"},
|
||||||
|
{"name": "Ghost", "level": 10, "item": null},
|
||||||
|
{"name": "Ghoul", "level": 2, "item": "muscle"},
|
||||||
|
{"name": "Humidity Giant", "level": 12, "item": "drops"},
|
||||||
|
{"name": "Beef Giant", "level": 11, "item": "steak"},
|
||||||
|
{"name": "Quartz Giant", "level": 10, "item": "crystal"},
|
||||||
|
{"name": "Porcelain Giant", "level": 9, "item": "fixture"},
|
||||||
|
{"name": "Rice Giant", "level": 8, "item": "grain"},
|
||||||
|
{"name": "Cloud Giant", "level": 12, "item": "condensation"},
|
||||||
|
{"name": "Fire Giant", "level": 11, "item": "cigarettes"},
|
||||||
|
{"name": "Frost Giant", "level": 10, "item": "snowman"},
|
||||||
|
{"name": "Hill Giant", "level": 8, "item": "corpse"},
|
||||||
|
{"name": "Stone Giant", "level": 9, "item": "hatchling"},
|
||||||
|
{"name": "Storm Giant", "level": 15, "item": "barometer"},
|
||||||
|
{"name": "Mini Giant", "level": 4, "item": "pompadour"},
|
||||||
|
{"name": "Gnoll", "level": 2, "item": "collar"},
|
||||||
|
{"name": "Gnome", "level": 1, "item": "hat"},
|
||||||
|
{"name": "Goblin", "level": 1, "item": "ear"},
|
||||||
|
{"name": "Grid Bug", "level": 1, "item": "carapace"},
|
||||||
|
{"name": "Jellyrock", "level": 9, "item": "seedling"},
|
||||||
|
{"name": "Beer Golem", "level": 15, "item": "foam"},
|
||||||
|
{"name": "Oxygen Golem", "level": 17, "item": "platelet"},
|
||||||
|
{"name": "Cardboard Golem", "level": 14, "item": "recycling"},
|
||||||
|
{"name": "Rubber Golem", "level": 16, "item": "ball"},
|
||||||
|
{"name": "Leather Golem", "level": 15, "item": "fob"},
|
||||||
|
{"name": "Gorgon", "level": 8, "item": "testicle"},
|
||||||
|
{"name": "Gray Ooze", "level": 3, "item": "gravy"},
|
||||||
|
{"name": "Green Slime", "level": 2, "item": "sample"},
|
||||||
|
{"name": "Griffon", "level": 7, "item": "nest"},
|
||||||
|
{"name": "Banshee", "level": 7, "item": "larynx"},
|
||||||
|
{"name": "Harpy", "level": 3, "item": "mascara"},
|
||||||
|
{"name": "Hell Hound", "level": 5, "item": "tongue"},
|
||||||
|
{"name": "Hippocampus", "level": 4, "item": "mane"},
|
||||||
|
{"name": "Hippogriff", "level": 3, "item": "egg"},
|
||||||
|
{"name": "Hobgoblin", "level": 1, "item": "patella"},
|
||||||
|
{"name": "Homunculus", "level": 2, "item": "fluid"},
|
||||||
|
{"name": "Hydra", "level": 8, "item": "gyrum"},
|
||||||
|
{"name": "Imp", "level": 2, "item": "tail"},
|
||||||
|
{"name": "Invisible Stalker", "level": 8, "item": null},
|
||||||
|
{"name": "Iron Peasant", "level": 3, "item": "chaff"},
|
||||||
|
{"name": "Jumpskin", "level": 3, "item": "shin"},
|
||||||
|
{"name": "Kobold", "level": 1, "item": "penis"},
|
||||||
|
{"name": "Leprechaun", "level": 1, "item": "wallet"},
|
||||||
|
{"name": "Leucrotta", "level": 6, "item": "hoof"},
|
||||||
|
{"name": "Lich", "level": 11, "item": "crown"},
|
||||||
|
{"name": "Lizard Man", "level": 2, "item": "tail"},
|
||||||
|
{"name": "Lurker", "level": 10, "item": "sac"},
|
||||||
|
{"name": "Manticore", "level": 6, "item": "spike"},
|
||||||
|
{"name": "Mastodon", "level": 12, "item": "tusk"},
|
||||||
|
{"name": "Medusa", "level": 6, "item": "eye"},
|
||||||
|
{"name": "Multicell", "level": 2, "item": "dendrite"},
|
||||||
|
{"name": "Pirate", "level": 1, "item": "booty"},
|
||||||
|
{"name": "Berserker", "level": 1, "item": "shirt"},
|
||||||
|
{"name": "Caveman", "level": 2, "item": "club"},
|
||||||
|
{"name": "Dervish", "level": 1, "item": "robe"},
|
||||||
|
{"name": "Merman", "level": 1, "item": "trident"},
|
||||||
|
{"name": "Mermaid", "level": 1, "item": "gills"},
|
||||||
|
{"name": "Mimic", "level": 9, "item": "hinge"},
|
||||||
|
{"name": "Mind Flayer", "level": 8, "item": "tentacle"},
|
||||||
|
{"name": "Minotaur", "level": 6, "item": "map"},
|
||||||
|
{"name": "Yellow Mold", "level": 1, "item": "spore"},
|
||||||
|
{"name": "Morkoth", "level": 7, "item": "teeth"},
|
||||||
|
{"name": "Mummy", "level": 6, "item": "gauze"},
|
||||||
|
{"name": "Naga", "level": 9, "item": "rattle"},
|
||||||
|
{"name": "Nebbish", "level": 1, "item": "belly"},
|
||||||
|
{"name": "Neo-Otyugh", "level": 11, "item": "organ "},
|
||||||
|
{"name": "Nixie", "level": 1, "item": "webbing"},
|
||||||
|
{"name": "Nymph", "level": 3, "item": "hanky"},
|
||||||
|
{"name": "Ochre Jelly", "level": 6, "item": "nucleus"},
|
||||||
|
{"name": "Octopus", "level": 2, "item": "beak"},
|
||||||
|
{"name": "Ogre", "level": 4, "item": "talon"},
|
||||||
|
{"name": "Ogre Mage", "level": 5, "item": "apparel"},
|
||||||
|
{"name": "Orc", "level": 1, "item": "snout"},
|
||||||
|
{"name": "Otyugh", "level": 7, "item": "organ"},
|
||||||
|
{"name": "Owlbear", "level": 5, "item": "feather"},
|
||||||
|
{"name": "Pegasus", "level": 4, "item": "aileron"},
|
||||||
|
{"name": "Peryton", "level": 4, "item": "antler"},
|
||||||
|
{"name": "Piercer", "level": 3, "item": "tip"},
|
||||||
|
{"name": "Pixie", "level": 1, "item": "dust"},
|
||||||
|
{"name": "Man-o-war", "level": 3, "item": "tentacle"},
|
||||||
|
{"name": "Purple Worm", "level": 15, "item": "dung"},
|
||||||
|
{"name": "Quasit", "level": 3, "item": "tail"},
|
||||||
|
{"name": "Rakshasa", "level": 7, "item": "pajamas"},
|
||||||
|
{"name": "Rat", "level": 0, "item": "tail"},
|
||||||
|
{"name": "Remorhaz", "level": 11, "item": "protrusion"},
|
||||||
|
{"name": "Roc", "level": 18, "item": "wing"},
|
||||||
|
{"name": "Roper", "level": 11, "item": "twine"},
|
||||||
|
{"name": "Rot Grub", "level": 1, "item": "eggsac"},
|
||||||
|
{"name": "Rust Monster", "level": 5, "item": "shavings"},
|
||||||
|
{"name": "Satyr", "level": 5, "item": "hoof"},
|
||||||
|
{"name": "Sea Hag", "level": 3, "item": "wart"},
|
||||||
|
{"name": "Silkie", "level": 3, "item": "fur"},
|
||||||
|
{"name": "Shadow", "level": 3, "item": "silhouette"},
|
||||||
|
{"name": "Shambling Mound", "level": 10, "item": "mulch"},
|
||||||
|
{"name": "Shedu", "level": 9, "item": "hoof"},
|
||||||
|
{"name": "Shrieker", "level": 3, "item": "stalk"},
|
||||||
|
{"name": "Skeleton", "level": 1, "item": "clavicle"},
|
||||||
|
{"name": "Spectre", "level": 7, "item": "vestige"},
|
||||||
|
{"name": "Sphinx", "level": 10, "item": "paw"},
|
||||||
|
{"name": "Spider", "level": 0, "item": "web"},
|
||||||
|
{"name": "Sprite", "level": 1, "item": "can"},
|
||||||
|
{"name": "Stirge", "level": 1, "item": "proboscis"},
|
||||||
|
{"name": "Stun Bear", "level": 5, "item": "tooth"},
|
||||||
|
{"name": "Stun Worm", "level": 2, "item": "trode"},
|
||||||
|
{"name": "Su-monster", "level": 5, "item": "tail"},
|
||||||
|
{"name": "Sylph", "level": 3, "item": "thigh"},
|
||||||
|
{"name": "Titan", "level": 20, "item": "sandal"},
|
||||||
|
{"name": "Trapper", "level": 12, "item": "shag"},
|
||||||
|
{"name": "Treant", "level": 10, "item": "acorn"},
|
||||||
|
{"name": "Triton", "level": 3, "item": "scale"},
|
||||||
|
{"name": "Troglodyte", "level": 2, "item": "tail"},
|
||||||
|
{"name": "Troll", "level": 6, "item": "hide"},
|
||||||
|
{"name": "Umber Hulk", "level": 8, "item": "claw"},
|
||||||
|
{"name": "Unicorn", "level": 4, "item": "blood"},
|
||||||
|
{"name": "Vampire", "level": 8, "item": "pancreas"},
|
||||||
|
{"name": "Wight", "level": 4, "item": "lung"},
|
||||||
|
{"name": "Will-o'-the-Wisp", "level": 9, "item": "wisp"},
|
||||||
|
{"name": "Wraith", "level": 5, "item": "finger"},
|
||||||
|
{"name": "Wyvern", "level": 7, "item": "wing"},
|
||||||
|
{"name": "Xorn", "level": 7, "item": "jaw"},
|
||||||
|
{"name": "Yeti", "level": 4, "item": "fur"},
|
||||||
|
{"name": "Zombie", "level": 2, "item": "forehead"},
|
||||||
|
{"name": "Wasp", "level": 0, "item": "stinger"},
|
||||||
|
{"name": "Rat", "level": 1, "item": "tail"},
|
||||||
|
{"name": "Bunny", "level": 0, "item": "ear"},
|
||||||
|
{"name": "Moth", "level": 0, "item": "dust"},
|
||||||
|
{"name": "Beagle", "level": 0, "item": "collar"},
|
||||||
|
{"name": "Midge", "level": 0, "item": "corpse"},
|
||||||
|
{"name": "Ostrich", "level": 1, "item": "beak"},
|
||||||
|
{"name": "Billy Goat", "level": 1, "item": "beard"},
|
||||||
|
{"name": "Bat", "level": 1, "item": "wing"},
|
||||||
|
{"name": "Koala", "level": 2, "item": "heart"},
|
||||||
|
{"name": "Wolf", "level": 2, "item": "paw"},
|
||||||
|
{"name": "Whippet", "level": 2, "item": "collar"},
|
||||||
|
{"name": "Uruk", "level": 2, "item": "boot"},
|
||||||
|
{"name": "Poroid", "level": 4, "item": "node"},
|
||||||
|
{"name": "Moakum", "level": 8, "item": "frenum"},
|
||||||
|
{"name": "Fly", "level": 0, "item": null},
|
||||||
|
{"name": "Hogbird", "level": 3, "item": "curl"},
|
||||||
|
{"name": "Wolog", "level": 4, "item": "lemma"}
|
||||||
|
]
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[
|
||||||
|
{"name": "Polished", "quality": 1},
|
||||||
|
{"name": "Serrated", "quality": 1},
|
||||||
|
{"name": "Heavy", "quality": 1},
|
||||||
|
{"name": "Pronged", "quality": 2},
|
||||||
|
{"name": "Steely", "quality": 2},
|
||||||
|
{"name": "Vicious", "quality": 3},
|
||||||
|
{"name": "Venomed", "quality": 4},
|
||||||
|
{"name": "Stabbity", "quality": 4},
|
||||||
|
{"name": "Dancing", "quality": 5},
|
||||||
|
{"name": "Invisible", "quality": 6},
|
||||||
|
{"name": "Vorpal", "quality": 7}
|
||||||
|
]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
{"name": "Dull", "quality": -2},
|
||||||
|
{"name": "Tarnished", "quality": -1},
|
||||||
|
{"name": "Rusty", "quality": -3},
|
||||||
|
{"name": "Padded", "quality": -5},
|
||||||
|
{"name": "Bent", "quality": -4},
|
||||||
|
{"name": "Mini", "quality": -4},
|
||||||
|
{"name": "Rubber", "quality": -6},
|
||||||
|
{"name": "Nerf", "quality": -7},
|
||||||
|
{"name": "Unbalanced", "quality": -2}
|
||||||
|
]
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
[
|
||||||
|
{"name": "Half Orc", "attr": ["HP Max"]},
|
||||||
|
{"name": "Half Man", "attr": ["CHA"]},
|
||||||
|
{"name": "Half Halfling", "attr": ["DEX"]},
|
||||||
|
{"name": "Double Hobbit", "attr": ["STR"]},
|
||||||
|
{"name": "Hob-Hobbit", "attr": ["DEX", "CON"]},
|
||||||
|
{"name": "Low Elf", "attr": ["CON"]},
|
||||||
|
{"name": "Dung Elf", "attr": ["WIS"]},
|
||||||
|
{"name": "Talking Pony", "attr": ["MP Max", "INT"]},
|
||||||
|
{"name": "Gyrognome", "attr": ["DEX"]},
|
||||||
|
{"name": "Lesser Dwarf", "attr": ["CON"]},
|
||||||
|
{"name": "Crested Dwarf", "attr": ["CHA"]},
|
||||||
|
{"name": "Eel Man", "attr": ["DEX"]},
|
||||||
|
{"name": "Panda Man", "attr": ["CON", "STR"]},
|
||||||
|
{"name": "Trans-Kobold", "attr": ["WIS"]},
|
||||||
|
{"name": "Enchanted Motorcycle", "attr": ["MP Max"]},
|
||||||
|
{"name": "Will o' the Wisp", "attr": ["WIS"]},
|
||||||
|
{"name": "Battle-Finch", "attr": ["DEX", "INT"]},
|
||||||
|
{"name": "Double Wookiee", "attr": ["STR"]},
|
||||||
|
{"name": "Skraeling", "attr": ["WIS"]},
|
||||||
|
{"name": "Demicanadian", "attr": ["CON"]},
|
||||||
|
{"name": "Land Squid", "attr": ["STR", "HP Max"]}
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
[
|
||||||
|
{"name": "Parasol", "quality": 0},
|
||||||
|
{"name": "Pie Plate", "quality": 1},
|
||||||
|
{"name": "Garbage Can Lid", "quality": 2},
|
||||||
|
{"name": "Buckler", "quality": 3},
|
||||||
|
{"name": "Plexiglass", "quality": 4},
|
||||||
|
{"name": "Fender", "quality": 4},
|
||||||
|
{"name": "Round Shield", "quality": 5},
|
||||||
|
{"name": "Carapace", "quality": 5},
|
||||||
|
{"name": "Scutum", "quality": 6},
|
||||||
|
{"name": "Propugner", "quality": 6},
|
||||||
|
{"name": "Kite Shield", "quality": 7},
|
||||||
|
{"name": "Pavise", "quality": 8},
|
||||||
|
{"name": "Tower Shield", "quality": 9},
|
||||||
|
{"name": "Baroque Shield", "quality": 11},
|
||||||
|
{"name": "Aegis", "quality": 12},
|
||||||
|
{"name": "Magnetic Field", "quality": 18}
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
[
|
||||||
|
"Diadem",
|
||||||
|
"Festoon",
|
||||||
|
"Gemstone",
|
||||||
|
"Phial",
|
||||||
|
"Tiara",
|
||||||
|
"Scabbard",
|
||||||
|
"Arrow",
|
||||||
|
"Lens",
|
||||||
|
"Lamp",
|
||||||
|
"Hymnal",
|
||||||
|
"Fleece",
|
||||||
|
"Laurel",
|
||||||
|
"Brooch",
|
||||||
|
"Gimlet",
|
||||||
|
"Cobble",
|
||||||
|
"Albatross",
|
||||||
|
"Brazier",
|
||||||
|
"Bandolier",
|
||||||
|
"Tome",
|
||||||
|
"Garnet",
|
||||||
|
"Amethyst",
|
||||||
|
"Candelabra",
|
||||||
|
"Corset",
|
||||||
|
"Sphere",
|
||||||
|
"Sceptre",
|
||||||
|
"Ankh",
|
||||||
|
"Talisman",
|
||||||
|
"Orb",
|
||||||
|
"Gammel",
|
||||||
|
"Ornament",
|
||||||
|
"Brocade",
|
||||||
|
"Galoon",
|
||||||
|
"Bijou",
|
||||||
|
"Spangle",
|
||||||
|
"Gimcrack",
|
||||||
|
"Hood",
|
||||||
|
"Vulpeculum"
|
||||||
|
]
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
[
|
||||||
|
"Slime Finger",
|
||||||
|
"Rabbit Punch",
|
||||||
|
"Hastiness",
|
||||||
|
"Good Move",
|
||||||
|
"Sadness",
|
||||||
|
"Seasick",
|
||||||
|
"Shoelaces",
|
||||||
|
"Inoculate",
|
||||||
|
"Cone of Annoyance",
|
||||||
|
"Magnetic Orb",
|
||||||
|
"Invisible Hands",
|
||||||
|
"Revolting Cloud",
|
||||||
|
"Aqueous Humor",
|
||||||
|
"Spectral Miasma",
|
||||||
|
"Clever Fellow",
|
||||||
|
"Lockjaw",
|
||||||
|
"History Lesson",
|
||||||
|
"Hydrophobia",
|
||||||
|
"Big Sister",
|
||||||
|
"Cone of Paste",
|
||||||
|
"Mulligan",
|
||||||
|
"Nestor's Bright Idea",
|
||||||
|
"Holy Batpole",
|
||||||
|
"Tumor (Benign)",
|
||||||
|
"Braingate",
|
||||||
|
"Summon a Bitch",
|
||||||
|
"Nonplus",
|
||||||
|
"Animate Nightstand",
|
||||||
|
"Eye of the Troglodyte",
|
||||||
|
"Curse Name",
|
||||||
|
"Dropsy",
|
||||||
|
"Vitreous Humor",
|
||||||
|
"Roger's Grand Illusion",
|
||||||
|
"Covet",
|
||||||
|
"Black Idaho",
|
||||||
|
"Astral Miasma",
|
||||||
|
"Spectral Oyster",
|
||||||
|
"Acrid Hands",
|
||||||
|
"Angioplasty",
|
||||||
|
"Grognor's Big Day Off",
|
||||||
|
"Tumor (Malignant)",
|
||||||
|
"Animate Tunic",
|
||||||
|
"Ursine Armor",
|
||||||
|
"Holy Roller",
|
||||||
|
"Tonsillectomy",
|
||||||
|
"Curse Family",
|
||||||
|
"Infinite Confusion"
|
||||||
|
]
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
[
|
||||||
|
"Mr.",
|
||||||
|
"Mrs.",
|
||||||
|
"Sir",
|
||||||
|
"Sgt.",
|
||||||
|
"Ms.",
|
||||||
|
"Captain",
|
||||||
|
"Chief",
|
||||||
|
"Admiral",
|
||||||
|
"Saint"
|
||||||
|
]
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
[
|
||||||
|
{"name": "Stick", "quality": 0},
|
||||||
|
{"name": "Broken Bottle", "quality": 1},
|
||||||
|
{"name": "Shiv", "quality": 1},
|
||||||
|
{"name": "Sprig", "quality": 1},
|
||||||
|
{"name": "Oxgoad", "quality": 1},
|
||||||
|
{"name": "Eelspear", "quality": 2},
|
||||||
|
{"name": "Bowie Knife", "quality": 2},
|
||||||
|
{"name": "Claw Hammer", "quality": 2},
|
||||||
|
{"name": "Handpeen", "quality": 2},
|
||||||
|
{"name": "Andiron", "quality": 3},
|
||||||
|
{"name": "Hatchet", "quality": 3},
|
||||||
|
{"name": "Tomahawk", "quality": 3},
|
||||||
|
{"name": "Hackbarm", "quality": 3},
|
||||||
|
{"name": "Crowbar", "quality": 4},
|
||||||
|
{"name": "Mace", "quality": 4},
|
||||||
|
{"name": "Battleadze", "quality": 4},
|
||||||
|
{"name": "Leafmace", "quality": 5},
|
||||||
|
{"name": "Shortsword", "quality": 5},
|
||||||
|
{"name": "Longiron", "quality": 5},
|
||||||
|
{"name": "Poachard", "quality": 5},
|
||||||
|
{"name": "Baselard", "quality": 5},
|
||||||
|
{"name": "Whinyard", "quality": 6},
|
||||||
|
{"name": "Blunderbuss", "quality": 6},
|
||||||
|
{"name": "Longsword", "quality": 6},
|
||||||
|
{"name": "Crankbow", "quality": 6},
|
||||||
|
{"name": "Blibo", "quality": 7},
|
||||||
|
{"name": "Broadsword", "quality": 7},
|
||||||
|
{"name": "Kreen", "quality": 7},
|
||||||
|
{"name": "Warhammer", "quality": 7},
|
||||||
|
{"name": "Morning Star", "quality": 8},
|
||||||
|
{"name": "Pole-adze", "quality": 8},
|
||||||
|
{"name": "Spontoon", "quality": 8},
|
||||||
|
{"name": "Bastard Sword", "quality": 9},
|
||||||
|
{"name": "Peen-arm", "quality": 9},
|
||||||
|
{"name": "Culverin", "quality": 10},
|
||||||
|
{"name": "Lance", "quality": 10},
|
||||||
|
{"name": "Halberd", "quality": 11},
|
||||||
|
{"name": "Poleax", "quality": 12},
|
||||||
|
{"name": "Bandyclef", "quality": 15}
|
||||||
|
]
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from collections.abc import Callable, Sequence
|
||||||
|
from dataclasses import dataclass, fields
|
||||||
|
from importlib import resources
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pqcli.config import (
|
||||||
|
Class,
|
||||||
|
EquipmentPreset,
|
||||||
|
Modifier,
|
||||||
|
Monster,
|
||||||
|
Race,
|
||||||
|
StatType,
|
||||||
|
)
|
||||||
|
|
||||||
|
if sys.version_info >= (3, 11):
|
||||||
|
from importlib.resources.abc import Traversable
|
||||||
|
else:
|
||||||
|
from importlib.abc import Traversable
|
||||||
|
|
||||||
|
TARGET_PLACEHOLDER = "{target}"
|
||||||
|
|
||||||
|
|
||||||
|
class DataError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GameData:
|
||||||
|
spells: tuple[str, ...]
|
||||||
|
offense_attrib: tuple[Modifier, ...]
|
||||||
|
defense_attrib: tuple[Modifier, ...]
|
||||||
|
offense_bad: tuple[Modifier, ...]
|
||||||
|
defense_bad: tuple[Modifier, ...]
|
||||||
|
shields: tuple[EquipmentPreset, ...]
|
||||||
|
armors: tuple[EquipmentPreset, ...]
|
||||||
|
weapons: tuple[EquipmentPreset, ...]
|
||||||
|
specials: tuple[str, ...]
|
||||||
|
item_attrib: tuple[str, ...]
|
||||||
|
item_ofs: tuple[str, ...]
|
||||||
|
boring_items: tuple[str, ...]
|
||||||
|
monsters: tuple[Monster, ...]
|
||||||
|
races: tuple[Race, ...]
|
||||||
|
classes: tuple[Class, ...]
|
||||||
|
titles: tuple[str, ...]
|
||||||
|
impressive_titles: tuple[str, ...]
|
||||||
|
combat_verbs: tuple[str, ...]
|
||||||
|
|
||||||
|
|
||||||
|
DATA_FILES = tuple(field.name for field in fields(GameData))
|
||||||
|
|
||||||
|
|
||||||
|
class _Context:
|
||||||
|
def __init__(self, path: str) -> None:
|
||||||
|
self.path = path
|
||||||
|
|
||||||
|
def error(self, message: str) -> DataError:
|
||||||
|
return DataError(f"{self.path}: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_keys(
|
||||||
|
ctx: _Context, where: str, obj: object, keys: Sequence[str]
|
||||||
|
) -> dict[str, object]:
|
||||||
|
if not isinstance(obj, dict):
|
||||||
|
raise ctx.error(f"{where} must be an object")
|
||||||
|
missing = [key for key in keys if key not in obj]
|
||||||
|
if missing:
|
||||||
|
raise ctx.error(f"{where} is missing {', '.join(map(repr, missing))}")
|
||||||
|
extra = [key for key in obj if key not in keys]
|
||||||
|
if extra:
|
||||||
|
raise ctx.error(f"{where} has unknown {', '.join(map(repr, extra))}")
|
||||||
|
return obj
|
||||||
|
|
||||||
|
|
||||||
|
def _str(ctx: _Context, where: str, value: object) -> str:
|
||||||
|
if not isinstance(value, str):
|
||||||
|
raise ctx.error(f"{where} must be a string")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _int(ctx: _Context, where: str, value: object) -> int:
|
||||||
|
if isinstance(value, bool) or not isinstance(value, int):
|
||||||
|
raise ctx.error(f"{where} must be an integer")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _stat(ctx: _Context, where: str, value: object) -> StatType:
|
||||||
|
name = _str(ctx, where, value)
|
||||||
|
for stat in StatType:
|
||||||
|
if name.lower() in (stat.value.lower(), stat.name):
|
||||||
|
return stat
|
||||||
|
allowed = ", ".join(stat.value for stat in StatType)
|
||||||
|
raise ctx.error(f"{where}: unknown stat {value!r} (use one of {allowed})")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_str(ctx: _Context, where: str, item: object) -> str:
|
||||||
|
return _str(ctx, where, item)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_verb(ctx: _Context, where: str, item: object) -> str:
|
||||||
|
verb = _str(ctx, where, item)
|
||||||
|
if TARGET_PLACEHOLDER not in verb:
|
||||||
|
raise ctx.error(f"{where} must contain {TARGET_PLACEHOLDER}")
|
||||||
|
return verb
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_modifier(ctx: _Context, where: str, item: object) -> Modifier:
|
||||||
|
obj = _check_keys(ctx, where, item, ["name", "quality"])
|
||||||
|
return Modifier(
|
||||||
|
_str(ctx, f"{where}.name", obj["name"]),
|
||||||
|
_int(ctx, f"{where}.quality", obj["quality"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_preset(ctx: _Context, where: str, item: object) -> EquipmentPreset:
|
||||||
|
obj = _check_keys(ctx, where, item, ["name", "quality"])
|
||||||
|
return EquipmentPreset(
|
||||||
|
_str(ctx, f"{where}.name", obj["name"]),
|
||||||
|
_int(ctx, f"{where}.quality", obj["quality"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_monster(ctx: _Context, where: str, item: object) -> Monster:
|
||||||
|
obj = _check_keys(ctx, where, item, ["name", "level", "item"])
|
||||||
|
loot = obj["item"]
|
||||||
|
return Monster(
|
||||||
|
_str(ctx, f"{where}.name", obj["name"]),
|
||||||
|
_int(ctx, f"{where}.level", obj["level"]),
|
||||||
|
None if loot is None else _str(ctx, f"{where}.item", loot),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_attrs(ctx: _Context, where: str, value: object) -> list[StatType]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ctx.error(f"{where} must be a list of stats")
|
||||||
|
return [_stat(ctx, f"{where}[{i}]", stat) for i, stat in enumerate(value)]
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_race(ctx: _Context, where: str, item: object) -> Race:
|
||||||
|
obj = _check_keys(ctx, where, item, ["name", "attr"])
|
||||||
|
return Race(
|
||||||
|
_str(ctx, f"{where}.name", obj["name"]),
|
||||||
|
_parse_attrs(ctx, f"{where}.attr", obj["attr"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_class(ctx: _Context, where: str, item: object) -> Class:
|
||||||
|
obj = _check_keys(ctx, where, item, ["name", "attr"])
|
||||||
|
return Class(
|
||||||
|
_str(ctx, f"{where}.name", obj["name"]),
|
||||||
|
_parse_attrs(ctx, f"{where}.attr", obj["attr"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
_PARSERS: dict[str, Callable[[_Context, str, object], object]] = {
|
||||||
|
"spells": _parse_str,
|
||||||
|
"offense_attrib": _parse_modifier,
|
||||||
|
"defense_attrib": _parse_modifier,
|
||||||
|
"offense_bad": _parse_modifier,
|
||||||
|
"defense_bad": _parse_modifier,
|
||||||
|
"shields": _parse_preset,
|
||||||
|
"armors": _parse_preset,
|
||||||
|
"weapons": _parse_preset,
|
||||||
|
"specials": _parse_str,
|
||||||
|
"item_attrib": _parse_str,
|
||||||
|
"item_ofs": _parse_str,
|
||||||
|
"boring_items": _parse_str,
|
||||||
|
"monsters": _parse_monster,
|
||||||
|
"races": _parse_race,
|
||||||
|
"classes": _parse_class,
|
||||||
|
"titles": _parse_str,
|
||||||
|
"impressive_titles": _parse_str,
|
||||||
|
"combat_verbs": _parse_verb,
|
||||||
|
}
|
||||||
|
assert set(_PARSERS) == set(DATA_FILES)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse(name: str, path: str, text: str) -> tuple[object, ...]:
|
||||||
|
ctx = _Context(path)
|
||||||
|
try:
|
||||||
|
value = json.loads(text)
|
||||||
|
except json.JSONDecodeError as ex:
|
||||||
|
raise ctx.error(f"invalid JSON: {ex}") from ex
|
||||||
|
if not isinstance(value, list):
|
||||||
|
raise ctx.error("must be a list")
|
||||||
|
if not value:
|
||||||
|
raise ctx.error("must not be empty")
|
||||||
|
parser = _PARSERS[name]
|
||||||
|
return tuple(parser(ctx, f"[{i}]", item) for i, item in enumerate(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _builtin_dir() -> Traversable:
|
||||||
|
return resources.files("pqcli") / "data"
|
||||||
|
|
||||||
|
|
||||||
|
def _read_text(path: Traversable, display: str) -> str:
|
||||||
|
try:
|
||||||
|
return path.read_text(encoding="utf-8")
|
||||||
|
except UnicodeDecodeError as ex:
|
||||||
|
raise DataError(f"{display}: not valid UTF-8: {ex}") from ex
|
||||||
|
except OSError as ex:
|
||||||
|
raise DataError(f"{display}: cannot read: {ex}") from ex
|
||||||
|
|
||||||
|
|
||||||
|
def load_game_data(override_dir: Path | None = None) -> GameData:
|
||||||
|
"""Load the built-in word lists, replacing any that override_dir has.
|
||||||
|
|
||||||
|
A missing override_dir is fine. Each <name>.json in it replaces the
|
||||||
|
whole built-in list of that name.
|
||||||
|
"""
|
||||||
|
overrides: set[str] = set()
|
||||||
|
if override_dir is not None and override_dir.exists():
|
||||||
|
if not override_dir.is_dir():
|
||||||
|
raise DataError(f"{override_dir}: not a directory")
|
||||||
|
for path in sorted(override_dir.glob("*.json")):
|
||||||
|
if path.stem in DATA_FILES:
|
||||||
|
overrides.add(path.stem)
|
||||||
|
else:
|
||||||
|
print(
|
||||||
|
f"pqcli: ignoring {path}: not a known data file "
|
||||||
|
f"(known: {', '.join(DATA_FILES)})",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
|
||||||
|
# The element type of each tuple depends on the GameData field it
|
||||||
|
# fills, so it is only checked when GameData receives it.
|
||||||
|
values: dict[str, tuple[Any, ...]] = {}
|
||||||
|
for name in DATA_FILES:
|
||||||
|
if name in overrides:
|
||||||
|
assert override_dir is not None
|
||||||
|
source: Traversable = override_dir / f"{name}.json"
|
||||||
|
display = str(source)
|
||||||
|
else:
|
||||||
|
source = _builtin_dir() / f"{name}.json"
|
||||||
|
display = f"<built-in>/{name}.json"
|
||||||
|
values[name] = _parse(name, display, _read_text(source, display))
|
||||||
|
return GameData(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def dump_builtin_data(target_dir: Path) -> list[Path]:
|
||||||
|
"""Copy the built-in data files to target_dir, never overwriting."""
|
||||||
|
targets = [target_dir / f"{name}.json" for name in DATA_FILES]
|
||||||
|
existing = [path for path in targets if path.exists()]
|
||||||
|
if existing:
|
||||||
|
raise DataError(
|
||||||
|
"refusing to overwrite "
|
||||||
|
+ ", ".join(str(path) for path in existing)
|
||||||
|
)
|
||||||
|
target_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for name, path in zip(DATA_FILES, targets):
|
||||||
|
source = _builtin_dir() / f"{name}.json"
|
||||||
|
path.write_text(
|
||||||
|
_read_text(source, f"<built-in>/{name}.json"), encoding="utf-8"
|
||||||
|
)
|
||||||
|
return targets
|
||||||
+71
-3
@@ -1,8 +1,29 @@
|
|||||||
import typing as T
|
import datetime
|
||||||
|
|
||||||
from pqcli import random
|
from pqcli import random
|
||||||
|
|
||||||
|
|
||||||
|
def format_float(num: float) -> str:
|
||||||
|
ret = f"{num:.01f}"
|
||||||
|
if ret.endswith("0"):
|
||||||
|
ret = ret[:-2]
|
||||||
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def format_timespan(timespan: datetime.timedelta) -> str:
|
||||||
|
num = timespan.total_seconds()
|
||||||
|
if num < 60.0:
|
||||||
|
return f"~{int(num)}s"
|
||||||
|
num /= 60
|
||||||
|
if num < 60.0:
|
||||||
|
return f"~{int(num)}m"
|
||||||
|
num /= 60
|
||||||
|
if num < 24.0:
|
||||||
|
return f"~{format_float(num)}h"
|
||||||
|
num /= 24
|
||||||
|
return f"~{format_float(num)}d"
|
||||||
|
|
||||||
|
|
||||||
def generate_name() -> str:
|
def generate_name() -> str:
|
||||||
parts = [
|
parts = [
|
||||||
"br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z".split("|"),
|
"br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z".split("|"),
|
||||||
@@ -33,6 +54,11 @@ def to_roman(num: int) -> str:
|
|||||||
ret = "-"
|
ret = "-"
|
||||||
num = -num
|
num = -num
|
||||||
|
|
||||||
|
while _rome(10000, "T"):
|
||||||
|
pass
|
||||||
|
_rome(9000, "MT")
|
||||||
|
_rome(5000, "A")
|
||||||
|
_rome(4000, "MA")
|
||||||
while _rome(1000, "M"):
|
while _rome(1000, "M"):
|
||||||
pass
|
pass
|
||||||
_rome(900, "CM")
|
_rome(900, "CM")
|
||||||
@@ -53,6 +79,43 @@ def to_roman(num: int) -> str:
|
|||||||
return ret
|
return ret
|
||||||
|
|
||||||
|
|
||||||
|
def to_arabic(roman: str) -> int:
|
||||||
|
num = 0
|
||||||
|
rest = roman.upper()
|
||||||
|
|
||||||
|
def _arab(ds: str, dn: int) -> bool:
|
||||||
|
nonlocal num, rest
|
||||||
|
if not rest.startswith(ds):
|
||||||
|
return False
|
||||||
|
rest = rest[len(ds) :]
|
||||||
|
num += dn
|
||||||
|
return True
|
||||||
|
|
||||||
|
while _arab("T", 10000):
|
||||||
|
pass
|
||||||
|
_arab("MT", 9000)
|
||||||
|
_arab("A", 5000)
|
||||||
|
_arab("MA", 4000)
|
||||||
|
while _arab("M", 1000):
|
||||||
|
pass
|
||||||
|
_arab("CM", 900)
|
||||||
|
_arab("D", 500)
|
||||||
|
_arab("CD", 400)
|
||||||
|
while _arab("C", 100):
|
||||||
|
pass
|
||||||
|
_arab("XC", 90)
|
||||||
|
_arab("L", 50)
|
||||||
|
_arab("XL", 40)
|
||||||
|
while _arab("X", 10):
|
||||||
|
pass
|
||||||
|
_arab("IX", 9)
|
||||||
|
_arab("V", 5)
|
||||||
|
_arab("IV", 4)
|
||||||
|
while _arab("I", 1):
|
||||||
|
pass
|
||||||
|
return num
|
||||||
|
|
||||||
|
|
||||||
def act_name(act: int) -> str:
|
def act_name(act: int) -> str:
|
||||||
if act == 0:
|
if act == 0:
|
||||||
return "Prologue"
|
return "Prologue"
|
||||||
@@ -75,7 +138,7 @@ def plural(subject: str) -> str:
|
|||||||
|
|
||||||
def indefinite(subject: str, qty: int) -> str:
|
def indefinite(subject: str, qty: int) -> str:
|
||||||
if qty == 1:
|
if qty == 1:
|
||||||
if subject.startswith(tuple("AEIOU?aeiou?")):
|
if subject.startswith(tuple("AEIOUÜaeiouü")):
|
||||||
return "an " + subject
|
return "an " + subject
|
||||||
return "a " + subject
|
return "a " + subject
|
||||||
return str(qty) + " " + plural(subject)
|
return str(qty) + " " + plural(subject)
|
||||||
@@ -87,7 +150,7 @@ def definite(subject: str, qty: int) -> str:
|
|||||||
return "the " + subject
|
return "the " + subject
|
||||||
|
|
||||||
|
|
||||||
def prefix(a: T.List[str], m: int, subject: str, sep: str = " ") -> str:
|
def prefix(a: list[str], m: int, subject: str, sep: str = " ") -> str:
|
||||||
m = abs(m)
|
m = abs(m)
|
||||||
if m < 1 or m > len(a):
|
if m < 1 or m > len(a):
|
||||||
return subject
|
return subject
|
||||||
@@ -122,3 +185,8 @@ def special(m: int, subject: str) -> str:
|
|||||||
return prefix(
|
return prefix(
|
||||||
["Battle-", "cursed ", "Were-", "undead ", "demon "], m, subject, ""
|
["Battle-", "cursed ", "Were-", "undead ", "demon "], m, subject, ""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def terminate_message(player_name: str) -> str:
|
||||||
|
adjective = random.choice(["faithful", "noble", "loyal", "brave"])
|
||||||
|
return f"Terminate {adjective} {player_name}?"
|
||||||
+288
-153
@@ -1,40 +1,85 @@
|
|||||||
|
import contextlib
|
||||||
import datetime
|
import datetime
|
||||||
|
import functools
|
||||||
import itertools
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
import typing as T
|
import math
|
||||||
|
from collections import defaultdict
|
||||||
|
from collections.abc import Callable, Generator, Iterator, Sequence
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
import urwid
|
|
||||||
|
|
||||||
from pqcli import random
|
from pqcli import random
|
||||||
from pqcli.config import *
|
from pqcli.config import (
|
||||||
from pqcli.lingo import *
|
PRIME_STATS,
|
||||||
|
Class,
|
||||||
|
EquipmentPreset,
|
||||||
|
EquipmentType,
|
||||||
|
Modifier,
|
||||||
|
Monster,
|
||||||
|
Race,
|
||||||
|
StatType,
|
||||||
|
)
|
||||||
|
from pqcli.game_data import TARGET_PLACEHOLDER, GameData
|
||||||
|
from pqcli.lingo import (
|
||||||
|
act_name,
|
||||||
|
big,
|
||||||
|
definite,
|
||||||
|
generate_name,
|
||||||
|
indefinite,
|
||||||
|
plural,
|
||||||
|
sick,
|
||||||
|
special,
|
||||||
|
young,
|
||||||
|
)
|
||||||
|
from pqcli.random import Alea, RngState
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
_SIGNALS: dict[tuple["SignalMixin", str], list[Callable[..., object]]] = (
|
||||||
|
defaultdict(list)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SignalMixin:
|
class SignalMixin:
|
||||||
def __init_subclass__(cls: T.Any, **kwargs: T.Any) -> None:
|
_muted = 0
|
||||||
urwid.signals.register_signal(cls, cls.signals)
|
|
||||||
super().__init_subclass__(**kwargs)
|
|
||||||
|
|
||||||
def emit(self, signal_name: str, *user_data: T.Any) -> None:
|
@staticmethod
|
||||||
urwid.signals.emit_signal(self, signal_name, *user_data)
|
@contextlib.contextmanager
|
||||||
|
def muted() -> Generator[None, None, None]:
|
||||||
|
"""Suppress all signal emission for the duration of the block."""
|
||||||
|
SignalMixin._muted += 1
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
SignalMixin._muted -= 1
|
||||||
|
|
||||||
def connect(self, signal_name: str, callback: T.Callable) -> None:
|
def emit(self, signal_name: str, *user_data: object) -> None:
|
||||||
urwid.signals.connect_signal(self, signal_name, callback)
|
if SignalMixin._muted:
|
||||||
|
return
|
||||||
|
for callback in _SIGNALS[self, signal_name]:
|
||||||
|
callback(*user_data)
|
||||||
|
|
||||||
def __getstate__(self) -> T.Any:
|
def connect(
|
||||||
return {
|
self, signal_name: str, callback: Callable[..., object]
|
||||||
key: value
|
) -> None:
|
||||||
for key, value in self.__dict__.items()
|
_SIGNALS[self, signal_name].append(callback)
|
||||||
if "signal" not in key
|
|
||||||
}
|
def disconnect(
|
||||||
|
self, signal_name: str, callback: Callable[..., object]
|
||||||
|
) -> None:
|
||||||
|
try:
|
||||||
|
idx = _SIGNALS[self, signal_name].index(callback)
|
||||||
|
except ValueError:
|
||||||
|
return
|
||||||
|
del _SIGNALS[self, signal_name][idx]
|
||||||
|
|
||||||
|
|
||||||
def level_up_time(level: int) -> int:
|
def level_up_time(level: int) -> int:
|
||||||
# seconds
|
# seconds: 20 minutes for level 1, exponential increase after that.
|
||||||
return 20 * level * 60
|
# floor(x + 0.5) mirrors JS Math.round, unlike Python's round().
|
||||||
|
return math.floor((20 + 1.15**level) * 60 + 0.5)
|
||||||
|
|
||||||
|
|
||||||
class Bar(SignalMixin):
|
class Bar(SignalMixin):
|
||||||
@@ -46,9 +91,12 @@ class Bar(SignalMixin):
|
|||||||
self.emit("change")
|
self.emit("change")
|
||||||
|
|
||||||
def reset(self, new_max: int, position: float = 0.0) -> None:
|
def reset(self, new_max: int, position: float = 0.0) -> None:
|
||||||
self._max = new_max
|
if not math.isclose(new_max, self._max) or not math.isclose(
|
||||||
self._position = position
|
position, self._position
|
||||||
self.emit("change")
|
):
|
||||||
|
self._max = new_max
|
||||||
|
self._position = position
|
||||||
|
self.emit("change")
|
||||||
|
|
||||||
def increment(self, inc: float) -> None:
|
def increment(self, inc: float) -> None:
|
||||||
self.reposition(self._position + inc)
|
self.reposition(self._position + inc)
|
||||||
@@ -66,17 +114,20 @@ class Bar(SignalMixin):
|
|||||||
return self._position >= self.max_
|
return self._position >= self.max_
|
||||||
|
|
||||||
def reposition(self, new_pos: float) -> None:
|
def reposition(self, new_pos: float) -> None:
|
||||||
self._position = float(min(new_pos, self._max))
|
old_pos = self._position
|
||||||
self.emit("change")
|
new_pos = float(min(new_pos, self._max))
|
||||||
|
self._position = new_pos
|
||||||
|
if not math.isclose(new_pos, old_pos):
|
||||||
|
self.emit("change")
|
||||||
|
|
||||||
|
|
||||||
class Stats(SignalMixin):
|
class Stats(SignalMixin):
|
||||||
signals = ["change"]
|
signals = ["change"]
|
||||||
|
|
||||||
def __init__(self, values: T.Dict[StatType, int]) -> None:
|
def __init__(self, values: dict[StatType, int]) -> None:
|
||||||
self._values = values
|
self._values = values
|
||||||
|
|
||||||
def __iter__(self) -> T.Iterator[T.Tuple[StatType, int]]:
|
def __iter__(self) -> Iterator[tuple[StatType, int]]:
|
||||||
return iter(self._values.items())
|
return iter(self._values.items())
|
||||||
|
|
||||||
def __getitem__(self, stat: StatType) -> int:
|
def __getitem__(self, stat: StatType) -> int:
|
||||||
@@ -102,22 +153,22 @@ class QuestBook(SignalMixin):
|
|||||||
signals = ["start_act", "start_quest"]
|
signals = ["start_act", "start_quest"]
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._quests: T.List[str] = []
|
self._quests: list[str] = []
|
||||||
self._act = 0
|
self._act = 0
|
||||||
self.plot_bar = Bar(max_=1)
|
self.plot_bar = Bar(max_=1)
|
||||||
self.quest_bar = Bar(max_=1)
|
self.quest_bar = Bar(max_=1)
|
||||||
self.monster: T.Optional[Monster] = None
|
self.monster: Monster | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def act(self) -> int:
|
def act(self) -> int:
|
||||||
return self._act
|
return self._act
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def quests(self) -> T.List[str]:
|
def quests(self) -> list[str]:
|
||||||
return self._quests
|
return self._quests
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def current_quest(self) -> T.Optional[str]:
|
def current_quest(self) -> str | None:
|
||||||
if self._quests:
|
if self._quests:
|
||||||
return self._quests[-1]
|
return self._quests[-1]
|
||||||
return None
|
return None
|
||||||
@@ -128,7 +179,7 @@ class QuestBook(SignalMixin):
|
|||||||
|
|
||||||
def add_quest(self, name: str) -> None:
|
def add_quest(self, name: str) -> None:
|
||||||
logger.info("Commencing quest: %s", name)
|
logger.info("Commencing quest: %s", name)
|
||||||
self._quests = self._quests[-100:]
|
self._quests = self._quests[-99:]
|
||||||
self._quests.append(name)
|
self._quests.append(name)
|
||||||
|
|
||||||
|
|
||||||
@@ -143,7 +194,7 @@ class Inventory(SignalMixin):
|
|||||||
|
|
||||||
def __init__(self, capacity: int = 0) -> None:
|
def __init__(self, capacity: int = 0) -> None:
|
||||||
self._gold = 0
|
self._gold = 0
|
||||||
self._items: T.List[InventoryItem] = []
|
self._items: list[InventoryItem] = []
|
||||||
self.encum_bar = Bar(max_=capacity)
|
self.encum_bar = Bar(max_=capacity)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -153,7 +204,7 @@ class Inventory(SignalMixin):
|
|||||||
def __len__(self) -> int:
|
def __len__(self) -> int:
|
||||||
return len(self._items)
|
return len(self._items)
|
||||||
|
|
||||||
def __iter__(self) -> T.Iterator[InventoryItem]:
|
def __iter__(self) -> Iterator[InventoryItem]:
|
||||||
return iter(self._items)
|
return iter(self._items)
|
||||||
|
|
||||||
def __getitem__(self, idx: int) -> InventoryItem:
|
def __getitem__(self, idx: int) -> InventoryItem:
|
||||||
@@ -170,7 +221,7 @@ class Inventory(SignalMixin):
|
|||||||
|
|
||||||
def pop(self, index: int) -> None:
|
def pop(self, index: int) -> None:
|
||||||
item = self._items[index]
|
item = self._items[index]
|
||||||
logger.info("Lost %s (qty=%d)", item.name, item.quantity)
|
logger.info("Lost %s", indefinite(item.name, item.quantity))
|
||||||
self._items.pop(index)
|
self._items.pop(index)
|
||||||
self.sync_encumbrance()
|
self.sync_encumbrance()
|
||||||
self.emit("item_del", item)
|
self.emit("item_del", item)
|
||||||
@@ -199,16 +250,16 @@ class Equipment(SignalMixin):
|
|||||||
signals = ["change"]
|
signals = ["change"]
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._items: T.Dict[EquipmentType, str] = {
|
self._items: dict[EquipmentType, str] = {
|
||||||
EquipmentType.weapon: "Sharp Rock",
|
EquipmentType.weapon: "Sharp Rock",
|
||||||
EquipmentType.hauberk: "-3 Burlap",
|
EquipmentType.hauberk: "-3 Burlap",
|
||||||
}
|
}
|
||||||
self.best = self[EquipmentType.weapon]
|
self.best = self[EquipmentType.weapon]
|
||||||
|
|
||||||
def __iter__(self) -> T.Iterator[T.Tuple[EquipmentType, str]]:
|
def __iter__(self) -> Iterator[tuple[EquipmentType, str]]:
|
||||||
return iter(self._items.items())
|
return iter(self._items.items())
|
||||||
|
|
||||||
def __getitem__(self, equipment_type: EquipmentType) -> T.Optional[str]:
|
def __getitem__(self, equipment_type: EquipmentType) -> str | None:
|
||||||
return self._items.get(equipment_type, None)
|
return self._items.get(equipment_type, None)
|
||||||
|
|
||||||
def put(self, equipment_type: EquipmentType, item_name: str) -> None:
|
def put(self, equipment_type: EquipmentType, item_name: str) -> None:
|
||||||
@@ -232,11 +283,17 @@ class SpellBook(SignalMixin):
|
|||||||
signals = ["add", "change"]
|
signals = ["add", "change"]
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._spells: T.List[Spell] = []
|
self._spells: list[Spell] = []
|
||||||
|
|
||||||
def __iter__(self) -> T.Iterator[Spell]:
|
def __iter__(self) -> Iterator[Spell]:
|
||||||
return iter(self._spells)
|
return iter(self._spells)
|
||||||
|
|
||||||
|
def __bool__(self) -> bool:
|
||||||
|
return bool(self._spells)
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._spells)
|
||||||
|
|
||||||
def add(self, spell_name: str, level: int) -> None:
|
def add(self, spell_name: str, level: int) -> None:
|
||||||
for spell in self._spells:
|
for spell in self._spells:
|
||||||
if spell.name == spell_name:
|
if spell.name == spell_name:
|
||||||
@@ -251,7 +308,7 @@ class SpellBook(SignalMixin):
|
|||||||
self.emit("add", spell)
|
self.emit("add", spell)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def best(self) -> T.Optional[Spell]:
|
def best(self) -> Spell | None:
|
||||||
if not self._spells:
|
if not self._spells:
|
||||||
return None
|
return None
|
||||||
return max(self._spells, key=lambda spell: spell.level)
|
return max(self._spells, key=lambda spell: spell.level)
|
||||||
@@ -265,7 +322,7 @@ class BaseTask:
|
|||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class KillTask(BaseTask):
|
class KillTask(BaseTask):
|
||||||
monster: T.Optional[Monster] = None
|
monster: Monster | None = None
|
||||||
|
|
||||||
|
|
||||||
class BuyTask(BaseTask):
|
class BuyTask(BaseTask):
|
||||||
@@ -292,6 +349,10 @@ class PlotTask(RegularTask):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class LoadingTask(RegularTask):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
class Player(SignalMixin):
|
class Player(SignalMixin):
|
||||||
signals = ["new_task", "level_up"]
|
signals = ["new_task", "level_up"]
|
||||||
|
|
||||||
@@ -302,12 +363,14 @@ class Player(SignalMixin):
|
|||||||
race: Race,
|
race: Race,
|
||||||
class_: Class,
|
class_: Class,
|
||||||
stats: Stats,
|
stats: Stats,
|
||||||
|
rng: Alea | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self.name: str = name
|
self.name: str = name
|
||||||
self.birthday: datetime.datetime = birthday
|
self.birthday: datetime.datetime = birthday
|
||||||
self.race: Race = race
|
self.race: Race = race
|
||||||
self.class_: Class = class_
|
self.class_: Class = class_
|
||||||
self.stats: Stats = stats
|
self.stats: Stats = stats
|
||||||
|
self.rng: Alea = rng or Alea()
|
||||||
self.elapsed = 0.0
|
self.elapsed = 0.0
|
||||||
|
|
||||||
self.exp_bar = Bar(max_=level_up_time(1))
|
self.exp_bar = Bar(max_=level_up_time(1))
|
||||||
@@ -319,12 +382,14 @@ class Player(SignalMixin):
|
|||||||
self.spell_book = SpellBook()
|
self.spell_book = SpellBook()
|
||||||
|
|
||||||
self.task_bar = Bar(max_=1)
|
self.task_bar = Bar(max_=1)
|
||||||
self.task: T.Optional[BaseTask] = None
|
self.task: BaseTask | None = None
|
||||||
self.queue: T.List[BaseTask] = []
|
self.queue: list[BaseTask] = []
|
||||||
|
|
||||||
def __setstate__(self, obj) -> None:
|
def __setstate__(self, obj: dict[str, Any]) -> None:
|
||||||
self.__dict__.update(obj)
|
self.__dict__.update(obj)
|
||||||
self.elapsed = obj.get("elapsed", 0.0)
|
self.elapsed = obj.get("elapsed", 0.0)
|
||||||
|
if "rng" not in obj:
|
||||||
|
self.rng = Alea()
|
||||||
|
|
||||||
def set_task(self, task: BaseTask) -> None:
|
def set_task(self, task: BaseTask) -> None:
|
||||||
self.task = task
|
self.task = task
|
||||||
@@ -333,9 +398,9 @@ class Player(SignalMixin):
|
|||||||
self.emit("new_task")
|
self.emit("new_task")
|
||||||
|
|
||||||
def equip_price(self) -> int:
|
def equip_price(self) -> int:
|
||||||
return 5 * (self.level ** 2) + 10 * self.level + 20
|
return 5 * (self.level**2) + 10 * self.level + 20
|
||||||
|
|
||||||
def level_up(self) -> None:
|
def level_up(self, data: GameData) -> None:
|
||||||
self.level += 1
|
self.level += 1
|
||||||
logger.info("Leveled up to level %d!", self.level)
|
logger.info("Leveled up to level %d!", self.level)
|
||||||
self.stats.increment(
|
self.stats.increment(
|
||||||
@@ -348,23 +413,23 @@ class Player(SignalMixin):
|
|||||||
)
|
)
|
||||||
self.win_stat()
|
self.win_stat()
|
||||||
self.win_stat()
|
self.win_stat()
|
||||||
self.win_spell()
|
self.win_spell(data)
|
||||||
self.exp_bar.reset(level_up_time(self.level))
|
self.exp_bar.reset(level_up_time(self.level))
|
||||||
self.emit("level_up")
|
self.emit("level_up")
|
||||||
|
|
||||||
def win_stat(self) -> bool:
|
def win_stat(self) -> bool:
|
||||||
chosen_stat: T.Optional[StatType] = None
|
chosen_stat: StatType | None = None
|
||||||
|
|
||||||
if random.odds(1, 2):
|
if random.odds(1, 2):
|
||||||
chosen_stat = random.choice(list(StatType))
|
chosen_stat = random.choice(list(StatType))
|
||||||
else:
|
else:
|
||||||
# favor the best stat so it will tend to clump
|
# favor the best stat so it will tend to clump
|
||||||
t = sum(value ** 2 for _stat, value in self.stats)
|
t = sum(self.stats[stat] ** 2 for stat in PRIME_STATS)
|
||||||
t = random.below(t)
|
t = random.below(t)
|
||||||
chosen_stat = None
|
chosen_stat = None
|
||||||
for stat, value in self.stats:
|
for stat in PRIME_STATS:
|
||||||
chosen_stat = stat
|
chosen_stat = stat
|
||||||
t -= value ** 2
|
t -= self.stats[stat] ** 2
|
||||||
if t < 0:
|
if t < 0:
|
||||||
break
|
break
|
||||||
|
|
||||||
@@ -374,31 +439,34 @@ class Player(SignalMixin):
|
|||||||
self.inventory.set_capacity(10 + self.stats[StatType.strength])
|
self.inventory.set_capacity(10 + self.stats[StatType.strength])
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def win_spell(self) -> None:
|
def win_spell(self, data: GameData) -> None:
|
||||||
|
spells = data.spells
|
||||||
self.spell_book.add(
|
self.spell_book.add(
|
||||||
SPELLS[
|
spells[
|
||||||
random.below_low(
|
random.below_low(
|
||||||
min(self.stats[StatType.wisdom] + self.level, len(SPELLS))
|
min(self.stats[StatType.wisdom] + self.level, len(spells))
|
||||||
)
|
)
|
||||||
],
|
],
|
||||||
1,
|
1,
|
||||||
)
|
)
|
||||||
|
|
||||||
def win_equipment(self) -> None:
|
def win_equipment(self, data: GameData) -> None:
|
||||||
choice = random.choice(list(EquipmentType))
|
choice = random.choice(list(EquipmentType))
|
||||||
|
|
||||||
stuff: T.List[EquipmentPreset]
|
stuff: Sequence[EquipmentPreset]
|
||||||
better: T.List[Modifier]
|
better: Sequence[Modifier]
|
||||||
worse: T.List[Modifier]
|
worse: Sequence[Modifier]
|
||||||
|
|
||||||
if choice == EquipmentType.weapon:
|
if choice == EquipmentType.weapon:
|
||||||
stuff = WEAPONS
|
stuff = data.weapons
|
||||||
better = OFFENSE_ATTRIB
|
better = data.offense_attrib
|
||||||
worse = OFFENSE_BAD
|
worse = data.offense_bad
|
||||||
else:
|
else:
|
||||||
stuff = SHIELDS if choice == EquipmentType.shield else ARMORS
|
stuff = (
|
||||||
better = DEFENSE_ATTRIB
|
data.shields if choice == EquipmentType.shield else data.armors
|
||||||
worse = DEFENSE_BAD
|
)
|
||||||
|
better = data.defense_attrib
|
||||||
|
worse = data.defense_bad
|
||||||
|
|
||||||
equipment = pick_equipment(stuff, self.level)
|
equipment = pick_equipment(stuff, self.level)
|
||||||
name = equipment.name
|
name = equipment.name
|
||||||
@@ -426,20 +494,36 @@ class Player(SignalMixin):
|
|||||||
logger.info("Gained %s %s", name, choice.value)
|
logger.info("Gained %s %s", name, choice.value)
|
||||||
self.equipment.put(choice, name)
|
self.equipment.put(choice, name)
|
||||||
|
|
||||||
def win_item(self) -> None:
|
def win_item(self, data: GameData) -> None:
|
||||||
self.inventory.add(special_item(), 1)
|
# Past 250 rows, sometimes add one more of something already carried.
|
||||||
|
# Upstream counts the Gold row too, and picking it yields 1 gold.
|
||||||
|
rows = len(self.inventory) + 1
|
||||||
|
if max(250, random.below(999)) < rows:
|
||||||
|
index = random.below(rows)
|
||||||
|
if index == 0:
|
||||||
|
self.inventory.add_gold(1)
|
||||||
|
else:
|
||||||
|
self.inventory.add(self.inventory[index - 1].name, 1)
|
||||||
|
else:
|
||||||
|
self.inventory.add(special_item(data), 1)
|
||||||
|
|
||||||
|
|
||||||
class Simulation:
|
class Simulation:
|
||||||
def __init__(self, player: Player) -> None:
|
def __init__(self, player: Player, data: GameData) -> None:
|
||||||
self.player = player
|
self.player = player
|
||||||
|
self.data = data
|
||||||
self.last_tick = datetime.datetime.now()
|
self.last_tick = datetime.datetime.now()
|
||||||
|
|
||||||
def tick(self, elapsed: float = 100.0) -> None:
|
def tick(self, elapsed: float = 100.0) -> None:
|
||||||
|
# each character plays on its own generator, saved along with it
|
||||||
|
with random.using(self.player.rng):
|
||||||
|
self._tick(elapsed)
|
||||||
|
|
||||||
|
def _tick(self, elapsed: float) -> None:
|
||||||
self.player.elapsed += elapsed
|
self.player.elapsed += elapsed
|
||||||
|
|
||||||
if self.player.task is None:
|
if self.player.task is None:
|
||||||
self.player.set_task(RegularTask("Loading", 2000))
|
self.player.set_task(LoadingTask("Loading", 2000))
|
||||||
self.player.queue += [
|
self.player.queue += [
|
||||||
RegularTask(
|
RegularTask(
|
||||||
"Experiencing an enigmatic and foreboding night vision",
|
"Experiencing an enigmatic and foreboding night vision",
|
||||||
@@ -462,7 +546,7 @@ class Simulation:
|
|||||||
),
|
),
|
||||||
PlotTask(f"Loading {act_name(1)}", 2000),
|
PlotTask(f"Loading {act_name(1)}", 2000),
|
||||||
]
|
]
|
||||||
self.player.quest_book.plot_bar.reset(28)
|
self.player.quest_book.plot_bar.reset(26)
|
||||||
return
|
return
|
||||||
|
|
||||||
if not self.player.task_bar.done:
|
if not self.player.task_bar.done:
|
||||||
@@ -473,7 +557,7 @@ class Simulation:
|
|||||||
gain = self.player.task and isinstance(self.player.task, KillTask)
|
gain = self.player.task and isinstance(self.player.task, KillTask)
|
||||||
if gain:
|
if gain:
|
||||||
if self.player.exp_bar.done:
|
if self.player.exp_bar.done:
|
||||||
self.player.level_up()
|
self.player.level_up(self.data)
|
||||||
else:
|
else:
|
||||||
self.player.exp_bar.increment(self.player.task_bar.max_ / 1000)
|
self.player.exp_bar.increment(self.player.task_bar.max_ / 1000)
|
||||||
|
|
||||||
@@ -497,6 +581,13 @@ class Simulation:
|
|||||||
self.player.quest_book.plot_bar.increment(
|
self.player.quest_book.plot_bar.increment(
|
||||||
self.player.task_bar.max_ / 1000
|
self.player.task_bar.max_ / 1000
|
||||||
)
|
)
|
||||||
|
elif self.player.quest_book.act == 0 and not isinstance(
|
||||||
|
self.player.task, LoadingTask | PlotTask
|
||||||
|
):
|
||||||
|
# the Prologue story tasks fill the plot bar
|
||||||
|
self.player.quest_book.plot_bar.increment(
|
||||||
|
self.player.task_bar.max_ / 1000
|
||||||
|
)
|
||||||
|
|
||||||
self.dequeue()
|
self.dequeue()
|
||||||
|
|
||||||
@@ -508,7 +599,7 @@ class Simulation:
|
|||||||
or self.player.task.monster.item is None
|
or self.player.task.monster.item is None
|
||||||
):
|
):
|
||||||
# npc
|
# npc
|
||||||
self.player.win_item()
|
self.player.win_item(self.data)
|
||||||
elif self.player.task.monster.item:
|
elif self.player.task.monster.item:
|
||||||
self.player.inventory.add(
|
self.player.inventory.add(
|
||||||
(
|
(
|
||||||
@@ -522,9 +613,9 @@ class Simulation:
|
|||||||
elif isinstance(self.player.task, BuyTask):
|
elif isinstance(self.player.task, BuyTask):
|
||||||
# buy some equipment
|
# buy some equipment
|
||||||
self.player.inventory.add_gold(-self.player.equip_price())
|
self.player.inventory.add_gold(-self.player.equip_price())
|
||||||
self.player.win_equipment()
|
self.player.win_equipment(self.data)
|
||||||
|
|
||||||
elif isinstance(self.player.task, (HeadingToMarketTask, SellTask)):
|
elif isinstance(self.player.task, HeadingToMarketTask | SellTask):
|
||||||
if isinstance(self.player.task, SellTask):
|
if isinstance(self.player.task, SellTask):
|
||||||
item = self.player.inventory[0]
|
item = self.player.inventory[0]
|
||||||
amount = item.quantity * self.player.level
|
amount = item.quantity * self.player.level
|
||||||
@@ -544,17 +635,22 @@ class Simulation:
|
|||||||
)
|
)
|
||||||
break
|
break
|
||||||
|
|
||||||
elif isinstance(self.player.task, PlotTask):
|
|
||||||
self.complete_act()
|
|
||||||
|
|
||||||
old = self.player.task
|
old = self.player.task
|
||||||
if self.player.queue:
|
if self.player.queue:
|
||||||
self.player.set_task(self.player.queue.pop(0))
|
task = self.player.queue.pop(0)
|
||||||
|
if isinstance(task, PlotTask):
|
||||||
|
# the act starts as its loading task does, as upstream
|
||||||
|
self.complete_act()
|
||||||
|
task = PlotTask(
|
||||||
|
f"Loading {act_name(self.player.quest_book.act)}",
|
||||||
|
task.duration,
|
||||||
|
)
|
||||||
|
self.player.set_task(task)
|
||||||
elif self.player.inventory.encum_bar.done:
|
elif self.player.inventory.encum_bar.done:
|
||||||
self.player.set_task(
|
self.player.set_task(
|
||||||
HeadingToMarketTask("Heading to market to sell loot", 4000)
|
HeadingToMarketTask("Heading to market to sell loot", 4000)
|
||||||
)
|
)
|
||||||
elif not isinstance(old, (KillTask, HeadingToKillingFieldsTask)):
|
elif not isinstance(old, KillTask | HeadingToKillingFieldsTask):
|
||||||
if self.player.inventory.gold > self.player.equip_price():
|
if self.player.inventory.gold > self.player.equip_price():
|
||||||
self.player.set_task(
|
self.player.set_task(
|
||||||
BuyTask(
|
BuyTask(
|
||||||
@@ -570,7 +666,9 @@ class Simulation:
|
|||||||
else:
|
else:
|
||||||
self.player.set_task(
|
self.player.set_task(
|
||||||
monster_task(
|
monster_task(
|
||||||
self.player.level, self.player.quest_book.monster
|
self.data,
|
||||||
|
self.player.level,
|
||||||
|
self.player.quest_book.monster,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -580,21 +678,21 @@ class Simulation:
|
|||||||
60 * 60 * (1 + 5 * self.player.quest_book.act)
|
60 * 60 * (1 + 5 * self.player.quest_book.act)
|
||||||
)
|
)
|
||||||
if self.player.quest_book.act > 1:
|
if self.player.quest_book.act > 1:
|
||||||
self.player.win_item()
|
self.player.win_item(self.data)
|
||||||
self.player.win_equipment()
|
self.player.win_equipment(self.data)
|
||||||
|
|
||||||
def complete_quest(self) -> None:
|
def complete_quest(self) -> None:
|
||||||
self.player.quest_book.quest_bar.reset(50 + random.below_low(1000))
|
self.player.quest_book.quest_bar.reset(50 + random.below(100))
|
||||||
if self.player.quest_book.current_quest:
|
if self.player.quest_book.current_quest:
|
||||||
logger.info(
|
logger.info(
|
||||||
"Quest completed: %s", self.player.quest_book.current_quest
|
"Quest completed: %s", self.player.quest_book.current_quest
|
||||||
)
|
)
|
||||||
random.choice(
|
random.choice(
|
||||||
[
|
[
|
||||||
self.player.win_spell,
|
functools.partial(self.player.win_spell, self.data),
|
||||||
self.player.win_equipment,
|
functools.partial(self.player.win_equipment, self.data),
|
||||||
self.player.win_stat,
|
self.player.win_stat,
|
||||||
self.player.win_item,
|
functools.partial(self.player.win_item, self.data),
|
||||||
]
|
]
|
||||||
)()
|
)()
|
||||||
|
|
||||||
@@ -602,20 +700,21 @@ class Simulation:
|
|||||||
caption = ""
|
caption = ""
|
||||||
choice = random.below(5)
|
choice = random.below(5)
|
||||||
if choice == 0:
|
if choice == 0:
|
||||||
self.player.quest_book.monster = unnamed_monster(
|
monster = unnamed_monster(
|
||||||
self.player.level, iterations=3
|
self.data, self.player.level, iterations=3
|
||||||
)
|
|
||||||
caption = "Exterminate " + definite(
|
|
||||||
self.player.quest_book.monster.name, 2
|
|
||||||
)
|
)
|
||||||
|
self.player.quest_book.monster = monster
|
||||||
|
caption = "Exterminate " + definite(monster.name, 2)
|
||||||
elif choice == 1:
|
elif choice == 1:
|
||||||
caption = "Seek " + definite(interesting_item(), 1)
|
caption = "Seek " + definite(interesting_item(self.data), 1)
|
||||||
elif choice == 2:
|
elif choice == 2:
|
||||||
caption = "Deliver this " + boring_item()
|
caption = "Deliver this " + boring_item(self.data)
|
||||||
elif choice == 3:
|
elif choice == 3:
|
||||||
caption = "Fetch me " + indefinite(boring_item(), 1)
|
caption = "Fetch me " + indefinite(boring_item(self.data), 1)
|
||||||
elif choice == 4:
|
elif choice == 4:
|
||||||
monster = unnamed_monster(self.player.level, iterations=1)
|
monster = unnamed_monster(
|
||||||
|
self.data, self.player.level, iterations=1
|
||||||
|
)
|
||||||
caption = "Placate " + definite(monster.name, 2)
|
caption = "Placate " + definite(monster.name, 2)
|
||||||
else:
|
else:
|
||||||
raise AssertionError
|
raise AssertionError
|
||||||
@@ -658,7 +757,7 @@ class Simulation:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
nemesis = named_monster(self.player.level + 3)
|
nemesis = named_monster(self.data, self.player.level + 3)
|
||||||
|
|
||||||
enqueue(
|
enqueue(
|
||||||
RegularTask(
|
RegularTask(
|
||||||
@@ -696,7 +795,7 @@ class Simulation:
|
|||||||
enqueue(
|
enqueue(
|
||||||
RegularTask(
|
RegularTask(
|
||||||
f"Victory! {nemesis} is slain! "
|
f"Victory! {nemesis} is slain! "
|
||||||
"Exhausted, you lose conciousness",
|
"Exhausted, you lose consciousness",
|
||||||
3000,
|
3000,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -707,24 +806,25 @@ class Simulation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
elif choice == 2:
|
elif choice == 2:
|
||||||
nemesis = impressive_guy()
|
nemesis = impressive_guy(self.data)
|
||||||
enqueue(
|
enqueue(
|
||||||
RegularTask(
|
RegularTask(
|
||||||
"Oh sweet relief! "
|
"Oh sweet relief! "
|
||||||
f"You've reached the protection of the good {nemesis}",
|
f"You've reached the kind protection of {nemesis}",
|
||||||
2000,
|
2000,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
enqueue(
|
enqueue(
|
||||||
RegularTask(
|
RegularTask(
|
||||||
"There is rejoicing, "
|
"There is rejoicing, "
|
||||||
f"and an unnerving encouter with {nemesis} in private",
|
f"and an unnerving encounter with {nemesis} in private",
|
||||||
3000,
|
3000,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
enqueue(
|
enqueue(
|
||||||
RegularTask(
|
RegularTask(
|
||||||
f"You forget your {boring_item()} and go back to get it",
|
f"You forget your {boring_item(self.data)} "
|
||||||
|
"and go back to get it",
|
||||||
2000,
|
2000,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@@ -738,7 +838,7 @@ class Simulation:
|
|||||||
)
|
)
|
||||||
enqueue(
|
enqueue(
|
||||||
RegularTask(
|
RegularTask(
|
||||||
"Who can possibly be trusted with this news!? ... "
|
"Who can possibly be trusted with this news!? -- "
|
||||||
"Oh yes, of course",
|
"Oh yes, of course",
|
||||||
3000,
|
3000,
|
||||||
)
|
)
|
||||||
@@ -754,57 +854,54 @@ class Simulation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def special_item() -> str:
|
def special_item(data: GameData) -> str:
|
||||||
return interesting_item() + " of " + T.cast(str, random.choice(ITEM_OFS))
|
return interesting_item(data) + " of " + random.choice(data.item_ofs)
|
||||||
|
|
||||||
|
|
||||||
def interesting_item() -> str:
|
def interesting_item(data: GameData) -> str:
|
||||||
return (
|
return random.choice(data.item_attrib) + " " + random.choice(data.specials)
|
||||||
T.cast(str, random.choice(ITEM_ATTRIB))
|
|
||||||
+ " "
|
|
||||||
+ T.cast(str, random.choice(SPECIALS))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def boring_item() -> str:
|
def boring_item(data: GameData) -> str:
|
||||||
return T.cast(str, random.choice(BORING_ITEMS))
|
return random.choice(data.boring_items)
|
||||||
|
|
||||||
|
|
||||||
def impressive_guy() -> str:
|
def impressive_guy(data: GameData) -> str:
|
||||||
return T.cast(str, random.choice(IMPRESSIVE_TITLES)) + (
|
if random.below(2):
|
||||||
" of the " + T.cast(Race, random.choice(RACES)).name
|
title = random.choice(data.impressive_titles)
|
||||||
if random.below(2)
|
race = random.choice(data.races)
|
||||||
else " of " + generate_name()
|
return f"the {title} of the {plural(race.name)}"
|
||||||
)
|
title = random.choice(data.impressive_titles)
|
||||||
|
return f"{title} {generate_name()} of {generate_name()}"
|
||||||
|
|
||||||
|
|
||||||
def unnamed_monster(level: int, iterations: int) -> Monster:
|
def unnamed_monster(data: GameData, level: int, iterations: int) -> Monster:
|
||||||
result = T.cast(Monster, random.choice(MONSTERS))
|
result = random.choice(data.monsters)
|
||||||
for _ in range(iterations):
|
for _ in range(iterations):
|
||||||
alternative = T.cast(Monster, random.choice(MONSTERS))
|
alternative = random.choice(data.monsters)
|
||||||
if abs(level - alternative.level) < abs(level - result.level):
|
if abs(level - alternative.level) < abs(level - result.level):
|
||||||
result = alternative
|
result = alternative
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def named_monster(level: int) -> str:
|
def named_monster(data: GameData, level: int) -> str:
|
||||||
monster = unnamed_monster(level, iterations=4)
|
monster = unnamed_monster(data, level, iterations=4)
|
||||||
return generate_name() + " the " + monster.name
|
return generate_name() + " the " + monster.name
|
||||||
|
|
||||||
|
|
||||||
def pick_equipment(
|
def pick_equipment(
|
||||||
source: T.List[EquipmentPreset], goal: int
|
source: Sequence[EquipmentPreset], goal: int
|
||||||
) -> EquipmentPreset:
|
) -> EquipmentPreset:
|
||||||
result = T.cast(EquipmentPreset, random.choice(source))
|
result = random.choice(source)
|
||||||
for _ in range(5):
|
for _ in range(5):
|
||||||
alternative = T.cast(EquipmentPreset, random.choice(source))
|
alternative = random.choice(source)
|
||||||
if abs(goal - alternative.quality) < abs(goal - result.quality):
|
if abs(goal - alternative.quality) < abs(goal - result.quality):
|
||||||
result = alternative
|
result = alternative
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
def monster_task(
|
def monster_task(
|
||||||
player_level: int, quest_monster: T.Optional[Monster]
|
data: GameData, player_level: int, quest_monster: Monster | None
|
||||||
) -> KillTask:
|
) -> KillTask:
|
||||||
level = player_level
|
level = player_level
|
||||||
for _ in range(level):
|
for _ in range(level):
|
||||||
@@ -814,15 +911,17 @@ def monster_task(
|
|||||||
level = 1
|
level = 1
|
||||||
|
|
||||||
is_definite = False
|
is_definite = False
|
||||||
monster: T.Optional[Monster] = None
|
monster: Monster | None = None
|
||||||
if random.odds(1, 25):
|
if random.odds(1, 25):
|
||||||
# use an NPC every once in a while
|
# use an NPC every once in a while
|
||||||
race = random.choice(RACES)
|
race = random.choice(data.races)
|
||||||
if random.odds(1, 2):
|
if random.odds(1, 2):
|
||||||
result = "passing " + race.name + " " + random.choice(CLASSES).name
|
result = (
|
||||||
|
"passing " + race.name + " " + random.choice(data.classes).name
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
result = (
|
result = (
|
||||||
random.choice_low(TITLES)
|
random.choice_low(data.titles)
|
||||||
+ " "
|
+ " "
|
||||||
+ generate_name()
|
+ generate_name()
|
||||||
+ " the "
|
+ " the "
|
||||||
@@ -836,8 +935,9 @@ def monster_task(
|
|||||||
result = monster.name
|
result = monster.name
|
||||||
lev = monster.level
|
lev = monster.level
|
||||||
else:
|
else:
|
||||||
# pick the monster out of so many random ones closest to the level we want
|
# pick the monster out of so many random ones closest to the level
|
||||||
monster = unnamed_monster(level, iterations=5)
|
# we want
|
||||||
|
monster = unnamed_monster(data, level, iterations=5)
|
||||||
result = monster.name
|
result = monster.name
|
||||||
lev = monster.level
|
lev = monster.level
|
||||||
|
|
||||||
@@ -876,39 +976,74 @@ def monster_task(
|
|||||||
result = indefinite(result, qty)
|
result = indefinite(result, qty)
|
||||||
|
|
||||||
duration = (2 * 3 * level * 1000) // player_level
|
duration = (2 * 3 * level * 1000) // player_level
|
||||||
return KillTask(f"Executing {result}", duration, monster=monster)
|
# with a single verb, skip the roll so the stock RNG stream is unchanged
|
||||||
|
verb = (
|
||||||
|
data.combat_verbs[0]
|
||||||
|
if len(data.combat_verbs) == 1
|
||||||
|
else random.choice(data.combat_verbs)
|
||||||
|
)
|
||||||
|
description = verb.replace(TARGET_PLACEHOLDER, result)
|
||||||
|
return KillTask(description, duration, monster=monster)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Roll:
|
||||||
|
stats: Stats
|
||||||
|
seed: RngState # generator state before the roll
|
||||||
|
after: RngState
|
||||||
|
|
||||||
|
|
||||||
class StatsBuilder:
|
class StatsBuilder:
|
||||||
def __init__(self) -> None:
|
def __init__(self, rng: Alea | None = None) -> None:
|
||||||
self.history: T.List[Stats] = []
|
self._rng = rng or Alea()
|
||||||
|
self.history: list[_Roll] = []
|
||||||
|
|
||||||
|
@property
|
||||||
|
def seed(self) -> RngState:
|
||||||
|
"""Generator state the current stats were rolled from.
|
||||||
|
|
||||||
|
Like upstream, a new character's game continues from this state,
|
||||||
|
so its first rolls replay the stat roll's numbers.
|
||||||
|
"""
|
||||||
|
return self.history[-1].seed
|
||||||
|
|
||||||
def roll(self) -> Stats:
|
def roll(self) -> Stats:
|
||||||
values: T.Dict[StatType, int] = {
|
seed = self._rng.state()
|
||||||
stat: 3 + random.below(6) + random.below(6) + random.below(6)
|
with random.using(self._rng):
|
||||||
for stat in PRIME_STATS
|
values: dict[StatType, int] = {
|
||||||
}
|
stat: 3 + random.below(6) + random.below(6) + random.below(6)
|
||||||
values[StatType.hp_max] = (
|
for stat in PRIME_STATS
|
||||||
random.below(8) + values[StatType.condition] // 6
|
}
|
||||||
)
|
values[StatType.hp_max] = (
|
||||||
values[StatType.mp_max] = (
|
random.below(8) + values[StatType.condition] // 6
|
||||||
random.below(8) + values[StatType.intelligence] // 6
|
)
|
||||||
)
|
values[StatType.mp_max] = (
|
||||||
|
random.below(8) + values[StatType.intelligence] // 6
|
||||||
|
)
|
||||||
stats = Stats(values)
|
stats = Stats(values)
|
||||||
self.history.append(stats)
|
self.history.append(_Roll(stats, seed, self._rng.state()))
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
def unroll(self) -> Stats:
|
def unroll(self) -> Stats:
|
||||||
self.history.pop()
|
if len(self.history) > 1:
|
||||||
return self.history[-1]
|
self.history.pop()
|
||||||
|
self._rng = Alea.from_state(self.history[-1].after)
|
||||||
|
return self.history[-1].stats
|
||||||
|
|
||||||
|
|
||||||
def create_player(
|
def create_player(
|
||||||
name: str, race: Race, class_: Class, stats: Stats
|
name: str,
|
||||||
|
race: Race,
|
||||||
|
class_: Class,
|
||||||
|
stats: Stats,
|
||||||
|
seed: RngState | None = None,
|
||||||
) -> Player:
|
) -> Player:
|
||||||
now = datetime.datetime.now()
|
|
||||||
random.seed(now)
|
|
||||||
player = Player(
|
player = Player(
|
||||||
birthday=now, name=name, race=race, class_=class_, stats=stats
|
birthday=datetime.datetime.now(),
|
||||||
|
name=name,
|
||||||
|
race=race,
|
||||||
|
class_=class_,
|
||||||
|
stats=stats,
|
||||||
|
rng=Alea.from_state(seed) if seed is not None else None,
|
||||||
)
|
)
|
||||||
return player
|
return player
|
||||||
+380
@@ -0,0 +1,380 @@
|
|||||||
|
"""Import and export Progress Quest web saves (.pqw files).
|
||||||
|
|
||||||
|
A .pqw file is base64 of one character's JSON, as the web version's roster
|
||||||
|
page downloads it. Characters keep their generator state both ways, so they
|
||||||
|
carry on exactly as they would have where they came from.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import binascii
|
||||||
|
import copy
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
from pqcli.config import Class, EquipmentType, Monster, Race, StatType
|
||||||
|
from pqcli.game_data import GameData
|
||||||
|
from pqcli.lingo import act_name, to_arabic, to_roman
|
||||||
|
from pqcli.mechanic import (
|
||||||
|
BaseTask,
|
||||||
|
BuyTask,
|
||||||
|
HeadingToKillingFieldsTask,
|
||||||
|
HeadingToMarketTask,
|
||||||
|
InventoryItem,
|
||||||
|
KillTask,
|
||||||
|
LoadingTask,
|
||||||
|
Player,
|
||||||
|
PlotTask,
|
||||||
|
RegularTask,
|
||||||
|
SellTask,
|
||||||
|
Simulation,
|
||||||
|
Spell,
|
||||||
|
Stats,
|
||||||
|
)
|
||||||
|
from pqcli.random import Alea, RngState
|
||||||
|
|
||||||
|
# Spells upstream renamed; its loader merges old saves the same way.
|
||||||
|
SPELL_RENAMES = [
|
||||||
|
("Innoculate", "Inoculate"),
|
||||||
|
("Tonsilectomy", "Tonsillectomy"),
|
||||||
|
]
|
||||||
|
|
||||||
|
# Fields of the web save that pqcli has no use for.
|
||||||
|
ONLINE_FIELDS = ("online", "guild", "motto")
|
||||||
|
|
||||||
|
LOADING_CAPTION = "Loading...."
|
||||||
|
|
||||||
|
|
||||||
|
class PqwError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def decode(text: str) -> dict[str, Any]:
|
||||||
|
"""Turn the contents of a .pqw file into the web save's game object."""
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode("".join(text.split()), validate=True)
|
||||||
|
except (binascii.Error, ValueError) as ex:
|
||||||
|
raise PqwError(f"not a .pqw file: {ex}") from ex
|
||||||
|
try:
|
||||||
|
decoded = raw.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
# saves from before 2022 were base64 of the raw JS string
|
||||||
|
decoded = raw.decode("latin-1")
|
||||||
|
try:
|
||||||
|
game = json.loads(decoded)
|
||||||
|
except json.JSONDecodeError as ex:
|
||||||
|
raise PqwError(f"not a .pqw file: {ex}") from ex
|
||||||
|
if not isinstance(game, dict) or "Traits" not in game:
|
||||||
|
raise PqwError("not a .pqw file: no character in it")
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
def encode(game: dict[str, Any]) -> str:
|
||||||
|
"""Turn a web save's game object into the contents of a .pqw file."""
|
||||||
|
text = json.dumps(game, ensure_ascii=False, separators=(",", ":"))
|
||||||
|
return base64.b64encode(text.encode("utf-8")).decode("ascii")
|
||||||
|
|
||||||
|
|
||||||
|
def _field(game: dict[str, Any], *path: str | int) -> Any:
|
||||||
|
value: Any = game
|
||||||
|
for key in path:
|
||||||
|
try:
|
||||||
|
value = value[key]
|
||||||
|
except (KeyError, IndexError, TypeError) as ex:
|
||||||
|
name = ".".join(map(str, path))
|
||||||
|
raise PqwError(f"missing field {name}") from ex
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _int(game: dict[str, Any], *path: str | int) -> int:
|
||||||
|
value = _field(game, *path)
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError) as ex:
|
||||||
|
name = ".".join(map(str, path))
|
||||||
|
raise PqwError(f"field {name} is not a number: {value!r}") from ex
|
||||||
|
|
||||||
|
|
||||||
|
def _bar(game: dict[str, Any], name: str) -> tuple[int, float]:
|
||||||
|
position = _field(game, name, "position")
|
||||||
|
if not isinstance(position, int | float):
|
||||||
|
raise PqwError(f"field {name}.position is not a number")
|
||||||
|
return _int(game, name, "max"), position
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_monster(text: str) -> Monster | None:
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
parts = text.split("|")
|
||||||
|
if len(parts) != 3:
|
||||||
|
raise PqwError(f"cannot read monster {text!r}")
|
||||||
|
name, level, item = parts
|
||||||
|
try:
|
||||||
|
return Monster(name, int(level), None if item == "*" else item)
|
||||||
|
except ValueError as ex:
|
||||||
|
raise PqwError(f"cannot read monster {text!r}") from ex
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_monster(monster: Monster) -> str:
|
||||||
|
return f"{monster.name}|{monster.level}|{monster.item or '*'}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_task(game: dict[str, Any]) -> BaseTask:
|
||||||
|
kind = str(_field(game, "task"))
|
||||||
|
caption = str(_field(game, "kill"))
|
||||||
|
duration = _bar(game, "TaskBar")[0]
|
||||||
|
if kind.startswith("kill|"):
|
||||||
|
monster = _parse_monster(kind.removeprefix("kill|"))
|
||||||
|
return KillTask(caption.removesuffix("..."), duration, monster)
|
||||||
|
if caption == LOADING_CAPTION:
|
||||||
|
return LoadingTask("Loading", duration)
|
||||||
|
task_class: type[BaseTask] = {
|
||||||
|
"buying": BuyTask,
|
||||||
|
"market": HeadingToMarketTask,
|
||||||
|
"sell": SellTask,
|
||||||
|
"heading": HeadingToKillingFieldsTask,
|
||||||
|
"": RegularTask,
|
||||||
|
}.get(kind, RegularTask)
|
||||||
|
return task_class(caption.removesuffix("..."), duration)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_task(task: BaseTask) -> tuple[str, str]:
|
||||||
|
if isinstance(task, LoadingTask):
|
||||||
|
return "", LOADING_CAPTION
|
||||||
|
caption = task.description + "..."
|
||||||
|
if isinstance(task, KillTask):
|
||||||
|
monster = task.monster or Monster("", 0, None)
|
||||||
|
return "kill|" + _encode_monster(monster), caption
|
||||||
|
for task_class, kind in [
|
||||||
|
(BuyTask, "buying"),
|
||||||
|
(HeadingToMarketTask, "market"),
|
||||||
|
(SellTask, "sell"),
|
||||||
|
(HeadingToKillingFieldsTask, "heading"),
|
||||||
|
]:
|
||||||
|
if isinstance(task, task_class):
|
||||||
|
return kind, caption
|
||||||
|
return "", caption
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_queue_entry(entry: Any) -> RegularTask:
|
||||||
|
parts = str(entry).split("|", 2)
|
||||||
|
if len(parts) != 3 or parts[0] not in {"task", "plot"}:
|
||||||
|
raise PqwError(f"cannot read queued task {entry!r}")
|
||||||
|
try:
|
||||||
|
duration = int(parts[1]) * 1000
|
||||||
|
except ValueError as ex:
|
||||||
|
raise PqwError(f"cannot read queued task {entry!r}") from ex
|
||||||
|
if parts[0] == "plot":
|
||||||
|
# renamed after the act it starts once it comes up
|
||||||
|
return PlotTask("Loading", duration)
|
||||||
|
return RegularTask(parts[2], duration)
|
||||||
|
|
||||||
|
|
||||||
|
def _encode_queue_entry(task: BaseTask) -> str:
|
||||||
|
seconds = task.duration // 1000
|
||||||
|
if isinstance(task, PlotTask):
|
||||||
|
return f"plot|{seconds}|Loading"
|
||||||
|
return f"task|{seconds}|{task.description}"
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_spells(game: dict[str, Any]) -> list[Spell]:
|
||||||
|
rows = [(str(row[0]), str(row[1])) for row in _field(game, "Spells")]
|
||||||
|
levels = {name: to_arabic(roman) for name, roman in rows}
|
||||||
|
spells = []
|
||||||
|
for name, _roman in rows:
|
||||||
|
renamed = dict(SPELL_RENAMES).get(name)
|
||||||
|
if renamed is not None:
|
||||||
|
levels[renamed] = levels.get(renamed, 0) + levels[name]
|
||||||
|
name = renamed
|
||||||
|
elif any(new == name and old in levels for old, new in SPELL_RENAMES):
|
||||||
|
continue # merged into the old spell's place
|
||||||
|
spells.append(Spell(name, levels[name]))
|
||||||
|
return spells
|
||||||
|
|
||||||
|
|
||||||
|
def _find_by_name(
|
||||||
|
items: tuple[Race, ...] | tuple[Class, ...], name: str
|
||||||
|
) -> Race | Class | None:
|
||||||
|
return next((item for item in items if item.name == name), None)
|
||||||
|
|
||||||
|
|
||||||
|
def import_game(
|
||||||
|
game: dict[str, Any], data: GameData
|
||||||
|
) -> tuple[Player, list[str]]:
|
||||||
|
"""Build a pqcli character from a web save, plus any warnings."""
|
||||||
|
warnings = []
|
||||||
|
for field in ONLINE_FIELDS:
|
||||||
|
if game.get(field):
|
||||||
|
warnings.append(f"dropped {field!r}: pqcli plays offline only")
|
||||||
|
|
||||||
|
name = str(_field(game, "Traits", "Name"))
|
||||||
|
race_name = str(_field(game, "Traits", "Race"))
|
||||||
|
class_name = str(_field(game, "Traits", "Class"))
|
||||||
|
race = _find_by_name(data.races, race_name) or Race(race_name, [])
|
||||||
|
class_ = _find_by_name(data.classes, class_name) or Class(class_name, [])
|
||||||
|
|
||||||
|
stats = Stats({stat: _int(game, "Stats", stat.value) for stat in StatType})
|
||||||
|
seed = _field(game, "seed")
|
||||||
|
if not (isinstance(seed, list) and len(seed) == 4):
|
||||||
|
raise PqwError("field seed is not a generator state")
|
||||||
|
stamp = game.get("birthstamp")
|
||||||
|
birthday = (
|
||||||
|
datetime.datetime.fromtimestamp(stamp / 1000)
|
||||||
|
if isinstance(stamp, int | float)
|
||||||
|
else datetime.datetime.now()
|
||||||
|
)
|
||||||
|
|
||||||
|
player = Player(
|
||||||
|
name=name,
|
||||||
|
birthday=birthday,
|
||||||
|
race=cast(Race, race),
|
||||||
|
class_=cast(Class, class_),
|
||||||
|
stats=stats,
|
||||||
|
rng=Alea.from_state(cast(RngState, tuple(seed))),
|
||||||
|
)
|
||||||
|
player.level = _int(game, "Traits", "Level")
|
||||||
|
player.elapsed = float(game.get("elapsed", 0)) * 1000
|
||||||
|
|
||||||
|
player.exp_bar.reset(*_bar(game, "ExpBar"))
|
||||||
|
player.task_bar.reset(*_bar(game, "TaskBar"))
|
||||||
|
player.quest_book.plot_bar.reset(*_bar(game, "PlotBar"))
|
||||||
|
player.quest_book.quest_bar.reset(*_bar(game, "QuestBar"))
|
||||||
|
|
||||||
|
player.quest_book._act = _int(game, "act")
|
||||||
|
player.quest_book._quests = [str(q) for q in _field(game, "Quests")]
|
||||||
|
player.quest_book.monster = _parse_monster(
|
||||||
|
str(game.get("questmonster", ""))
|
||||||
|
)
|
||||||
|
|
||||||
|
for kind in EquipmentType:
|
||||||
|
item = str(_field(game, "Equips").get(kind.value, ""))
|
||||||
|
if item:
|
||||||
|
player.equipment.put(kind, item)
|
||||||
|
player.equipment.best = str(game.get("bestequip", player.equipment.best))
|
||||||
|
|
||||||
|
rows = _field(game, "Inventory")
|
||||||
|
if not rows or rows[0][0] != "Gold":
|
||||||
|
raise PqwError("field Inventory does not start with Gold")
|
||||||
|
player.inventory._gold = _int(game, "Inventory", 0, 1)
|
||||||
|
player.inventory._items = [
|
||||||
|
InventoryItem(str(row[0]), int(row[1])) for row in rows[1:]
|
||||||
|
]
|
||||||
|
player.inventory.encum_bar.reset(*_bar(game, "EncumBar"))
|
||||||
|
|
||||||
|
player.spell_book._spells = _parse_spells(game)
|
||||||
|
|
||||||
|
player.task = _parse_task(game)
|
||||||
|
player.queue = [_parse_queue_entry(e) for e in _field(game, "queue")]
|
||||||
|
return player, warnings
|
||||||
|
|
||||||
|
|
||||||
|
def export_game(player: Player, data: GameData) -> dict[str, Any]:
|
||||||
|
"""Build a web save from a pqcli character."""
|
||||||
|
if player.task is None:
|
||||||
|
# never played: set up the Prologue first, on a copy
|
||||||
|
player = copy.deepcopy(player)
|
||||||
|
Simulation(player, data).tick(0)
|
||||||
|
assert player.task is not None
|
||||||
|
|
||||||
|
seed = list(player.rng.state())
|
||||||
|
birthstamp = int(player.birthday.timestamp() * 1000)
|
||||||
|
now = datetime.datetime.now()
|
||||||
|
kind, caption = _encode_task(player.task)
|
||||||
|
best_stat = player.stats.best_prime
|
||||||
|
best_spell = player.spell_book.best
|
||||||
|
|
||||||
|
quest_monster = player.quest_book.monster
|
||||||
|
# upstream picks the quest monster by its index in the monster list
|
||||||
|
quest_monster_index = (
|
||||||
|
data.monsters.index(quest_monster)
|
||||||
|
if quest_monster in data.monsters
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
def bar(position: float, max_: int) -> dict[str, float]:
|
||||||
|
return {"position": position, "max": max_}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"Traits": {
|
||||||
|
"Name": player.name,
|
||||||
|
"Race": player.race.name,
|
||||||
|
"Class": player.class_.name,
|
||||||
|
"Level": player.level,
|
||||||
|
},
|
||||||
|
"dna": seed,
|
||||||
|
"seed": seed,
|
||||||
|
"birthday": player.birthday.strftime("%a %b %d %Y %H:%M:%S"),
|
||||||
|
"birthstamp": birthstamp,
|
||||||
|
"date": now.strftime("%a %b %d %Y %H:%M:%S"),
|
||||||
|
"stamp": int(now.timestamp() * 1000),
|
||||||
|
"Stats": {stat.value: player.stats[stat] for stat in StatType},
|
||||||
|
"beststat": f"{best_stat.value} {player.stats[best_stat]}",
|
||||||
|
"task": kind,
|
||||||
|
"tasks": 0,
|
||||||
|
"elapsed": int(player.elapsed // 1000),
|
||||||
|
"bestequip": player.equipment.best,
|
||||||
|
"Equips": {
|
||||||
|
kind.value: player.equipment[kind] or "" for kind in EquipmentType
|
||||||
|
},
|
||||||
|
"Inventory": [["Gold", player.inventory.gold]]
|
||||||
|
+ [[item.name, item.quantity] for item in player.inventory],
|
||||||
|
"Spells": [
|
||||||
|
[spell.name, to_roman(spell.level)] for spell in player.spell_book
|
||||||
|
],
|
||||||
|
"bestspell": (
|
||||||
|
f"{best_spell.name} {to_roman(best_spell.level)}"
|
||||||
|
if best_spell
|
||||||
|
else ""
|
||||||
|
),
|
||||||
|
"act": player.quest_book.act,
|
||||||
|
"bestplot": act_name(player.quest_book.act),
|
||||||
|
"Quests": list(player.quest_book.quests),
|
||||||
|
"bestquest": player.quest_book.current_quest or "",
|
||||||
|
"questmonster": (
|
||||||
|
_encode_monster(quest_monster) if quest_monster else ""
|
||||||
|
),
|
||||||
|
"questmonsterindex": quest_monster_index,
|
||||||
|
"kill": caption,
|
||||||
|
"ExpBar": bar(player.exp_bar.position, player.exp_bar.max_),
|
||||||
|
"EncumBar": bar(
|
||||||
|
player.inventory.encum_bar.position,
|
||||||
|
player.inventory.encum_bar.max_,
|
||||||
|
),
|
||||||
|
"PlotBar": bar(
|
||||||
|
player.quest_book.plot_bar.position,
|
||||||
|
player.quest_book.plot_bar.max_,
|
||||||
|
),
|
||||||
|
"QuestBar": bar(
|
||||||
|
player.quest_book.quest_bar.position,
|
||||||
|
player.quest_book.quest_bar.max_,
|
||||||
|
),
|
||||||
|
"TaskBar": bar(player.task_bar.position, player.task_bar.max_),
|
||||||
|
"queue": [_encode_queue_entry(task) for task in player.queue],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def read_file(path: Path, data: GameData) -> tuple[Player, list[str]]:
|
||||||
|
"""Import the character in a .pqw file, plus any warnings."""
|
||||||
|
try:
|
||||||
|
text = path.read_text(encoding="ascii", errors="replace")
|
||||||
|
except OSError as ex:
|
||||||
|
raise PqwError(f"cannot read {path}: {ex.strerror}") from ex
|
||||||
|
return import_game(decode(text), data)
|
||||||
|
|
||||||
|
|
||||||
|
def write_file(player: Player, path: Path, data: GameData) -> None:
|
||||||
|
"""Export a character to a .pqw file, never overwriting one."""
|
||||||
|
text = encode(export_game(player, data))
|
||||||
|
try:
|
||||||
|
with path.open("x", encoding="ascii") as handle:
|
||||||
|
handle.write(text)
|
||||||
|
except FileExistsError as ex:
|
||||||
|
raise PqwError(f"{path} already exists") from ex
|
||||||
|
except OSError as ex:
|
||||||
|
raise PqwError(f"cannot write {path}: {ex.strerror}") from ex
|
||||||
|
|
||||||
|
|
||||||
|
def default_file_name(player: Player) -> str:
|
||||||
|
# the web version names its downloads the same way
|
||||||
|
return f"{player.name}.pqw"
|
||||||
+105
-7
@@ -1,21 +1,119 @@
|
|||||||
import random
|
import contextlib
|
||||||
import typing as T
|
import time
|
||||||
|
from collections.abc import Callable, Generator, Sequence
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
RngState = tuple[float, float, float, int]
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
_2_POW_32 = 0x100000000
|
||||||
|
_2_POW_MINUS_32 = 2.3283064365386963e-10
|
||||||
|
|
||||||
|
|
||||||
def seed(source: T.Any) -> None:
|
def _to_uint32(value: float) -> float:
|
||||||
random.seed(source)
|
# JS `value >>> 0` for the non-negative values Mash produces
|
||||||
|
return float(int(value) % _2_POW_32)
|
||||||
|
|
||||||
|
|
||||||
def choice(source: T.Sequence[T.Any]) -> T.Any:
|
def _mash() -> Callable[[str], float]:
|
||||||
|
n = float(0xEFC8249D)
|
||||||
|
|
||||||
|
def mash(data: str) -> float:
|
||||||
|
nonlocal n
|
||||||
|
# JS strings are UTF-16, and charCodeAt() returns code units
|
||||||
|
units = data.encode("utf-16-le")
|
||||||
|
for i in range(0, len(units), 2):
|
||||||
|
n += units[i] | (units[i + 1] << 8)
|
||||||
|
h = 0.02519603282416938 * n
|
||||||
|
n = _to_uint32(h)
|
||||||
|
h -= n
|
||||||
|
h *= n
|
||||||
|
n = _to_uint32(h)
|
||||||
|
h -= n
|
||||||
|
n += h * _2_POW_32
|
||||||
|
return _to_uint32(n) * _2_POW_MINUS_32
|
||||||
|
|
||||||
|
return mash
|
||||||
|
|
||||||
|
|
||||||
|
class Alea:
|
||||||
|
"""Johannes Baagøe's Alea PRNG (MIT), as used by Progress Quest web.
|
||||||
|
|
||||||
|
Ported bit for bit so that games seeded alike play out alike. Saves
|
||||||
|
pickle this class by import path, so it must stay in this module.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, *args: str) -> None:
|
||||||
|
if not args:
|
||||||
|
args = (str(int(time.time() * 1000)),)
|
||||||
|
mash = _mash()
|
||||||
|
self._s0 = mash(" ")
|
||||||
|
self._s1 = mash(" ")
|
||||||
|
self._s2 = mash(" ")
|
||||||
|
self._c = 1
|
||||||
|
for arg in args:
|
||||||
|
self._s0 -= mash(arg)
|
||||||
|
if self._s0 < 0:
|
||||||
|
self._s0 += 1
|
||||||
|
self._s1 -= mash(arg)
|
||||||
|
if self._s1 < 0:
|
||||||
|
self._s1 += 1
|
||||||
|
self._s2 -= mash(arg)
|
||||||
|
if self._s2 < 0:
|
||||||
|
self._s2 += 1
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_state(cls, state: RngState) -> "Alea":
|
||||||
|
rng = cls.__new__(cls)
|
||||||
|
rng._s0, rng._s1, rng._s2, rng._c = state
|
||||||
|
return rng
|
||||||
|
|
||||||
|
def state(self) -> RngState:
|
||||||
|
return (self._s0, self._s1, self._s2, self._c)
|
||||||
|
|
||||||
|
def random(self) -> float:
|
||||||
|
t = 2091639 * self._s0 + self._c * _2_POW_MINUS_32
|
||||||
|
self._s0 = self._s1
|
||||||
|
self._s1 = self._s2
|
||||||
|
self._c = int(t)
|
||||||
|
self._s2 = t - self._c
|
||||||
|
return self._s2
|
||||||
|
|
||||||
|
def uint32(self) -> int:
|
||||||
|
return int(self.random() * _2_POW_32)
|
||||||
|
|
||||||
|
|
||||||
|
_current = Alea()
|
||||||
|
|
||||||
|
|
||||||
|
def seed(source: str) -> None:
|
||||||
|
global _current
|
||||||
|
_current = Alea(source)
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def using(rng: Alea) -> Generator[None, None, None]:
|
||||||
|
"""Draw from rng instead of the shared generator inside the block."""
|
||||||
|
global _current
|
||||||
|
previous = _current
|
||||||
|
_current = rng
|
||||||
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
_current = previous
|
||||||
|
|
||||||
|
|
||||||
|
def choice(source: Sequence[T]) -> T:
|
||||||
return source[below(len(source))]
|
return source[below(len(source))]
|
||||||
|
|
||||||
|
|
||||||
def choice_low(source: T.Sequence[T.Any]) -> T.Any:
|
def choice_low(source: Sequence[T]) -> T:
|
||||||
return source[below_low(len(source))]
|
return source[below_low(len(source))]
|
||||||
|
|
||||||
|
|
||||||
def below(num: int) -> int:
|
def below(num: int) -> int:
|
||||||
return random.randint(0, num - 1)
|
return _current.uint32() % num
|
||||||
|
|
||||||
|
|
||||||
def below_low(num: int) -> int:
|
def below_low(num: int) -> int:
|
||||||
|
|||||||
+13
-3
@@ -1,17 +1,19 @@
|
|||||||
|
import logging
|
||||||
import pickle
|
import pickle
|
||||||
import typing as T
|
from datetime import datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from pqcli.mechanic import Player
|
from pqcli.mechanic import Player
|
||||||
|
|
||||||
|
|
||||||
class Roster:
|
class Roster:
|
||||||
def __init__(self, path: Path, players: T.List[Player]) -> None:
|
def __init__(self, path: Path, players: list[Player]) -> None:
|
||||||
self.path = path
|
self.path = path
|
||||||
self.players = players
|
self.players = players
|
||||||
|
self._last_save = datetime.now()
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def load(path: T.Union[str, Path]) -> "Roster":
|
def load(path: str | Path) -> "Roster":
|
||||||
real_path = Path(path)
|
real_path = Path(path)
|
||||||
if real_path.exists():
|
if real_path.exists():
|
||||||
return Roster(
|
return Roster(
|
||||||
@@ -20,10 +22,18 @@ class Roster:
|
|||||||
return Roster(real_path, players=[])
|
return Roster(real_path, players=[])
|
||||||
|
|
||||||
def save(self) -> None:
|
def save(self) -> None:
|
||||||
|
self._last_save = datetime.now()
|
||||||
old_path = self.path.with_name(self.path.name + ".old")
|
old_path = self.path.with_name(self.path.name + ".old")
|
||||||
tmp_path = self.path.with_name(self.path.name + ".new")
|
tmp_path = self.path.with_name(self.path.name + ".new")
|
||||||
tmp_path.parent.mkdir(parents=True, exist_ok=True)
|
tmp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
tmp_path.write_bytes(pickle.dumps(self.players))
|
tmp_path.write_bytes(pickle.dumps(self.players))
|
||||||
if self.path.exists():
|
if self.path.exists():
|
||||||
|
if old_path.exists():
|
||||||
|
old_path.unlink()
|
||||||
self.path.rename(old_path)
|
self.path.rename(old_path)
|
||||||
tmp_path.rename(self.path)
|
tmp_path.rename(self.path)
|
||||||
|
|
||||||
|
def save_periodically(self) -> None:
|
||||||
|
if (datetime.now() - self._last_save).total_seconds() >= 300:
|
||||||
|
logging.info("Saving...")
|
||||||
|
self.save()
|
||||||
-115
@@ -1,115 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import datetime
|
|
||||||
import logging
|
|
||||||
import sys
|
|
||||||
from pathlib import Path
|
|
||||||
from signal import SIG_DFL, SIGPIPE, signal
|
|
||||||
|
|
||||||
from pqcli import random
|
|
||||||
from pqcli.config import CLASSES, RACES
|
|
||||||
from pqcli.mechanic import (
|
|
||||||
Player,
|
|
||||||
Simulation,
|
|
||||||
StatsBuilder,
|
|
||||||
act_name,
|
|
||||||
create_player,
|
|
||||||
generate_name,
|
|
||||||
)
|
|
||||||
from pqcli.roster import Roster
|
|
||||||
|
|
||||||
signal(SIGPIPE, SIG_DFL)
|
|
||||||
logging.basicConfig(
|
|
||||||
stream=sys.stdout, level=logging.DEBUG, format="%(message)s"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# make things predictable
|
|
||||||
SEED_A = 1_664_525
|
|
||||||
SEED_B = 2141
|
|
||||||
SEED_C = 1_013_904_223
|
|
||||||
|
|
||||||
|
|
||||||
def custom_random(num: int) -> int:
|
|
||||||
global SEED_A, SEED_B, SEED_C
|
|
||||||
SEED_B = (SEED_A * SEED_B + SEED_C) % 0x100000000
|
|
||||||
return SEED_B % num
|
|
||||||
|
|
||||||
|
|
||||||
random.below = custom_random
|
|
||||||
|
|
||||||
|
|
||||||
def character_sheet(player: Player) -> None:
|
|
||||||
print(f"{player.name} the {player.race.name}")
|
|
||||||
print(f"Level {player.level} {player.class_.name}")
|
|
||||||
print(f"Plot stage: {act_name(player.quest_book.act)}")
|
|
||||||
print(f"Quest: {player.quest_book.current_quest}")
|
|
||||||
print()
|
|
||||||
print("Stats:")
|
|
||||||
for stat, value in player.stats:
|
|
||||||
print(f" {stat.value}: {value}")
|
|
||||||
print()
|
|
||||||
print(f"Equipment (best: {player.equipment.best}):")
|
|
||||||
for equipment, item_name in player.equipment:
|
|
||||||
print(f" {equipment.value}: {item_name}")
|
|
||||||
print()
|
|
||||||
print("Spell Book:")
|
|
||||||
for spell in player.spell_book:
|
|
||||||
print(f" {spell.name}: {spell.level}")
|
|
||||||
print()
|
|
||||||
print("Inventory:")
|
|
||||||
print(f" Gold: {player.inventory.gold}")
|
|
||||||
for item in player.inventory:
|
|
||||||
print(f" {item.name}: {item.quantity}")
|
|
||||||
|
|
||||||
|
|
||||||
def parse_args() -> argparse.Namespace:
|
|
||||||
parser = argparse.ArgumentParser()
|
|
||||||
parser.add_argument("-r", "--roster", type=Path)
|
|
||||||
parser.add_argument("-l", "--max-level", type=int, default=5)
|
|
||||||
return parser.parse_args()
|
|
||||||
|
|
||||||
|
|
||||||
def random_player() -> Player:
|
|
||||||
return create_player(
|
|
||||||
stats=StatsBuilder().roll(),
|
|
||||||
name=generate_name(),
|
|
||||||
race=random.choice(RACES),
|
|
||||||
class_=random.choice(CLASSES),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
args = parse_args()
|
|
||||||
|
|
||||||
if args.roster:
|
|
||||||
roster = Roster.load(args.roster)
|
|
||||||
if not roster.players:
|
|
||||||
roster.players.append(random_player())
|
|
||||||
roster.save()
|
|
||||||
player = roster.players[0]
|
|
||||||
else:
|
|
||||||
roster = None
|
|
||||||
player = random_player()
|
|
||||||
|
|
||||||
start = datetime.datetime.now()
|
|
||||||
elapsed = 0
|
|
||||||
last_level = 0
|
|
||||||
simulation = Simulation(player)
|
|
||||||
try:
|
|
||||||
while True:
|
|
||||||
if player.level != last_level:
|
|
||||||
last_level = player.level
|
|
||||||
character_sheet(player)
|
|
||||||
if player.level >= args.max_level:
|
|
||||||
break
|
|
||||||
simulation.tick()
|
|
||||||
elapsed += 100
|
|
||||||
finally:
|
|
||||||
print("real time: ", (datetime.datetime.now() - start))
|
|
||||||
print("virtual time: ", datetime.timedelta(milliseconds=elapsed))
|
|
||||||
if roster:
|
|
||||||
roster.save()
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import json
|
|
||||||
import typing as T
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
import xdg
|
|
||||||
|
|
||||||
from pqcli import random
|
|
||||||
from pqcli.mechanic import Player
|
|
||||||
from pqcli.roster import Roster
|
|
||||||
from pqcli.ui.confirm_dialog import ConfirmDialog
|
|
||||||
from pqcli.ui.game_view import GameView
|
|
||||||
from pqcli.ui.new_game_view import NewGameView
|
|
||||||
from pqcli.ui.roster_view import RosterView
|
|
||||||
|
|
||||||
|
|
||||||
TICK_FREQ = 0.1
|
|
||||||
SAVE_FREQ = 60.0
|
|
||||||
PALETTE: T.List[T.Tuple[str, ...]] = [
|
|
||||||
("button", "", ""),
|
|
||||||
("button-focus", "light red", "black"),
|
|
||||||
("linebox", "", ""),
|
|
||||||
("linebox-focus", "light red", "black"),
|
|
||||||
("linebox-content", "", ""),
|
|
||||||
("linebox-content-focus", "", ""),
|
|
||||||
("progressbar-normal", "", "", "standout"),
|
|
||||||
("progressbar-done", "black", "light red"),
|
|
||||||
("progressbar-smooth", "light red", ""),
|
|
||||||
("scrollbar", "light gray", "black"),
|
|
||||||
("scrollbar-focus", "light red", "black"),
|
|
||||||
("scrollable", "", ""),
|
|
||||||
("scrollable-focus", "", ""),
|
|
||||||
]
|
|
||||||
PALETTE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "palette.json"
|
|
||||||
|
|
||||||
|
|
||||||
def load_palette() -> bool:
|
|
||||||
global PALETTE
|
|
||||||
if PALETTE_PATH.exists():
|
|
||||||
PALETTE = json.loads(PALETTE_PATH.read_text())
|
|
||||||
return True
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
def save_palette() -> None:
|
|
||||||
global PALETTE
|
|
||||||
PALETTE_PATH.parent.mkdir(exist_ok=True, parents=True)
|
|
||||||
PALETTE_PATH.write_text(json.dumps(PALETTE, indent=4))
|
|
||||||
|
|
||||||
|
|
||||||
class ConfirmExitDialog(ConfirmDialog):
|
|
||||||
def __init__(self, old_view: urwid.Widget) -> None:
|
|
||||||
super().__init__("Really quit?", old_view)
|
|
||||||
|
|
||||||
|
|
||||||
def bind_commands() -> None:
|
|
||||||
for key, direction in {
|
|
||||||
"k": "up",
|
|
||||||
"j": "down",
|
|
||||||
"h": "left",
|
|
||||||
"l": "right",
|
|
||||||
}.items():
|
|
||||||
for key_variant in {key, key.upper()}:
|
|
||||||
urwid.command_map[key_variant] = f"cursor {direction}"
|
|
||||||
|
|
||||||
|
|
||||||
class Ui:
|
|
||||||
def __init__(self, roster: Roster, args: argparse.Namespace) -> None:
|
|
||||||
bind_commands()
|
|
||||||
|
|
||||||
if args.use_config:
|
|
||||||
if not load_palette():
|
|
||||||
save_palette() # create a file for the user to edit
|
|
||||||
|
|
||||||
self.roster = roster
|
|
||||||
self.args = args
|
|
||||||
self.loop = urwid.MainLoop(
|
|
||||||
None, PALETTE, unhandled_input=self.unhandled_input
|
|
||||||
)
|
|
||||||
self.old_views: T.List[urwid.Widget] = []
|
|
||||||
|
|
||||||
self.switch_to_roster_view()
|
|
||||||
|
|
||||||
def run(self) -> None:
|
|
||||||
self.loop.set_alarm_in(TICK_FREQ, self.on_tick)
|
|
||||||
self.loop.set_alarm_in(SAVE_FREQ, self.on_save)
|
|
||||||
self.loop.run()
|
|
||||||
|
|
||||||
def unhandled_input(self, key: str) -> bool:
|
|
||||||
callback = getattr(self.loop.widget, "unhandled_input", None)
|
|
||||||
if callback and callback(key):
|
|
||||||
return True
|
|
||||||
|
|
||||||
if key == "ctrl q":
|
|
||||||
self.switch_to_exit_view()
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
def switch_to_roster_view(self) -> None:
|
|
||||||
self.loop.widget = RosterView(self.roster)
|
|
||||||
self._connect("load_game", self.switch_to_game_view)
|
|
||||||
self._connect("new_game", self.switch_to_new_game_view)
|
|
||||||
self._connect("delete_game", self.switch_to_delete_player_view)
|
|
||||||
self._connect("exit", self.switch_to_exit_view)
|
|
||||||
|
|
||||||
def switch_to_exit_view(self) -> None:
|
|
||||||
if isinstance(self.loop.widget, ConfirmExitDialog):
|
|
||||||
return
|
|
||||||
self.old_views.append(self.loop.widget)
|
|
||||||
self.loop.widget = ConfirmExitDialog(self.old_views[-1])
|
|
||||||
self._connect("confirm", self.exit)
|
|
||||||
self._connect("cancel", self.cancel_dialog)
|
|
||||||
|
|
||||||
def switch_to_new_game_view(self) -> None:
|
|
||||||
self.loop.widget = NewGameView()
|
|
||||||
self._connect("confirm", self.create_player)
|
|
||||||
self._connect("cancel", self.switch_to_roster_view)
|
|
||||||
|
|
||||||
def create_player(self, player: Player) -> None:
|
|
||||||
self.roster.players.append(player)
|
|
||||||
self.switch_to_roster_view()
|
|
||||||
|
|
||||||
def delete_player(self, player_idx: int) -> None:
|
|
||||||
del self.roster.players[player_idx]
|
|
||||||
self.switch_to_roster_view()
|
|
||||||
|
|
||||||
def switch_to_game_view(self, player_idx: int) -> None:
|
|
||||||
player = self.roster.players[player_idx]
|
|
||||||
self.loop.widget = GameView(self.loop, player, self.args)
|
|
||||||
self._connect("cancel", self.switch_to_roster_view)
|
|
||||||
|
|
||||||
def switch_to_delete_player_view(self, player_idx: int) -> None:
|
|
||||||
adjective = random.choice(["faithful", "noble", "loyal", "brave"])
|
|
||||||
player_name = self.roster.players[player_idx].name
|
|
||||||
|
|
||||||
self.old_views.append(self.loop.widget)
|
|
||||||
self.loop.widget = ConfirmDialog(
|
|
||||||
f"Terminate {adjective} {player_name}?", self.old_views[-1]
|
|
||||||
)
|
|
||||||
self._connect("confirm", lambda: self.delete_player(player_idx))
|
|
||||||
self._connect("cancel", self.cancel_dialog)
|
|
||||||
|
|
||||||
def exit(self) -> None:
|
|
||||||
raise urwid.ExitMainLoop()
|
|
||||||
|
|
||||||
def cancel_dialog(self) -> None:
|
|
||||||
assert len(self.old_views)
|
|
||||||
self.loop.widget = self.old_views.pop()
|
|
||||||
|
|
||||||
def _connect(self, signal_name: str, callback: T.Callable) -> None:
|
|
||||||
urwid.signals.connect_signal(
|
|
||||||
self.loop.widget,
|
|
||||||
signal_name,
|
|
||||||
lambda _sender, *args: callback(*args),
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_tick(self, _loop: urwid.MainLoop, _user_data: T.Any) -> None:
|
|
||||||
if hasattr(self.loop.widget, "tick"):
|
|
||||||
self.loop.widget.tick()
|
|
||||||
self.loop.set_alarm_in(TICK_FREQ, self.on_tick)
|
|
||||||
|
|
||||||
def on_save(self, _loop: urwid.MainLoop, _user_data: T.Any) -> None:
|
|
||||||
if self.args.use_saves:
|
|
||||||
self.roster.save()
|
|
||||||
self.loop.set_alarm_in(SAVE_FREQ, self.on_save)
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import abc
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
from pqcli.game_data import GameData
|
||||||
|
from pqcli.mechanic import Player
|
||||||
|
from pqcli.roster import Roster
|
||||||
|
|
||||||
|
|
||||||
|
class BaseUserInterface(abc.ABC):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
roster: Roster,
|
||||||
|
player: Player | None,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
data: GameData,
|
||||||
|
) -> None:
|
||||||
|
self.roster = roster
|
||||||
|
self.data = data
|
||||||
|
self.args = args
|
||||||
|
self.player = player
|
||||||
|
|
||||||
|
@abc.abstractmethod
|
||||||
|
def run(self) -> None:
|
||||||
|
raise NotImplementedError("not implemented")
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import argparse
|
||||||
|
import enum
|
||||||
|
import logging
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from types import FrameType
|
||||||
|
from typing import TypeVar
|
||||||
|
|
||||||
|
from pqcli import lingo, pqw
|
||||||
|
from pqcli.config import PRIME_STATS
|
||||||
|
from pqcli.game_data import GameData
|
||||||
|
from pqcli.mechanic import Player, Simulation, StatsBuilder, create_player
|
||||||
|
from pqcli.roster import Roster
|
||||||
|
from pqcli.ui.base import BaseUserInterface
|
||||||
|
|
||||||
|
LOGO = r"""
|
||||||
|
____ ___ _
|
||||||
|
| _ \ _ __ ___ __ _ _ __ ___ ___ ___ / _ \ _ _ ___ ___| |_
|
||||||
|
| |_) | '__/ _ \ / _` | '__/ _ \/ __/ __| | | | | | |/ _ \/ __| __|
|
||||||
|
| __/| | | (_) | (_| | | | __/\__ \__ \ |_| | |_| | __/\__ \ |_
|
||||||
|
|_| |_| \___/ \__, |_| \___||___/___/\__\_\\__,_|\___||___/\__|
|
||||||
|
|___/
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
T = TypeVar("T")
|
||||||
|
|
||||||
|
|
||||||
|
class MainMenu(enum.IntEnum):
|
||||||
|
create = 1
|
||||||
|
play = 2
|
||||||
|
info = 3
|
||||||
|
delete = 4
|
||||||
|
import_ = 5
|
||||||
|
export = 6
|
||||||
|
quit = 7
|
||||||
|
|
||||||
|
|
||||||
|
class BasicUserInterface(BaseUserInterface):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
roster: Roster,
|
||||||
|
player: Player | None,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
data: GameData,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(roster, player, args, data)
|
||||||
|
|
||||||
|
def signal_handler(sig: int, frame: FrameType | None) -> None:
|
||||||
|
logging.info("Quitting")
|
||||||
|
if self.args.use_saves:
|
||||||
|
self.roster.save()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
logging.basicConfig(
|
||||||
|
stream=sys.stdout,
|
||||||
|
level=logging.INFO,
|
||||||
|
format="[%(asctime)s] %(message)s",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.logo()
|
||||||
|
if self.player:
|
||||||
|
self.play(self.player)
|
||||||
|
else:
|
||||||
|
self.main_menu()
|
||||||
|
|
||||||
|
def logo(self) -> None:
|
||||||
|
print(LOGO)
|
||||||
|
|
||||||
|
def main_menu(self) -> None:
|
||||||
|
while True:
|
||||||
|
choice = self.menu(
|
||||||
|
[
|
||||||
|
(MainMenu.create, "Create a new character"),
|
||||||
|
(MainMenu.play, "Play as character"),
|
||||||
|
(MainMenu.info, "Query character info"),
|
||||||
|
(MainMenu.delete, "Delete a character"),
|
||||||
|
(MainMenu.import_, "Import a character (.pqw)"),
|
||||||
|
(MainMenu.export, "Export a character (.pqw)"),
|
||||||
|
(MainMenu.quit, "Quit"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
if choice == MainMenu.create:
|
||||||
|
player = self.create_player()
|
||||||
|
|
||||||
|
elif choice == MainMenu.play:
|
||||||
|
player = self.choose_player()
|
||||||
|
if not player:
|
||||||
|
continue
|
||||||
|
self.play(player)
|
||||||
|
|
||||||
|
elif choice == MainMenu.info:
|
||||||
|
player = self.choose_player()
|
||||||
|
if not player:
|
||||||
|
continue
|
||||||
|
self.print_player_info(player)
|
||||||
|
|
||||||
|
elif choice == MainMenu.delete:
|
||||||
|
player = self.choose_player()
|
||||||
|
if not player:
|
||||||
|
continue
|
||||||
|
self.delete_player(player)
|
||||||
|
|
||||||
|
elif choice == MainMenu.import_:
|
||||||
|
self.import_player()
|
||||||
|
|
||||||
|
elif choice == MainMenu.export:
|
||||||
|
player = self.choose_player()
|
||||||
|
if not player:
|
||||||
|
continue
|
||||||
|
self.export_player(player)
|
||||||
|
|
||||||
|
elif choice == MainMenu.quit:
|
||||||
|
self.quit()
|
||||||
|
break
|
||||||
|
|
||||||
|
def create_player(self, *, auto_play: bool = True) -> Player | None:
|
||||||
|
name = input("Name your new character: ")
|
||||||
|
if not name:
|
||||||
|
print("Cancelled.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
race = self.menu([(race, race.name) for race in self.data.races])
|
||||||
|
class_ = self.menu(
|
||||||
|
[(class_, class_.name) for class_ in self.data.classes]
|
||||||
|
)
|
||||||
|
|
||||||
|
stats_builder = StatsBuilder()
|
||||||
|
while True:
|
||||||
|
stats = stats_builder.roll()
|
||||||
|
for stat in PRIME_STATS:
|
||||||
|
print(f"{stat.value}: {stats[stat]}")
|
||||||
|
print(f"Total: {sum(stats[stat] for stat in PRIME_STATS)}")
|
||||||
|
if self.confirm("Is this okay?"):
|
||||||
|
break
|
||||||
|
|
||||||
|
player = create_player(
|
||||||
|
name=name,
|
||||||
|
race=race,
|
||||||
|
class_=class_,
|
||||||
|
stats=stats,
|
||||||
|
seed=stats_builder.seed,
|
||||||
|
)
|
||||||
|
self.roster.players.append(player)
|
||||||
|
if auto_play and self.confirm(
|
||||||
|
"Do you want to play as your new character?"
|
||||||
|
):
|
||||||
|
self.play(player)
|
||||||
|
return player
|
||||||
|
|
||||||
|
def play(self, player: Player) -> None:
|
||||||
|
print(f"Playing as {player.name}")
|
||||||
|
simulation = Simulation(player, self.data)
|
||||||
|
last_tick = datetime.now()
|
||||||
|
last_level = 0
|
||||||
|
while True:
|
||||||
|
elapsed = datetime.now() - last_tick
|
||||||
|
simulation.tick(elapsed.total_seconds() * 1000)
|
||||||
|
last_tick = datetime.now()
|
||||||
|
wait = (timedelta(milliseconds=100) - elapsed).total_seconds()
|
||||||
|
wait = max(0, wait)
|
||||||
|
time.sleep(wait)
|
||||||
|
if player.level != last_level:
|
||||||
|
last_level = player.level
|
||||||
|
self.print_player_info(player)
|
||||||
|
if self.args.use_saves:
|
||||||
|
self.roster.save_periodically()
|
||||||
|
|
||||||
|
def print_player_info(self, player: Player) -> None:
|
||||||
|
print("--- Character Sheet ---")
|
||||||
|
print(f"Name: {player.name}")
|
||||||
|
print(f"Race: {player.race.name}")
|
||||||
|
print(f"Class: {player.class_.name}")
|
||||||
|
print(f"Level: {player.level}")
|
||||||
|
print()
|
||||||
|
for stat, value in player.stats:
|
||||||
|
print(f"{stat.value}: {value}")
|
||||||
|
print()
|
||||||
|
print("--- Spell Book ---")
|
||||||
|
if not player.spell_book:
|
||||||
|
print("No spells memorized yet.")
|
||||||
|
else:
|
||||||
|
for spell in player.spell_book:
|
||||||
|
print(f"{spell.name} {lingo.to_roman(spell.level)}")
|
||||||
|
print()
|
||||||
|
print("--- Equipment ---")
|
||||||
|
for equipment_type, name in player.equipment:
|
||||||
|
print(f"{equipment_type.value}: {name}")
|
||||||
|
print()
|
||||||
|
print("--- Inventory ---")
|
||||||
|
print(f"Gold: {player.inventory.gold}")
|
||||||
|
for item in player.inventory:
|
||||||
|
print(f"{item.name}: {item.quantity}")
|
||||||
|
print()
|
||||||
|
print("--- Plot ---")
|
||||||
|
print(f"Current act: {lingo.to_roman(player.quest_book.act)}")
|
||||||
|
print(f"Current quest: {player.quest_book.current_quest or '?'}")
|
||||||
|
print(
|
||||||
|
f"Current task: {player.task.description if player.task else '?'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def delete_player(self, player: Player) -> None:
|
||||||
|
if self.confirm(lingo.terminate_message(player.name)):
|
||||||
|
del self.roster.players[self.roster.players.index(player)]
|
||||||
|
|
||||||
|
def quit(self) -> None:
|
||||||
|
if self.args.use_saves:
|
||||||
|
self.roster.save()
|
||||||
|
|
||||||
|
def import_player(self) -> None:
|
||||||
|
path = input("Import from .pqw file: ").strip()
|
||||||
|
if not path:
|
||||||
|
print("Cancelled.")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
player, warnings = pqw.read_file(
|
||||||
|
Path(path).expanduser(), self.data
|
||||||
|
)
|
||||||
|
except pqw.PqwError as ex:
|
||||||
|
print(f"Cannot import: {ex}")
|
||||||
|
return
|
||||||
|
for warning in warnings:
|
||||||
|
print(f"Warning: {warning}")
|
||||||
|
self.roster.players.append(player)
|
||||||
|
if self.args.use_saves:
|
||||||
|
self.roster.save()
|
||||||
|
print(f"Imported {player.name}.")
|
||||||
|
|
||||||
|
def export_player(self, player: Player) -> None:
|
||||||
|
default = pqw.default_file_name(player)
|
||||||
|
path = input(f"Export to .pqw file [{default}]: ").strip() or default
|
||||||
|
try:
|
||||||
|
pqw.write_file(player, Path(path).expanduser(), self.data)
|
||||||
|
except pqw.PqwError as ex:
|
||||||
|
print(f"Cannot export: {ex}")
|
||||||
|
return
|
||||||
|
print(f"Exported {player.name} to {path}.")
|
||||||
|
|
||||||
|
def choose_player(self) -> Player | None:
|
||||||
|
if not self.roster.players:
|
||||||
|
print("No characters to choose from!")
|
||||||
|
return None
|
||||||
|
|
||||||
|
return self.menu(
|
||||||
|
[(player, player.name) for player in self.roster.players],
|
||||||
|
title="Choose your character",
|
||||||
|
)
|
||||||
|
|
||||||
|
def confirm(self, message: str) -> bool:
|
||||||
|
while True:
|
||||||
|
choice = input(f"{message} [y/n] ")
|
||||||
|
if choice.lower() in {"y", "yes", "1"}:
|
||||||
|
return True
|
||||||
|
if choice.lower() in {"n", "no", "0"}:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def menu(
|
||||||
|
self,
|
||||||
|
options: Sequence[tuple[T, str]],
|
||||||
|
title: str | None = None,
|
||||||
|
) -> T:
|
||||||
|
print()
|
||||||
|
if title:
|
||||||
|
print(f"{title}:")
|
||||||
|
for i, item in enumerate(options, 1):
|
||||||
|
_, description = item
|
||||||
|
print(f"{i}) {description}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
num = int(input("Your choice: "))
|
||||||
|
except ValueError:
|
||||||
|
print("Not a number")
|
||||||
|
continue
|
||||||
|
if num not in range(1, len(options) + 1):
|
||||||
|
print(f"Expected a number between 1..{len(options)}")
|
||||||
|
continue
|
||||||
|
return options[num - 1][0]
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
import typing as T
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
|
|
||||||
from pqcli.ui.custom_button import CustomButton
|
|
||||||
from pqcli.ui.custom_line_box import CustomLineBox
|
|
||||||
from pqcli.ui.layout import NPile
|
|
||||||
|
|
||||||
|
|
||||||
class ConfirmDialog(urwid.Overlay):
|
|
||||||
signals = ["confirm", "cancel"]
|
|
||||||
|
|
||||||
def __init__(self, label: str, parent: urwid.Widget) -> None:
|
|
||||||
question = urwid.Text(("bold", label), "center")
|
|
||||||
yes_btn = CustomButton(
|
|
||||||
"Yes", hint="Y", on_press=lambda _user_data: self.confirm()
|
|
||||||
)
|
|
||||||
no_btn = CustomButton(
|
|
||||||
"No", hint="N", on_press=lambda _user_data: self.cancel()
|
|
||||||
)
|
|
||||||
|
|
||||||
line_box = CustomLineBox(
|
|
||||||
urwid.Filler(NPile([question, no_btn, yes_btn], outermost=True))
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
line_box, parent, "center", len(label) + 6, "middle", 5
|
|
||||||
)
|
|
||||||
|
|
||||||
def keypress(self, size: T.Any, key: str) -> T.Optional[str]:
|
|
||||||
if key in {"y", "Y"}:
|
|
||||||
self.confirm()
|
|
||||||
return None
|
|
||||||
if key in {"n", "N"}:
|
|
||||||
self.cancel()
|
|
||||||
return None
|
|
||||||
return T.cast(T.Optional[str], super().keypress(size, key))
|
|
||||||
|
|
||||||
def confirm(self) -> None:
|
|
||||||
self._emit("confirm")
|
|
||||||
|
|
||||||
def cancel(self) -> None:
|
|
||||||
self._emit("cancel")
|
|
||||||
|
|
||||||
def tick(self) -> None:
|
|
||||||
if hasattr(self.bottom_w, "tick"):
|
|
||||||
self.bottom_w.tick()
|
|
||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import argparse
|
||||||
|
import curses
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from types import FrameType
|
||||||
|
|
||||||
|
from pqcli import lingo, pqw
|
||||||
|
from pqcli.config import Class, Race
|
||||||
|
from pqcli.game_data import GameData
|
||||||
|
from pqcli.mechanic import Player, Stats, create_player
|
||||||
|
from pqcli.random import RngState
|
||||||
|
from pqcli.roster import Roster
|
||||||
|
from pqcli.ui.base import BaseUserInterface
|
||||||
|
from pqcli.ui.curses.colors import (
|
||||||
|
COLOR_FOCUSED,
|
||||||
|
COLOR_HIGHLIGHT,
|
||||||
|
COLOR_LOGO,
|
||||||
|
COLOR_LOGO_ALT,
|
||||||
|
COLOR_PROGRESSBAR,
|
||||||
|
COLOR_SCROLLBAR_THUMB,
|
||||||
|
COLOR_SCROLLBAR_TRACK,
|
||||||
|
has_colors,
|
||||||
|
set_colors,
|
||||||
|
)
|
||||||
|
from pqcli.ui.curses.views import (
|
||||||
|
BaseView,
|
||||||
|
ChooseCharacterClassView,
|
||||||
|
ChooseCharacterNameView,
|
||||||
|
ChooseCharacterRaceView,
|
||||||
|
ChooseCharacterStatsView,
|
||||||
|
ChooseCharacterView,
|
||||||
|
ConfirmView,
|
||||||
|
GameView,
|
||||||
|
MessageView,
|
||||||
|
RosterView,
|
||||||
|
TextInputView,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StopMainLoop(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class CursesUserInterface(BaseUserInterface):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
roster: Roster,
|
||||||
|
player: Player,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
data: GameData,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(roster, player, args, data)
|
||||||
|
os.environ.setdefault("ESCDELAY", "25")
|
||||||
|
self._screen = curses.initscr()
|
||||||
|
self._screen.nodelay(True)
|
||||||
|
self._screen.keypad(True)
|
||||||
|
curses.noecho()
|
||||||
|
curses.curs_set(0)
|
||||||
|
|
||||||
|
set_colors(args.colors)
|
||||||
|
if has_colors():
|
||||||
|
curses.start_color()
|
||||||
|
curses.use_default_colors()
|
||||||
|
curses.init_pair(COLOR_LOGO, -1, -1)
|
||||||
|
curses.init_pair(COLOR_LOGO_ALT, -1, 9)
|
||||||
|
curses.init_pair(COLOR_FOCUSED, -1, 8)
|
||||||
|
curses.init_pair(COLOR_SCROLLBAR_THUMB, -1, 7)
|
||||||
|
curses.init_pair(COLOR_SCROLLBAR_TRACK, -1, 0)
|
||||||
|
curses.init_pair(COLOR_PROGRESSBAR, 0, 9)
|
||||||
|
curses.init_pair(COLOR_HIGHLIGHT, 3, -1)
|
||||||
|
|
||||||
|
def signal_handler(sig: int, frame: FrameType | None) -> None:
|
||||||
|
if self.args.use_saves:
|
||||||
|
self.roster.save()
|
||||||
|
exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, signal_handler)
|
||||||
|
|
||||||
|
self._roster_view = RosterView(self._screen)
|
||||||
|
self._roster_view.on_create += self._switch_to_create_char_name_view
|
||||||
|
self._roster_view.on_delete += self._switch_to_delete_char_view
|
||||||
|
self._roster_view.on_play += self._switch_to_play_view
|
||||||
|
self._roster_view.on_import += self._switch_to_import_view
|
||||||
|
self._roster_view.on_export += self._switch_to_export_char_view
|
||||||
|
self._roster_view.on_quit += self._quit
|
||||||
|
self._view: BaseView = self._roster_view
|
||||||
|
self._view.start()
|
||||||
|
|
||||||
|
def run(self) -> None:
|
||||||
|
try:
|
||||||
|
if self.player:
|
||||||
|
self._switch_to_game_view(self.player)
|
||||||
|
else:
|
||||||
|
self._switch_to_roster_view()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
key = self._screen.getch()
|
||||||
|
|
||||||
|
if key == curses.ERR:
|
||||||
|
time.sleep(0.1)
|
||||||
|
self._view.idle()
|
||||||
|
continue
|
||||||
|
|
||||||
|
if key == curses.KEY_RESIZE:
|
||||||
|
self._view.stop()
|
||||||
|
self._view.start()
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self._view.keypress(key)
|
||||||
|
except StopMainLoop:
|
||||||
|
break
|
||||||
|
|
||||||
|
finally:
|
||||||
|
curses.endwin()
|
||||||
|
curses.curs_set(1)
|
||||||
|
|
||||||
|
def _switch_to_roster_view(self) -> None:
|
||||||
|
self._switch_view(self._roster_view)
|
||||||
|
|
||||||
|
def _switch_to_create_char_name_view(
|
||||||
|
self, player_name: str | None = None
|
||||||
|
) -> None:
|
||||||
|
view = ChooseCharacterNameView(self._screen, player_name)
|
||||||
|
view.on_cancel += self._switch_to_roster_view
|
||||||
|
view.on_confirm += self._switch_to_create_char_race_view
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_create_char_race_view(
|
||||||
|
self, player_name: str, race: Race | None = None
|
||||||
|
) -> None:
|
||||||
|
view = ChooseCharacterRaceView(self._screen, self.data.races, race)
|
||||||
|
view.on_cancel += lambda: self._switch_to_create_char_name_view(
|
||||||
|
player_name
|
||||||
|
)
|
||||||
|
view.on_confirm += lambda race: self._switch_to_create_char_class_view(
|
||||||
|
player_name, race
|
||||||
|
)
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_create_char_class_view(
|
||||||
|
self, player_name: str, race: Race, class_: Class | None = None
|
||||||
|
) -> None:
|
||||||
|
view = ChooseCharacterClassView(
|
||||||
|
self._screen, self.data.classes, class_
|
||||||
|
)
|
||||||
|
view.on_cancel += lambda: self._switch_to_create_char_race_view(
|
||||||
|
player_name, race
|
||||||
|
)
|
||||||
|
view.on_confirm += lambda class_: (
|
||||||
|
self._switch_to_create_char_stats_view(player_name, race, class_)
|
||||||
|
)
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_create_char_stats_view(
|
||||||
|
self, player_name: str, race: Race, class_: Class
|
||||||
|
) -> None:
|
||||||
|
view = ChooseCharacterStatsView(self._screen)
|
||||||
|
view.on_cancel += lambda: self._switch_to_create_char_class_view(
|
||||||
|
player_name, race, class_
|
||||||
|
)
|
||||||
|
view.on_confirm += lambda stats, seed: self._create_character(
|
||||||
|
player_name, race, class_, stats, seed
|
||||||
|
)
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_delete_char_view(self) -> None:
|
||||||
|
view = ChooseCharacterView(
|
||||||
|
self._screen, self.roster, "Choose character to delete"
|
||||||
|
)
|
||||||
|
view.on_cancel += self._switch_to_roster_view
|
||||||
|
view.on_confirm += self._switch_to_confirm_delete_char_view
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_confirm_delete_char_view(self, player: Player) -> None:
|
||||||
|
view = ConfirmView(self._screen, lingo.terminate_message(player.name))
|
||||||
|
view.on_cancel += self._switch_to_delete_char_view
|
||||||
|
view.on_confirm += lambda: self._delete_character(player)
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_play_view(self) -> None:
|
||||||
|
view = ChooseCharacterView(
|
||||||
|
self._screen, self.roster, "Choose character to play"
|
||||||
|
)
|
||||||
|
view.on_cancel += self._switch_to_roster_view
|
||||||
|
view.on_confirm += self._switch_to_game_view
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_import_view(self) -> None:
|
||||||
|
view = TextInputView(self._screen, "Import from .pqw file:", "")
|
||||||
|
view.on_cancel += self._switch_to_roster_view
|
||||||
|
view.on_confirm += self._import_character
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_export_char_view(self) -> None:
|
||||||
|
view = ChooseCharacterView(
|
||||||
|
self._screen, self.roster, "Choose character to export"
|
||||||
|
)
|
||||||
|
view.on_cancel += self._switch_to_roster_view
|
||||||
|
view.on_confirm += self._switch_to_export_file_view
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_export_file_view(self, player: Player) -> None:
|
||||||
|
view = TextInputView(
|
||||||
|
self._screen,
|
||||||
|
f"Export {player.name} to .pqw file:",
|
||||||
|
pqw.default_file_name(player),
|
||||||
|
)
|
||||||
|
view.on_cancel += self._switch_to_export_char_view
|
||||||
|
view.on_confirm += lambda path: self._export_character(player, path)
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_to_message_view(self, message: str) -> None:
|
||||||
|
view = MessageView(self._screen, message)
|
||||||
|
view.on_close += self._switch_to_roster_view
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _import_character(self, path: str) -> None:
|
||||||
|
try:
|
||||||
|
player, warnings = pqw.read_file(
|
||||||
|
Path(path).expanduser(), self.data
|
||||||
|
)
|
||||||
|
except pqw.PqwError as ex:
|
||||||
|
self._switch_to_message_view(f"Cannot import: {ex}")
|
||||||
|
return
|
||||||
|
self.roster.players.append(player)
|
||||||
|
if self.args.use_saves:
|
||||||
|
self.roster.save()
|
||||||
|
self._switch_to_message_view(
|
||||||
|
"\n".join([f"Imported {player.name}.", *warnings])
|
||||||
|
)
|
||||||
|
|
||||||
|
def _export_character(self, player: Player, path: str) -> None:
|
||||||
|
try:
|
||||||
|
pqw.write_file(player, Path(path).expanduser(), self.data)
|
||||||
|
except pqw.PqwError as ex:
|
||||||
|
self._switch_to_message_view(f"Cannot export: {ex}")
|
||||||
|
return
|
||||||
|
self._switch_to_message_view(f"Exported {player.name} to {path}.")
|
||||||
|
|
||||||
|
def _switch_to_game_view(self, player: Player) -> None:
|
||||||
|
view = GameView(
|
||||||
|
self._screen, self.roster, player, self.args, self.data
|
||||||
|
)
|
||||||
|
view.on_exit += self._switch_to_roster_view
|
||||||
|
self._switch_view(view)
|
||||||
|
|
||||||
|
def _switch_view(self, view: BaseView) -> None:
|
||||||
|
self._view.stop()
|
||||||
|
self._view = view
|
||||||
|
self._view.start()
|
||||||
|
|
||||||
|
def _create_character(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
race: Race,
|
||||||
|
class_: Class,
|
||||||
|
stats: Stats,
|
||||||
|
seed: RngState,
|
||||||
|
) -> None:
|
||||||
|
player = create_player(name, race, class_, stats, seed)
|
||||||
|
self.roster.players.append(player)
|
||||||
|
if self.args.use_saves:
|
||||||
|
self.roster.save()
|
||||||
|
self._switch_to_game_view(player)
|
||||||
|
|
||||||
|
def _delete_character(self, player: Player) -> None:
|
||||||
|
self.roster.players.remove(player)
|
||||||
|
self._switch_to_roster_view()
|
||||||
|
|
||||||
|
def _quit(self) -> None:
|
||||||
|
raise StopMainLoop
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
_HAS_COLORS = False
|
||||||
|
|
||||||
|
COLOR_LOGO = 1
|
||||||
|
COLOR_LOGO_ALT = 2
|
||||||
|
COLOR_FOCUSED = 3
|
||||||
|
COLOR_SCROLLBAR_TRACK = 4
|
||||||
|
COLOR_SCROLLBAR_THUMB = 5
|
||||||
|
COLOR_PROGRESSBAR = 6
|
||||||
|
COLOR_HIGHLIGHT = 7
|
||||||
|
|
||||||
|
|
||||||
|
def has_colors() -> bool:
|
||||||
|
return _HAS_COLORS
|
||||||
|
|
||||||
|
|
||||||
|
def set_colors(has_colors: bool) -> None:
|
||||||
|
global _HAS_COLORS
|
||||||
|
_HAS_COLORS = has_colors
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from collections.abc import Callable
|
||||||
|
from typing import Generic, ParamSpec
|
||||||
|
|
||||||
|
P = ParamSpec("P")
|
||||||
|
|
||||||
|
|
||||||
|
class EventHandler(Generic[P]):
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.callbacks: list[Callable[P, object]] = []
|
||||||
|
|
||||||
|
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None:
|
||||||
|
for callback in self.callbacks:
|
||||||
|
callback(*args, **kwargs)
|
||||||
|
|
||||||
|
def __iadd__(self, callback: Callable[P, object]) -> "EventHandler[P]":
|
||||||
|
self.callbacks.append(callback)
|
||||||
|
return self
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import curses
|
||||||
|
import curses.ascii
|
||||||
|
from collections.abc import Callable, Iterable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TypeVar, overload
|
||||||
|
|
||||||
|
KEYS_CYCLE = {curses.ascii.TAB}
|
||||||
|
KEYS_DOWN = set(map(ord, "jJ")) | {curses.KEY_DOWN}
|
||||||
|
KEYS_LEFT = set(map(ord, "hH")) | {curses.KEY_LEFT}
|
||||||
|
KEYS_RIGHT = set(map(ord, "lL")) | {curses.KEY_RIGHT}
|
||||||
|
KEYS_UP = set(map(ord, "kK")) | {curses.KEY_UP}
|
||||||
|
KEYS_CANCEL = set(map(ord, "qQ")) | {curses.ascii.ESC}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Choice:
|
||||||
|
keys: list[int]
|
||||||
|
desc: str
|
||||||
|
callback: Callable[[], object]
|
||||||
|
|
||||||
|
|
||||||
|
ItemT = TypeVar("ItemT")
|
||||||
|
DefaultT = TypeVar("DefaultT")
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def first(source: Iterable[ItemT]) -> ItemT | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@overload
|
||||||
|
def first(source: Iterable[ItemT], default: DefaultT) -> ItemT | DefaultT: ...
|
||||||
|
|
||||||
|
|
||||||
|
def first(
|
||||||
|
source: Iterable[ItemT], default: DefaultT | None = None
|
||||||
|
) -> ItemT | DefaultT | None:
|
||||||
|
return next(iter(source), default)
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
from .base_view import BaseView
|
||||||
|
from .choose_character_view import ChooseCharacterView
|
||||||
|
from .confirm_view import ConfirmView
|
||||||
|
from .create_character_view import (
|
||||||
|
ChooseCharacterClassView,
|
||||||
|
ChooseCharacterNameView,
|
||||||
|
ChooseCharacterRaceView,
|
||||||
|
ChooseCharacterStatsView,
|
||||||
|
)
|
||||||
|
from .game_view import GameView
|
||||||
|
from .message_view import MessageView
|
||||||
|
from .roster_view import RosterView
|
||||||
|
from .text_input_view import TextInputView
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"BaseView",
|
||||||
|
"ChooseCharacterView",
|
||||||
|
"ConfirmView",
|
||||||
|
"ChooseCharacterClassView",
|
||||||
|
"ChooseCharacterNameView",
|
||||||
|
"ChooseCharacterRaceView",
|
||||||
|
"ChooseCharacterStatsView",
|
||||||
|
"GameView",
|
||||||
|
"MessageView",
|
||||||
|
"RosterView",
|
||||||
|
"TextInputView",
|
||||||
|
]
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
|
||||||
|
class BaseView:
|
||||||
|
def __init__(self, screen: curses.window) -> None:
|
||||||
|
self.screen = screen
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def idle(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def keypress(self, key: int) -> None:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import curses
|
||||||
|
import functools
|
||||||
|
|
||||||
|
from pqcli.lingo import act_name, to_roman
|
||||||
|
from pqcli.mechanic import Player
|
||||||
|
from pqcli.roster import Roster
|
||||||
|
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
|
||||||
|
from pqcli.ui.curses.views.menu_view import MenuView
|
||||||
|
|
||||||
|
|
||||||
|
class ChooseCharacterView(MenuView[Player]):
|
||||||
|
def __init__(
|
||||||
|
self, screen: curses.window, roster: Roster, title: str
|
||||||
|
) -> None:
|
||||||
|
super().__init__(screen, title)
|
||||||
|
|
||||||
|
for y, player in enumerate(roster.players, 1):
|
||||||
|
key: str | None = str(y % 10) if y <= 10 else None
|
||||||
|
|
||||||
|
best_equip = player.equipment.best
|
||||||
|
|
||||||
|
best_spell = player.spell_book.best
|
||||||
|
best_spell_name = (
|
||||||
|
(best_spell.name + " " + to_roman(best_spell.level))
|
||||||
|
if best_spell
|
||||||
|
else "-"
|
||||||
|
)
|
||||||
|
|
||||||
|
best_stat_name = (
|
||||||
|
f"{player.stats.best_prime.value} "
|
||||||
|
f"{player.stats[player.stats.best_prime]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
label = (
|
||||||
|
f"[{key or '-'}] "
|
||||||
|
f"{player.name} the {player.race.name} "
|
||||||
|
f"({act_name(player.quest_book.act)})\n"
|
||||||
|
f" Level {player.level} {player.class_.name}\n"
|
||||||
|
f" {best_equip} / {best_spell_name} / {best_stat_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
self._choices.append(
|
||||||
|
Choice(
|
||||||
|
keys=[ord(key)] if key is not None else [],
|
||||||
|
desc=label,
|
||||||
|
callback=functools.partial(self.on_confirm, player),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._choices.append(
|
||||||
|
Choice(
|
||||||
|
keys=list(KEYS_CANCEL),
|
||||||
|
desc="[Q] Cancel",
|
||||||
|
callback=self.on_cancel,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
from pqcli.ui.curses.event_handler import EventHandler
|
||||||
|
from pqcli.ui.curses.util import Choice
|
||||||
|
from pqcli.ui.curses.widgets import Menu
|
||||||
|
|
||||||
|
from .base_view import BaseView
|
||||||
|
|
||||||
|
|
||||||
|
class ConfirmView(BaseView):
|
||||||
|
def __init__(self, screen: curses.window, title: str) -> None:
|
||||||
|
super().__init__(screen)
|
||||||
|
|
||||||
|
self.title = f"{title}:"
|
||||||
|
self.on_cancel = EventHandler[[]]()
|
||||||
|
self.on_confirm = EventHandler[[]]()
|
||||||
|
|
||||||
|
self.choices: list[Choice] = [
|
||||||
|
Choice(
|
||||||
|
keys=list(map(ord, "yY")),
|
||||||
|
desc="[Y] Yes",
|
||||||
|
callback=self.on_confirm,
|
||||||
|
),
|
||||||
|
Choice(
|
||||||
|
keys=list(map(ord, "nNqQ\N{ESC}")),
|
||||||
|
desc="[N] No",
|
||||||
|
callback=self.on_cancel,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
scr_height, scr_width = self.screen.getmaxyx()
|
||||||
|
self.screen.erase()
|
||||||
|
self.screen.noutrefresh()
|
||||||
|
|
||||||
|
self.main_menu = Menu(
|
||||||
|
header=self.title,
|
||||||
|
choices=self.choices,
|
||||||
|
active_choice=0,
|
||||||
|
scr_height=scr_height,
|
||||||
|
scr_width=scr_width,
|
||||||
|
)
|
||||||
|
self.main_menu.render()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.main_menu.stop()
|
||||||
|
|
||||||
|
def keypress(self, key: int) -> None:
|
||||||
|
self.main_menu.keypress(key)
|
||||||
|
self.main_menu.render()
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
from .choose_character_class_view import ChooseCharacterClassView
|
||||||
|
from .choose_character_name_view import ChooseCharacterNameView
|
||||||
|
from .choose_character_race_view import ChooseCharacterRaceView
|
||||||
|
from .choose_character_stats_view import ChooseCharacterStatsView
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ChooseCharacterClassView",
|
||||||
|
"ChooseCharacterNameView",
|
||||||
|
"ChooseCharacterRaceView",
|
||||||
|
"ChooseCharacterStatsView",
|
||||||
|
]
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import curses
|
||||||
|
import functools
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from pqcli.config import Class
|
||||||
|
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
|
||||||
|
from pqcli.ui.curses.views.menu_view import MenuView
|
||||||
|
|
||||||
|
|
||||||
|
class ChooseCharacterClassView(MenuView[Class]):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
screen: curses.window,
|
||||||
|
classes: Sequence[Class],
|
||||||
|
class_: Class | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
screen,
|
||||||
|
"Choose character class",
|
||||||
|
classes.index(class_) if class_ is not None else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
for y, class_ in enumerate(classes, 1):
|
||||||
|
key: str | None = str(y % 10) if y <= 10 else None
|
||||||
|
self._choices.append(
|
||||||
|
Choice(
|
||||||
|
keys=[ord(key)] if key is not None else [],
|
||||||
|
desc=f"[{key or '-'}] {class_.name}",
|
||||||
|
callback=functools.partial(self.on_confirm, class_),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._choices.append(
|
||||||
|
Choice(
|
||||||
|
keys=list(KEYS_CANCEL),
|
||||||
|
desc="[Q] Cancel",
|
||||||
|
callback=self.on_cancel,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
import curses
|
||||||
|
import curses.ascii
|
||||||
|
|
||||||
|
from pqcli.mechanic import generate_name
|
||||||
|
from pqcli.ui.curses.event_handler import EventHandler
|
||||||
|
from pqcli.ui.curses.util import KEYS_CANCEL, KEYS_CYCLE, KEYS_DOWN, KEYS_UP
|
||||||
|
from pqcli.ui.curses.views.base_view import BaseView
|
||||||
|
from pqcli.ui.curses.widgets.focusable import focus_standout
|
||||||
|
|
||||||
|
|
||||||
|
class ChooseCharacterNameView(BaseView):
|
||||||
|
def __init__(
|
||||||
|
self, screen: curses.window, character_name: str | None = None
|
||||||
|
) -> None:
|
||||||
|
super().__init__(screen)
|
||||||
|
|
||||||
|
self.on_cancel = EventHandler[[]]()
|
||||||
|
self.on_confirm = EventHandler[[str]]()
|
||||||
|
|
||||||
|
self._active_widget = 0
|
||||||
|
self._win: curses.window | None = None
|
||||||
|
self._text_win: curses.window | None = None
|
||||||
|
self._text = character_name or generate_name()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self.screen.erase()
|
||||||
|
self.screen.noutrefresh()
|
||||||
|
|
||||||
|
scr_height, scr_width = self.screen.getmaxyx()
|
||||||
|
|
||||||
|
h = 8
|
||||||
|
w = 30
|
||||||
|
y = (scr_height - h - 2) // 2
|
||||||
|
x = (scr_width - w - 2) // 2
|
||||||
|
self._text_win = curses.newwin(3, w + 2, y + 2, x - 1)
|
||||||
|
self._win = curses.newwin(h + 2, w + 2, y, x)
|
||||||
|
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
del self._win
|
||||||
|
del self._text_win
|
||||||
|
self._win = None
|
||||||
|
self._text_win = None
|
||||||
|
|
||||||
|
def keypress(self, key: int) -> None:
|
||||||
|
if key == curses.KEY_F5:
|
||||||
|
self._text = generate_name()
|
||||||
|
|
||||||
|
elif key == curses.KEY_F10:
|
||||||
|
self._finish()
|
||||||
|
|
||||||
|
elif key in KEYS_CYCLE:
|
||||||
|
self._active_widget = (self._active_widget + 1) % 4
|
||||||
|
|
||||||
|
elif self._active_widget == 0:
|
||||||
|
if key == curses.ascii.ESC:
|
||||||
|
self.on_cancel()
|
||||||
|
elif curses.ascii.isprint(key):
|
||||||
|
self._text += chr(key)
|
||||||
|
elif key == curses.KEY_BACKSPACE or key == curses.ascii.DEL:
|
||||||
|
self._text = self._text[:-1]
|
||||||
|
elif key == curses.ascii.NL:
|
||||||
|
self._finish()
|
||||||
|
elif key == curses.ascii.ETB: # ^w
|
||||||
|
self._text = ""
|
||||||
|
elif key == curses.KEY_DOWN:
|
||||||
|
self._active_widget = 1
|
||||||
|
|
||||||
|
else:
|
||||||
|
if key == curses.ascii.NL:
|
||||||
|
if self._active_widget == 1:
|
||||||
|
self._text = generate_name()
|
||||||
|
elif self._active_widget == 2:
|
||||||
|
self._finish()
|
||||||
|
elif self._active_widget == 3:
|
||||||
|
self.on_cancel()
|
||||||
|
else:
|
||||||
|
assert False
|
||||||
|
|
||||||
|
elif key in KEYS_CANCEL:
|
||||||
|
self.on_cancel()
|
||||||
|
|
||||||
|
elif key in KEYS_DOWN and self._active_widget < 3:
|
||||||
|
self._active_widget += 1
|
||||||
|
|
||||||
|
elif key in KEYS_UP and self._active_widget > 0:
|
||||||
|
self._active_widget -= 1
|
||||||
|
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _finish(self) -> None:
|
||||||
|
self._text = self._text.strip()
|
||||||
|
if self._text:
|
||||||
|
self.on_confirm(self._text)
|
||||||
|
else:
|
||||||
|
self.on_cancel()
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
if not self._win or not self._text_win:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._win.erase()
|
||||||
|
self._win.addstr(0, 0, "Choose character name:")
|
||||||
|
with focus_standout(self._active_widget == 1, self._win):
|
||||||
|
self._win.addstr(6, 0, "[F5 ] Generate random name")
|
||||||
|
with focus_standout(self._active_widget == 2, self._win):
|
||||||
|
self._win.addstr(7, 0, "[F10 ] Continue")
|
||||||
|
with focus_standout(self._active_widget == 3, self._win):
|
||||||
|
self._win.addstr(8, 0, "[Esc ] Cancel")
|
||||||
|
self._win.refresh()
|
||||||
|
|
||||||
|
self._text_win.erase()
|
||||||
|
with focus_standout(self._active_widget == 0, self._text_win):
|
||||||
|
self._text_win.box()
|
||||||
|
self._text_win.addnstr(
|
||||||
|
1, 1, self._text, self._text_win.getmaxyx()[1] - 2
|
||||||
|
)
|
||||||
|
self._text_win.refresh()
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import curses
|
||||||
|
import functools
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from pqcli.config import Race
|
||||||
|
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
|
||||||
|
from pqcli.ui.curses.views.menu_view import MenuView
|
||||||
|
|
||||||
|
|
||||||
|
class ChooseCharacterRaceView(MenuView[Race]):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
screen: curses.window,
|
||||||
|
races: Sequence[Race],
|
||||||
|
race: Race | None = None,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
screen,
|
||||||
|
"Choose character race",
|
||||||
|
races.index(race) if race is not None else 0,
|
||||||
|
)
|
||||||
|
|
||||||
|
for y, race in enumerate(races, 1):
|
||||||
|
key: str | None = str(y % 10) if y <= 10 else None
|
||||||
|
self._choices.append(
|
||||||
|
Choice(
|
||||||
|
keys=[ord(key)] if key is not None else [],
|
||||||
|
desc=f"[{key or '-'}] {race.name}",
|
||||||
|
callback=functools.partial(self.on_confirm, race),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
self._choices.append(
|
||||||
|
Choice(
|
||||||
|
keys=list(KEYS_CANCEL),
|
||||||
|
desc="[Q] Cancel",
|
||||||
|
callback=self.on_cancel,
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import curses
|
||||||
|
import curses.ascii
|
||||||
|
|
||||||
|
from pqcli.config import PRIME_STATS
|
||||||
|
from pqcli.mechanic import Stats, StatsBuilder
|
||||||
|
from pqcli.random import RngState
|
||||||
|
from pqcli.ui.curses.event_handler import EventHandler
|
||||||
|
from pqcli.ui.curses.util import KEYS_CANCEL, KEYS_CYCLE, KEYS_DOWN, KEYS_UP
|
||||||
|
from pqcli.ui.curses.views.base_view import BaseView
|
||||||
|
from pqcli.ui.curses.widgets.focusable import focus_standout
|
||||||
|
|
||||||
|
|
||||||
|
class ChooseCharacterStatsView(BaseView):
|
||||||
|
def __init__(self, screen: curses.window) -> None:
|
||||||
|
super().__init__(screen)
|
||||||
|
|
||||||
|
self.on_cancel = EventHandler[[]]()
|
||||||
|
self.on_confirm = EventHandler[[Stats, RngState]]()
|
||||||
|
|
||||||
|
self._active_widget = 0
|
||||||
|
self._win: curses.window | None = None
|
||||||
|
self._stats_win: curses.window | None = None
|
||||||
|
|
||||||
|
self._stats_builder = StatsBuilder()
|
||||||
|
self._stats = self._stats_builder.roll()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
scr_height, scr_width = self.screen.getmaxyx()
|
||||||
|
|
||||||
|
h2 = len(PRIME_STATS) + 2
|
||||||
|
h = 7 + h2
|
||||||
|
w = 30
|
||||||
|
y = (scr_height - h - 2) // 2
|
||||||
|
x = (scr_width - w - 2) // 2
|
||||||
|
|
||||||
|
self.screen.erase()
|
||||||
|
self.screen.noutrefresh()
|
||||||
|
|
||||||
|
self._win = curses.newwin(h, w, y, x)
|
||||||
|
self._stats_win = curses.newwin(h2, w, y + 2, x - 1)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
del self._win
|
||||||
|
del self._stats_win
|
||||||
|
self._win = None
|
||||||
|
self._stats_win = None
|
||||||
|
|
||||||
|
def keypress(self, key: int) -> None:
|
||||||
|
if key in KEYS_CANCEL:
|
||||||
|
self.on_cancel()
|
||||||
|
|
||||||
|
elif key == curses.ascii.NL:
|
||||||
|
if self._active_widget == 0:
|
||||||
|
self._stats = self._stats_builder.roll()
|
||||||
|
elif self._active_widget == 1:
|
||||||
|
self._stats = self._stats_builder.unroll()
|
||||||
|
elif self._active_widget == 2:
|
||||||
|
self.on_confirm(self._stats, self._stats_builder.seed)
|
||||||
|
elif self._active_widget == 3:
|
||||||
|
self.on_cancel()
|
||||||
|
else:
|
||||||
|
assert False
|
||||||
|
|
||||||
|
elif key == curses.KEY_F5:
|
||||||
|
self._stats = self._stats_builder.roll()
|
||||||
|
|
||||||
|
elif key == curses.KEY_F6:
|
||||||
|
self._stats = self._stats_builder.unroll()
|
||||||
|
|
||||||
|
elif key == curses.KEY_F10:
|
||||||
|
self.on_confirm(self._stats, self._stats_builder.seed)
|
||||||
|
|
||||||
|
elif key in KEYS_CYCLE:
|
||||||
|
self._active_widget = (self._active_widget + 1) % 4
|
||||||
|
|
||||||
|
elif key in KEYS_DOWN and self._active_widget < 3:
|
||||||
|
self._active_widget += 1
|
||||||
|
|
||||||
|
elif key in KEYS_UP and self._active_widget > 0:
|
||||||
|
self._active_widget -= 1
|
||||||
|
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
if not self._win or not self._stats_win:
|
||||||
|
return
|
||||||
|
|
||||||
|
y, x = self._win.getbegyx()
|
||||||
|
stats_h = self._stats_win.getmaxyx()[0]
|
||||||
|
|
||||||
|
self._win.erase()
|
||||||
|
self._win.addstr(0, 0, "Roll character stats:")
|
||||||
|
with focus_standout(self._active_widget == 0, self._win):
|
||||||
|
self._win.addstr(3 + stats_h + 0, 0, "[F5 ] Roll")
|
||||||
|
with focus_standout(self._active_widget == 1, self._win):
|
||||||
|
self._win.addstr(3 + stats_h + 1, 0, "[F6 ] Unroll")
|
||||||
|
with focus_standout(self._active_widget == 2, self._win):
|
||||||
|
self._win.addstr(3 + stats_h + 2, 0, "[F10 ] Continue")
|
||||||
|
with focus_standout(self._active_widget == 3, self._win):
|
||||||
|
self._win.addstr(3 + stats_h + 3, 0, "[Esc ] Cancel")
|
||||||
|
self._win.noutrefresh()
|
||||||
|
|
||||||
|
self._stats_win.erase()
|
||||||
|
for y, stat in enumerate(PRIME_STATS):
|
||||||
|
self._stats_win.addstr(
|
||||||
|
y + 1, 1, f"{stat.value}: {self._stats[stat]}"
|
||||||
|
)
|
||||||
|
self._stats_win.box()
|
||||||
|
self._stats_win.noutrefresh()
|
||||||
|
|
||||||
|
curses.doupdate()
|
||||||
@@ -0,0 +1,279 @@
|
|||||||
|
import argparse
|
||||||
|
import curses
|
||||||
|
import curses.ascii
|
||||||
|
import datetime
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from pqcli.game_data import GameData
|
||||||
|
from pqcli.mechanic import Player, SignalMixin, Simulation
|
||||||
|
from pqcli.roster import Roster
|
||||||
|
from pqcli.ui.curses.event_handler import EventHandler
|
||||||
|
from pqcli.ui.curses.util import (
|
||||||
|
KEYS_CANCEL,
|
||||||
|
KEYS_CYCLE,
|
||||||
|
KEYS_DOWN,
|
||||||
|
KEYS_LEFT,
|
||||||
|
KEYS_RIGHT,
|
||||||
|
KEYS_UP,
|
||||||
|
)
|
||||||
|
from pqcli.ui.curses.views.base_view import BaseView
|
||||||
|
from pqcli.ui.curses.widgets import Focusable
|
||||||
|
|
||||||
|
from .character_sheet_window import CharacterSheetWindow
|
||||||
|
from .equipment_window import EquipmentWindow
|
||||||
|
from .inventory_window import InventoryWindow
|
||||||
|
from .plot_window import PlotWindow
|
||||||
|
from .quest_book_window import QuestBookWindow
|
||||||
|
from .spell_book_window import SpellBookWindow
|
||||||
|
from .task_progress_window import TaskProgressWindow
|
||||||
|
|
||||||
|
|
||||||
|
class GameWindow(Protocol):
|
||||||
|
def stop(self) -> None: ...
|
||||||
|
|
||||||
|
def sync(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class PageScrollable(Protocol):
|
||||||
|
def scroll_page_up(self) -> None: ...
|
||||||
|
|
||||||
|
def scroll_page_down(self) -> None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class GameView(BaseView):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
screen: curses.window,
|
||||||
|
roster: Roster,
|
||||||
|
player: Player,
|
||||||
|
args: argparse.Namespace,
|
||||||
|
data: GameData,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(screen)
|
||||||
|
self.on_exit = EventHandler[[]]()
|
||||||
|
|
||||||
|
self._roster = roster
|
||||||
|
self._player = player
|
||||||
|
self._args = args
|
||||||
|
|
||||||
|
self._simulation = Simulation(player, data)
|
||||||
|
self._last_tick = datetime.datetime.now()
|
||||||
|
|
||||||
|
def keypress(self, key: int) -> None:
|
||||||
|
focused = self.focused
|
||||||
|
|
||||||
|
if key in KEYS_CANCEL:
|
||||||
|
self.on_exit()
|
||||||
|
|
||||||
|
elif key in {curses.KEY_PPAGE}:
|
||||||
|
if isinstance(focused, PageScrollable):
|
||||||
|
focused.scroll_page_up()
|
||||||
|
|
||||||
|
elif key in {curses.KEY_NPAGE}:
|
||||||
|
if isinstance(focused, PageScrollable):
|
||||||
|
focused.scroll_page_down()
|
||||||
|
|
||||||
|
elif key in KEYS_CYCLE:
|
||||||
|
if focused == self._char_sheet_win:
|
||||||
|
self.focus(self._spell_book_win)
|
||||||
|
elif focused == self._spell_book_win:
|
||||||
|
self.focus(self._equipment_win)
|
||||||
|
elif focused == self._equipment_win:
|
||||||
|
self.focus(self._inventory_win)
|
||||||
|
elif focused == self._inventory_win:
|
||||||
|
self.focus(self._plot_win)
|
||||||
|
elif focused == self._plot_win:
|
||||||
|
self.focus(self._quest_book_win)
|
||||||
|
elif focused == self._quest_book_win:
|
||||||
|
self.focus(self._char_sheet_win)
|
||||||
|
|
||||||
|
elif key in KEYS_DOWN:
|
||||||
|
if focused == self._char_sheet_win:
|
||||||
|
self.focus(self._spell_book_win)
|
||||||
|
elif focused == self._equipment_win:
|
||||||
|
self.focus(self._inventory_win)
|
||||||
|
elif focused == self._plot_win:
|
||||||
|
self.focus(self._quest_book_win)
|
||||||
|
|
||||||
|
elif key in KEYS_UP:
|
||||||
|
if focused == self._spell_book_win:
|
||||||
|
self.focus(self._char_sheet_win)
|
||||||
|
elif focused == self._inventory_win:
|
||||||
|
self.focus(self._equipment_win)
|
||||||
|
elif focused == self._quest_book_win:
|
||||||
|
self.focus(self._plot_win)
|
||||||
|
|
||||||
|
elif key in KEYS_LEFT:
|
||||||
|
if focused == self._equipment_win:
|
||||||
|
self.focus(self._char_sheet_win)
|
||||||
|
elif focused == self._inventory_win:
|
||||||
|
self.focus(self._spell_book_win)
|
||||||
|
elif focused == self._plot_win:
|
||||||
|
self.focus(self._equipment_win)
|
||||||
|
elif focused == self._quest_book_win:
|
||||||
|
self.focus(self._inventory_win)
|
||||||
|
|
||||||
|
elif key in KEYS_RIGHT:
|
||||||
|
if focused == self._char_sheet_win:
|
||||||
|
self.focus(self._equipment_win)
|
||||||
|
elif focused == self._spell_book_win:
|
||||||
|
self.focus(self._inventory_win)
|
||||||
|
elif focused == self._equipment_win:
|
||||||
|
self.focus(self._plot_win)
|
||||||
|
elif focused == self._inventory_win:
|
||||||
|
self.focus(self._quest_book_win)
|
||||||
|
|
||||||
|
elif self._args.cheats and key == ord("t"):
|
||||||
|
self._simulation.tick()
|
||||||
|
|
||||||
|
elif self._args.cheats and key == ord("T"):
|
||||||
|
for _ in range(100):
|
||||||
|
self._simulation.tick()
|
||||||
|
|
||||||
|
elif self._args.cheats and key == curses.ascii.DC4: # ^t
|
||||||
|
with SignalMixin.muted():
|
||||||
|
for _ in range(10000):
|
||||||
|
self._simulation.tick()
|
||||||
|
for child in self._children:
|
||||||
|
child.sync()
|
||||||
|
|
||||||
|
else:
|
||||||
|
super().keypress(key)
|
||||||
|
|
||||||
|
curses.doupdate()
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
scr_height, scr_width = self.screen.getmaxyx()
|
||||||
|
col1_width = int(scr_width * 0.3)
|
||||||
|
col3_width = col1_width
|
||||||
|
col2_width = scr_width - col1_width - col3_width
|
||||||
|
|
||||||
|
self.screen.erase()
|
||||||
|
self.screen.noutrefresh()
|
||||||
|
|
||||||
|
self._big_win = curses.newwin(scr_height, scr_width)
|
||||||
|
self._col1_win = self._big_win.derwin(scr_height - 2, col1_width, 0, 0)
|
||||||
|
self._col2_win = self._big_win.derwin(
|
||||||
|
self._col1_win.getmaxyx()[0],
|
||||||
|
col2_width,
|
||||||
|
0,
|
||||||
|
self._col1_win.getmaxyx()[1],
|
||||||
|
)
|
||||||
|
self._col3_win = self._big_win.derwin(
|
||||||
|
self._col1_win.getmaxyx()[0],
|
||||||
|
col3_width,
|
||||||
|
0,
|
||||||
|
self._col1_win.getmaxyx()[1] + self._col2_win.getmaxyx()[1],
|
||||||
|
)
|
||||||
|
|
||||||
|
self._char_sheet_win = CharacterSheetWindow(
|
||||||
|
player=self._player,
|
||||||
|
parent=self._col1_win,
|
||||||
|
h=min(17, self._col1_win.getmaxyx()[0]),
|
||||||
|
w=self._col1_win.getmaxyx()[1],
|
||||||
|
y=0,
|
||||||
|
x=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._spell_book_win = SpellBookWindow(
|
||||||
|
player=self._player,
|
||||||
|
parent=self._col1_win,
|
||||||
|
h=self._col1_win.getmaxyx()[0]
|
||||||
|
- self._char_sheet_win.getmaxyx()[0],
|
||||||
|
w=self._col1_win.getmaxyx()[1],
|
||||||
|
y=self._char_sheet_win.getmaxyx()[0],
|
||||||
|
x=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._equipment_win = EquipmentWindow(
|
||||||
|
player=self._player,
|
||||||
|
parent=self._col2_win,
|
||||||
|
h=min(15, self._col2_win.getmaxyx()[0]),
|
||||||
|
w=self._col2_win.getmaxyx()[1],
|
||||||
|
y=0,
|
||||||
|
x=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._inventory_win = InventoryWindow(
|
||||||
|
player=self._player,
|
||||||
|
parent=self._col2_win,
|
||||||
|
h=max(
|
||||||
|
1,
|
||||||
|
self._col2_win.getmaxyx()[0]
|
||||||
|
- self._equipment_win.getmaxyx()[0],
|
||||||
|
),
|
||||||
|
w=self._col2_win.getmaxyx()[1],
|
||||||
|
y=self._equipment_win.getmaxyx()[0],
|
||||||
|
x=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._plot_win = PlotWindow(
|
||||||
|
player=self._player,
|
||||||
|
parent=self._col3_win,
|
||||||
|
h=self._equipment_win.getmaxyx()[0],
|
||||||
|
w=self._col3_win.getmaxyx()[1],
|
||||||
|
y=0,
|
||||||
|
x=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._quest_book_win = QuestBookWindow(
|
||||||
|
player=self._player,
|
||||||
|
parent=self._col3_win,
|
||||||
|
h=max(
|
||||||
|
1, self._col3_win.getmaxyx()[0] - self._plot_win.getmaxyx()[0]
|
||||||
|
),
|
||||||
|
w=self._col3_win.getmaxyx()[1],
|
||||||
|
y=self._plot_win.getmaxyx()[0],
|
||||||
|
x=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._task_win = TaskProgressWindow(
|
||||||
|
player=self._player,
|
||||||
|
parent=self._big_win,
|
||||||
|
h=2,
|
||||||
|
w=scr_width,
|
||||||
|
y=scr_height - 2,
|
||||||
|
x=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
focusable_children = (
|
||||||
|
self._char_sheet_win,
|
||||||
|
self._spell_book_win,
|
||||||
|
self._equipment_win,
|
||||||
|
self._inventory_win,
|
||||||
|
self._plot_win,
|
||||||
|
self._quest_book_win,
|
||||||
|
)
|
||||||
|
self._focusable_children: list[Focusable] = list(focusable_children)
|
||||||
|
self._children: list[GameWindow] = [
|
||||||
|
*focusable_children,
|
||||||
|
self._task_win,
|
||||||
|
]
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
for child in self._children:
|
||||||
|
child.stop()
|
||||||
|
del self._col1_win
|
||||||
|
del self._col2_win
|
||||||
|
del self._col3_win
|
||||||
|
del self._big_win
|
||||||
|
|
||||||
|
def idle(self) -> None:
|
||||||
|
elapsed = datetime.datetime.now() - self._last_tick
|
||||||
|
self._simulation.tick(elapsed.total_seconds() * 1000)
|
||||||
|
self._last_tick = datetime.datetime.now()
|
||||||
|
if self._args.use_saves:
|
||||||
|
self._roster.save_periodically()
|
||||||
|
curses.doupdate()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def focused(self) -> Focusable:
|
||||||
|
for widget in self._focusable_children:
|
||||||
|
if widget.focused:
|
||||||
|
return widget
|
||||||
|
raise AssertionError
|
||||||
|
|
||||||
|
def focus(self, widget: Focusable) -> None:
|
||||||
|
self.focused.focused = False
|
||||||
|
widget.focused = True
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
from pqcli.mechanic import Player, StatType
|
||||||
|
|
||||||
|
from .progress_bar_window import DataTableProgressBarWindow
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterSheetWindow(DataTableProgressBarWindow):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
player: Player,
|
||||||
|
parent: curses.window,
|
||||||
|
h: int,
|
||||||
|
w: int,
|
||||||
|
y: int,
|
||||||
|
x: int,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
parent,
|
||||||
|
h,
|
||||||
|
w,
|
||||||
|
y,
|
||||||
|
x,
|
||||||
|
" Character Sheet ",
|
||||||
|
align_right=False,
|
||||||
|
show_time=True,
|
||||||
|
)
|
||||||
|
self._on_focus_change += self._render
|
||||||
|
|
||||||
|
self._focused = True
|
||||||
|
|
||||||
|
self._player = player
|
||||||
|
self._player.connect("level_up", self._sync_traits)
|
||||||
|
self._player.stats.connect("change", self._sync_traits)
|
||||||
|
self._player.exp_bar.connect("change", self._sync_exp)
|
||||||
|
|
||||||
|
self.sync()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
self._player.disconnect("level_up", self._sync_traits)
|
||||||
|
self._player.stats.disconnect("change", self._sync_traits)
|
||||||
|
self._player.exp_bar.disconnect("change", self._sync_exp)
|
||||||
|
|
||||||
|
def sync(self) -> None:
|
||||||
|
self._sync_traits()
|
||||||
|
self._sync_exp()
|
||||||
|
|
||||||
|
def _sync_traits(self) -> None:
|
||||||
|
if not self._win:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._data_table.clear()
|
||||||
|
self._data_table.add("Name", self._player.name)
|
||||||
|
self._data_table.add("Race", self._player.race.name)
|
||||||
|
self._data_table.add("Class", self._player.class_.name)
|
||||||
|
self._data_table.add("Level", str(self._player.level))
|
||||||
|
self._data_table.add(" " * 15, "")
|
||||||
|
for stat in StatType:
|
||||||
|
self._data_table.add(stat.value, str(self._player.stats[stat]))
|
||||||
|
|
||||||
|
self._render_data_table()
|
||||||
|
|
||||||
|
def _sync_exp(self) -> None:
|
||||||
|
self._cur_pos = self._player.exp_bar.position
|
||||||
|
self._max_pos = self._player.exp_bar.max_
|
||||||
|
self._progress_title = (
|
||||||
|
f"Experience ({self._max_pos - self._cur_pos:.0f} XP to go)"
|
||||||
|
)
|
||||||
|
self._render_progress_bar()
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
from pqcli.mechanic import EquipmentType, Player
|
||||||
|
from pqcli.ui.curses.widgets import DataTable, Focusable, WindowWrapper
|
||||||
|
|
||||||
|
|
||||||
|
class EquipmentWindow(Focusable, WindowWrapper):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
player: Player,
|
||||||
|
parent: curses.window,
|
||||||
|
h: int,
|
||||||
|
w: int,
|
||||||
|
y: int,
|
||||||
|
x: int,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent, h, w, y, x)
|
||||||
|
self._on_focus_change += self._render
|
||||||
|
|
||||||
|
self._data_table = DataTable(
|
||||||
|
self._win, h - 2, w - 2, 1, 1, align_right=False
|
||||||
|
)
|
||||||
|
|
||||||
|
self._player = player
|
||||||
|
self._player.equipment.connect("change", self._sync_equipment_change)
|
||||||
|
|
||||||
|
self.sync()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
super().stop()
|
||||||
|
self._data_table.stop()
|
||||||
|
|
||||||
|
self._player.equipment.disconnect(
|
||||||
|
"change", self._sync_equipment_change
|
||||||
|
)
|
||||||
|
|
||||||
|
def sync(self) -> None:
|
||||||
|
self._data_table.clear()
|
||||||
|
for equipment_type in EquipmentType:
|
||||||
|
self._data_table.add(
|
||||||
|
equipment_type.value.ljust(15),
|
||||||
|
self._player.equipment[equipment_type] or "",
|
||||||
|
)
|
||||||
|
self._data_table.select(None)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _sync_equipment_change(
|
||||||
|
self, equipment_type: EquipmentType, item_name: str | None
|
||||||
|
) -> None:
|
||||||
|
self._data_table.set(equipment_type.value, item_name or "")
|
||||||
|
self._data_table.select(equipment_type.value)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
if not self._win:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.focus_standout(self._win):
|
||||||
|
self._win.box()
|
||||||
|
text = " Equipment "
|
||||||
|
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
|
||||||
|
self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1]))
|
||||||
|
|
||||||
|
self._win.noutrefresh()
|
||||||
|
self._data_table.render()
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
from pqcli.mechanic import InventoryItem, Player
|
||||||
|
|
||||||
|
from .progress_bar_window import DataTableProgressBarWindow
|
||||||
|
|
||||||
|
|
||||||
|
class InventoryWindow(DataTableProgressBarWindow):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
player: Player,
|
||||||
|
parent: curses.window,
|
||||||
|
h: int,
|
||||||
|
w: int,
|
||||||
|
y: int,
|
||||||
|
x: int,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
parent,
|
||||||
|
h,
|
||||||
|
w,
|
||||||
|
y,
|
||||||
|
x,
|
||||||
|
" Inventory ",
|
||||||
|
align_right=True,
|
||||||
|
show_time=False,
|
||||||
|
)
|
||||||
|
self._on_focus_change += self._render
|
||||||
|
|
||||||
|
self._player = player
|
||||||
|
self._player.connect("new_task", self._on_new_task)
|
||||||
|
self._player.inventory.connect("gold_change", self._sync_gold)
|
||||||
|
self._player.inventory.connect("item_add", self._sync_item_add)
|
||||||
|
self._player.inventory.connect("item_del", self._sync_item_del)
|
||||||
|
self._player.inventory.connect("item_change", self._sync_item_change)
|
||||||
|
self._player.inventory.encum_bar.connect(
|
||||||
|
"change", self._sync_encumbrance
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sync()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
self._player.disconnect("new_task", self._on_new_task)
|
||||||
|
self._player.inventory.disconnect("gold_change", self._sync_gold)
|
||||||
|
self._player.inventory.disconnect("item_add", self._sync_item_add)
|
||||||
|
self._player.inventory.disconnect("item_del", self._sync_item_del)
|
||||||
|
self._player.inventory.disconnect(
|
||||||
|
"item_change", self._sync_item_change
|
||||||
|
)
|
||||||
|
self._player.inventory.encum_bar.disconnect(
|
||||||
|
"change", self._sync_encumbrance
|
||||||
|
)
|
||||||
|
|
||||||
|
def scroll_page_up(self) -> None:
|
||||||
|
self._data_table.scroll_page_up()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def scroll_page_down(self) -> None:
|
||||||
|
self._data_table.scroll_page_down()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def sync(self) -> None:
|
||||||
|
self._sync_gold()
|
||||||
|
self._sync_items()
|
||||||
|
self._sync_encumbrance()
|
||||||
|
|
||||||
|
def _sync_encumbrance(self) -> None:
|
||||||
|
self._cur_pos = self._player.inventory.encum_bar.position
|
||||||
|
self._max_pos = self._player.inventory.encum_bar.max_
|
||||||
|
self._progress_title = (
|
||||||
|
f"Encumbrance ({self._cur_pos:.0f}/{self._max_pos} cubits)"
|
||||||
|
)
|
||||||
|
self._render_progress_bar()
|
||||||
|
|
||||||
|
def _sync_gold(self) -> None:
|
||||||
|
self._data_table.set("Gold", str(self._player.inventory.gold))
|
||||||
|
self._data_table.select("Gold")
|
||||||
|
self._render_data_table()
|
||||||
|
|
||||||
|
def _sync_items(self) -> None:
|
||||||
|
self._data_table.clear()
|
||||||
|
self._sync_gold()
|
||||||
|
for item in self._player.inventory:
|
||||||
|
self._data_table.add(item.name, str(item.quantity))
|
||||||
|
self._data_table.scroll_to_item(0)
|
||||||
|
self._data_table.select(None)
|
||||||
|
self._render_data_table()
|
||||||
|
|
||||||
|
def _sync_item_add(self, item: InventoryItem) -> None:
|
||||||
|
self._data_table.add(item.name, str(item.quantity))
|
||||||
|
self._data_table.select(item.name)
|
||||||
|
self._render_data_table()
|
||||||
|
|
||||||
|
def _sync_item_del(self, item: InventoryItem) -> None:
|
||||||
|
self._data_table.delete(item.name)
|
||||||
|
self._data_table.select(None)
|
||||||
|
self._render_data_table()
|
||||||
|
|
||||||
|
def _sync_item_change(self, item: InventoryItem) -> None:
|
||||||
|
self._data_table.set(item.name, str(item.quantity))
|
||||||
|
self._data_table.select(item.name)
|
||||||
|
self._render_data_table()
|
||||||
|
|
||||||
|
def _on_new_task(self) -> None:
|
||||||
|
description = (
|
||||||
|
self._player.task.description if self._player.task else "?"
|
||||||
|
)
|
||||||
|
if description.lower().startswith("sell"):
|
||||||
|
self._data_table.scroll_to_item(0)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
from pqcli.lingo import act_name
|
||||||
|
from pqcli.mechanic import Player
|
||||||
|
|
||||||
|
from .progress_bar_window import ListBoxProgressBarWindow
|
||||||
|
|
||||||
|
|
||||||
|
class PlotWindow(ListBoxProgressBarWindow):
|
||||||
|
cutoff = 100
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
player: Player,
|
||||||
|
parent: curses.window,
|
||||||
|
h: int,
|
||||||
|
w: int,
|
||||||
|
y: int,
|
||||||
|
x: int,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
parent, h, w, y, x, " Plot Development ", show_time=True
|
||||||
|
)
|
||||||
|
self._on_focus_change += self._render
|
||||||
|
|
||||||
|
self._player = player
|
||||||
|
self._player.quest_book.connect("start_act", self._sync_act_add)
|
||||||
|
self._player.quest_book.plot_bar.connect("change", self._sync_position)
|
||||||
|
|
||||||
|
self.sync()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
super().stop()
|
||||||
|
self._list_box.stop()
|
||||||
|
|
||||||
|
self._player.quest_book.disconnect("start_act", self._sync_act_add)
|
||||||
|
self._player.quest_book.plot_bar.disconnect(
|
||||||
|
"change", self._sync_position
|
||||||
|
)
|
||||||
|
|
||||||
|
def scroll_page_up(self) -> None:
|
||||||
|
self._list_box.scroll_page_up()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def scroll_page_down(self) -> None:
|
||||||
|
self._list_box.scroll_page_down()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def sync(self) -> None:
|
||||||
|
self._sync_acts()
|
||||||
|
self._sync_position()
|
||||||
|
|
||||||
|
def _sync_acts(self) -> None:
|
||||||
|
self._list_box.clear()
|
||||||
|
for act_number in range(
|
||||||
|
max(0, self._player.quest_book.act - self.cutoff),
|
||||||
|
self._player.quest_book.act + 1,
|
||||||
|
):
|
||||||
|
self._sync_act_add(act_number)
|
||||||
|
self._render_list_box()
|
||||||
|
|
||||||
|
def _sync_act_add(self, act_number: int) -> None:
|
||||||
|
self._list_box.delete(0, max(0, len(self._list_box) - self.cutoff))
|
||||||
|
prev = self._list_box.get(-1)
|
||||||
|
if prev is not None:
|
||||||
|
self._list_box.set(-1, "[X] " + prev[4:])
|
||||||
|
self._list_box.add("[ ] " + act_name(act_number))
|
||||||
|
self._list_box.select(-1)
|
||||||
|
self._render_list_box()
|
||||||
|
|
||||||
|
def _sync_position(self) -> None:
|
||||||
|
self._cur_pos = self._player.quest_book.plot_bar.position
|
||||||
|
self._max_pos = self._player.quest_book.plot_bar.max_
|
||||||
|
self._render_progress_bar()
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
from pqcli.ui.curses.widgets import (
|
||||||
|
DataTable,
|
||||||
|
Focusable,
|
||||||
|
ListBox,
|
||||||
|
ProgressBar,
|
||||||
|
WindowWrapper,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressBarWindow(Focusable, WindowWrapper):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: curses.window,
|
||||||
|
h: int,
|
||||||
|
w: int,
|
||||||
|
y: int,
|
||||||
|
x: int,
|
||||||
|
title: str,
|
||||||
|
show_time: bool,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent, h, w, y, x)
|
||||||
|
|
||||||
|
self._title = title
|
||||||
|
self._cur_pos = 0.0
|
||||||
|
self._max_pos = 1.0
|
||||||
|
self._progress_title = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._progress_bar_win = (
|
||||||
|
self._win.derwin(3, w, h - 3, 0) if self._win else None
|
||||||
|
)
|
||||||
|
except curses.error:
|
||||||
|
self._progress_bar_win = None
|
||||||
|
self._progress_bar = ProgressBar(
|
||||||
|
self._progress_bar_win, 1, w - 2, 1, 1, show_time=show_time
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
super().stop()
|
||||||
|
self._progress_bar.stop()
|
||||||
|
|
||||||
|
del self._progress_bar_win
|
||||||
|
self._progress_bar_win = None
|
||||||
|
|
||||||
|
def _render_progress_bar(self) -> None:
|
||||||
|
if not self._progress_bar_win:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._progress_bar_win.erase()
|
||||||
|
|
||||||
|
with self.focus_standout(self._progress_bar_win):
|
||||||
|
self._progress_bar_win.border(
|
||||||
|
curses.ACS_VLINE,
|
||||||
|
curses.ACS_VLINE,
|
||||||
|
curses.ACS_HLINE,
|
||||||
|
curses.ACS_HLINE,
|
||||||
|
curses.ACS_LTEE,
|
||||||
|
curses.ACS_RTEE,
|
||||||
|
curses.ACS_LLCORNER,
|
||||||
|
curses.ACS_LRCORNER,
|
||||||
|
)
|
||||||
|
|
||||||
|
text = self._progress_title
|
||||||
|
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
|
||||||
|
# Determine the number of characters to write
|
||||||
|
nwrite = min(len(text), self._progress_bar_win.getmaxyx()[1])
|
||||||
|
# The amount MUST be greater than zero to avoid linux errors
|
||||||
|
if nwrite > 0:
|
||||||
|
self._progress_bar_win.addnstr(
|
||||||
|
0,
|
||||||
|
x,
|
||||||
|
text,
|
||||||
|
nwrite,
|
||||||
|
)
|
||||||
|
self._progress_bar.set_position(self._cur_pos, self._max_pos)
|
||||||
|
self._progress_bar_win.noutrefresh()
|
||||||
|
|
||||||
|
|
||||||
|
class DataTableProgressBarWindow(ProgressBarWindow):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: curses.window,
|
||||||
|
h: int,
|
||||||
|
w: int,
|
||||||
|
y: int,
|
||||||
|
x: int,
|
||||||
|
title: str,
|
||||||
|
align_right: bool,
|
||||||
|
show_time: bool,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent, h, w, y, x, title, show_time)
|
||||||
|
|
||||||
|
self._data_table = DataTable(
|
||||||
|
self._win, h - 4, w - 2, 1, 1, align_right
|
||||||
|
)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
super().stop()
|
||||||
|
self._data_table.stop()
|
||||||
|
|
||||||
|
def _render_data_table(self) -> None:
|
||||||
|
if not self._win:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.focus_standout(self._win):
|
||||||
|
self._win.box()
|
||||||
|
x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2)
|
||||||
|
self._win.addnstr(
|
||||||
|
0, x, self._title, min(len(self._title), self.getmaxyx()[1])
|
||||||
|
)
|
||||||
|
|
||||||
|
self._win.noutrefresh()
|
||||||
|
self._data_table.render()
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
self._render_data_table()
|
||||||
|
self._render_progress_bar()
|
||||||
|
|
||||||
|
|
||||||
|
class ListBoxProgressBarWindow(ProgressBarWindow):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: curses.window,
|
||||||
|
h: int,
|
||||||
|
w: int,
|
||||||
|
y: int,
|
||||||
|
x: int,
|
||||||
|
title: str,
|
||||||
|
show_time: bool,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent, h, w, y, x, title, show_time)
|
||||||
|
|
||||||
|
self._list_box = ListBox(self._win, h - 4, w - 2, 1, 1)
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
super().stop()
|
||||||
|
self._list_box.stop()
|
||||||
|
|
||||||
|
def _render_list_box(self) -> None:
|
||||||
|
if not self._win:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.focus_standout(self._win):
|
||||||
|
self._win.box()
|
||||||
|
x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2)
|
||||||
|
self._win.addnstr(
|
||||||
|
0, x, self._title, min(len(self._title), self.getmaxyx()[1])
|
||||||
|
)
|
||||||
|
|
||||||
|
self._win.noutrefresh()
|
||||||
|
self._list_box.render()
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
self._render_list_box()
|
||||||
|
self._render_progress_bar()
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
from pqcli.mechanic import Player
|
||||||
|
|
||||||
|
from .progress_bar_window import ListBoxProgressBarWindow
|
||||||
|
|
||||||
|
|
||||||
|
class QuestBookWindow(ListBoxProgressBarWindow):
|
||||||
|
cutoff = 100
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
player: Player,
|
||||||
|
parent: curses.window,
|
||||||
|
h: int,
|
||||||
|
w: int,
|
||||||
|
y: int,
|
||||||
|
x: int,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent, h, w, y, x, " Quests ", show_time=True)
|
||||||
|
self._on_focus_change += self._render
|
||||||
|
|
||||||
|
self._player = player
|
||||||
|
self._player.quest_book.connect("start_quest", self._sync_quest_add)
|
||||||
|
self._player.quest_book.quest_bar.connect(
|
||||||
|
"change", self._sync_position
|
||||||
|
)
|
||||||
|
|
||||||
|
self.sync()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
super().stop()
|
||||||
|
self._list_box.stop()
|
||||||
|
|
||||||
|
self._player.quest_book.disconnect("start_quest", self._sync_quest_add)
|
||||||
|
self._player.quest_book.quest_bar.disconnect(
|
||||||
|
"change", self._sync_position
|
||||||
|
)
|
||||||
|
|
||||||
|
def scroll_page_up(self) -> None:
|
||||||
|
self._list_box.scroll_page_up()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def scroll_page_down(self) -> None:
|
||||||
|
self._list_box.scroll_page_down()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def sync(self) -> None:
|
||||||
|
self._sync_quests()
|
||||||
|
self._sync_position()
|
||||||
|
|
||||||
|
def _sync_quests(self) -> None:
|
||||||
|
self._list_box.clear()
|
||||||
|
for quest_name in self._player.quest_book.quests[-self.cutoff :]:
|
||||||
|
self._sync_quest_add(quest_name)
|
||||||
|
self._list_box.select(-1)
|
||||||
|
self._render_list_box()
|
||||||
|
|
||||||
|
def _sync_quest_add(self, quest_name: str) -> None:
|
||||||
|
self._list_box.delete(0, max(0, len(self._list_box) - self.cutoff))
|
||||||
|
prev = self._list_box.get(-1)
|
||||||
|
if prev is not None:
|
||||||
|
self._list_box.set(-1, "[X] " + prev[4:])
|
||||||
|
self._list_box.add("[ ] " + quest_name)
|
||||||
|
self._list_box.select(-1)
|
||||||
|
self._render_list_box()
|
||||||
|
|
||||||
|
def _sync_position(self) -> None:
|
||||||
|
self._cur_pos = self._player.quest_book.quest_bar.position
|
||||||
|
self._max_pos = self._player.quest_book.quest_bar.max_
|
||||||
|
self._render_progress_bar()
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
from pqcli.lingo import to_roman
|
||||||
|
from pqcli.mechanic import Player, Spell
|
||||||
|
from pqcli.ui.curses.widgets import DataTable, Focusable, WindowWrapper
|
||||||
|
|
||||||
|
|
||||||
|
class SpellBookWindow(Focusable, WindowWrapper):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
player: Player,
|
||||||
|
parent: curses.window,
|
||||||
|
h: int,
|
||||||
|
w: int,
|
||||||
|
y: int,
|
||||||
|
x: int,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent, h, w, y, x)
|
||||||
|
self._on_focus_change += self._render
|
||||||
|
|
||||||
|
self._data_table = DataTable(
|
||||||
|
self._win, h - 2, w - 2, 1, 1, align_right=True
|
||||||
|
)
|
||||||
|
|
||||||
|
self._player = player
|
||||||
|
self._player.spell_book.connect("add", self._sync_spell_add)
|
||||||
|
self._player.spell_book.connect("change", self._sync_spell_change)
|
||||||
|
|
||||||
|
self.sync()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
super().stop()
|
||||||
|
self._data_table.stop()
|
||||||
|
|
||||||
|
self._player.spell_book.disconnect("add", self._sync_spell_add)
|
||||||
|
self._player.spell_book.disconnect("change", self._sync_spell_change)
|
||||||
|
|
||||||
|
def scroll_page_up(self) -> None:
|
||||||
|
self._data_table.scroll_page_up()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def scroll_page_down(self) -> None:
|
||||||
|
self._data_table.scroll_page_down()
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def sync(self) -> None:
|
||||||
|
self._data_table.clear()
|
||||||
|
for spell in self._player.spell_book:
|
||||||
|
self._data_table.add(spell.name, to_roman(spell.level))
|
||||||
|
self._data_table.select(None)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _sync_spell_add(self, spell: Spell) -> None:
|
||||||
|
self._data_table.add(spell.name, to_roman(spell.level))
|
||||||
|
self._data_table.select(spell.name)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _sync_spell_change(self, spell: Spell) -> None:
|
||||||
|
self._data_table.set(spell.name, to_roman(spell.level))
|
||||||
|
self._data_table.select(spell.name)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
if not self._win:
|
||||||
|
return
|
||||||
|
|
||||||
|
with self.focus_standout(self._win):
|
||||||
|
self._win.box()
|
||||||
|
text = " Spell Book "
|
||||||
|
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
|
||||||
|
self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1]))
|
||||||
|
|
||||||
|
self._win.noutrefresh()
|
||||||
|
self._data_table.render()
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
from pqcli.mechanic import Player
|
||||||
|
from pqcli.ui.curses.widgets import (
|
||||||
|
Focusable,
|
||||||
|
Label,
|
||||||
|
ProgressBar,
|
||||||
|
WindowWrapper,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TaskProgressWindow(Focusable, WindowWrapper):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
player: Player,
|
||||||
|
parent: curses.window,
|
||||||
|
h: int,
|
||||||
|
w: int,
|
||||||
|
y: int,
|
||||||
|
x: int,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent, h, w, y, x)
|
||||||
|
|
||||||
|
self._desc_win = Label(self._win, 1, w, 0, 0)
|
||||||
|
self._progress_bar = ProgressBar(
|
||||||
|
self._win, 1, w, 1, 0, show_time=False
|
||||||
|
)
|
||||||
|
|
||||||
|
self._player = player
|
||||||
|
self._player.connect("new_task", self._sync_task_name)
|
||||||
|
self._player.task_bar.connect("change", self._sync_position)
|
||||||
|
|
||||||
|
self.sync()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
super().stop()
|
||||||
|
|
||||||
|
self._desc_win.stop()
|
||||||
|
self._progress_bar.stop()
|
||||||
|
|
||||||
|
self._player.disconnect("new_task", self._sync_task_name)
|
||||||
|
self._player.task_bar.disconnect("change", self._sync_position)
|
||||||
|
|
||||||
|
def sync(self) -> None:
|
||||||
|
self._sync_task_name()
|
||||||
|
self._sync_position()
|
||||||
|
|
||||||
|
def _sync_task_name(self) -> None:
|
||||||
|
self._desc_win.set_text(
|
||||||
|
f"{self._player.task.description}..." if self._player.task else "?"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _sync_position(self) -> None:
|
||||||
|
self._progress_bar.set_position(
|
||||||
|
self._player.task_bar.position, self._player.task_bar.max_
|
||||||
|
)
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import curses
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from pqcli.ui.curses.event_handler import EventHandler
|
||||||
|
from pqcli.ui.curses.util import Choice
|
||||||
|
from pqcli.ui.curses.views.base_view import BaseView
|
||||||
|
from pqcli.ui.curses.widgets import Menu
|
||||||
|
|
||||||
|
ItemT = TypeVar("ItemT")
|
||||||
|
|
||||||
|
|
||||||
|
class MenuView(BaseView, Generic[ItemT]):
|
||||||
|
def __init__(
|
||||||
|
self, screen: curses.window, title: str, active_choice: int = 0
|
||||||
|
) -> None:
|
||||||
|
super().__init__(screen)
|
||||||
|
|
||||||
|
self.on_cancel = EventHandler[[]]()
|
||||||
|
self.on_confirm = EventHandler[[ItemT]]()
|
||||||
|
|
||||||
|
self._title = f"{title}:"
|
||||||
|
self._choices: list[Choice] = []
|
||||||
|
self._active_choice = active_choice
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
scr_height, scr_width = self.screen.getmaxyx()
|
||||||
|
self.screen.erase()
|
||||||
|
self.screen.noutrefresh()
|
||||||
|
|
||||||
|
self.main_menu = Menu(
|
||||||
|
header=self._title,
|
||||||
|
choices=self._choices,
|
||||||
|
active_choice=self._active_choice,
|
||||||
|
scr_height=scr_height,
|
||||||
|
scr_width=scr_width,
|
||||||
|
)
|
||||||
|
self.main_menu.render()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.main_menu.stop()
|
||||||
|
|
||||||
|
def keypress(self, key: int) -> None:
|
||||||
|
self.main_menu.keypress(key)
|
||||||
|
self.main_menu.render()
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import curses
|
||||||
|
import curses.ascii
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
from pqcli.ui.curses.event_handler import EventHandler
|
||||||
|
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
|
||||||
|
from pqcli.ui.curses.widgets import Menu
|
||||||
|
|
||||||
|
from .base_view import BaseView
|
||||||
|
|
||||||
|
|
||||||
|
class MessageView(BaseView):
|
||||||
|
"""Show a message until it is dismissed."""
|
||||||
|
|
||||||
|
def __init__(self, screen: curses.window, message: str) -> None:
|
||||||
|
super().__init__(screen)
|
||||||
|
|
||||||
|
self.message = message
|
||||||
|
self.on_close = EventHandler[[]]()
|
||||||
|
|
||||||
|
self.choices: list[Choice] = [
|
||||||
|
Choice(
|
||||||
|
keys=[curses.ascii.NL, *KEYS_CANCEL],
|
||||||
|
desc="[Enter] OK",
|
||||||
|
callback=self.on_close,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
scr_height, scr_width = self.screen.getmaxyx()
|
||||||
|
self.screen.erase()
|
||||||
|
self.screen.noutrefresh()
|
||||||
|
|
||||||
|
width = max(20, scr_width - 6)
|
||||||
|
header = "\n".join(
|
||||||
|
"\n".join(textwrap.wrap(line, width) or [""])
|
||||||
|
for line in self.message.split("\n")
|
||||||
|
)
|
||||||
|
self.main_menu = Menu(
|
||||||
|
header=header,
|
||||||
|
choices=self.choices,
|
||||||
|
active_choice=0,
|
||||||
|
scr_height=scr_height,
|
||||||
|
scr_width=scr_width,
|
||||||
|
)
|
||||||
|
self.main_menu.render()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.main_menu.stop()
|
||||||
|
|
||||||
|
def keypress(self, key: int) -> None:
|
||||||
|
self.main_menu.keypress(key)
|
||||||
|
self.main_menu.render()
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
from pqcli.ui.curses.colors import COLOR_LOGO, COLOR_LOGO_ALT, has_colors
|
||||||
|
from pqcli.ui.curses.event_handler import EventHandler
|
||||||
|
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
|
||||||
|
from pqcli.ui.curses.widgets import Menu
|
||||||
|
|
||||||
|
from .base_view import BaseView
|
||||||
|
|
||||||
|
LOGO = """
|
||||||
|
█▀▀▄ ▄▀▀█
|
||||||
|
▀▄ ▀▄ ▄▀ ▄▀
|
||||||
|
▀▄ ▀▄ ▄▀ ▄▀
|
||||||
|
▀▄ ▄▀ ▄▀
|
||||||
|
▄▀ ▄▀▄
|
||||||
|
▄▄▄▄▀ ▄▀▄ ▀▄▄▄▄
|
||||||
|
▀▄ █▄▄▀ ▀▄▄█ ▄▀
|
||||||
|
▄▀█▄ █ █ ▄█▀▄
|
||||||
|
▄▄▀▄▀ ▀▀ ▀▀ ▀▄▀▄▄
|
||||||
|
█▄█ █▄█
|
||||||
|
|
||||||
|
█▀▀▄ ▄▀▀▄ █
|
||||||
|
█▄▄▀ █▀▀ ▄▀▀▄ ▄▀▀▄ █▀▀ ▄▀▀▄ ▄▀▀▀ ▄▀▀▀ █ █ █ █ ▄▀▀▄ ▄▀▀▀ ▀█▀
|
||||||
|
█ █ █ █ ▀▄▄█ █ █▀▀ ▀▀▄ ▀▀▄ █ ▌█ █ █ █▀▀ ▀▀▄ █
|
||||||
|
▀ ▀ ▀▀ ▄▄▀ ▀ ▀▀ ▀▀▀ ▀▀▀ ▀▀▌ ▀▀ ▀▀ ▀▀▀ ▀
|
||||||
|
""".strip("\n")
|
||||||
|
|
||||||
|
|
||||||
|
class MainMenu(Menu):
|
||||||
|
def __init__(
|
||||||
|
self, choices: list[Choice], scr_height: int, scr_width: int
|
||||||
|
) -> None:
|
||||||
|
super().__init__(
|
||||||
|
header=LOGO,
|
||||||
|
choices=choices,
|
||||||
|
active_choice=0,
|
||||||
|
scr_height=scr_height,
|
||||||
|
scr_width=scr_width,
|
||||||
|
)
|
||||||
|
pad = self._pad
|
||||||
|
assert pad is not None
|
||||||
|
if has_colors():
|
||||||
|
for y in range(len(self._header_lines)):
|
||||||
|
pad.chgat(y, 0, curses.color_pair(COLOR_LOGO))
|
||||||
|
pad.chgat(3, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
|
||||||
|
pad.chgat(4, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
|
||||||
|
pad.chgat(5, 32, 4, curses.color_pair(COLOR_LOGO_ALT))
|
||||||
|
pad.chgat(5, 27, 4, curses.color_pair(COLOR_LOGO_ALT))
|
||||||
|
pad.chgat(6, 34, 2, curses.color_pair(COLOR_LOGO_ALT))
|
||||||
|
pad.chgat(6, 27, 2, curses.color_pair(COLOR_LOGO_ALT))
|
||||||
|
|
||||||
|
|
||||||
|
class RosterView(BaseView):
|
||||||
|
def __init__(self, screen: curses.window) -> None:
|
||||||
|
super().__init__(screen)
|
||||||
|
|
||||||
|
self.on_create = EventHandler[[]]()
|
||||||
|
self.on_play = EventHandler[[]]()
|
||||||
|
self.on_delete = EventHandler[[]]()
|
||||||
|
self.on_import = EventHandler[[]]()
|
||||||
|
self.on_export = EventHandler[[]]()
|
||||||
|
self.on_quit = EventHandler[[]]()
|
||||||
|
|
||||||
|
self.choices: list[Choice] = [
|
||||||
|
Choice(
|
||||||
|
keys=list(map(ord, "cC")),
|
||||||
|
desc="[C] Create new character",
|
||||||
|
callback=self.on_create,
|
||||||
|
),
|
||||||
|
Choice(
|
||||||
|
keys=list(map(ord, "pP")),
|
||||||
|
desc="[P] Play",
|
||||||
|
callback=self.on_play,
|
||||||
|
),
|
||||||
|
Choice(
|
||||||
|
keys=list(map(ord, "dD")),
|
||||||
|
desc="[D] Delete character",
|
||||||
|
callback=self.on_delete,
|
||||||
|
),
|
||||||
|
Choice(
|
||||||
|
keys=list(map(ord, "iI")),
|
||||||
|
desc="[I] Import character (.pqw)",
|
||||||
|
callback=self.on_import,
|
||||||
|
),
|
||||||
|
Choice(
|
||||||
|
keys=list(map(ord, "eE")),
|
||||||
|
desc="[E] Export character (.pqw)",
|
||||||
|
callback=self.on_export,
|
||||||
|
),
|
||||||
|
Choice(
|
||||||
|
keys=list(KEYS_CANCEL), desc="[Q] Quit", callback=self.on_quit
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
scr_height, scr_width = self.screen.getmaxyx()
|
||||||
|
self.screen.erase()
|
||||||
|
self.screen.noutrefresh()
|
||||||
|
|
||||||
|
self.main_menu = MainMenu(
|
||||||
|
choices=self.choices, scr_height=scr_height, scr_width=scr_width
|
||||||
|
)
|
||||||
|
self.main_menu.render()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self.main_menu.stop()
|
||||||
|
|
||||||
|
def keypress(self, key: int) -> None:
|
||||||
|
self.main_menu.keypress(key)
|
||||||
|
self.main_menu.render()
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import curses
|
||||||
|
import curses.ascii
|
||||||
|
|
||||||
|
from pqcli.ui.curses.event_handler import EventHandler
|
||||||
|
from pqcli.ui.curses.views.base_view import BaseView
|
||||||
|
|
||||||
|
|
||||||
|
class TextInputView(BaseView):
|
||||||
|
"""Ask for one line of text, such as a file name."""
|
||||||
|
|
||||||
|
def __init__(self, screen: curses.window, title: str, text: str) -> None:
|
||||||
|
super().__init__(screen)
|
||||||
|
|
||||||
|
self.on_cancel = EventHandler[[]]()
|
||||||
|
self.on_confirm = EventHandler[[str]]()
|
||||||
|
|
||||||
|
self._title = title
|
||||||
|
self._text = text
|
||||||
|
self._win: curses.window | None = None
|
||||||
|
self._text_win: curses.window | None = None
|
||||||
|
|
||||||
|
def start(self) -> None:
|
||||||
|
self.screen.erase()
|
||||||
|
self.screen.noutrefresh()
|
||||||
|
|
||||||
|
scr_height, scr_width = self.screen.getmaxyx()
|
||||||
|
h = 6
|
||||||
|
w = min(60, scr_width - 4)
|
||||||
|
y = (scr_height - h - 2) // 2
|
||||||
|
x = (scr_width - w - 2) // 2
|
||||||
|
self._win = curses.newwin(h + 2, w + 2, y, x)
|
||||||
|
self._text_win = curses.newwin(3, w + 2, y + 2, x - 1)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
self._win = None
|
||||||
|
self._text_win = None
|
||||||
|
|
||||||
|
def keypress(self, key: int) -> None:
|
||||||
|
if key == curses.ascii.ESC:
|
||||||
|
self.on_cancel()
|
||||||
|
return
|
||||||
|
if key == curses.ascii.NL:
|
||||||
|
text = self._text.strip()
|
||||||
|
if text:
|
||||||
|
self.on_confirm(text)
|
||||||
|
else:
|
||||||
|
self.on_cancel()
|
||||||
|
return
|
||||||
|
if key in {curses.KEY_BACKSPACE, curses.ascii.DEL}:
|
||||||
|
self._text = self._text[:-1]
|
||||||
|
elif key == curses.ascii.ETB: # ^w
|
||||||
|
self._text = ""
|
||||||
|
elif curses.ascii.isprint(key):
|
||||||
|
self._text += chr(key)
|
||||||
|
self._render()
|
||||||
|
|
||||||
|
def _render(self) -> None:
|
||||||
|
if not self._win or not self._text_win:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._win.erase()
|
||||||
|
self._win.addstr(0, 0, self._title)
|
||||||
|
self._win.addstr(5, 0, "[Enter] Continue")
|
||||||
|
self._win.addstr(6, 0, "[Esc ] Cancel")
|
||||||
|
self._win.refresh()
|
||||||
|
|
||||||
|
width = self._text_win.getmaxyx()[1] - 2
|
||||||
|
self._text_win.erase()
|
||||||
|
self._text_win.box()
|
||||||
|
# keep the end of long paths in view
|
||||||
|
self._text_win.addnstr(1, 1, self._text[-width + 1 :], width)
|
||||||
|
self._text_win.refresh()
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
from .base import Widget, WindowWrapper
|
||||||
|
from .data_table import DataTable
|
||||||
|
from .focusable import Focusable
|
||||||
|
from .label import Label
|
||||||
|
from .list_box import ListBox
|
||||||
|
from .menu import Menu
|
||||||
|
from .progress_bar import ProgressBar
|
||||||
|
from .scrollable import Scrollable
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Widget",
|
||||||
|
"WindowWrapper",
|
||||||
|
"DataTable",
|
||||||
|
"Focusable",
|
||||||
|
"Label",
|
||||||
|
"ListBox",
|
||||||
|
"Menu",
|
||||||
|
"ProgressBar",
|
||||||
|
"Scrollable",
|
||||||
|
]
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
|
||||||
|
class Widget:
|
||||||
|
def __del__(self) -> None:
|
||||||
|
self.stop()
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class WindowWrapper(Widget):
|
||||||
|
def __init__(
|
||||||
|
self, parent: curses.window | None, h: int, w: int, y: int, x: int
|
||||||
|
) -> None:
|
||||||
|
self._parent = parent
|
||||||
|
self._win: curses.window | None
|
||||||
|
|
||||||
|
if not parent:
|
||||||
|
self._win = None
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self._win = parent.derwin(h, w, y, x)
|
||||||
|
self._win.noutrefresh()
|
||||||
|
except curses.error:
|
||||||
|
self._win = None
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
del self._win
|
||||||
|
self._win = None
|
||||||
|
|
||||||
|
def getbegyx(self) -> tuple[int, int]:
|
||||||
|
if not self._win:
|
||||||
|
return (0, 0)
|
||||||
|
return self._win.getbegyx()
|
||||||
|
|
||||||
|
def getmaxyx(self) -> tuple[int, int]:
|
||||||
|
if not self._win:
|
||||||
|
return (0, 0)
|
||||||
|
return self._win.getmaxyx()
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors
|
||||||
|
|
||||||
|
from .scrollable import Scrollable
|
||||||
|
|
||||||
|
|
||||||
|
class DataTable(Scrollable[tuple[str, str]]):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: curses.window | None,
|
||||||
|
h: int,
|
||||||
|
w: int,
|
||||||
|
y: int,
|
||||||
|
x: int,
|
||||||
|
align_right: bool = False,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent, h, w, y, x)
|
||||||
|
|
||||||
|
self._align_right = align_right
|
||||||
|
self._selected: int | None = None
|
||||||
|
|
||||||
|
def add(self, text: str, value: str) -> None:
|
||||||
|
self._items.append((text, value))
|
||||||
|
self.scroll_to_item(-1)
|
||||||
|
|
||||||
|
def set(self, text: str, value: str) -> None:
|
||||||
|
idx = self.get_idx(text)
|
||||||
|
if idx is None:
|
||||||
|
self.add(text, value)
|
||||||
|
else:
|
||||||
|
self._items[idx] = (self._items[idx][0], value)
|
||||||
|
self.scroll_to_item(idx)
|
||||||
|
|
||||||
|
def delete(self, text: str) -> None:
|
||||||
|
idx = self.get_idx(text)
|
||||||
|
if idx is not None:
|
||||||
|
del self._items[idx]
|
||||||
|
self.scroll_to_item(idx)
|
||||||
|
|
||||||
|
def select(self, text: str | None) -> None:
|
||||||
|
self._selected = None if text is None else self.get_idx(text)
|
||||||
|
|
||||||
|
def get_idx(self, text: str) -> int | None:
|
||||||
|
for idx, row in enumerate(self._items):
|
||||||
|
if row[0].strip() == text.strip():
|
||||||
|
return idx
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _render_impl(self, h: int, w: int) -> None:
|
||||||
|
assert self._pad
|
||||||
|
if self._align_right:
|
||||||
|
for y, row in enumerate(self._items):
|
||||||
|
if y == self._selected and has_colors():
|
||||||
|
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
|
||||||
|
self._pad.move(y, 0)
|
||||||
|
self._pad.addnstr(row[0], min(len(row[0]), w))
|
||||||
|
col2_x = max(0, w - len(row[1]))
|
||||||
|
if col2_x < w:
|
||||||
|
self._pad.move(y, col2_x)
|
||||||
|
self._pad.addstr(row[1])
|
||||||
|
if y == self._selected and has_colors():
|
||||||
|
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
|
||||||
|
else:
|
||||||
|
col2_x = self._get_col_width(0)
|
||||||
|
for y, row in enumerate(self._items):
|
||||||
|
if y == self._selected and has_colors():
|
||||||
|
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
|
||||||
|
self._pad.addnstr(y, 0, row[0], min(len(row[0]), w))
|
||||||
|
# Determine the number of characters to write
|
||||||
|
nwrite = min(len(row[1]), w - col2_x)
|
||||||
|
# Ensure we have enough columns, and we are writing more
|
||||||
|
# than zero
|
||||||
|
if col2_x < w and nwrite > 0:
|
||||||
|
self._pad.addnstr(y, col2_x, row[1], nwrite)
|
||||||
|
if y == self._selected and has_colors():
|
||||||
|
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
|
||||||
|
|
||||||
|
def _get_col_width(self, x: int) -> int:
|
||||||
|
return max([len(row[0]) for row in self._items] + [0])
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import contextlib
|
||||||
|
import curses
|
||||||
|
from collections.abc import Generator
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from pqcli.ui.curses.colors import COLOR_FOCUSED, has_colors
|
||||||
|
from pqcli.ui.curses.event_handler import EventHandler
|
||||||
|
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def focus_standout(
|
||||||
|
focused: bool, win: curses.window
|
||||||
|
) -> Generator[None, None, None]:
|
||||||
|
if focused:
|
||||||
|
if has_colors():
|
||||||
|
win.attron(curses.color_pair(COLOR_FOCUSED))
|
||||||
|
else:
|
||||||
|
win.standout()
|
||||||
|
yield
|
||||||
|
if focused:
|
||||||
|
if has_colors():
|
||||||
|
win.attroff(curses.color_pair(COLOR_FOCUSED))
|
||||||
|
else:
|
||||||
|
win.standend()
|
||||||
|
|
||||||
|
|
||||||
|
class Focusable:
|
||||||
|
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
self._focused = False
|
||||||
|
self._on_focus_change = EventHandler[[]]()
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def focused(self) -> bool:
|
||||||
|
return self._focused
|
||||||
|
|
||||||
|
@focused.setter
|
||||||
|
def focused(self, focused: bool) -> None:
|
||||||
|
self._focused = focused
|
||||||
|
self._on_focus_change()
|
||||||
|
|
||||||
|
@contextlib.contextmanager
|
||||||
|
def focus_standout(
|
||||||
|
self, win: curses.window
|
||||||
|
) -> Generator[None, None, None]:
|
||||||
|
with focus_standout(self._focused, win):
|
||||||
|
yield
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
from .base import WindowWrapper
|
||||||
|
|
||||||
|
|
||||||
|
class Label(WindowWrapper):
|
||||||
|
def set_text(self, text: str) -> None:
|
||||||
|
if not self._win:
|
||||||
|
return
|
||||||
|
self._win.erase()
|
||||||
|
self._win.addnstr(text, min(len(text), self.getmaxyx()[1] - 1))
|
||||||
|
self._win.noutrefresh()
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import curses
|
||||||
|
|
||||||
|
from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors
|
||||||
|
|
||||||
|
from .scrollable import Scrollable
|
||||||
|
|
||||||
|
|
||||||
|
class ListBox(Scrollable[str]):
|
||||||
|
def __init__(
|
||||||
|
self, parent: curses.window | None, h: int, w: int, y: int, x: int
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent, h, w, y, x)
|
||||||
|
self._selected: int | None = None
|
||||||
|
|
||||||
|
def add(self, text: str) -> None:
|
||||||
|
self._items.append(text)
|
||||||
|
self.scroll_to_item(-1)
|
||||||
|
|
||||||
|
def set(self, idx: int, value: str) -> None:
|
||||||
|
if idx < 0:
|
||||||
|
idx %= len(self._items)
|
||||||
|
self._items[idx] = value
|
||||||
|
|
||||||
|
def get(self, idx: int) -> str | None:
|
||||||
|
if not self._items:
|
||||||
|
return None
|
||||||
|
if idx < 0:
|
||||||
|
idx %= len(self._items)
|
||||||
|
try:
|
||||||
|
return self._items[idx]
|
||||||
|
except IndexError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def delete(self, idx: int, count: int = 1) -> None:
|
||||||
|
del self._items[idx : idx + count]
|
||||||
|
|
||||||
|
def select(self, idx: int | None) -> None:
|
||||||
|
if idx is not None and idx < 0 and self._items:
|
||||||
|
idx %= len(self._items)
|
||||||
|
self._selected = idx
|
||||||
|
|
||||||
|
def _render_impl(self, h: int, w: int) -> None:
|
||||||
|
assert self._pad
|
||||||
|
for y, item in enumerate(self._items):
|
||||||
|
if y == self._selected and has_colors():
|
||||||
|
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
|
||||||
|
self._pad.addnstr(y, 0, item, min(len(item), w))
|
||||||
|
if y == self._selected and has_colors():
|
||||||
|
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import curses
|
||||||
|
import curses.ascii
|
||||||
|
|
||||||
|
from pqcli.ui.curses.util import KEYS_CYCLE, KEYS_DOWN, KEYS_UP, Choice
|
||||||
|
from pqcli.ui.curses.widgets.focusable import focus_standout
|
||||||
|
|
||||||
|
from .base import Widget
|
||||||
|
|
||||||
|
|
||||||
|
class Menu(Widget):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
header: str,
|
||||||
|
choices: list[Choice],
|
||||||
|
active_choice: int,
|
||||||
|
scr_height: int,
|
||||||
|
scr_width: int,
|
||||||
|
) -> None:
|
||||||
|
self._header_lines = header.split("\n")
|
||||||
|
self._choices = choices
|
||||||
|
self._scr_width = scr_width
|
||||||
|
self._scr_height = scr_height
|
||||||
|
|
||||||
|
all_lines = self._header_lines + sum(
|
||||||
|
[choice.desc.splitlines() for choice in choices], []
|
||||||
|
)
|
||||||
|
|
||||||
|
w = max(map(len, all_lines)) + 1
|
||||||
|
h = len(all_lines) + 1
|
||||||
|
pad = curses.newpad(h, w)
|
||||||
|
self._pad: curses.window | None = pad
|
||||||
|
|
||||||
|
for y, line in enumerate(self._header_lines):
|
||||||
|
pad.move(y, 0)
|
||||||
|
pad.addstr(line)
|
||||||
|
|
||||||
|
self._active_choice = active_choice
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
del self._pad
|
||||||
|
self._pad = None
|
||||||
|
|
||||||
|
def getmaxyx(self) -> tuple[int, int]:
|
||||||
|
if not self._pad:
|
||||||
|
return (0, 0)
|
||||||
|
return self._pad.getmaxyx()
|
||||||
|
|
||||||
|
def next(self) -> None:
|
||||||
|
self._active_choice = min(
|
||||||
|
len(self._choices) - 1, self._active_choice + 1
|
||||||
|
)
|
||||||
|
|
||||||
|
def prev(self) -> None:
|
||||||
|
self._active_choice = max(0, self._active_choice - 1)
|
||||||
|
|
||||||
|
def cycle(self) -> None:
|
||||||
|
self._active_choice = (self._active_choice + 1) % len(self._choices)
|
||||||
|
|
||||||
|
def keypress(self, key: int) -> None:
|
||||||
|
for choice in self._choices:
|
||||||
|
if key in choice.keys:
|
||||||
|
choice.callback()
|
||||||
|
return
|
||||||
|
|
||||||
|
if key == curses.ascii.NL:
|
||||||
|
self._choices[self._active_choice].callback()
|
||||||
|
elif key in KEYS_DOWN:
|
||||||
|
self.next()
|
||||||
|
elif key in KEYS_UP:
|
||||||
|
self.prev()
|
||||||
|
elif key in KEYS_CYCLE:
|
||||||
|
self.cycle()
|
||||||
|
|
||||||
|
def render(self) -> None:
|
||||||
|
if not self._pad:
|
||||||
|
return
|
||||||
|
|
||||||
|
y = len(self._header_lines) + 1
|
||||||
|
for i, choice in enumerate(self._choices):
|
||||||
|
self._pad.move(y, 0)
|
||||||
|
with focus_standout(i == self._active_choice, self._pad):
|
||||||
|
lines = choice.desc.splitlines()
|
||||||
|
max_len = max(map(len, lines))
|
||||||
|
lines = [line.ljust(max_len) for line in lines]
|
||||||
|
self._pad.addstr("\n".join(lines))
|
||||||
|
y += len(choice.desc.splitlines())
|
||||||
|
|
||||||
|
self._pad.refresh(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
(self._scr_height - self._pad.getmaxyx()[0]) // 2,
|
||||||
|
(self._scr_width - self._pad.getmaxyx()[1]) // 2,
|
||||||
|
self._scr_height - 1,
|
||||||
|
self._scr_width - 1,
|
||||||
|
)
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import curses
|
||||||
|
import datetime
|
||||||
|
|
||||||
|
from pqcli.lingo import format_timespan
|
||||||
|
from pqcli.ui.curses.colors import COLOR_PROGRESSBAR, has_colors
|
||||||
|
from pqcli.ui.curses.widgets.base import WindowWrapper
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressBar(WindowWrapper):
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
parent: curses.window | None,
|
||||||
|
h: int,
|
||||||
|
w: int,
|
||||||
|
y: int,
|
||||||
|
x: int,
|
||||||
|
show_time: bool,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent, h, w, y, x)
|
||||||
|
|
||||||
|
self._cur_pos = 0.0
|
||||||
|
self._max_pos = 1.0
|
||||||
|
|
||||||
|
self._show_time = show_time
|
||||||
|
self._last_tick: tuple[datetime.datetime, float] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def time_left(self) -> datetime.timedelta | None:
|
||||||
|
if self._last_tick is None:
|
||||||
|
return None
|
||||||
|
time_then, pos_then = self._last_tick
|
||||||
|
time_now, pos_now = datetime.datetime.now(), self._cur_pos
|
||||||
|
div = (time_now - time_then).total_seconds()
|
||||||
|
if div == 0:
|
||||||
|
return None
|
||||||
|
speed = (pos_now - pos_then) / div
|
||||||
|
if not speed:
|
||||||
|
return None
|
||||||
|
return datetime.timedelta(seconds=(self._max_pos - pos_now) / speed)
|
||||||
|
|
||||||
|
def set_position(self, cur_pos: float, max_pos: float) -> None:
|
||||||
|
if not self._win:
|
||||||
|
return
|
||||||
|
self._win.erase()
|
||||||
|
|
||||||
|
if self._last_tick is None or cur_pos == 0 or max_pos != self._max_pos:
|
||||||
|
self._last_tick = (datetime.datetime.now(), cur_pos)
|
||||||
|
self._cur_pos = cur_pos
|
||||||
|
self._max_pos = max_pos
|
||||||
|
|
||||||
|
text = f"{cur_pos / max_pos:.02%}"
|
||||||
|
if self.time_left and self._show_time:
|
||||||
|
text += f" ({format_timespan(self.time_left)})"
|
||||||
|
|
||||||
|
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
|
||||||
|
self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1] - 1))
|
||||||
|
x = int(cur_pos * self.getmaxyx()[1] // max_pos)
|
||||||
|
if x > 0:
|
||||||
|
self._win.chgat(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
(
|
||||||
|
curses.color_pair(COLOR_PROGRESSBAR)
|
||||||
|
if has_colors()
|
||||||
|
else curses.A_REVERSE
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if x < self.getmaxyx()[1]:
|
||||||
|
self._win.chgat(0, x, curses.A_NORMAL)
|
||||||
|
self._win.noutrefresh()
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import curses
|
||||||
|
from typing import Generic, TypeVar
|
||||||
|
|
||||||
|
from pqcli.ui.curses.colors import (
|
||||||
|
COLOR_SCROLLBAR_THUMB,
|
||||||
|
COLOR_SCROLLBAR_TRACK,
|
||||||
|
has_colors,
|
||||||
|
)
|
||||||
|
|
||||||
|
from .base import WindowWrapper
|
||||||
|
|
||||||
|
ItemT = TypeVar("ItemT")
|
||||||
|
|
||||||
|
|
||||||
|
class Scrollable(WindowWrapper, Generic[ItemT]):
|
||||||
|
def __init__(
|
||||||
|
self, parent: curses.window | None, h: int, w: int, y: int, x: int
|
||||||
|
) -> None:
|
||||||
|
super().__init__(parent, h, w, y, x)
|
||||||
|
self._scroll_pos = 0
|
||||||
|
self._pad: curses.window | None = curses.newpad(1, 1)
|
||||||
|
self._items: list[ItemT] = []
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self._items = []
|
||||||
|
self.scroll_to_item(0)
|
||||||
|
|
||||||
|
def scroll_page_up(self) -> None:
|
||||||
|
self.scroll_to_item(max(0, self._scroll_pos - self.getmaxyx()[0]))
|
||||||
|
|
||||||
|
def scroll_page_down(self) -> None:
|
||||||
|
self.scroll_to_item(
|
||||||
|
min(
|
||||||
|
len(self._items) - 1,
|
||||||
|
self._scroll_pos + self.getmaxyx()[0] * 2 - 1,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def scroll_to_item(self, y: int) -> None:
|
||||||
|
if not self._items or not self._win:
|
||||||
|
return
|
||||||
|
if y < 0:
|
||||||
|
y %= len(self._items)
|
||||||
|
|
||||||
|
h = self.getmaxyx()[0]
|
||||||
|
delta = 1 if self._scroll_pos < y else -1
|
||||||
|
while not self._scroll_pos <= y < self._scroll_pos + h:
|
||||||
|
self._scroll_pos += delta
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
super().stop()
|
||||||
|
del self._pad
|
||||||
|
self._pad = None
|
||||||
|
|
||||||
|
def __len__(self) -> int:
|
||||||
|
return len(self._items)
|
||||||
|
|
||||||
|
def render(self) -> None:
|
||||||
|
if not self._pad or not self._win:
|
||||||
|
return
|
||||||
|
|
||||||
|
if self._items:
|
||||||
|
self._pad.resize(
|
||||||
|
max(1, len(self._items)), max(1, self.getmaxyx()[1] + 1)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._pad.resize(1, 1)
|
||||||
|
|
||||||
|
self._win.erase()
|
||||||
|
self._win.noutrefresh()
|
||||||
|
|
||||||
|
self._pad.erase()
|
||||||
|
h, w = self.getmaxyx()
|
||||||
|
y, x = self.getbegyx()
|
||||||
|
|
||||||
|
try:
|
||||||
|
if self._scroll_pos > 0 or self._scroll_pos + h < len(self._items):
|
||||||
|
y1 = self._scroll_pos
|
||||||
|
y2 = self._scroll_pos + h
|
||||||
|
thumb_y1 = int(y1 * h // len(self._items))
|
||||||
|
thumb_y2 = int(y2 * h // len(self._items))
|
||||||
|
|
||||||
|
attr_thumb = (
|
||||||
|
curses.color_pair(COLOR_SCROLLBAR_THUMB)
|
||||||
|
if has_colors()
|
||||||
|
else curses.A_REVERSE
|
||||||
|
)
|
||||||
|
attr_track = (
|
||||||
|
curses.color_pair(COLOR_SCROLLBAR_TRACK)
|
||||||
|
if has_colors()
|
||||||
|
else curses.A_NORMAL
|
||||||
|
)
|
||||||
|
|
||||||
|
for win_y in range(h):
|
||||||
|
self._win.chgat(
|
||||||
|
win_y,
|
||||||
|
w - 1,
|
||||||
|
(
|
||||||
|
attr_thumb
|
||||||
|
if thumb_y1 <= win_y <= thumb_y2
|
||||||
|
else attr_track
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self._win.noutrefresh()
|
||||||
|
|
||||||
|
self._render_impl(h, w - 1)
|
||||||
|
self._pad.noutrefresh(
|
||||||
|
self._scroll_pos, 0, y, x, y + h - 1, x + w - 2
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self._render_impl(h, w)
|
||||||
|
self._pad.noutrefresh(
|
||||||
|
self._scroll_pos, 0, y, x, y + h - 1, x + w - 1
|
||||||
|
)
|
||||||
|
|
||||||
|
except curses.error:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _render_impl(self, h: int, w: int) -> None:
|
||||||
|
raise NotImplementedError("not implemented")
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
import typing as T
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
|
|
||||||
_MAGIC = 999 # where to display the cursor (high value = don't display it)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomButton(urwid.Button):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
label: str,
|
|
||||||
*args: T.Any,
|
|
||||||
hint: T.Optional[str] = None,
|
|
||||||
**kwargs: T.Any,
|
|
||||||
) -> None:
|
|
||||||
super().__init__("", *args, **kwargs)
|
|
||||||
self._w = urwid.AttrMap(
|
|
||||||
urwid.Columns(
|
|
||||||
[
|
|
||||||
(urwid.PACK, urwid.Text("< ")),
|
|
||||||
urwid.SelectableIcon([label], _MAGIC),
|
|
||||||
(
|
|
||||||
urwid.PACK,
|
|
||||||
urwid.Text(f"[{hint}]" if hint else "", align="right"),
|
|
||||||
),
|
|
||||||
(urwid.PACK, urwid.Text(" >")),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
"button",
|
|
||||||
"button-focus",
|
|
||||||
)
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import typing as T
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
|
|
||||||
|
|
||||||
class CustomLineBox(urwid.AttrMap):
|
|
||||||
def __init__(
|
|
||||||
self, widget: urwid.Widget, *args: T.Any, **kwargs: T.Any
|
|
||||||
) -> None:
|
|
||||||
super().__init__(
|
|
||||||
urwid.LineBox(
|
|
||||||
urwid.AttrMap(widget, "linebox-content", "linebox-content"),
|
|
||||||
*args,
|
|
||||||
**kwargs,
|
|
||||||
),
|
|
||||||
"linebox",
|
|
||||||
"linebox-focus",
|
|
||||||
)
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import datetime
|
|
||||||
import typing as T
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
|
|
||||||
|
|
||||||
def format_float(num: float) -> str:
|
|
||||||
ret = f"{num:.01f}"
|
|
||||||
if ret.endswith("0"):
|
|
||||||
ret = ret[:-2]
|
|
||||||
return ret
|
|
||||||
|
|
||||||
|
|
||||||
def format_timespan(timespan: datetime.timedelta) -> str:
|
|
||||||
num = timespan.total_seconds()
|
|
||||||
if num < 60.0:
|
|
||||||
return f"~{int(num)}s"
|
|
||||||
num /= 60
|
|
||||||
if num < 60.0:
|
|
||||||
return f"~{int(num)}m"
|
|
||||||
num /= 60
|
|
||||||
if num < 24.0:
|
|
||||||
return f"~{format_float(num)}h"
|
|
||||||
num /= 24
|
|
||||||
return f"~{format_float(num)}d"
|
|
||||||
|
|
||||||
|
|
||||||
class CustomProgressBar(urwid.Widget):
|
|
||||||
_sizing = frozenset(["flow"])
|
|
||||||
text_align = "center"
|
|
||||||
eighths = " ▏▎▍▌▋▊▉"
|
|
||||||
|
|
||||||
def __init__(self, show_time: bool) -> None:
|
|
||||||
self.show_time = show_time
|
|
||||||
self.position = 0.0
|
|
||||||
self.max_ = 1.0
|
|
||||||
self.normal = "progressbar-normal"
|
|
||||||
self.complete = "progressbar-done"
|
|
||||||
self.smooth = "progressbar-smooth"
|
|
||||||
self.last_tick: T.Optional[datetime.datetime, float] = None
|
|
||||||
|
|
||||||
@property
|
|
||||||
def time_left(self) -> T.Optional[datetime.timedelta]:
|
|
||||||
if self.last_tick is None:
|
|
||||||
return None
|
|
||||||
time_now = datetime.datetime.now()
|
|
||||||
time_then = self.last_tick[0]
|
|
||||||
pos_now = self.position
|
|
||||||
pos_then = self.last_tick[1]
|
|
||||||
speed = (pos_now - pos_then) / (time_now - time_then).total_seconds()
|
|
||||||
if not speed:
|
|
||||||
return None
|
|
||||||
return datetime.timedelta(seconds=(self.max_ - pos_now) / speed)
|
|
||||||
|
|
||||||
def set_completion(self, value: float) -> None:
|
|
||||||
if self.last_tick is None or value == 0:
|
|
||||||
self.last_tick = (datetime.datetime.now(), value)
|
|
||||||
self.position = value
|
|
||||||
self._invalidate()
|
|
||||||
|
|
||||||
def set_max(self, value: T.Union[int, float]) -> None:
|
|
||||||
if self.max_ == value:
|
|
||||||
return
|
|
||||||
self.last_tick = None
|
|
||||||
self.max_ = value
|
|
||||||
self._invalidate()
|
|
||||||
|
|
||||||
def reset(self, cur: float, max_: T.Union[int, float]) -> None:
|
|
||||||
if self.last_tick is None or cur == 0 or max_ != self.max_:
|
|
||||||
self.last_tick = (datetime.datetime.now(), cur)
|
|
||||||
self.position = cur
|
|
||||||
self.max_ = max_
|
|
||||||
self._invalidate()
|
|
||||||
|
|
||||||
def rows(self, size: T.Tuple[int, int], focus: bool = False) -> int:
|
|
||||||
return 1
|
|
||||||
|
|
||||||
def get_text(self) -> str:
|
|
||||||
percent = min(100.0, max(0.0, self.position * 100.0 / self.max_))
|
|
||||||
ret = f"{percent:.02f}%"
|
|
||||||
if self.time_left and self.show_time:
|
|
||||||
ret += f" ({format_timespan(self.time_left)})"
|
|
||||||
return ret
|
|
||||||
|
|
||||||
# workaround for https://github.com/urwid/urwid/issues/317
|
|
||||||
def render(self, size: T.Tuple[int, int], focus: bool = False) -> T.Any:
|
|
||||||
(maxcol,) = size
|
|
||||||
txt = urwid.Text(self.get_text(), self.text_align, "clip")
|
|
||||||
c = txt.render((maxcol,))
|
|
||||||
t = c._text[0]
|
|
||||||
|
|
||||||
cf = self.position * maxcol / self.max_
|
|
||||||
ccol_dirty = int(cf)
|
|
||||||
ccol = len(
|
|
||||||
c._text[0][:ccol_dirty].decode("utf-8", "ignore").encode("utf-8")
|
|
||||||
)
|
|
||||||
|
|
||||||
cs = 0
|
|
||||||
cs = int((cf - ccol) * 8)
|
|
||||||
|
|
||||||
if ccol < 0 or (ccol == 0 and cs == 0):
|
|
||||||
c._attr = [[(self.normal, maxcol)]]
|
|
||||||
elif ccol >= maxcol:
|
|
||||||
c._attr = [[(self.complete, maxcol)]]
|
|
||||||
elif cs and c._text[0][ccol] == 32:
|
|
||||||
t = c._text[0]
|
|
||||||
cenc = self.eighths[cs].encode("utf-8")
|
|
||||||
c._text[0] = t[:ccol] + cenc + t[ccol + 1 :]
|
|
||||||
a = []
|
|
||||||
if ccol > 0:
|
|
||||||
a.append((self.complete, ccol))
|
|
||||||
a.append((self.smooth, len(cenc)))
|
|
||||||
if maxcol - ccol - 1 > 0:
|
|
||||||
a.append((self.normal, maxcol - ccol - 1))
|
|
||||||
c._attr = [a]
|
|
||||||
c._cs = [[(None, len(c._text[0]))]]
|
|
||||||
else:
|
|
||||||
c._attr = [[(self.complete, ccol), (self.normal, maxcol - ccol)]]
|
|
||||||
return c
|
|
||||||
@@ -1,64 +0,0 @@
|
|||||||
import typing as T
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
|
|
||||||
from pqcli.ui.layout import NColumns, NPile
|
|
||||||
|
|
||||||
|
|
||||||
class DataTable(NPile):
|
|
||||||
def __init__(self, columns: T.Any) -> None:
|
|
||||||
self.column_count = len(columns)
|
|
||||||
self._columns = columns
|
|
||||||
self._rows: T.List[T.List[urwid.Widget]] = []
|
|
||||||
super().__init__([])
|
|
||||||
|
|
||||||
@property
|
|
||||||
def row_count(self) -> int:
|
|
||||||
return len(self._rows)
|
|
||||||
|
|
||||||
@property
|
|
||||||
def data_rows(self) -> T.Iterable[T.List[urwid.Widget]]:
|
|
||||||
# "rows" was already used by urwid
|
|
||||||
return self._rows
|
|
||||||
|
|
||||||
def add_row(self, *widgets: T.List[urwid.Widget]) -> None:
|
|
||||||
if len(widgets) != self.column_count:
|
|
||||||
raise VauleError(f"Expected {self.column_count} widgets")
|
|
||||||
|
|
||||||
self.contents.append(
|
|
||||||
(
|
|
||||||
NColumns(
|
|
||||||
list((*x, y) for x, y in zip(self._columns, widgets))
|
|
||||||
),
|
|
||||||
(urwid.PACK, 1),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.resize_columns()
|
|
||||||
self._rows.append(widgets)
|
|
||||||
|
|
||||||
def delete_row(self, idx: int) -> None:
|
|
||||||
del self.contents[idx]
|
|
||||||
del self._rows[idx]
|
|
||||||
self.resize_columns()
|
|
||||||
|
|
||||||
def delete_rows(self, start_idx: int, end_idx: int) -> None:
|
|
||||||
del self.contents[start_idx:end_idx]
|
|
||||||
del self._rows[start_idx:end_idx]
|
|
||||||
self.resize_columns()
|
|
||||||
|
|
||||||
def resize_columns(self) -> None:
|
|
||||||
if not self._rows:
|
|
||||||
return
|
|
||||||
|
|
||||||
for i, sizing in enumerate(self._columns):
|
|
||||||
if isinstance(sizing, tuple) and sizing[0] == urwid.PACK:
|
|
||||||
width = max(
|
|
||||||
row_widgets[i].pack()[0] for row_widgets in self._rows
|
|
||||||
)
|
|
||||||
for item in self.contents:
|
|
||||||
columns = item[0]
|
|
||||||
columns.contents[i] = (
|
|
||||||
columns.contents[i][0],
|
|
||||||
(urwid.GIVEN, width, None),
|
|
||||||
)
|
|
||||||
@@ -1,34 +0,0 @@
|
|||||||
import urwid
|
|
||||||
|
|
||||||
from pqcli.ui.layout import NPile
|
|
||||||
|
|
||||||
|
|
||||||
class DoubleLineBox(urwid.AttrMap):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
top_widget: urwid.Widget,
|
|
||||||
bottom_widget: urwid.Widget,
|
|
||||||
top_title: str = "",
|
|
||||||
bottom_title: str = "",
|
|
||||||
) -> None:
|
|
||||||
self.top_line_box = urwid.LineBox(
|
|
||||||
urwid.AttrMap(top_widget, "linebox-content", "linebox-content"),
|
|
||||||
title=top_title,
|
|
||||||
bline="",
|
|
||||||
)
|
|
||||||
|
|
||||||
self.bottom_line_box = urwid.LineBox(
|
|
||||||
urwid.AttrMap(bottom_widget, "linebox-content", "linebox-content"),
|
|
||||||
title=bottom_title,
|
|
||||||
tlcorner="├",
|
|
||||||
trcorner="┤",
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
NPile([self.top_line_box, (urwid.PACK, self.bottom_line_box)]),
|
|
||||||
"linebox",
|
|
||||||
"linebox-focus",
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_bottom_title(self, text: str) -> None:
|
|
||||||
self.bottom_line_box.set_title(text)
|
|
||||||
@@ -1,446 +0,0 @@
|
|||||||
import argparse
|
|
||||||
import contextlib
|
|
||||||
import datetime
|
|
||||||
import typing as T
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
|
|
||||||
from pqcli.config import EquipmentType, StatType
|
|
||||||
from pqcli.lingo import act_name, to_roman
|
|
||||||
from pqcli.mechanic import (
|
|
||||||
InventoryItem,
|
|
||||||
Player,
|
|
||||||
SignalMixin,
|
|
||||||
Simulation,
|
|
||||||
Spell,
|
|
||||||
)
|
|
||||||
from pqcli.ui.custom_line_box import CustomLineBox
|
|
||||||
from pqcli.ui.custom_progress_bar import CustomProgressBar
|
|
||||||
from pqcli.ui.data_table import DataTable
|
|
||||||
from pqcli.ui.double_line_box import DoubleLineBox
|
|
||||||
from pqcli.ui.layout import NColumns, NPile
|
|
||||||
from pqcli.ui.read_only_check_box import ReadOnlyCheckBox
|
|
||||||
from pqcli.ui.scrollable import Scrollable, ScrollBar
|
|
||||||
|
|
||||||
|
|
||||||
class CharacterSheetView(DoubleLineBox):
|
|
||||||
def __init__(self, player: Player) -> None:
|
|
||||||
self.player = player
|
|
||||||
|
|
||||||
self.level_text = urwid.Text("")
|
|
||||||
self.stat_texts = {stat: urwid.Text("") for stat in list(StatType)}
|
|
||||||
self.exp_bar = CustomProgressBar(show_time=True)
|
|
||||||
|
|
||||||
data_table = DataTable(columns=[(urwid.WEIGHT, 1), (urwid.WEIGHT, 2)])
|
|
||||||
data_table.add_row(urwid.Text("Name"), urwid.Text(player.name))
|
|
||||||
data_table.add_row(urwid.Text("Race"), urwid.Text(player.race.name))
|
|
||||||
data_table.add_row(urwid.Text("Class"), urwid.Text(player.class_.name))
|
|
||||||
data_table.add_row(urwid.Text("Level"), self.level_text)
|
|
||||||
data_table.add_row(urwid.Divider(), urwid.Divider())
|
|
||||||
for stat in StatType:
|
|
||||||
data_table.add_row(urwid.Text(stat.value), self.stat_texts[stat])
|
|
||||||
|
|
||||||
self.player.connect("level_up", self.sync_level)
|
|
||||||
self.player.stats.connect("change", self.sync_stats)
|
|
||||||
self.player.exp_bar.connect("change", self.sync_exp)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
top_widget=ScrollBar(Scrollable(data_table)),
|
|
||||||
bottom_widget=self.exp_bar,
|
|
||||||
top_title="Character Sheet",
|
|
||||||
bottom_title="Experience",
|
|
||||||
)
|
|
||||||
self.sync()
|
|
||||||
|
|
||||||
def sync(self) -> None:
|
|
||||||
self.sync_level()
|
|
||||||
self.sync_stats()
|
|
||||||
self.sync_exp()
|
|
||||||
|
|
||||||
def sync_level(self) -> None:
|
|
||||||
self.level_text.set_text(str(self.player.level))
|
|
||||||
|
|
||||||
def sync_stats(self) -> None:
|
|
||||||
for stat in StatType:
|
|
||||||
self.stat_texts[stat].set_text(str(self.player.stats[stat]))
|
|
||||||
|
|
||||||
def sync_exp(self) -> None:
|
|
||||||
cur = self.player.exp_bar.position
|
|
||||||
max_ = self.player.exp_bar.max_
|
|
||||||
self.exp_bar.reset(cur, max_)
|
|
||||||
self.set_bottom_title(f"Experience ({max_-cur:.0f} XP to go)")
|
|
||||||
|
|
||||||
|
|
||||||
class SpellBookView(CustomLineBox):
|
|
||||||
def __init__(self, player: Player) -> None:
|
|
||||||
self.player = player
|
|
||||||
|
|
||||||
self.data_table = DataTable(columns=[(urwid.WEIGHT, 3), (urwid.PACK,)])
|
|
||||||
self.scrollable = Scrollable(self.data_table)
|
|
||||||
|
|
||||||
self.player.spell_book.connect("add", self.sync_spell_add)
|
|
||||||
self.player.spell_book.connect("change", self.sync_spell_change)
|
|
||||||
|
|
||||||
super().__init__(ScrollBar(self.scrollable), title="Spell Book")
|
|
||||||
self.sync()
|
|
||||||
|
|
||||||
def sync(self) -> None:
|
|
||||||
self.data_table.delete_rows(0, len(self.data_table.data_rows))
|
|
||||||
for spell in self.player.spell_book:
|
|
||||||
self.sync_spell_add(spell)
|
|
||||||
|
|
||||||
def sync_spell_add(self, spell: Spell) -> None:
|
|
||||||
self.data_table.add_row(
|
|
||||||
urwid.Text(spell.name), urwid.Text(to_roman(spell.level))
|
|
||||||
)
|
|
||||||
self.scrollable.set_scrollpos(len(self.data_table.data_rows) - 1)
|
|
||||||
|
|
||||||
def sync_spell_change(self, spell: Spell) -> None:
|
|
||||||
for row_widgets in self.data_table.data_rows:
|
|
||||||
if row_widgets[0].text == spell.name:
|
|
||||||
row_widgets[1].set_text(to_roman(spell.level))
|
|
||||||
self.data_table.resize_columns()
|
|
||||||
|
|
||||||
|
|
||||||
class EquipmentView(CustomLineBox):
|
|
||||||
def __init__(self, player: Player) -> None:
|
|
||||||
self.player = player
|
|
||||||
|
|
||||||
self.value_texts = {
|
|
||||||
equipment_type: urwid.Text("") for equipment_type in EquipmentType
|
|
||||||
}
|
|
||||||
data_table = DataTable(columns=[(urwid.WEIGHT, 1), (urwid.WEIGHT, 3)])
|
|
||||||
for equipment_type in EquipmentType:
|
|
||||||
data_table.add_row(
|
|
||||||
urwid.Text(equipment_type.value),
|
|
||||||
self.value_texts[equipment_type],
|
|
||||||
)
|
|
||||||
|
|
||||||
self.player.equipment.connect("change", self.sync_equipment_change)
|
|
||||||
|
|
||||||
super().__init__(ScrollBar(Scrollable(data_table)), title="Equipment")
|
|
||||||
self.sync()
|
|
||||||
|
|
||||||
def sync(self) -> None:
|
|
||||||
for equipment_type in EquipmentType:
|
|
||||||
self.sync_equipment_change(
|
|
||||||
equipment_type, self.player.equipment[equipment_type]
|
|
||||||
)
|
|
||||||
|
|
||||||
def sync_equipment_change(
|
|
||||||
self, equipment_type: EquipmentType, item_name: T.Optional[str]
|
|
||||||
) -> None:
|
|
||||||
self.value_texts[equipment_type].set_text(item_name or "")
|
|
||||||
|
|
||||||
|
|
||||||
class InventoryView(DoubleLineBox):
|
|
||||||
def __init__(self, player: Player) -> None:
|
|
||||||
self.player = player
|
|
||||||
|
|
||||||
self.gold_text = urwid.Text("", align=urwid.RIGHT)
|
|
||||||
self.data_table = DataTable(columns=[(urwid.WEIGHT, 3), (urwid.PACK,)])
|
|
||||||
self.data_table.add_row(urwid.Text("Gold"), self.gold_text)
|
|
||||||
self.scrollable = Scrollable(self.data_table)
|
|
||||||
self.encumbrance_bar = CustomProgressBar(show_time=False)
|
|
||||||
|
|
||||||
self.player.connect("new_task", self.on_new_task)
|
|
||||||
self.player.inventory.connect("gold_change", self.sync_gold)
|
|
||||||
self.player.inventory.connect("item_add", self.sync_item_add)
|
|
||||||
self.player.inventory.connect("item_del", self.sync_item_del)
|
|
||||||
self.player.inventory.connect("item_change", self.sync_item_change)
|
|
||||||
self.player.inventory.encum_bar.connect(
|
|
||||||
"change", self.sync_encumbrance_position
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
top_widget=ScrollBar(self.scrollable),
|
|
||||||
top_title="Inventory",
|
|
||||||
bottom_widget=self.encumbrance_bar,
|
|
||||||
bottom_title="Encumbrance",
|
|
||||||
)
|
|
||||||
self.sync()
|
|
||||||
|
|
||||||
def sync(self) -> None:
|
|
||||||
self.sync_gold()
|
|
||||||
self.sync_items()
|
|
||||||
self.sync_encumbrance_position()
|
|
||||||
|
|
||||||
def sync_gold(self) -> None:
|
|
||||||
self.gold_text.set_text(str(self.player.inventory.gold))
|
|
||||||
|
|
||||||
def sync_items(self) -> None:
|
|
||||||
self.data_table.delete_rows(1, len(self.data_table.data_rows) - 1)
|
|
||||||
for item in self.player.inventory:
|
|
||||||
self.sync_item_add(item)
|
|
||||||
|
|
||||||
def sync_encumbrance_position(self) -> None:
|
|
||||||
cur = self.player.inventory.encum_bar.position
|
|
||||||
max_ = self.player.inventory.encum_bar.max_
|
|
||||||
self.encumbrance_bar.reset(cur, max_)
|
|
||||||
self.set_bottom_title(f"Encumbrance ({cur:.0f}/{max_} cubits)")
|
|
||||||
|
|
||||||
def sync_item_add(self, item: InventoryItem) -> None:
|
|
||||||
self.data_table.add_row(
|
|
||||||
urwid.Text(item.name),
|
|
||||||
urwid.Text(str(item.quantity), align=urwid.RIGHT),
|
|
||||||
)
|
|
||||||
self.scrollable.set_scrollpos(self.scrollable.rows_max() - 1)
|
|
||||||
|
|
||||||
def sync_item_del(self, item: InventoryItem) -> None:
|
|
||||||
idx = self.get_item_idx_by_name(item.name)
|
|
||||||
if idx is not None:
|
|
||||||
self.data_table.delete_row(idx)
|
|
||||||
|
|
||||||
def sync_item_change(self, item: InventoryItem) -> None:
|
|
||||||
idx = self.get_item_idx_by_name(item.name)
|
|
||||||
if idx is not None:
|
|
||||||
self.data_table.data_rows[idx][1].set_text(str(item.quantity))
|
|
||||||
|
|
||||||
def get_item_idx_by_name(self, name: str) -> T.Optional[int]:
|
|
||||||
for i, row_widgets in enumerate(self.data_table.data_rows):
|
|
||||||
if row_widgets[0].text == name:
|
|
||||||
return i
|
|
||||||
return None
|
|
||||||
|
|
||||||
def on_new_task(self) -> None:
|
|
||||||
description = self.player.task.description if self.player.task else "?"
|
|
||||||
if description.lower().startswith("sell"):
|
|
||||||
self.scrollable.set_scrollpos(0)
|
|
||||||
|
|
||||||
|
|
||||||
class PlotView(DoubleLineBox):
|
|
||||||
cutoff = 100
|
|
||||||
|
|
||||||
def __init__(self, player: Player) -> None:
|
|
||||||
self.player = player
|
|
||||||
|
|
||||||
self.data_table = DataTable(columns=[(urwid.WEIGHT, 1)])
|
|
||||||
self.scrollable = Scrollable(self.data_table)
|
|
||||||
self.plot_bar = CustomProgressBar(show_time=True)
|
|
||||||
|
|
||||||
self.player.quest_book.connect("start_act", self.sync_act_add)
|
|
||||||
self.player.quest_book.plot_bar.connect("change", self.sync_position)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
top_widget=ScrollBar(self.scrollable),
|
|
||||||
top_title="Plot Development",
|
|
||||||
bottom_widget=self.plot_bar,
|
|
||||||
)
|
|
||||||
self.sync()
|
|
||||||
|
|
||||||
def sync(self) -> None:
|
|
||||||
self.sync_acts()
|
|
||||||
self.sync_position()
|
|
||||||
|
|
||||||
def sync_acts(self) -> None:
|
|
||||||
self.data_table.delete_rows(0, len(self.data_table.data_rows))
|
|
||||||
for act_number in range(
|
|
||||||
max(0, self.player.quest_book.act - self.cutoff),
|
|
||||||
self.player.quest_book.act + 1,
|
|
||||||
):
|
|
||||||
self.sync_act_add(act_number)
|
|
||||||
|
|
||||||
def sync_act_add(self, act_number: int) -> None:
|
|
||||||
self.data_table.delete_rows(
|
|
||||||
0, max(0, self.data_table.row_count - self.cutoff)
|
|
||||||
)
|
|
||||||
if self.data_table.data_rows:
|
|
||||||
self.data_table.data_rows[-1][0].set_state(True)
|
|
||||||
self.data_table.add_row(
|
|
||||||
ReadOnlyCheckBox(act_name(act_number), state=False)
|
|
||||||
)
|
|
||||||
self.scrollable.set_scrollpos(self.scrollable.rows_max() - 1)
|
|
||||||
|
|
||||||
def sync_position(self) -> None:
|
|
||||||
self.plot_bar.reset(
|
|
||||||
self.player.quest_book.plot_bar.position,
|
|
||||||
self.player.quest_book.plot_bar.max_,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class QuestBookView(DoubleLineBox):
|
|
||||||
cutoff = 100
|
|
||||||
|
|
||||||
def __init__(self, player: Player) -> None:
|
|
||||||
self.player = player
|
|
||||||
|
|
||||||
self.data_table = DataTable(columns=[(urwid.WEIGHT, 1)])
|
|
||||||
self.quest_bar = CustomProgressBar(show_time=True)
|
|
||||||
self.scrollable = Scrollable(self.data_table)
|
|
||||||
|
|
||||||
self.player.quest_book.connect("start_quest", self.sync_quest_add)
|
|
||||||
self.player.quest_book.quest_bar.connect("change", self.sync_position)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
top_widget=ScrollBar(self.scrollable),
|
|
||||||
top_title="Quests",
|
|
||||||
bottom_widget=self.quest_bar,
|
|
||||||
)
|
|
||||||
self.sync()
|
|
||||||
|
|
||||||
def sync(self) -> None:
|
|
||||||
self.sync_quests()
|
|
||||||
self.sync_position()
|
|
||||||
|
|
||||||
def sync_quests(self) -> None:
|
|
||||||
for quest_name in self.player.quest_book.quests[-self.cutoff :]:
|
|
||||||
self.sync_quest_add(quest_name)
|
|
||||||
|
|
||||||
def sync_quest_add(self, quest_name: str) -> None:
|
|
||||||
self.data_table.delete_rows(
|
|
||||||
0, max(0, self.data_table.row_count - self.cutoff)
|
|
||||||
)
|
|
||||||
if self.data_table.data_rows:
|
|
||||||
self.data_table.data_rows[-1][0].set_state(True)
|
|
||||||
self.data_table.add_row(ReadOnlyCheckBox(quest_name, state=False))
|
|
||||||
self.scrollable.set_scrollpos(self.scrollable.rows_max() - 1)
|
|
||||||
|
|
||||||
def sync_position(self) -> None:
|
|
||||||
self.quest_bar.reset(
|
|
||||||
self.player.quest_book.quest_bar.position,
|
|
||||||
self.player.quest_book.quest_bar.max_,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TaskView(NPile):
|
|
||||||
def __init__(self, player: Player) -> None:
|
|
||||||
self.player = player
|
|
||||||
|
|
||||||
self.current_task_text = urwid.Text("...")
|
|
||||||
self.current_task_bar = CustomProgressBar(show_time=False)
|
|
||||||
|
|
||||||
self.player.connect("new_task", self.sync_task_name)
|
|
||||||
self.player.task_bar.connect("change", self.sync_position)
|
|
||||||
|
|
||||||
super().__init__([self.current_task_text, self.current_task_bar])
|
|
||||||
self.sync()
|
|
||||||
|
|
||||||
def sync(self) -> None:
|
|
||||||
self.sync_task_name()
|
|
||||||
self.sync_position()
|
|
||||||
|
|
||||||
def sync_task_name(self) -> None:
|
|
||||||
self.current_task_text.set_text(
|
|
||||||
f"{self.player.task.description}..." if self.player.task else "?"
|
|
||||||
)
|
|
||||||
|
|
||||||
def sync_position(self) -> None:
|
|
||||||
self.current_task_bar.reset(
|
|
||||||
self.player.task_bar.position, self.player.task_bar.max_
|
|
||||||
)
|
|
||||||
|
|
||||||
def pack(self, size: T.Tuple[int, int]) -> T.Tuple[int, int]:
|
|
||||||
return (size[0], 2)
|
|
||||||
|
|
||||||
|
|
||||||
class GameView(NPile):
|
|
||||||
signals = ["cancel"]
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self, loop: urwid.MainLoop, player: Player, args: argparse.Namespace
|
|
||||||
) -> None:
|
|
||||||
self.loop = loop
|
|
||||||
self.player = player
|
|
||||||
self.args = args
|
|
||||||
self.simulation = Simulation(player)
|
|
||||||
self.last_tick = datetime.datetime.now()
|
|
||||||
|
|
||||||
self.character_sheet_view = CharacterSheetView(player)
|
|
||||||
self.spell_book_view = SpellBookView(player)
|
|
||||||
self.equipment_view = EquipmentView(player)
|
|
||||||
self.inventory_view = InventoryView(player)
|
|
||||||
self.plot_view = PlotView(player)
|
|
||||||
self.quest_book_view = QuestBookView(player)
|
|
||||||
self.task_view = TaskView(player)
|
|
||||||
|
|
||||||
self.columns = NColumns(
|
|
||||||
[
|
|
||||||
(
|
|
||||||
urwid.WEIGHT,
|
|
||||||
4,
|
|
||||||
NPile(
|
|
||||||
[(19, self.character_sheet_view), self.spell_book_view]
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
urwid.WEIGHT,
|
|
||||||
5,
|
|
||||||
NPile([(15, self.equipment_view), self.inventory_view]),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
urwid.WEIGHT,
|
|
||||||
4,
|
|
||||||
NPile([(15, self.plot_view), self.quest_book_view]),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
[self.columns, (urwid.PACK, self.task_view)], outermost=True
|
|
||||||
)
|
|
||||||
|
|
||||||
def cancel(self) -> None:
|
|
||||||
self._emit("cancel")
|
|
||||||
|
|
||||||
def tick(self) -> None:
|
|
||||||
now = datetime.datetime.now()
|
|
||||||
elapsed = (now - self.last_tick).total_seconds()
|
|
||||||
self.simulation.tick(elapsed * 1000)
|
|
||||||
self.last_tick = datetime.datetime.now()
|
|
||||||
|
|
||||||
def keypress(self, size: T.Tuple[int, int], key: str) -> bool:
|
|
||||||
if key == "esc":
|
|
||||||
self.cancel()
|
|
||||||
return None
|
|
||||||
|
|
||||||
if self._command_map[key] == urwid.CURSOR_RIGHT:
|
|
||||||
old_focus_position = self.columns.focus.focus_position
|
|
||||||
self.columns.focus_position = min(
|
|
||||||
self.columns.focus_position + 1, len(self.columns.contents) - 1
|
|
||||||
)
|
|
||||||
self.columns.focus.focus_position = old_focus_position
|
|
||||||
return None
|
|
||||||
|
|
||||||
if self._command_map[key] == urwid.CURSOR_LEFT:
|
|
||||||
old_focus_position = self.columns.focus.focus_position
|
|
||||||
self.columns.focus_position = max(
|
|
||||||
self.columns.focus_position - 1, 0
|
|
||||||
)
|
|
||||||
self.columns.focus.focus_position = old_focus_position
|
|
||||||
return None
|
|
||||||
|
|
||||||
if self.args.cheats:
|
|
||||||
if key == "e":
|
|
||||||
self.simulation.player.exp_bar.increment(1)
|
|
||||||
return None
|
|
||||||
|
|
||||||
fast_forward = {"t": 1, "T": 100, "ctrl t": 10000}
|
|
||||||
if key in fast_forward:
|
|
||||||
iterations = fast_forward[key]
|
|
||||||
ctx_mgr = (
|
|
||||||
self.suppress_updates()
|
|
||||||
if iterations >= 1000
|
|
||||||
else contextlib.suppress()
|
|
||||||
)
|
|
||||||
|
|
||||||
with ctx_mgr:
|
|
||||||
for _ in range(iterations):
|
|
||||||
self.simulation.tick()
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
return super().keypress(size, key)
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def suppress_updates(self) -> T.Generator:
|
|
||||||
old = SignalMixin.emit
|
|
||||||
SignalMixin.emit = lambda *_: None
|
|
||||||
yield
|
|
||||||
self.character_sheet_view.sync()
|
|
||||||
self.spell_book_view.sync()
|
|
||||||
self.equipment_view.sync()
|
|
||||||
self.inventory_view.sync()
|
|
||||||
self.plot_view.sync()
|
|
||||||
self.quest_book_view.sync()
|
|
||||||
self.task_view.sync()
|
|
||||||
SignalMixin.emit = old
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
# Copyright (c) 2013-2016 Christian Geier et al.
|
|
||||||
#
|
|
||||||
# Permission is hereby granted, free of charge, to any person obtaining
|
|
||||||
# a copy of this software and associated documentation files (the
|
|
||||||
# "Software"), to deal in the Software without restriction, including
|
|
||||||
# without limitation the rights to use, copy, modify, merge, publish,
|
|
||||||
# distribute, sublicense, and/or sell copies of the Software, and to
|
|
||||||
# permit persons to whom the Software is furnished to do so, subject to
|
|
||||||
# the following conditions:
|
|
||||||
#
|
|
||||||
# The above copyright notice and this permission notice shall be
|
|
||||||
# included in all copies or substantial portions of the Software.
|
|
||||||
#
|
|
||||||
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
||||||
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
||||||
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
||||||
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
||||||
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
||||||
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
||||||
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
||||||
|
|
||||||
import typing as T
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
|
|
||||||
|
|
||||||
class SupportsNext:
|
|
||||||
"""
|
|
||||||
Classes inheriting from SupportsNext must implement the following methods:
|
|
||||||
_select_first_selectable
|
|
||||||
_select_last_selectable
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self.outermost = kwargs.get("outermost", False)
|
|
||||||
if "outermost" in kwargs:
|
|
||||||
kwargs.pop("outermost")
|
|
||||||
super().__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class NextMixin(SupportsNext):
|
|
||||||
"""Implements SupportsNext for urwid.Pile and urwid.Columns"""
|
|
||||||
|
|
||||||
def _select_first_selectable(self):
|
|
||||||
"""
|
|
||||||
select our first selectable item
|
|
||||||
(recursively if that item SupportsNext)
|
|
||||||
"""
|
|
||||||
i = self._first_selectable()
|
|
||||||
self.set_focus(i)
|
|
||||||
if isinstance(self.contents[i][0], SupportsNext):
|
|
||||||
self.contents[i][0]._select_first_selectable()
|
|
||||||
|
|
||||||
def _select_last_selectable(self):
|
|
||||||
"""
|
|
||||||
select our last selectable item
|
|
||||||
(recursively if that item SupportsNext)
|
|
||||||
"""
|
|
||||||
i = self._last_selectable()
|
|
||||||
self.set_focus(i)
|
|
||||||
if isinstance(self._contents[i][0], SupportsNext):
|
|
||||||
self.contents[i][0]._select_last_selectable()
|
|
||||||
|
|
||||||
def _first_selectable(self):
|
|
||||||
"""return sequence number of self.contents last selectable item"""
|
|
||||||
for j in range(0, len(self._contents)):
|
|
||||||
if self._contents[j][0].selectable():
|
|
||||||
return j
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _last_selectable(self):
|
|
||||||
"""return sequence number of self._contents last selectable item"""
|
|
||||||
for j in range(len(self._contents) - 1, -1, -1):
|
|
||||||
if self._contents[j][0].selectable():
|
|
||||||
return j
|
|
||||||
return False
|
|
||||||
|
|
||||||
def keypress(self, size, key) -> T.Optional[str]:
|
|
||||||
key = super().keypress(size, key)
|
|
||||||
|
|
||||||
if key == "tab":
|
|
||||||
if (
|
|
||||||
self.outermost
|
|
||||||
and self.focus_position == self._last_selectable()
|
|
||||||
):
|
|
||||||
self._select_first_selectable()
|
|
||||||
return None
|
|
||||||
|
|
||||||
for i in range(self.focus_position + 1, len(self._contents)):
|
|
||||||
if self._contents[i][0].selectable():
|
|
||||||
self.set_focus(i)
|
|
||||||
if isinstance(self._contents[i][0], SupportsNext):
|
|
||||||
self._contents[i][0]._select_first_selectable()
|
|
||||||
return None
|
|
||||||
|
|
||||||
elif key == "shift tab":
|
|
||||||
if (
|
|
||||||
self.outermost
|
|
||||||
and self.focus_position == self._first_selectable()
|
|
||||||
):
|
|
||||||
self._select_last_selectable()
|
|
||||||
return None
|
|
||||||
|
|
||||||
for i in range(self.focus_position - 1, 0 - 1, -1):
|
|
||||||
if self._contents[i][0].selectable():
|
|
||||||
self.set_focus(i)
|
|
||||||
if isinstance(self._contents[i][0], SupportsNext):
|
|
||||||
self._contents[i][0]._select_last_selectable()
|
|
||||||
return None
|
|
||||||
|
|
||||||
return key
|
|
||||||
|
|
||||||
|
|
||||||
class NPile(NextMixin, urwid.Pile):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
class NColumns(NextMixin, urwid.Columns):
|
|
||||||
pass
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
import random
|
|
||||||
import typing as T
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
import urwid_readline
|
|
||||||
|
|
||||||
from pqcli.config import CLASSES, PRIME_STATS, RACES
|
|
||||||
from pqcli.mechanic import StatsBuilder, create_player, generate_name
|
|
||||||
from pqcli.ui.custom_button import CustomButton
|
|
||||||
from pqcli.ui.custom_line_box import CustomLineBox
|
|
||||||
from pqcli.ui.layout import NColumns, NPile
|
|
||||||
|
|
||||||
|
|
||||||
class StatsBox(CustomLineBox):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.stats_builder = StatsBuilder()
|
|
||||||
self.stats = self.stats_builder.roll()
|
|
||||||
self.stat_labels = {stat: urwid.Text("0") for stat in PRIME_STATS}
|
|
||||||
self.total_label = urwid.Text("0")
|
|
||||||
self.update_values()
|
|
||||||
|
|
||||||
roll_button = CustomButton(
|
|
||||||
"Roll", hint="F5", on_press=self.on_roll_press
|
|
||||||
)
|
|
||||||
unroll_button = CustomButton(
|
|
||||||
"Unroll", hint="F6", on_press=self.on_unroll_press
|
|
||||||
)
|
|
||||||
|
|
||||||
value_texts = list(self.stat_labels.values())
|
|
||||||
label_texts = [urwid.Text(f"{stat.value}: ") for stat in PRIME_STATS]
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
urwid.Filler(
|
|
||||||
NPile(
|
|
||||||
[
|
|
||||||
NColumns([NPile(label_texts), NPile(value_texts)]),
|
|
||||||
urwid.Divider(),
|
|
||||||
NColumns([urwid.Text("Total: "), self.total_label]),
|
|
||||||
urwid.Divider(),
|
|
||||||
roll_button,
|
|
||||||
unroll_button,
|
|
||||||
]
|
|
||||||
)
|
|
||||||
),
|
|
||||||
title="Stats",
|
|
||||||
)
|
|
||||||
|
|
||||||
def roll(self) -> None:
|
|
||||||
self.stats = self.stats_builder.roll()
|
|
||||||
self.update_values()
|
|
||||||
|
|
||||||
def unroll(self) -> None:
|
|
||||||
try:
|
|
||||||
self.stats = self.stats_builder.unroll()
|
|
||||||
except IndexError:
|
|
||||||
return
|
|
||||||
self.update_values()
|
|
||||||
|
|
||||||
def on_roll_press(self, _user_data: T.Any) -> None:
|
|
||||||
self.roll()
|
|
||||||
|
|
||||||
def on_unroll_press(self, _user_data: T.Any) -> None:
|
|
||||||
self.unroll()
|
|
||||||
|
|
||||||
def update_values(self) -> None:
|
|
||||||
for stat, edit in self.stat_labels.items():
|
|
||||||
edit.set_text(str(self.stats[stat]))
|
|
||||||
self.total_label.set_text(
|
|
||||||
str(sum(self.stats[stat] for stat in self.stat_labels.keys()))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class RaceBox(CustomLineBox):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.race = random.choice(RACES)
|
|
||||||
|
|
||||||
radio_buttons: T.List[urwid.RadioButton] = []
|
|
||||||
for race in RACES:
|
|
||||||
urwid.RadioButton(
|
|
||||||
group=radio_buttons,
|
|
||||||
label=race.name,
|
|
||||||
state=race.name == self.race.name,
|
|
||||||
on_state_change=self.on_state_change,
|
|
||||||
user_data=race,
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(urwid.ListBox(radio_buttons), title="Race")
|
|
||||||
|
|
||||||
def on_state_change(
|
|
||||||
self, _widget: urwid.RadioButton, new_state: bool, user_data: T.Any
|
|
||||||
) -> None:
|
|
||||||
if new_state:
|
|
||||||
self.race = user_data
|
|
||||||
|
|
||||||
|
|
||||||
class ClassBox(CustomLineBox):
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.class_ = random.choice(CLASSES)
|
|
||||||
|
|
||||||
radio_buttons: T.List[urwid.RadioButton] = []
|
|
||||||
for class_ in CLASSES:
|
|
||||||
urwid.RadioButton(
|
|
||||||
group=radio_buttons,
|
|
||||||
label=class_.name,
|
|
||||||
state=class_.name == self.class_.name,
|
|
||||||
on_state_change=self.on_state_change,
|
|
||||||
user_data=class_,
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(urwid.ListBox(radio_buttons), title="Class")
|
|
||||||
|
|
||||||
def on_state_change(
|
|
||||||
self, _widget: urwid.RadioButton, new_state: bool, user_data: T.Any
|
|
||||||
) -> None:
|
|
||||||
if new_state:
|
|
||||||
self.class_ = user_data
|
|
||||||
|
|
||||||
|
|
||||||
class NewGameView(NPile):
|
|
||||||
signals = ["confirm", "cancel"]
|
|
||||||
|
|
||||||
def __init__(self) -> None:
|
|
||||||
self.race_box = RaceBox()
|
|
||||||
self.class_box = ClassBox()
|
|
||||||
self.stats_box = StatsBox()
|
|
||||||
|
|
||||||
self.char_name_edit = urwid_readline.ReadlineEdit(
|
|
||||||
"Name: ", generate_name()
|
|
||||||
)
|
|
||||||
generate_char_name_btn = CustomButton(
|
|
||||||
"Generate random name",
|
|
||||||
hint="F7",
|
|
||||||
on_press=self.on_generate_char_name_press,
|
|
||||||
)
|
|
||||||
|
|
||||||
buttons_box = urwid.Filler(
|
|
||||||
urwid.Padding(
|
|
||||||
NPile(
|
|
||||||
[
|
|
||||||
CustomButton(
|
|
||||||
"Sold!", hint="F10", on_press=self.on_confirm_press
|
|
||||||
),
|
|
||||||
CustomButton(
|
|
||||||
"Cancel", hint="Esc", on_press=self.on_cancel_press
|
|
||||||
),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
width=20,
|
|
||||||
align="center",
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
[
|
|
||||||
(1, urwid.Filler(self.char_name_edit)),
|
|
||||||
(
|
|
||||||
1,
|
|
||||||
urwid.Filler(
|
|
||||||
urwid.Padding(
|
|
||||||
generate_char_name_btn,
|
|
||||||
left=len(self.char_name_edit.caption),
|
|
||||||
width=25,
|
|
||||||
)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
NColumns(
|
|
||||||
[
|
|
||||||
(urwid.WEIGHT, 2, self.race_box),
|
|
||||||
(urwid.WEIGHT, 2, self.class_box),
|
|
||||||
NPile([(13, self.stats_box), buttons_box]),
|
|
||||||
]
|
|
||||||
),
|
|
||||||
],
|
|
||||||
outermost=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def generate_random_char_name(self) -> None:
|
|
||||||
self.char_name_edit.edit_text = generate_name()
|
|
||||||
self.char_name_edit.edit_pos = len(self.char_name_edit.edit_text)
|
|
||||||
|
|
||||||
def confirm(self) -> None:
|
|
||||||
player = create_player(
|
|
||||||
name=self.char_name_edit.edit_text,
|
|
||||||
race=self.race_box.race,
|
|
||||||
class_=self.class_box.class_,
|
|
||||||
stats=self.stats_box.stats,
|
|
||||||
)
|
|
||||||
self._emit("confirm", player)
|
|
||||||
|
|
||||||
def cancel(self) -> None:
|
|
||||||
self._emit("cancel")
|
|
||||||
|
|
||||||
def on_generate_char_name_press(self, _user_data: T.Any) -> None:
|
|
||||||
self.generate_random_char_name()
|
|
||||||
|
|
||||||
def on_confirm_press(self, _user_data: T.Any) -> None:
|
|
||||||
self.confirm()
|
|
||||||
|
|
||||||
def on_cancel_press(self, _user_data: T.Any) -> None:
|
|
||||||
self.cancel()
|
|
||||||
|
|
||||||
def unhandled_input(self, key: str) -> bool:
|
|
||||||
if key == "esc":
|
|
||||||
self.cancel()
|
|
||||||
return True
|
|
||||||
|
|
||||||
if key == "f7":
|
|
||||||
self.generate_random_char_name()
|
|
||||||
return True
|
|
||||||
|
|
||||||
if key == "f5":
|
|
||||||
self.stats_box.roll()
|
|
||||||
return True
|
|
||||||
|
|
||||||
if key == "f6":
|
|
||||||
self.stats_box.unroll()
|
|
||||||
return True
|
|
||||||
|
|
||||||
if key == "f10":
|
|
||||||
self.confirm()
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
import urwid
|
|
||||||
|
|
||||||
|
|
||||||
class ReadOnlyCheckBox(urwid.Text):
|
|
||||||
def __init__(self, text: str, state: bool = False) -> None:
|
|
||||||
self.icons = {
|
|
||||||
key: widget.text for key, widget in urwid.CheckBox.states.items()
|
|
||||||
}
|
|
||||||
self._caption = text
|
|
||||||
|
|
||||||
super().__init__("")
|
|
||||||
|
|
||||||
self.set_state(state)
|
|
||||||
|
|
||||||
def set_state(self, state: bool) -> None:
|
|
||||||
self.set_text(self.icons[state] + " " + self._caption)
|
|
||||||
@@ -1,115 +0,0 @@
|
|||||||
import typing as T
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
|
|
||||||
from pqcli.lingo import act_name, to_roman
|
|
||||||
from pqcli.roster import Roster
|
|
||||||
from pqcli.ui.custom_button import CustomButton
|
|
||||||
from pqcli.ui.layout import NColumns, NPile
|
|
||||||
|
|
||||||
|
|
||||||
class RosterView(urwid.Filler):
|
|
||||||
signals = ["load_game", "new_game", "delete_game", "exit"]
|
|
||||||
|
|
||||||
def __init__(self, roster: Roster) -> None:
|
|
||||||
self.roster = roster
|
|
||||||
|
|
||||||
logo = urwid.BigText("Progress Quest", urwid.HalfBlock5x4Font())
|
|
||||||
|
|
||||||
buttons = []
|
|
||||||
for player_idx, player in enumerate(self.roster.players):
|
|
||||||
best_equip = player.equipment.best
|
|
||||||
|
|
||||||
best_spell = player.spell_book.best
|
|
||||||
if best_spell:
|
|
||||||
best_spell_name = (
|
|
||||||
best_spell.name + " " + to_roman(best_spell.level)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
best_spell_name = "-"
|
|
||||||
|
|
||||||
best_stat_name = (
|
|
||||||
f"{player.stats.best_prime.value} "
|
|
||||||
f"{player.stats[player.stats.best_prime]}"
|
|
||||||
)
|
|
||||||
|
|
||||||
label = (
|
|
||||||
f"{player.name} the {player.race.name} "
|
|
||||||
f"({act_name(player.quest_book.act)})\n"
|
|
||||||
f"Level {player.level} {player.class_.name}\n"
|
|
||||||
f"{best_equip} / {best_spell_name} / {best_stat_name}"
|
|
||||||
)
|
|
||||||
|
|
||||||
buttons.append(
|
|
||||||
NColumns(
|
|
||||||
[
|
|
||||||
CustomButton(
|
|
||||||
label=label,
|
|
||||||
on_press=self.on_resume_game_press,
|
|
||||||
user_data=player_idx,
|
|
||||||
),
|
|
||||||
(
|
|
||||||
10,
|
|
||||||
CustomButton(
|
|
||||||
label="Delete",
|
|
||||||
on_press=self.on_delete_game_press,
|
|
||||||
user_data=player_idx,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
buttons.append(urwid.Divider())
|
|
||||||
|
|
||||||
buttons.append(
|
|
||||||
CustomButton(
|
|
||||||
label="Create a new character",
|
|
||||||
hint="F1",
|
|
||||||
on_press=self.on_new_game_press,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
buttons.append(
|
|
||||||
CustomButton(label="Exit", hint="Esc", on_press=self.on_exit_press)
|
|
||||||
)
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
urwid.Padding(
|
|
||||||
NPile(
|
|
||||||
[urwid.Padding(logo, width="clip"), urwid.Divider()]
|
|
||||||
+ buttons,
|
|
||||||
outermost=True,
|
|
||||||
),
|
|
||||||
align="center",
|
|
||||||
width=logo.pack()[0],
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def on_resume_game_press(
|
|
||||||
self, _widget: urwid.Widget, user_data: T.Any
|
|
||||||
) -> None:
|
|
||||||
player_idx = T.cast(int, user_data)
|
|
||||||
self._emit("load_game", player_idx)
|
|
||||||
|
|
||||||
def on_delete_game_press(
|
|
||||||
self, _widget: urwid.Widget, user_data: T.Any
|
|
||||||
) -> None:
|
|
||||||
player_idx = T.cast(int, user_data)
|
|
||||||
self._emit("delete_game", player_idx)
|
|
||||||
|
|
||||||
def on_new_game_press(self, _user_data: T.Any) -> None:
|
|
||||||
self._emit("new_game")
|
|
||||||
|
|
||||||
def on_exit_press(self, _user_data: T.Any) -> None:
|
|
||||||
self._emit("exit")
|
|
||||||
|
|
||||||
def unhandled_input(self, key: str) -> bool:
|
|
||||||
if key == "esc":
|
|
||||||
self._emit("exit")
|
|
||||||
return True
|
|
||||||
|
|
||||||
if key == "f1":
|
|
||||||
self._emit("new_game")
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
@@ -1,498 +0,0 @@
|
|||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details
|
|
||||||
# http://www.gnu.org/licenses/gpl-3.0.txt
|
|
||||||
|
|
||||||
# Taken from stig (stig.tui.scroll).
|
|
||||||
# Changes compared to upstream:
|
|
||||||
# - reformatted the code
|
|
||||||
# - added AttrMap wrapping
|
|
||||||
# - added padding support
|
|
||||||
# - removed up/down arrow navigation (for widget navigation)
|
|
||||||
|
|
||||||
import typing as T
|
|
||||||
|
|
||||||
import urwid
|
|
||||||
from urwid.widget import BOX, FIXED, FLOW
|
|
||||||
|
|
||||||
# Scroll actions
|
|
||||||
SCROLL_LINE_UP = "line up"
|
|
||||||
SCROLL_LINE_DOWN = "line down"
|
|
||||||
SCROLL_PAGE_UP = "page up"
|
|
||||||
SCROLL_PAGE_DOWN = "page down"
|
|
||||||
SCROLL_TO_TOP = "to top"
|
|
||||||
SCROLL_TO_END = "to end"
|
|
||||||
|
|
||||||
# Scrollbar positions
|
|
||||||
SCROLLBAR_LEFT = "left"
|
|
||||||
SCROLLBAR_RIGHT = "right"
|
|
||||||
|
|
||||||
|
|
||||||
class EmptyCanvas(urwid.Canvas):
|
|
||||||
def __init__(self, cols: int, rows: int) -> None:
|
|
||||||
super().__init__()
|
|
||||||
self._text, cs = urwid.apply_target_encoding(" ")
|
|
||||||
self._cs = cs[0][0]
|
|
||||||
self.size = cols, rows
|
|
||||||
self.cursor = None
|
|
||||||
|
|
||||||
def cols(self) -> int:
|
|
||||||
return self.size[0]
|
|
||||||
|
|
||||||
def rows(self) -> int:
|
|
||||||
return self.size[1]
|
|
||||||
|
|
||||||
def content(
|
|
||||||
self,
|
|
||||||
trim_left: int = 0,
|
|
||||||
trim_top: int = 0,
|
|
||||||
cols: T.Optional[int] = None,
|
|
||||||
rows: T.Optional[int] = None,
|
|
||||||
attr: T.Optional[str] = None,
|
|
||||||
) -> T.Generator:
|
|
||||||
if cols is None:
|
|
||||||
cols = self.size[0]
|
|
||||||
if rows is None:
|
|
||||||
rows = self.size[1]
|
|
||||||
|
|
||||||
line = [(None, self._cs, self._text * cols)]
|
|
||||||
for i in range(rows):
|
|
||||||
yield line
|
|
||||||
|
|
||||||
def content_delta(self, other: T.Any) -> T.Any:
|
|
||||||
if other is self:
|
|
||||||
return [self.cols()] * self.rows()
|
|
||||||
return self.content()
|
|
||||||
|
|
||||||
|
|
||||||
class _Scrollable(urwid.WidgetDecoration):
|
|
||||||
def __init__(self, widget):
|
|
||||||
"""Box widget that makes a fixed or flow widget vertically scrollable
|
|
||||||
|
|
||||||
TODO: Focusable widgets are handled, including switching focus, but
|
|
||||||
possibly not intuitively, depending on the arrangement of widgets.
|
|
||||||
When switching focus to a widget that is ouside of the visible part of
|
|
||||||
the original widget, the canvas scrolls up/down to the focused widget.
|
|
||||||
It would be better to scroll until the next focusable widget is in
|
|
||||||
sight first. But for that to work we must somehow obtain a list of
|
|
||||||
focusable rows in the original canvas.
|
|
||||||
"""
|
|
||||||
if not any(s in widget.sizing() for s in (FIXED, FLOW)):
|
|
||||||
raise ValueError("Not a fixed or flow widget: %r" % widget)
|
|
||||||
self._trim_top = 0
|
|
||||||
self._scroll_action = None
|
|
||||||
self._forward_keypress = None
|
|
||||||
self._old_cursor_coords = None
|
|
||||||
self._rows_max_cached = 0
|
|
||||||
super().__init__(widget)
|
|
||||||
|
|
||||||
def sizing(self):
|
|
||||||
return frozenset([BOX])
|
|
||||||
|
|
||||||
def selectable(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
def render(self, size, focus=False):
|
|
||||||
maxcol, maxrow = size
|
|
||||||
|
|
||||||
# Render complete original widget
|
|
||||||
ow = self._original_widget
|
|
||||||
ow_size = self._get_original_widget_size(size)
|
|
||||||
canv_full = ow.render(ow_size, focus)
|
|
||||||
|
|
||||||
# Make full canvas editable
|
|
||||||
canv = urwid.CompositeCanvas(canv_full)
|
|
||||||
canv_cols, canv_rows = canv.cols(), canv.rows()
|
|
||||||
|
|
||||||
if canv_cols <= maxcol:
|
|
||||||
pad_width = maxcol - canv_cols
|
|
||||||
if pad_width > 0:
|
|
||||||
# Canvas is narrower than available horizontal space
|
|
||||||
canv.pad_trim_left_right(0, pad_width)
|
|
||||||
|
|
||||||
if canv_rows <= maxrow:
|
|
||||||
fill_height = maxrow - canv_rows
|
|
||||||
if fill_height > 0:
|
|
||||||
# Canvas is lower than available vertical space
|
|
||||||
canv.pad_trim_top_bottom(0, fill_height)
|
|
||||||
|
|
||||||
if canv_cols <= maxcol and canv_rows <= maxrow:
|
|
||||||
# Canvas is small enough to fit without trimming
|
|
||||||
return canv
|
|
||||||
|
|
||||||
self._adjust_trim_top(canv, size)
|
|
||||||
|
|
||||||
# Trim canvas if necessary
|
|
||||||
trim_top = self._trim_top
|
|
||||||
trim_end = canv_rows - maxrow - trim_top
|
|
||||||
trim_right = canv_cols - maxcol
|
|
||||||
if trim_top > 0:
|
|
||||||
canv.trim(trim_top)
|
|
||||||
if trim_end > 0:
|
|
||||||
canv.trim_end(trim_end)
|
|
||||||
if trim_right > 0:
|
|
||||||
canv.pad_trim_left_right(0, -trim_right)
|
|
||||||
|
|
||||||
# Disable cursor display if cursor is outside of visible canvas parts
|
|
||||||
if canv.cursor is not None:
|
|
||||||
curscol, cursrow = canv.cursor
|
|
||||||
if cursrow >= maxrow or cursrow < 0:
|
|
||||||
canv.cursor = None
|
|
||||||
|
|
||||||
# Figure out whether we should forward keypresses to original widget
|
|
||||||
if canv.cursor is not None:
|
|
||||||
# Trimmed canvas contains the cursor, e.g. in an Edit widget
|
|
||||||
self._forward_keypress = True
|
|
||||||
else:
|
|
||||||
if canv_full.cursor is not None:
|
|
||||||
# Full canvas contains the cursor, but scrolled out of view
|
|
||||||
self._forward_keypress = False
|
|
||||||
else:
|
|
||||||
# Original widget does not have a cursor, but may be selectable
|
|
||||||
|
|
||||||
# FIXME: Using ow.selectable() is bad because the original
|
|
||||||
# widget may be selectable because it's a container widget with
|
|
||||||
# a key-grabbing widget that is scrolled out of view.
|
|
||||||
# ow.selectable() returns True anyway because it doesn't know
|
|
||||||
# how we trimmed our canvas.
|
|
||||||
#
|
|
||||||
# To fix this, we need to resolve ow.focus and somehow
|
|
||||||
# ask canv whether it contains bits of the focused widget.
|
|
||||||
# I can't see a way to do that.
|
|
||||||
if ow.selectable():
|
|
||||||
self._forward_keypress = True
|
|
||||||
else:
|
|
||||||
self._forward_keypress = False
|
|
||||||
|
|
||||||
return canv
|
|
||||||
|
|
||||||
def keypress(self, size, key):
|
|
||||||
# Maybe offer key to original widget
|
|
||||||
if self._forward_keypress:
|
|
||||||
ow = self._original_widget
|
|
||||||
ow_size = self._get_original_widget_size(size)
|
|
||||||
|
|
||||||
# Remember previous cursor position if possible
|
|
||||||
if hasattr(ow, "get_cursor_coords"):
|
|
||||||
self._old_cursor_coords = ow.get_cursor_coords(ow_size)
|
|
||||||
|
|
||||||
key = ow.keypress(ow_size, key)
|
|
||||||
if key is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
# Handle up/down, page up/down, etc
|
|
||||||
command_map = self._command_map
|
|
||||||
if command_map[key] == urwid.CURSOR_PAGE_UP:
|
|
||||||
self._scroll_action = SCROLL_PAGE_UP
|
|
||||||
elif command_map[key] == urwid.CURSOR_PAGE_DOWN:
|
|
||||||
self._scroll_action = SCROLL_PAGE_DOWN
|
|
||||||
|
|
||||||
elif command_map[key] == urwid.CURSOR_MAX_LEFT: # 'home'
|
|
||||||
self._scroll_action = SCROLL_TO_TOP
|
|
||||||
elif command_map[key] == urwid.CURSOR_MAX_RIGHT: # 'end'
|
|
||||||
self._scroll_action = SCROLL_TO_END
|
|
||||||
|
|
||||||
else:
|
|
||||||
return key
|
|
||||||
|
|
||||||
self._invalidate()
|
|
||||||
|
|
||||||
def mouse_event(self, size, event, button, col, row, focus):
|
|
||||||
ow = self._original_widget
|
|
||||||
if hasattr(ow, "mouse_event"):
|
|
||||||
ow_size = self._get_original_widget_size(size)
|
|
||||||
row += self._trim_top
|
|
||||||
return ow.mouse_event(ow_size, event, button, col, row, focus)
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _adjust_trim_top(self, canv, size):
|
|
||||||
"""Adjust self._trim_top according to self._scroll_action"""
|
|
||||||
action = self._scroll_action
|
|
||||||
self._scroll_action = None
|
|
||||||
|
|
||||||
maxcol, maxrow = size
|
|
||||||
trim_top = self._trim_top
|
|
||||||
canv_rows = canv.rows()
|
|
||||||
|
|
||||||
if trim_top < 0:
|
|
||||||
# Negative trim_top values use bottom of canvas as reference
|
|
||||||
trim_top = canv_rows - maxrow + trim_top + 1
|
|
||||||
|
|
||||||
if canv_rows <= maxrow:
|
|
||||||
self._trim_top = 0 # Reset scroll position
|
|
||||||
return
|
|
||||||
|
|
||||||
def ensure_bounds(new_trim_top):
|
|
||||||
return max(0, min(canv_rows - maxrow, new_trim_top))
|
|
||||||
|
|
||||||
if action == SCROLL_LINE_UP:
|
|
||||||
self._trim_top = ensure_bounds(trim_top - 1)
|
|
||||||
elif action == SCROLL_LINE_DOWN:
|
|
||||||
self._trim_top = ensure_bounds(trim_top + 1)
|
|
||||||
|
|
||||||
elif action == SCROLL_PAGE_UP:
|
|
||||||
self._trim_top = ensure_bounds(trim_top - maxrow + 1)
|
|
||||||
elif action == SCROLL_PAGE_DOWN:
|
|
||||||
self._trim_top = ensure_bounds(trim_top + maxrow - 1)
|
|
||||||
|
|
||||||
elif action == SCROLL_TO_TOP:
|
|
||||||
self._trim_top = 0
|
|
||||||
elif action == SCROLL_TO_END:
|
|
||||||
self._trim_top = canv_rows - maxrow
|
|
||||||
|
|
||||||
else:
|
|
||||||
self._trim_top = ensure_bounds(trim_top)
|
|
||||||
|
|
||||||
# If the cursor was moved by the most recent keypress, adjust trim_top
|
|
||||||
# so that the new cursor position is within the displayed canvas part.
|
|
||||||
# But don't do this if the cursor is at the top/bottom edge so we can still scroll out
|
|
||||||
if (
|
|
||||||
self._old_cursor_coords is not None
|
|
||||||
and self._old_cursor_coords != canv.cursor
|
|
||||||
):
|
|
||||||
self._old_cursor_coords = None
|
|
||||||
curscol, cursrow = canv.cursor
|
|
||||||
if cursrow < self._trim_top:
|
|
||||||
self._trim_top = cursrow
|
|
||||||
elif cursrow >= self._trim_top + maxrow:
|
|
||||||
self._trim_top = max(0, cursrow - maxrow + 1)
|
|
||||||
|
|
||||||
def _get_original_widget_size(self, size):
|
|
||||||
ow = self._original_widget
|
|
||||||
sizing = ow.sizing()
|
|
||||||
if FIXED in sizing:
|
|
||||||
return ()
|
|
||||||
elif FLOW in sizing:
|
|
||||||
return (size[0],)
|
|
||||||
|
|
||||||
def get_scrollpos(self, size=None, focus=False):
|
|
||||||
"""Current scrolling position
|
|
||||||
|
|
||||||
Lower limit is 0, upper limit is the maximum number of rows with the
|
|
||||||
given maxcol minus maxrow.
|
|
||||||
|
|
||||||
NOTE: The returned value may be too low or too high if the position has
|
|
||||||
changed but the widget wasn't rendered yet.
|
|
||||||
"""
|
|
||||||
return self._trim_top
|
|
||||||
|
|
||||||
def set_scrollpos(self, position):
|
|
||||||
"""Set scrolling position
|
|
||||||
|
|
||||||
If `position` is positive it is interpreted as lines from the top.
|
|
||||||
If `position` is negative it is interpreted as lines from the bottom.
|
|
||||||
|
|
||||||
Values that are too high or too low values are automatically adjusted
|
|
||||||
during rendering.
|
|
||||||
"""
|
|
||||||
self._trim_top = int(position)
|
|
||||||
self._invalidate()
|
|
||||||
|
|
||||||
def rows_max(self, size=None, focus=False):
|
|
||||||
"""Return the number of rows for `size`
|
|
||||||
|
|
||||||
If `size` is not given, the currently rendered number of rows is returned.
|
|
||||||
"""
|
|
||||||
if size is not None:
|
|
||||||
ow = self._original_widget
|
|
||||||
ow_size = self._get_original_widget_size(size)
|
|
||||||
sizing = ow.sizing()
|
|
||||||
if FIXED in sizing:
|
|
||||||
self._rows_max_cached = ow.pack(ow_size, focus)[1]
|
|
||||||
elif FLOW in sizing:
|
|
||||||
self._rows_max_cached = ow.rows(ow_size, focus)
|
|
||||||
else:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Not a flow/box widget: %r" % self._original_widget
|
|
||||||
)
|
|
||||||
return self._rows_max_cached
|
|
||||||
|
|
||||||
|
|
||||||
class _ScrollBar(urwid.WidgetDecoration):
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
widget,
|
|
||||||
thumb_char="\N{FULL BLOCK}",
|
|
||||||
trough_char=" ",
|
|
||||||
side=SCROLLBAR_RIGHT,
|
|
||||||
width=1,
|
|
||||||
padding=0,
|
|
||||||
):
|
|
||||||
"""Box widget that adds a scrollbar to `widget`
|
|
||||||
|
|
||||||
`widget` must be a box widget with the following methods:
|
|
||||||
- `get_scrollpos` takes the arguments `size` and `focus` and returns
|
|
||||||
the index of the first visible row.
|
|
||||||
- `set_scrollpos` (optional; needed for mouse click support) takes the
|
|
||||||
index of the first visible row.
|
|
||||||
- `rows_max` takes `size` and `focus` and returns the total number of
|
|
||||||
rows `widget` can render.
|
|
||||||
|
|
||||||
`thumb_char` is the character used for the scrollbar handle.
|
|
||||||
`trough_char` is used for the space above and below the handle.
|
|
||||||
`side` must be 'left' or 'right'.
|
|
||||||
`width` specifies the number of columns the scrollbar uses.
|
|
||||||
`padding` specifies the number of columns the scrollbar is separated
|
|
||||||
from the content by.
|
|
||||||
"""
|
|
||||||
if BOX not in widget.sizing():
|
|
||||||
raise ValueError("Not a box widget: %r" % widget)
|
|
||||||
super().__init__(widget)
|
|
||||||
self._thumb_char = thumb_char
|
|
||||||
self._trough_char = trough_char
|
|
||||||
self._scrollbar_side = side
|
|
||||||
self._scrollbar_width = max(1, width)
|
|
||||||
self._padding_width = padding
|
|
||||||
self._original_widget_size = (0, 0)
|
|
||||||
|
|
||||||
def sizing(self):
|
|
||||||
return frozenset((BOX,))
|
|
||||||
|
|
||||||
def selectable(self):
|
|
||||||
return True
|
|
||||||
|
|
||||||
def render(self, size, focus=False):
|
|
||||||
maxcol, maxrow = size
|
|
||||||
sb_width = self._scrollbar_width
|
|
||||||
pad_width = self._padding_width
|
|
||||||
self._original_widget_size = ow_size = (
|
|
||||||
maxcol - sb_width - pad_width,
|
|
||||||
maxrow,
|
|
||||||
)
|
|
||||||
|
|
||||||
ow = self._original_widget
|
|
||||||
ow_base = self.scrolling_base_widget
|
|
||||||
ow_rows_max = ow_base.rows_max(size, focus)
|
|
||||||
if ow_rows_max <= maxrow:
|
|
||||||
# Canvas fits without scrolling - no scrollbar needed
|
|
||||||
self._original_widget_size = size
|
|
||||||
return ow.render(size, focus)
|
|
||||||
ow_rows_max = ow_base.rows_max(ow_size, focus)
|
|
||||||
|
|
||||||
ow_canv = ow.render(ow_size, focus)
|
|
||||||
|
|
||||||
pos = ow_base.get_scrollpos(ow_size, focus)
|
|
||||||
posmax = ow_rows_max - maxrow
|
|
||||||
|
|
||||||
# Thumb shrinks/grows according to the ratio of
|
|
||||||
# <number of visible lines> / <number of total lines>
|
|
||||||
thumb_weight = min(1, maxrow / max(1, ow_rows_max))
|
|
||||||
thumb_height = max(1, round(thumb_weight * maxrow))
|
|
||||||
|
|
||||||
# Thumb may only touch top/bottom if the first/last row is visible
|
|
||||||
top_weight = float(pos) / max(1, posmax)
|
|
||||||
top_height = int((maxrow - thumb_height) * top_weight)
|
|
||||||
if top_height == 0 and top_weight > 0:
|
|
||||||
top_height = 1
|
|
||||||
|
|
||||||
# Bottom part is remaining space
|
|
||||||
bottom_height = maxrow - thumb_height - top_height
|
|
||||||
assert thumb_height + top_height + bottom_height == maxrow
|
|
||||||
|
|
||||||
# Create scrollbar canvas
|
|
||||||
top = urwid.SolidCanvas(self._trough_char, sb_width, 1)
|
|
||||||
thumb = urwid.SolidCanvas(self._thumb_char, sb_width, 1)
|
|
||||||
bottom = urwid.SolidCanvas(self._trough_char, sb_width, 1)
|
|
||||||
sb_canv = urwid.CanvasCombine(
|
|
||||||
[(top, None, False)] * top_height
|
|
||||||
+ [(thumb, None, False)] * thumb_height
|
|
||||||
+ [(bottom, None, False)] * bottom_height
|
|
||||||
)
|
|
||||||
|
|
||||||
# Create padding canvas
|
|
||||||
pad_canv = EmptyCanvas(pad_width, maxrow)
|
|
||||||
|
|
||||||
combinelist = [
|
|
||||||
(ow_canv, None, True, ow_size[0]),
|
|
||||||
(pad_canv, None, False, pad_width),
|
|
||||||
(sb_canv, None, False, sb_width),
|
|
||||||
]
|
|
||||||
if self._scrollbar_side != SCROLLBAR_LEFT:
|
|
||||||
return urwid.CanvasJoin(combinelist)
|
|
||||||
else:
|
|
||||||
return urwid.CanvasJoin(reversed(combinelist))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def scrollbar_width(self):
|
|
||||||
"""Columns the scrollbar uses"""
|
|
||||||
return max(1, self._scrollbar_width)
|
|
||||||
|
|
||||||
@scrollbar_width.setter
|
|
||||||
def scrollbar_width(self, width):
|
|
||||||
self._scrollbar_width = max(1, int(width))
|
|
||||||
self._invalidate()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def scrollbar_side(self):
|
|
||||||
"""Where to display the scrollbar; must be 'left' or 'right'"""
|
|
||||||
return self._scrollbar_side
|
|
||||||
|
|
||||||
@scrollbar_side.setter
|
|
||||||
def scrollbar_side(self, side):
|
|
||||||
if side not in (SCROLLBAR_LEFT, SCROLLBAR_RIGHT):
|
|
||||||
raise ValueError(
|
|
||||||
'scrollbar_side must be "left" or "right", not %r' % side
|
|
||||||
)
|
|
||||||
self._scrollbar_side = side
|
|
||||||
self._invalidate()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def scrolling_base_widget(self):
|
|
||||||
"""Nearest `original_widget` that is compatible with the scrolling API"""
|
|
||||||
|
|
||||||
def orig_iter(w):
|
|
||||||
while hasattr(w, "original_widget"):
|
|
||||||
w = w.original_widget
|
|
||||||
yield w
|
|
||||||
yield w
|
|
||||||
|
|
||||||
def is_scrolling_widget(w):
|
|
||||||
return hasattr(w, "get_scrollpos") and hasattr(w, "rows_max")
|
|
||||||
|
|
||||||
for w in orig_iter(self):
|
|
||||||
if is_scrolling_widget(w):
|
|
||||||
return w
|
|
||||||
raise ValueError("Not compatible to be wrapped by ScrollBar: %r" % w)
|
|
||||||
|
|
||||||
def keypress(self, size, key):
|
|
||||||
return self._original_widget.keypress(self._original_widget_size, key)
|
|
||||||
|
|
||||||
def mouse_event(self, size, event, button, col, row, focus):
|
|
||||||
ow = self._original_widget
|
|
||||||
ow_size = self._original_widget_size
|
|
||||||
handled = False
|
|
||||||
if hasattr(ow, "mouse_event"):
|
|
||||||
handled = ow.mouse_event(ow_size, event, button, col, row, focus)
|
|
||||||
|
|
||||||
if not handled and hasattr(ow, "set_scrollpos"):
|
|
||||||
if button == 4: # scroll wheel up
|
|
||||||
pos = ow.get_scrollpos(ow_size)
|
|
||||||
ow.set_scrollpos(pos - 1)
|
|
||||||
return True
|
|
||||||
elif button == 5: # scroll wheel down
|
|
||||||
pos = ow.get_scrollpos(ow_size)
|
|
||||||
ow.set_scrollpos(pos + 1)
|
|
||||||
return True
|
|
||||||
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class Scrollable(urwid.AttrWrap):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(
|
|
||||||
_Scrollable(*args, **kwargs), "scrollable", "scrollable-focus"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class ScrollBar(urwid.AttrWrap):
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
super().__init__(
|
|
||||||
_ScrollBar(*args, **kwargs), "scrollbar", "scrollbar-focus"
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
[project]
|
||||||
|
name = "pqcli"
|
||||||
|
version = "1.1.0"
|
||||||
|
description = "Progress Quest: the CLI edition"
|
||||||
|
authors = [{ name = "Laura Kurczewska", email = "dash@wind.garden" }]
|
||||||
|
license = "MIT"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.10,<4.0"
|
||||||
|
classifiers = [
|
||||||
|
"Environment :: Console",
|
||||||
|
"Development Status :: 4 - Beta",
|
||||||
|
"Intended Audience :: End Users/Desktop",
|
||||||
|
"License :: OSI Approved :: MIT License",
|
||||||
|
"Natural Language :: English",
|
||||||
|
"Operating System :: POSIX :: Linux",
|
||||||
|
"Programming Language :: Python :: 3 :: Only",
|
||||||
|
"Programming Language :: Python :: 3.10",
|
||||||
|
"Topic :: Games/Entertainment :: Role-Playing",
|
||||||
|
"Topic :: Games/Entertainment :: Simulation",
|
||||||
|
]
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
"xdg-base-dirs>=6.0.0,<7.0.0",
|
||||||
|
"urwid>=2.1.2,<3.0.0",
|
||||||
|
"urwid-readline>=0.13,<1.0.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
windows = ["windows-curses>=2.3.0,<3.0.0"]
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://github.com/rr-/pq-cli.git"
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
pqcli = "pqcli.__main__:main"
|
||||||
|
|
||||||
|
[build-system]
|
||||||
|
requires = ["hatchling"]
|
||||||
|
build-backend = "hatchling.build"
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = ["prek>=0.2", "pytest>=8.0", "ty>=0.0.89"]
|
||||||
|
|
||||||
|
[tool.ty.src]
|
||||||
|
# Only the package and its tests; agent worktrees under .claude/ would
|
||||||
|
# otherwise be checked as duplicate copies of the project.
|
||||||
|
include = ["pqcli", "tests"]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
line-length = 79
|
||||||
|
|
||||||
|
[tool.ruff.lint]
|
||||||
|
select = ["E", "F", "W", "I", "UP"]
|
||||||
|
|
||||||
|
[tool.ruff.lint.isort]
|
||||||
|
known-first-party = ["pqcli"]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 104 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.3 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 80 KiB |
@@ -1,26 +0,0 @@
|
|||||||
from setuptools import find_packages, setup
|
|
||||||
|
|
||||||
setup(
|
|
||||||
author="rr-",
|
|
||||||
author_email="rr-@sakuya.pl",
|
|
||||||
name="pqcli",
|
|
||||||
long_description="Progress Quest: the CLI edition",
|
|
||||||
version="0.3",
|
|
||||||
url="https://github.com/rr-/pq-cli.git",
|
|
||||||
packages=find_packages(),
|
|
||||||
entry_points={"console_scripts": ["pqcli = pqcli.__main__:main"]},
|
|
||||||
package_dir={"pqcli": "pqcli"},
|
|
||||||
install_requires=["xdg", "urwid", "urwid_readline"],
|
|
||||||
classifiers=[
|
|
||||||
"Environment :: Console",
|
|
||||||
"Development Status :: 4 - Beta",
|
|
||||||
"Intended Audience :: End Users/Desktop",
|
|
||||||
"License :: OSI Approved :: MIT License",
|
|
||||||
"Natural Language :: English",
|
|
||||||
"Operating System :: POSIX :: Linux",
|
|
||||||
"Programming Language :: Python :: 3 :: Only",
|
|
||||||
"Programming Language :: Python :: 3.7",
|
|
||||||
"Topic :: Games/Entertainment :: Role-Playing",
|
|
||||||
"Topic :: Games/Entertainment :: Simulation",
|
|
||||||
],
|
|
||||||
)
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).parent))
|
||||||
Vendored
+1902
File diff suppressed because it is too large.
Load diff
Vendored
+1900
File diff suppressed because it is too large.
Load diff
Vendored
+1901
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,86 @@
|
|||||||
|
import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from pqcli import random
|
||||||
|
from pqcli.config import StatType
|
||||||
|
from pqcli.game_data import load_game_data
|
||||||
|
from pqcli.mechanic import Player, Simulation, Stats, monster_task
|
||||||
|
from pqcli.random import Alea
|
||||||
|
|
||||||
|
STATS = {
|
||||||
|
StatType.strength: 12,
|
||||||
|
StatType.condition: 11,
|
||||||
|
StatType.dexterity: 10,
|
||||||
|
StatType.intelligence: 13,
|
||||||
|
StatType.wisdom: 14,
|
||||||
|
StatType.charisma: 9,
|
||||||
|
StatType.hp_max: 6,
|
||||||
|
StatType.mp_max: 5,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_trace(seed: str, ticks: int = 3000) -> list[str]:
|
||||||
|
"""Play a deterministic game and return everything that happened."""
|
||||||
|
data = load_game_data()
|
||||||
|
player = Player(
|
||||||
|
name="Tester",
|
||||||
|
birthday=datetime.datetime(2000, 1, 1),
|
||||||
|
race=data.races[0],
|
||||||
|
class_=data.classes[0],
|
||||||
|
stats=Stats(dict(STATS)),
|
||||||
|
rng=Alea(seed),
|
||||||
|
)
|
||||||
|
random.seed(seed)
|
||||||
|
simulation = Simulation(player, data)
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
|
||||||
|
def record_task() -> None:
|
||||||
|
assert player.task is not None
|
||||||
|
lines.append(f"task: {player.task.description}")
|
||||||
|
|
||||||
|
player.connect("new_task", record_task)
|
||||||
|
for _ in range(ticks):
|
||||||
|
simulation.tick(100000.0)
|
||||||
|
|
||||||
|
with random.using(player.rng):
|
||||||
|
# plot cinematics and quests are rare in a short run, so force them
|
||||||
|
for _ in range(30):
|
||||||
|
simulation.interplot_cinematic()
|
||||||
|
simulation.complete_quest()
|
||||||
|
lines.extend(f"queued: {task.description}" for task in player.queue)
|
||||||
|
player.queue.clear()
|
||||||
|
|
||||||
|
# high level monsters are unreachable in a short run
|
||||||
|
for level in range(1, 100):
|
||||||
|
task = monster_task(data, level, player.quest_book.monster)
|
||||||
|
lines.append(f"monster: {task.description} {task.duration}")
|
||||||
|
|
||||||
|
lines.append(f"level: {player.level}")
|
||||||
|
lines.append(f"act: {player.quest_book.act}")
|
||||||
|
lines.append(f"gold: {player.inventory.gold}")
|
||||||
|
for stat, value in player.stats:
|
||||||
|
lines.append(f"stat: {stat.value} {value}")
|
||||||
|
for equipment_type, item in player.equipment:
|
||||||
|
lines.append(f"equipment: {equipment_type.value} {item}")
|
||||||
|
for inventory_item in player.inventory:
|
||||||
|
lines.append(
|
||||||
|
f"inventory: {inventory_item.name} {inventory_item.quantity}"
|
||||||
|
)
|
||||||
|
for spell in player.spell_book:
|
||||||
|
lines.append(f"spell: {spell.name} {spell.level}")
|
||||||
|
for quest in player.quest_book.quests:
|
||||||
|
lines.append(f"quest: {quest}")
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
SEEDS = ["alpha", "beta", "gamma"]
|
||||||
|
FIXTURES = Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Re-record the parity fixtures. Only do this on purpose, when a change
|
||||||
|
# is meant to alter gameplay.
|
||||||
|
for seed in SEEDS:
|
||||||
|
lines = run_trace(seed)
|
||||||
|
(FIXTURES / f"parity_{seed}.txt").write_text("\n".join(lines) + "\n")
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from test_pqw import played
|
||||||
|
|
||||||
|
from pqcli.game_data import load_game_data
|
||||||
|
from pqcli.pqw import read_file, write_file
|
||||||
|
|
||||||
|
|
||||||
|
def pqcli(
|
||||||
|
config: Path, *args: str, cwd: Path | None = None
|
||||||
|
) -> subprocess.CompletedProcess[str]:
|
||||||
|
env = {**os.environ, "XDG_CONFIG_HOME": str(config)}
|
||||||
|
return subprocess.run(
|
||||||
|
[sys.executable, "-m", "pqcli", *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
env=env,
|
||||||
|
cwd=cwd,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_then_export(tmp_path: Path) -> None:
|
||||||
|
data = load_game_data()
|
||||||
|
source = tmp_path / "in.pqw"
|
||||||
|
write_file(played(3000).player, source, data)
|
||||||
|
|
||||||
|
result = pqcli(tmp_path, "--import-save", str(source))
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert result.stdout == "Imported Exportée\n"
|
||||||
|
assert (tmp_path / "pqcli" / "save.dat").exists()
|
||||||
|
|
||||||
|
listed = pqcli(tmp_path, "--list-saves")
|
||||||
|
assert listed.stdout == "1. Exportée\n"
|
||||||
|
|
||||||
|
target = tmp_path / "out.pqw"
|
||||||
|
result = pqcli(tmp_path, "--export-save", "1", str(target))
|
||||||
|
assert result.returncode == 0, result.stderr
|
||||||
|
assert read_file(target, data)[0].name == "Exportée"
|
||||||
|
|
||||||
|
|
||||||
|
def test_import_bad_file_changes_nothing(tmp_path: Path) -> None:
|
||||||
|
bad = tmp_path / "bad.pqw"
|
||||||
|
bad.write_text("garbage!")
|
||||||
|
result = pqcli(tmp_path, "--import-save", str(bad))
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "not a .pqw file" in result.stderr
|
||||||
|
assert not (tmp_path / "pqcli" / "save.dat").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_export_rejects_unknown_character(tmp_path: Path) -> None:
|
||||||
|
result = pqcli(tmp_path, "--export-save", "3", str(tmp_path / "x.pqw"))
|
||||||
|
assert result.returncode == 1
|
||||||
|
assert "Invalid player" in result.stderr
|
||||||
|
assert not (tmp_path / "x.pqw").exists()
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import pickle
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pqcli import random
|
||||||
|
from pqcli.config import Class, Monster, Race, StatType
|
||||||
|
from pqcli.game_data import (
|
||||||
|
DATA_FILES,
|
||||||
|
DataError,
|
||||||
|
GameData,
|
||||||
|
dump_builtin_data,
|
||||||
|
load_game_data,
|
||||||
|
)
|
||||||
|
from pqcli.mechanic import Player, Stats, monster_task
|
||||||
|
|
||||||
|
|
||||||
|
def write(directory: Path, name: str, value: object) -> Path:
|
||||||
|
path = directory / f"{name}.json"
|
||||||
|
path.write_text(json.dumps(value), encoding="utf-8")
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def test_builtin_data_loads() -> None:
|
||||||
|
data = load_game_data()
|
||||||
|
assert data.spells[0] == "Slime Finger"
|
||||||
|
assert data.monsters[0] == Monster("Anhkheg", 6, "chitin")
|
||||||
|
assert data.races[0] == Race("Half Orc", [StatType.hp_max])
|
||||||
|
assert data.classes[-1] == Class("Vermineer", [StatType.intelligence])
|
||||||
|
assert data.combat_verbs == ("Executing {target}",)
|
||||||
|
assert all(getattr(data, name) for name in DATA_FILES)
|
||||||
|
|
||||||
|
|
||||||
|
def test_builtin_data_keeps_quirks() -> None:
|
||||||
|
data = load_game_data()
|
||||||
|
names = [monster.name for monster in data.monsters]
|
||||||
|
assert names.count("Rat") == 2
|
||||||
|
assert Monster("Neo-Otyugh", 11, "organ ") in data.monsters
|
||||||
|
assert any(monster.item is None for monster in data.monsters)
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_override_dir_uses_builtin(tmp_path: Path) -> None:
|
||||||
|
assert load_game_data(tmp_path / "nope") == load_game_data()
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_replaces_only_that_list(tmp_path: Path) -> None:
|
||||||
|
write(tmp_path, "spells", ["Zap", "Bonk"])
|
||||||
|
write(
|
||||||
|
tmp_path,
|
||||||
|
"races",
|
||||||
|
[{"name": "Robot", "attr": ["STR", "hp_max", "Wisdom"]}],
|
||||||
|
)
|
||||||
|
builtin = load_game_data()
|
||||||
|
data = load_game_data(tmp_path)
|
||||||
|
assert data.spells == ("Zap", "Bonk")
|
||||||
|
assert data.races == (
|
||||||
|
Race("Robot", [StatType.strength, StatType.hp_max, StatType.wisdom]),
|
||||||
|
)
|
||||||
|
assert data.monsters == builtin.monsters
|
||||||
|
assert data.classes == builtin.classes
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_accepts_all_shapes(tmp_path: Path) -> None:
|
||||||
|
write(tmp_path, "monsters", [{"name": "Bob", "level": 0, "item": None}])
|
||||||
|
write(tmp_path, "weapons", [{"name": "Spoon", "quality": -1}])
|
||||||
|
write(tmp_path, "offense_bad", [{"name": "Soggy", "quality": -2}])
|
||||||
|
data = load_game_data(tmp_path)
|
||||||
|
assert data.monsters == (Monster("Bob", 0, None),)
|
||||||
|
assert data.weapons[0].name == "Spoon"
|
||||||
|
assert data.offense_bad[0].quality == -2
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_file_warns(
|
||||||
|
tmp_path: Path, capsys: pytest.CaptureFixture[str]
|
||||||
|
) -> None:
|
||||||
|
write(tmp_path, "monster", [])
|
||||||
|
load_game_data(tmp_path)
|
||||||
|
assert "monster.json" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"name,value,message",
|
||||||
|
[
|
||||||
|
("spells", [], "must not be empty"),
|
||||||
|
("spells", {"a": 1}, "must be a list"),
|
||||||
|
("spells", ["ok", 3], "[1]"),
|
||||||
|
("monsters", [{"name": "Imp", "level": "2", "item": "x"}], "level"),
|
||||||
|
("monsters", [{"name": "Imp", "level": True, "item": "x"}], "level"),
|
||||||
|
("monsters", [{"name": "Imp", "level": 2}], "item"),
|
||||||
|
("monsters", [{"name": "Imp", "level": 2, "item": 1}], "item"),
|
||||||
|
("weapons", [{"name": "Spoon", "quality": 1, "x": 1}], "x"),
|
||||||
|
("races", [{"name": "Elf", "attr": ["LUCK"]}], "LUCK"),
|
||||||
|
("races", [{"name": "Elf", "attr": "STR"}], "attr"),
|
||||||
|
("combat_verbs", ["Hitting"], "{target}"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_override_raises(
|
||||||
|
tmp_path: Path, name: str, value: object, message: str
|
||||||
|
) -> None:
|
||||||
|
write(tmp_path, name, value)
|
||||||
|
with pytest.raises(DataError) as excinfo:
|
||||||
|
load_game_data(tmp_path)
|
||||||
|
assert f"{name}.json" in str(excinfo.value)
|
||||||
|
assert message in str(excinfo.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_json_raises(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "spells.json").write_text("[oops", encoding="utf-8")
|
||||||
|
with pytest.raises(DataError, match="spells.json"):
|
||||||
|
load_game_data(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_non_utf8_raises(tmp_path: Path) -> None:
|
||||||
|
(tmp_path / "spells.json").write_bytes(b'["\xff"]')
|
||||||
|
with pytest.raises(DataError, match="spells.json"):
|
||||||
|
load_game_data(tmp_path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_override_dir_must_be_directory(tmp_path: Path) -> None:
|
||||||
|
path = write(tmp_path, "spells", ["Zap"])
|
||||||
|
with pytest.raises(DataError, match="not a directory"):
|
||||||
|
load_game_data(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_combat_verbs_are_used(tmp_path: Path) -> None:
|
||||||
|
verbs = [
|
||||||
|
"Fighting bravely against {target}",
|
||||||
|
"Kicking the butt of {target}",
|
||||||
|
]
|
||||||
|
write(tmp_path, "combat_verbs", verbs)
|
||||||
|
data = load_game_data(tmp_path)
|
||||||
|
random.seed("verbs")
|
||||||
|
seen = set()
|
||||||
|
for _ in range(200):
|
||||||
|
description = monster_task(data, 5, None).description
|
||||||
|
prefix = description.split(" ")[0]
|
||||||
|
assert prefix in {"Fighting", "Kicking"}
|
||||||
|
assert "{target}" not in description
|
||||||
|
seen.add(prefix)
|
||||||
|
assert seen == {"Fighting", "Kicking"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_dump_builtin_data_round_trips(tmp_path: Path) -> None:
|
||||||
|
written = dump_builtin_data(tmp_path / "out")
|
||||||
|
assert sorted(path.stem for path in written) == sorted(DATA_FILES)
|
||||||
|
assert load_game_data(tmp_path / "out") == load_game_data()
|
||||||
|
|
||||||
|
|
||||||
|
def test_dump_builtin_data_refuses_to_overwrite(tmp_path: Path) -> None:
|
||||||
|
existing = write(tmp_path, "spells", ["Mine"])
|
||||||
|
with pytest.raises(DataError, match="spells.json"):
|
||||||
|
dump_builtin_data(tmp_path)
|
||||||
|
assert json.loads(existing.read_text()) == ["Mine"]
|
||||||
|
assert list(tmp_path.iterdir()) == [existing]
|
||||||
|
|
||||||
|
|
||||||
|
def test_saved_player_survives_removed_race(tmp_path: Path) -> None:
|
||||||
|
data = load_game_data()
|
||||||
|
player = Player(
|
||||||
|
name="Saved",
|
||||||
|
birthday=datetime.datetime(2000, 1, 1),
|
||||||
|
race=data.races[3],
|
||||||
|
class_=data.classes[2],
|
||||||
|
stats=Stats({stat: 10 for stat in StatType}),
|
||||||
|
)
|
||||||
|
blob = pickle.dumps(player)
|
||||||
|
|
||||||
|
write(tmp_path, "races", [{"name": "Robot", "attr": ["STR"]}])
|
||||||
|
load_game_data(tmp_path)
|
||||||
|
restored = pickle.loads(blob)
|
||||||
|
assert restored.race == Race("Double Hobbit", [StatType.strength])
|
||||||
|
assert type(restored.race).__module__ == "pqcli.config"
|
||||||
|
|
||||||
|
|
||||||
|
def test_game_data_is_immutable() -> None:
|
||||||
|
data = load_game_data()
|
||||||
|
assert isinstance(data, GameData)
|
||||||
|
with pytest.raises(AttributeError):
|
||||||
|
setattr(data, "spells", ())
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
from pqcli.lingo import indefinite, to_arabic, to_roman
|
||||||
|
|
||||||
|
|
||||||
|
def test_indefinite_article() -> None:
|
||||||
|
assert indefinite("Orc", 1) == "an Orc"
|
||||||
|
assert indefinite("Ürn", 1) == "an Ürn"
|
||||||
|
assert indefinite("über-Orc", 1) == "an über-Orc"
|
||||||
|
assert indefinite("?", 1) == "a ?"
|
||||||
|
assert indefinite("Rat", 1) == "a Rat"
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_roman() -> None:
|
||||||
|
assert to_roman(0) == "N"
|
||||||
|
assert to_roman(-4) == "-IV"
|
||||||
|
assert to_roman(1994) == "MCMXCIV"
|
||||||
|
# pq-web aa37e4a: T = 10000, A = 5000
|
||||||
|
assert to_roman(4000) == "MA"
|
||||||
|
assert to_roman(5000) == "A"
|
||||||
|
assert to_roman(9000) == "MT"
|
||||||
|
assert to_roman(14999) == "TMACMXCIX"
|
||||||
|
assert to_roman(23456) == "TTMMMCDLVI"
|
||||||
|
|
||||||
|
|
||||||
|
def test_to_arabic_inverts_to_roman() -> None:
|
||||||
|
for num in [*range(1, 5000), 9000, 9999, 14999, 23456]:
|
||||||
|
assert to_arabic(to_roman(num)) == num
|
||||||
|
assert to_arabic("N") == 0
|
||||||
|
assert to_arabic("iv") == 4
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
import datetime
|
||||||
|
import pickle
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from pqcli import random
|
||||||
|
from pqcli.config import PRIME_STATS, StatType
|
||||||
|
from pqcli.game_data import load_game_data
|
||||||
|
from pqcli.lingo import plural
|
||||||
|
from pqcli.mechanic import (
|
||||||
|
Player,
|
||||||
|
Simulation,
|
||||||
|
Stats,
|
||||||
|
StatsBuilder,
|
||||||
|
create_player,
|
||||||
|
impressive_guy,
|
||||||
|
level_up_time,
|
||||||
|
)
|
||||||
|
from pqcli.random import Alea
|
||||||
|
|
||||||
|
|
||||||
|
def test_level_up_time_matches_upstream() -> None:
|
||||||
|
# Math.round((20 + Math.pow(1.15, level)) * 60), pq-web 5cd39f1
|
||||||
|
assert level_up_time(1) == 1269
|
||||||
|
assert level_up_time(10) == 1443
|
||||||
|
assert level_up_time(30) == 5173
|
||||||
|
assert level_up_time(100) == 70460007
|
||||||
|
|
||||||
|
|
||||||
|
RACE = load_game_data().races[0]
|
||||||
|
CLASS = load_game_data().classes[0]
|
||||||
|
|
||||||
|
|
||||||
|
def make_simulation() -> Simulation:
|
||||||
|
data = load_game_data()
|
||||||
|
player = Player(
|
||||||
|
name="Tester",
|
||||||
|
birthday=datetime.datetime(2000, 1, 1),
|
||||||
|
race=data.races[0],
|
||||||
|
class_=data.classes[0],
|
||||||
|
stats=Stats({stat: 10 for stat in StatType}),
|
||||||
|
rng=Alea("test"),
|
||||||
|
)
|
||||||
|
return Simulation(player, data)
|
||||||
|
|
||||||
|
|
||||||
|
def test_quest_length_matches_upstream() -> None:
|
||||||
|
# QuestBar.reset(50 + Random(100)), pq-web f7d5e83
|
||||||
|
random.seed("quests")
|
||||||
|
simulation = make_simulation()
|
||||||
|
lengths = set()
|
||||||
|
for _ in range(2000):
|
||||||
|
simulation.complete_quest()
|
||||||
|
lengths.add(simulation.player.quest_book.quest_bar.max_)
|
||||||
|
assert min(lengths) == 50
|
||||||
|
assert max(lengths) == 149
|
||||||
|
|
||||||
|
|
||||||
|
def test_weighted_stat_gain_skips_hp_and_mp(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
# the "favor the best stat" branch only covers prime stats, pq-web 48c42e6
|
||||||
|
monkeypatch.setattr(random, "odds", lambda chance, out_of: False)
|
||||||
|
random.seed("stats")
|
||||||
|
player = make_simulation().player
|
||||||
|
player.stats.increment(StatType.hp_max, 1000)
|
||||||
|
for _ in range(500):
|
||||||
|
player.win_stat()
|
||||||
|
assert player.stats[StatType.hp_max] == 1010
|
||||||
|
assert player.stats[StatType.mp_max] == 10
|
||||||
|
assert sum(player.stats[stat] for stat in PRIME_STATS) == 60 + 500
|
||||||
|
|
||||||
|
|
||||||
|
def fake_below(monkeypatch: pytest.MonkeyPatch, *values: int) -> None:
|
||||||
|
queue = list(values)
|
||||||
|
monkeypatch.setattr(random, "below", lambda num: queue.pop(0) % num)
|
||||||
|
|
||||||
|
|
||||||
|
def full_inventory(rows: int) -> Simulation:
|
||||||
|
simulation = make_simulation()
|
||||||
|
for i in range(rows):
|
||||||
|
simulation.player.inventory.add(f"item {i}", 1)
|
||||||
|
return simulation
|
||||||
|
|
||||||
|
|
||||||
|
def test_win_item_adds_new_item_below_cap(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
simulation = full_inventory(10)
|
||||||
|
random.seed("items")
|
||||||
|
simulation.player.win_item(simulation.data)
|
||||||
|
assert len(simulation.player.inventory) == 11
|
||||||
|
|
||||||
|
|
||||||
|
def test_win_item_repeats_existing_item_past_cap(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
# Max(250, Random(999)) < rows: add one of a random row, pq-web 3cbd386
|
||||||
|
simulation = full_inventory(300)
|
||||||
|
inventory = simulation.player.inventory
|
||||||
|
fake_below(monkeypatch, 260, 6) # rows include Gold, so 6 is "item 5"
|
||||||
|
simulation.player.win_item(simulation.data)
|
||||||
|
assert len(inventory) == 300
|
||||||
|
assert inventory[5].quantity == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_win_item_can_pick_gold_row(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
simulation = full_inventory(300)
|
||||||
|
fake_below(monkeypatch, 0, 0)
|
||||||
|
simulation.player.win_item(simulation.data)
|
||||||
|
assert simulation.player.inventory.gold == 1
|
||||||
|
assert len(simulation.player.inventory) == 300
|
||||||
|
|
||||||
|
|
||||||
|
def test_win_item_past_cap_can_still_add_new_item(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
simulation = full_inventory(300)
|
||||||
|
random.seed("items")
|
||||||
|
fake_below(monkeypatch, 301, 0, 0, 0)
|
||||||
|
simulation.player.win_item(simulation.data)
|
||||||
|
assert len(simulation.player.inventory) == 301
|
||||||
|
|
||||||
|
|
||||||
|
def test_impressive_guy_formats() -> None:
|
||||||
|
# pq-web cea0d8c
|
||||||
|
data = load_game_data()
|
||||||
|
titles = "|".join(re.escape(title) for title in data.impressive_titles)
|
||||||
|
races = "|".join(re.escape(plural(race.name)) for race in data.races)
|
||||||
|
pattern = re.compile(
|
||||||
|
rf"the ({titles}) of the ({races})"
|
||||||
|
rf"|({titles}) [A-Z][a-z]+ of [A-Z][a-z]+"
|
||||||
|
)
|
||||||
|
random.seed("guys")
|
||||||
|
guys = [impressive_guy(data) for _ in range(200)]
|
||||||
|
for guy in guys:
|
||||||
|
assert pattern.fullmatch(guy), guy
|
||||||
|
assert any(guy.startswith("the ") for guy in guys)
|
||||||
|
assert not all(guy.startswith("the ") for guy in guys)
|
||||||
|
|
||||||
|
|
||||||
|
def test_impressive_titles_match_upstream() -> None:
|
||||||
|
assert load_game_data().impressive_titles[-3:] == (
|
||||||
|
"Chancellor",
|
||||||
|
"Baroness",
|
||||||
|
"Inquistor", # sic, as upstream
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_cinematic_text_matches_upstream() -> None:
|
||||||
|
random.seed("cinematics")
|
||||||
|
simulation = make_simulation()
|
||||||
|
for _ in range(60):
|
||||||
|
simulation.interplot_cinematic()
|
||||||
|
texts = [task.description for task in simulation.player.queue]
|
||||||
|
assert any("You've reached the kind protection of " in t for t in texts)
|
||||||
|
assert any(t.endswith("news!? -- Oh yes, of course") for t in texts)
|
||||||
|
|
||||||
|
|
||||||
|
def test_quest_log_keeps_100_quests() -> None:
|
||||||
|
quest_book = make_simulation().player.quest_book
|
||||||
|
for i in range(150):
|
||||||
|
quest_book.add_quest(f"quest {i}")
|
||||||
|
assert len(quest_book.quests) == 100
|
||||||
|
assert quest_book.quests[-1] == "quest 149"
|
||||||
|
|
||||||
|
|
||||||
|
def test_prologue_fills_plot_bar() -> None:
|
||||||
|
# upstream advances the plot bar on any task while act == 0, and starts
|
||||||
|
# act I as its loading task starts
|
||||||
|
simulation = make_simulation()
|
||||||
|
player = simulation.player
|
||||||
|
seen: list[tuple[str, float, int, int]] = []
|
||||||
|
|
||||||
|
def record() -> None:
|
||||||
|
assert player.task is not None
|
||||||
|
bar = player.quest_book.plot_bar
|
||||||
|
seen.append(
|
||||||
|
(
|
||||||
|
player.task.description,
|
||||||
|
bar.position,
|
||||||
|
bar.max_,
|
||||||
|
player.quest_book.act,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
player.connect("new_task", record)
|
||||||
|
while len(seen) < 7:
|
||||||
|
simulation.tick(100000.0)
|
||||||
|
assert [(position, act) for _, position, _, act in seen[:5]] == [
|
||||||
|
(0, 0),
|
||||||
|
(0, 0),
|
||||||
|
(10, 0),
|
||||||
|
(16, 0),
|
||||||
|
(22, 0),
|
||||||
|
]
|
||||||
|
assert seen[5] == ("Loading Act I", 0, 21600, 1)
|
||||||
|
assert seen[6][0] == "Heading to the killing fields"
|
||||||
|
|
||||||
|
|
||||||
|
def test_player_rng_survives_save_and_load() -> None:
|
||||||
|
simulation = make_simulation()
|
||||||
|
for _ in range(50):
|
||||||
|
simulation.tick(100000.0)
|
||||||
|
restored = pickle.loads(pickle.dumps(simulation.player))
|
||||||
|
assert restored.rng.state() == simulation.player.rng.state()
|
||||||
|
|
||||||
|
|
||||||
|
def test_old_save_without_rng_gets_one() -> None:
|
||||||
|
player = make_simulation().player
|
||||||
|
state = player.__dict__.copy()
|
||||||
|
del state["rng"]
|
||||||
|
restored = Player.__new__(Player)
|
||||||
|
restored.__setstate__(state)
|
||||||
|
assert isinstance(restored.rng, Alea)
|
||||||
|
|
||||||
|
|
||||||
|
def test_tick_draws_from_player_rng_only() -> None:
|
||||||
|
random.seed("global")
|
||||||
|
expected = Alea("global").uint32() % 1000
|
||||||
|
simulation = make_simulation()
|
||||||
|
before = simulation.player.rng.state()
|
||||||
|
for _ in range(50):
|
||||||
|
simulation.tick(100000.0)
|
||||||
|
assert simulation.player.rng.state() != before
|
||||||
|
assert random.below(1000) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_new_game_replays_stat_roll_numbers() -> None:
|
||||||
|
# upstream seeds the game with the state from before the stat roll
|
||||||
|
builder = StatsBuilder(Alea("roll"))
|
||||||
|
builder.roll()
|
||||||
|
stats = builder.roll()
|
||||||
|
player = create_player("New", RACE, CLASS, stats, builder.seed)
|
||||||
|
assert player.rng.state() == builder.history[-1].seed
|
||||||
|
with random.using(player.rng):
|
||||||
|
rerolled = StatsBuilder(player.rng).roll()
|
||||||
|
assert dict(rerolled) == dict(stats)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unroll_restores_previous_stats_and_generator() -> None:
|
||||||
|
builder = StatsBuilder(Alea("roll"))
|
||||||
|
first = builder.roll()
|
||||||
|
after_first = builder.history[-1].after
|
||||||
|
builder.roll()
|
||||||
|
assert builder.unroll() is first
|
||||||
|
assert builder.seed == builder.history[-1].seed
|
||||||
|
assert builder._rng.state() == after_first
|
||||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user