mirror of
https://github.com/rr-/pq-cli.git
synced 2026-09-28 22:33:59 -06:00
Compare commits
114
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f63f96d87 | ||
|
|
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 | ||
|
|
5545e2324a | ||
|
|
000b11c26c | ||
|
|
b9a0934e74 | ||
|
|
a823c9041e | ||
|
|
58758d35d5 | ||
|
|
e7d5947299 | ||
|
|
16ec0cfa5d | ||
|
|
ce7a422482 | ||
|
|
3cdafe4ec8 | ||
|
|
9bfbaf1646 | ||
|
|
4bb0e9250a | ||
|
|
a930b2b3db | ||
|
|
2372dcdcaa | ||
|
|
8e25fbea04 | ||
|
|
70ab6eab48 | ||
|
|
37252d2727 | ||
|
|
284f549966 | ||
|
|
904951acf4 | ||
|
|
3b19191120 | ||
|
|
72fe5439b0 | ||
|
|
88a936c293 | ||
|
|
0b74d1a36e | ||
|
|
14149cd112 | ||
|
|
bb233b82b8 | ||
|
|
723c4aae88 | ||
|
|
6bd12797f0 | ||
|
|
066fc692e4 | ||
|
|
86f59d8fb2 | ||
|
|
78e39e639e | ||
|
|
6c2fed9d40 | ||
|
|
b5b9554f9b | ||
|
|
3afa965eeb | ||
|
|
b00cf34e8f | ||
|
|
c7c5abf7c8 | ||
|
|
c8d8d646e8 | ||
|
|
7692d2e531 | ||
|
|
69cab66c9e | ||
|
|
eb07950527 |
@@ -0,0 +1,4 @@
|
||||
dist/
|
||||
build/
|
||||
__pycache__
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
repos:
|
||||
|
||||
- repo: https://github.com/pycqa/isort
|
||||
rev: 5.13.2
|
||||
hooks:
|
||||
- id: isort
|
||||
additional_dependencies: [toml]
|
||||
- repo: https://github.com/psf/black
|
||||
rev: 24.4.2
|
||||
hooks:
|
||||
- id: black
|
||||
+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"]
|
||||
@@ -1,5 +1,5 @@
|
||||
<p align="center">
|
||||
<img alt="ProgressQuest" src="http://progressquest.com/pq.png">
|
||||
<img alt="Progress Quest" src="http://progressquest.com/pq.png">
|
||||
</p>
|
||||
|
||||
Relive the great adventure… this time in the terminal realm!
|
||||
@@ -10,27 +10,101 @@ Relive the great adventure… this time in the terminal realm!
|
||||
|
||||
## 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
|
||||
- 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
|
||||
|
||||
## How it looks like
|
||||
|
||||

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

|
||||

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

|
||||
|
||||
## How to install
|
||||
|
||||
If you have Python 3.7, just run `pip install --user pqcli` and you're good to go!
|
||||
Then type `pqcli` to run the game.
|
||||
|
||||
In case if you want to use the git version, the process is just a little bit
|
||||
more complex:
|
||||
In case if you want to use the git version, the process is just a bit more complex:
|
||||
|
||||
```console
|
||||
$ git clone https://github.com/rr-/pq-cli.git
|
||||
$ cd pq-cli
|
||||
$ 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.
|
||||
|
||||
## Contributing
|
||||
|
||||
```sh
|
||||
# Clone the repository:
|
||||
git clone https://github.com/rr-/pqcli.git
|
||||
cd pqcli
|
||||
|
||||
# Install to a local venv:
|
||||
poetry install
|
||||
|
||||
# Install pre-commit hooks:
|
||||
poetry run pre-commit install
|
||||
|
||||
# Enter the venv:
|
||||
poetry shell
|
||||
```
|
||||
|
||||
This project uses [poetry](https://python-poetry.org/) for packaging.
|
||||
Install instructions are available at [poetry#installation](https://python-poetry.org/docs/#installation).
|
||||
|
||||
## 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:
|
||||
Generated
+298
@@ -0,0 +1,298 @@
|
||||
# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand.
|
||||
|
||||
[[package]]
|
||||
name = "cfgv"
|
||||
version = "3.4.0"
|
||||
description = "Validate configuration and produce human readable error messages."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"},
|
||||
{file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.3.8"
|
||||
description = "Distribution utilities"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"},
|
||||
{file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.14.0"
|
||||
description = "A platform independent file lock."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "filelock-3.14.0-py3-none-any.whl", hash = "sha256:43339835842f110ca7ae60f1e1c160714c5a6afd15a2873419ab185334975c0f"},
|
||||
{file = "filelock-3.14.0.tar.gz", hash = "sha256:6ea72da3be9b8c82afd3edcf99f2fffbb5076335a5ae4d03248bb5b6c3eae78a"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
|
||||
testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8.0.1)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"]
|
||||
typing = ["typing-extensions (>=4.8)"]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.5.36"
|
||||
description = "File identification library for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "identify-2.5.36-py2.py3-none-any.whl", hash = "sha256:37d93f380f4de590500d9dba7db359d0d3da95ffe7f9de1753faa159e71e7dfa"},
|
||||
{file = "identify-2.5.36.tar.gz", hash = "sha256:e5e00f54165f9047fbebeb4a560f9acfb8af4c88232be60a488e9b68d122745d"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
license = ["ukkonen"]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.8.0"
|
||||
description = "Node.js virtual environment builder"
|
||||
optional = false
|
||||
python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*"
|
||||
files = [
|
||||
{file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"},
|
||||
{file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
setuptools = "*"
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.2.1"
|
||||
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`."
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "platformdirs-4.2.1-py3-none-any.whl", hash = "sha256:17d5a1161b3fd67b390023cb2d3b026bbd40abde6fdb052dfbd3a29c3ba22ee1"},
|
||||
{file = "platformdirs-4.2.1.tar.gz", hash = "sha256:031cd18d4ec63ec53e82dceaac0417d218a6863f7745dfcc9efe7793b7039bdf"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"]
|
||||
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"]
|
||||
type = ["mypy (>=1.8)"]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "2.21.0"
|
||||
description = "A framework for managing and maintaining multi-language pre-commit hooks."
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "pre_commit-2.21.0-py2.py3-none-any.whl", hash = "sha256:e2f91727039fc39a92f58a588a25b87f936de6567eed4f0e673e0507edc75bad"},
|
||||
{file = "pre_commit-2.21.0.tar.gz", hash = "sha256:31ef31af7e474a8d8995027fefdfcf509b5c913ff31f2015b4ec4beb26a6f658"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
cfgv = ">=2.0.0"
|
||||
identify = ">=1.0.0"
|
||||
nodeenv = ">=0.11.1"
|
||||
pyyaml = ">=5.1"
|
||||
virtualenv = ">=20.10.0"
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.1"
|
||||
description = "YAML parser and emitter for Python"
|
||||
optional = false
|
||||
python-versions = ">=3.6"
|
||||
files = [
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"},
|
||||
{file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"},
|
||||
{file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"},
|
||||
{file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"},
|
||||
{file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"},
|
||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"},
|
||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"},
|
||||
{file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"},
|
||||
{file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"},
|
||||
{file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"},
|
||||
{file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"},
|
||||
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"},
|
||||
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"},
|
||||
{file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"},
|
||||
{file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"},
|
||||
{file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"},
|
||||
{file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"},
|
||||
{file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"},
|
||||
{file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "70.0.0"
|
||||
description = "Easily download, build, install, upgrade, and uninstall Python packages"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "setuptools-70.0.0-py3-none-any.whl", hash = "sha256:54faa7f2e8d2d11bcd2c07bed282eef1046b5c080d1c32add737d7b5817b1ad4"},
|
||||
{file = "setuptools-70.0.0.tar.gz", hash = "sha256:f211a66637b8fa059bb28183da127d4e86396c991a942b028c6650d4319c3fd0"},
|
||||
]
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "pyproject-hooks (!=1.1)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
|
||||
testing = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "importlib-metadata", "ini2toml[lite] (>=0.14)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "mypy (==1.9)", "packaging (>=23.2)", "pip (>=19.1)", "pyproject-hooks (!=1.1)", "pytest (>=6,!=8.1.1)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-subprocess", "pytest-timeout", "pytest-xdist (>=3)", "tomli", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.11.0"
|
||||
description = "Backported and Experimental Type Hints for Python 3.8+"
|
||||
optional = false
|
||||
python-versions = ">=3.8"
|
||||
files = [
|
||||
{file = "typing_extensions-4.11.0-py3-none-any.whl", hash = "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"},
|
||||
{file = "typing_extensions-4.11.0.tar.gz", hash = "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urwid"
|
||||
version = "2.6.11"
|
||||
description = "A full-featured console (xterm et al.) user interface library"
|
||||
optional = false
|
||||
python-versions = ">3.7"
|
||||
files = [
|
||||
{file = "urwid-2.6.11-py3-none-any.whl", hash = "sha256:7e18c34d416fffd4e39410450347f4637222bd99cb6fb1523ffa50d77a0e22cf"},
|
||||
{file = "urwid-2.6.11.tar.gz", hash = "sha256:52770007d734d7387ae0421e7b7769c4c5ec67e91a5f4df54e858e314062e475"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
typing-extensions = "*"
|
||||
wcwidth = "*"
|
||||
|
||||
[package.extras]
|
||||
curses = ["windows-curses"]
|
||||
glib = ["PyGObject"]
|
||||
lcd = ["pyserial"]
|
||||
serial = ["pyserial"]
|
||||
tornado = ["tornado (>=5.0)"]
|
||||
trio = ["exceptiongroup", "trio (>=0.22.0)"]
|
||||
twisted = ["twisted"]
|
||||
zmq = ["zmq"]
|
||||
|
||||
[[package]]
|
||||
name = "urwid-readline"
|
||||
version = "0.13"
|
||||
description = "A textbox edit widget for urwid that supports readline shortcuts"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "urwid_readline-0.13.tar.gz", hash = "sha256:018020cbc864bb5ed87be17dc26b069eae2755cb29f3a9c569aac3bded1efaf4"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
urwid = "*"
|
||||
|
||||
[package.extras]
|
||||
dev = ["black", "pytest"]
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.26.1"
|
||||
description = "Virtual Python Environment builder"
|
||||
optional = false
|
||||
python-versions = ">=3.7"
|
||||
files = [
|
||||
{file = "virtualenv-20.26.1-py3-none-any.whl", hash = "sha256:7aa9982a728ae5892558bff6a2839c00b9ed145523ece2274fad6f414690ae75"},
|
||||
{file = "virtualenv-20.26.1.tar.gz", hash = "sha256:604bfdceaeece392802e6ae48e69cec49168b9c5f4a44e483963f9242eb0e78b"},
|
||||
]
|
||||
|
||||
[package.dependencies]
|
||||
distlib = ">=0.3.7,<1"
|
||||
filelock = ">=3.12.2,<4"
|
||||
platformdirs = ">=3.9.1,<5"
|
||||
|
||||
[package.extras]
|
||||
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2,!=7.3)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"]
|
||||
test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.13"
|
||||
description = "Measures the displayed width of unicode strings in a terminal"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "wcwidth-0.2.13-py2.py3-none-any.whl", hash = "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859"},
|
||||
{file = "wcwidth-0.2.13.tar.gz", hash = "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-curses"
|
||||
version = "2.3.2"
|
||||
description = "Support for the standard curses module on Windows"
|
||||
optional = false
|
||||
python-versions = "*"
|
||||
files = [
|
||||
{file = "windows_curses-2.3.2-cp310-cp310-win32.whl", hash = "sha256:0286d35c9a2589731af3cf2b1251635a400f4b61aef2b9c081f6c98e7887a170"},
|
||||
{file = "windows_curses-2.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:b5e68520c7e92dff72914e4126cadb5b8eb3b6c968d955de6d35ff42306da8c0"},
|
||||
{file = "windows_curses-2.3.2-cp311-cp311-win32.whl", hash = "sha256:95d2a288af6172270da5ca9225aa99eeae98595c6e90f3574aa9b9f2fc1d2619"},
|
||||
{file = "windows_curses-2.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:9b7ecd0f21f643e22a979effff25f62200626bb9853ea8b9aacf0bbcaab0950d"},
|
||||
{file = "windows_curses-2.3.2-cp312-cp312-win32.whl", hash = "sha256:4546122f5bec2fb46c1706c020d971bcfc4d9a5158372f25ba7472a834b0f165"},
|
||||
{file = "windows_curses-2.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:e3f27939f39143c513f444a8c0419b2737e8de55fbe5d63d765992512125366d"},
|
||||
{file = "windows_curses-2.3.2-cp36-cp36m-win32.whl", hash = "sha256:a570f744a62108d024a7775b3b156b2ae2380fc971237c3bd2742341e7151f22"},
|
||||
{file = "windows_curses-2.3.2-cp36-cp36m-win_amd64.whl", hash = "sha256:f81294465a67e43ddc50c1b52711b100b002fa1238f87d84d0cf94b785c4fe75"},
|
||||
{file = "windows_curses-2.3.2-cp37-cp37m-win32.whl", hash = "sha256:72ff5d8963fbb3aa662bfced2c5ea22dc3ed58bac827a3bff74a1de5eacdbe57"},
|
||||
{file = "windows_curses-2.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:70d8cb4ddad43f695a266f79c7a31d40ac8aee2a17cf8e06ecfd4a71589ad30d"},
|
||||
{file = "windows_curses-2.3.2-cp38-cp38-win32.whl", hash = "sha256:6bc698058081408685975256f46f570c32f8d7e1f4f82f9d6c66c300c6daff89"},
|
||||
{file = "windows_curses-2.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:f73bfd283f86d3ac0a72b74307ccc99ea0fd008a732d80db95d31fbafeee3c66"},
|
||||
{file = "windows_curses-2.3.2-cp39-cp39-win32.whl", hash = "sha256:e4ec5245f0c00ede45b033a885511eea80d5928c9bd3ceb523fbfb086370a4df"},
|
||||
{file = "windows_curses-2.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:00d5d000b68db38bd97b6a5b90ee4b4c915a7cef7492fc5aa9ecc7794ee2ca93"},
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xdg-base-dirs"
|
||||
version = "6.0.1"
|
||||
description = "Variables defined by the XDG Base Directory Specification"
|
||||
optional = false
|
||||
python-versions = ">=3.10,<4.0"
|
||||
files = [
|
||||
{file = "xdg_base_dirs-6.0.1-py3-none-any.whl", hash = "sha256:63f6ebc1721ced2e86c340856e004ef829501a30a37e17079c52cfaf0e1741b9"},
|
||||
{file = "xdg_base_dirs-6.0.1.tar.gz", hash = "sha256:b4c8f4ba72d1286018b25eea374ec6fbf4fddda3d4137edf50de95de53e195a6"},
|
||||
]
|
||||
|
||||
[metadata]
|
||||
lock-version = "2.0"
|
||||
python-versions = ">=3.10,<4.0"
|
||||
content-hash = "02215828ce6f456df6dadbcc5fedf206bae64c57f7eaedab471370d385470e88"
|
||||
+89
-6
@@ -1,28 +1,111 @@
|
||||
import argparse
|
||||
import sys
|
||||
import typing as T
|
||||
from pathlib import Path
|
||||
|
||||
import xdg
|
||||
from xdg_base_dirs import xdg_config_home
|
||||
|
||||
from pqcli.mechanic import Player
|
||||
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"
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(prog="pqcli")
|
||||
parser.add_argument("--cheats", help="enable cheats", action="store_true")
|
||||
|
||||
group = parser.add_mutually_exclusive_group()
|
||||
group.set_defaults(ui=CursesUserInterface)
|
||||
group.add_argument(
|
||||
"--basic",
|
||||
dest="ui",
|
||||
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(
|
||||
"--no-save",
|
||||
dest="use_saves",
|
||||
action="store_false",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
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",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def list_players(roster: Roster, file: T.Optional[T.Any] = 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) -> 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)
|
||||
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 main() -> None:
|
||||
args = parse_args()
|
||||
roster = Roster.load(SAVE_PATH)
|
||||
|
||||
if args.list_saves:
|
||||
list_players(roster)
|
||||
exit(0)
|
||||
|
||||
player: T.Optional[Player] = 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)
|
||||
|
||||
try:
|
||||
Ui(roster, args).run()
|
||||
ui = args.ui(roster, player, args)
|
||||
ui.run()
|
||||
finally:
|
||||
roster.save()
|
||||
if args.use_saves:
|
||||
roster.save()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+5
-5
@@ -48,7 +48,7 @@ SPELLS = [
|
||||
"Sadness",
|
||||
"Seasick",
|
||||
"Shoelaces",
|
||||
"Innoculate",
|
||||
"Inoculate",
|
||||
"Cone of Annoyance",
|
||||
"Magnetic Orb",
|
||||
"Invisible Hands",
|
||||
@@ -85,7 +85,7 @@ SPELLS = [
|
||||
"Animate Tunic",
|
||||
"Ursine Armor",
|
||||
"Holy Roller",
|
||||
"Tonsilectomy",
|
||||
"Tonsillectomy",
|
||||
"Curse Family",
|
||||
"Infinite Confusion",
|
||||
]
|
||||
@@ -484,9 +484,9 @@ MONSTERS = [
|
||||
Monster("Pentasaurus", 12, "head"),
|
||||
Monster("Stegosaurus", 18, "plate"),
|
||||
Monster("Triceratops", 16, "horn"),
|
||||
Monster("Tyranosauraus Rex", 18, "forearm"),
|
||||
Monster("Tyrannosaurus Rex", 18, "forearm"),
|
||||
Monster("Djinn", 7, "lamp"),
|
||||
Monster("Doppleganger", 4, "face"),
|
||||
Monster("Doppelganger", 4, "face"),
|
||||
Monster("Black Dragon", 7, None),
|
||||
Monster("Plaid Dragon", 7, "sporrin"),
|
||||
Monster("Blue Dragon", 9, None),
|
||||
@@ -555,7 +555,7 @@ MONSTERS = [
|
||||
Monster("Hippocampus", 4, "mane"),
|
||||
Monster("Hippogriff", 3, "egg"),
|
||||
Monster("Hobgoblin", 1, "patella"),
|
||||
Monster("Homonculus", 2, "fluid"),
|
||||
Monster("Homunculus", 2, "fluid"),
|
||||
Monster("Hydra", 8, "gyrum"),
|
||||
Monster("Imp", 2, "tail"),
|
||||
Monster("Invisible Stalker", 8, None),
|
||||
|
||||
@@ -1,8 +1,30 @@
|
||||
import datetime
|
||||
import typing as T
|
||||
|
||||
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:
|
||||
parts = [
|
||||
"br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z".split("|"),
|
||||
@@ -122,3 +144,8 @@ def special(m: int, subject: str) -> str:
|
||||
return prefix(
|
||||
["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}?"
|
||||
|
||||
+129
-68
@@ -1,35 +1,76 @@
|
||||
import datetime
|
||||
import itertools
|
||||
import logging
|
||||
import math
|
||||
import typing as T
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
|
||||
import urwid
|
||||
|
||||
from pqcli import random
|
||||
from pqcli.config import *
|
||||
from pqcli.lingo import *
|
||||
from pqcli.config import (
|
||||
ARMORS,
|
||||
BORING_ITEMS,
|
||||
CLASSES,
|
||||
DEFENSE_ATTRIB,
|
||||
DEFENSE_BAD,
|
||||
IMPRESSIVE_TITLES,
|
||||
ITEM_ATTRIB,
|
||||
ITEM_OFS,
|
||||
MONSTERS,
|
||||
OFFENSE_ATTRIB,
|
||||
OFFENSE_BAD,
|
||||
PRIME_STATS,
|
||||
RACES,
|
||||
SHIELDS,
|
||||
SPECIALS,
|
||||
SPELLS,
|
||||
TITLES,
|
||||
WEAPONS,
|
||||
Class,
|
||||
EquipmentPreset,
|
||||
EquipmentType,
|
||||
Modifier,
|
||||
Monster,
|
||||
Race,
|
||||
StatType,
|
||||
)
|
||||
from pqcli.lingo import (
|
||||
act_name,
|
||||
big,
|
||||
definite,
|
||||
generate_name,
|
||||
indefinite,
|
||||
sick,
|
||||
special,
|
||||
young,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_SIGNALS: T.Dict[
|
||||
T.Tuple["SignalMixin", str], T.List[T.Callable[..., T.Any]]
|
||||
] = defaultdict(list)
|
||||
|
||||
|
||||
class SignalMixin:
|
||||
def __init_subclass__(cls: T.Any, **kwargs: T.Any) -> None:
|
||||
urwid.signals.register_signal(cls, cls.signals)
|
||||
super().__init_subclass__(**kwargs)
|
||||
|
||||
def emit(self, signal_name: str, *user_data: T.Any) -> None:
|
||||
urwid.signals.emit_signal(self, signal_name, *user_data)
|
||||
for callback in _SIGNALS[self, signal_name]:
|
||||
callback(*user_data)
|
||||
|
||||
def connect(self, signal_name: str, callback: T.Callable) -> None:
|
||||
urwid.signals.connect_signal(self, signal_name, callback)
|
||||
def connect(
|
||||
self, signal_name: str, callback: T.Callable[..., T.Any]
|
||||
) -> None:
|
||||
_SIGNALS[self, signal_name].append(callback)
|
||||
|
||||
def __getstate__(self) -> T.Any:
|
||||
return {
|
||||
key: value
|
||||
for key, value in self.__dict__.items()
|
||||
if "signal" not in key
|
||||
}
|
||||
def disconnect(
|
||||
self, signal_name: str, callback: T.Callable[..., T.Any]
|
||||
) -> 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:
|
||||
@@ -46,9 +87,12 @@ class Bar(SignalMixin):
|
||||
self.emit("change")
|
||||
|
||||
def reset(self, new_max: int, position: float = 0.0) -> None:
|
||||
self._max = new_max
|
||||
self._position = position
|
||||
self.emit("change")
|
||||
if not math.isclose(new_max, self._max) or not math.isclose(
|
||||
position, self._position
|
||||
):
|
||||
self._max = new_max
|
||||
self._position = position
|
||||
self.emit("change")
|
||||
|
||||
def increment(self, inc: float) -> None:
|
||||
self.reposition(self._position + inc)
|
||||
@@ -66,8 +110,11 @@ class Bar(SignalMixin):
|
||||
return self._position >= self.max_
|
||||
|
||||
def reposition(self, new_pos: float) -> None:
|
||||
self._position = float(min(new_pos, self._max))
|
||||
self.emit("change")
|
||||
old_pos = self._position
|
||||
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):
|
||||
@@ -86,6 +133,12 @@ class Stats(SignalMixin):
|
||||
def best(self) -> StatType:
|
||||
return max(self, key=lambda kv: kv[1])[0]
|
||||
|
||||
@property
|
||||
def best_prime(self) -> StatType:
|
||||
return max(
|
||||
(kv for kv in self if kv[0] in PRIME_STATS), key=lambda kv: kv[1]
|
||||
)[0]
|
||||
|
||||
def increment(self, stat: StatType, qty: int = 1) -> None:
|
||||
self._values[stat] += qty
|
||||
logger.info("Increased %s to %d", stat.value, self[stat])
|
||||
@@ -93,7 +146,7 @@ class Stats(SignalMixin):
|
||||
|
||||
|
||||
class QuestBook(SignalMixin):
|
||||
signals = ["complete_act", "complete_quest"]
|
||||
signals = ["start_act", "start_quest"]
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._quests: T.List[str] = []
|
||||
@@ -118,7 +171,7 @@ class QuestBook(SignalMixin):
|
||||
|
||||
def increment_act(self) -> None:
|
||||
self._act += 1
|
||||
self.emit("complete_act")
|
||||
self.emit("start_act", self._act)
|
||||
|
||||
def add_quest(self, name: str) -> None:
|
||||
logger.info("Commencing quest: %s", name)
|
||||
@@ -164,7 +217,7 @@ class Inventory(SignalMixin):
|
||||
|
||||
def pop(self, index: int) -> None:
|
||||
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.sync_encumbrance()
|
||||
self.emit("item_del", item)
|
||||
@@ -231,6 +284,12 @@ class SpellBook(SignalMixin):
|
||||
def __iter__(self) -> T.Iterator[Spell]:
|
||||
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:
|
||||
for spell in self._spells:
|
||||
if spell.name == spell_name:
|
||||
@@ -302,6 +361,7 @@ class Player(SignalMixin):
|
||||
self.race: Race = race
|
||||
self.class_: Class = class_
|
||||
self.stats: Stats = stats
|
||||
self.elapsed = 0.0
|
||||
|
||||
self.exp_bar = Bar(max_=level_up_time(1))
|
||||
self.level = 1
|
||||
@@ -311,10 +371,14 @@ class Player(SignalMixin):
|
||||
self.equipment = Equipment()
|
||||
self.spell_book = SpellBook()
|
||||
|
||||
self.task_bar = Bar(max_=0)
|
||||
self.task_bar = Bar(max_=1)
|
||||
self.task: T.Optional[BaseTask] = None
|
||||
self.queue: T.List[BaseTask] = []
|
||||
|
||||
def __setstate__(self, obj: T.Any) -> None:
|
||||
self.__dict__.update(obj)
|
||||
self.elapsed = obj.get("elapsed", 0.0)
|
||||
|
||||
def set_task(self, task: BaseTask) -> None:
|
||||
self.task = task
|
||||
self.task_bar.reset(task.duration)
|
||||
@@ -322,7 +386,7 @@ class Player(SignalMixin):
|
||||
self.emit("new_task")
|
||||
|
||||
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:
|
||||
self.level += 1
|
||||
@@ -348,12 +412,12 @@ class Player(SignalMixin):
|
||||
chosen_stat = random.choice(list(StatType))
|
||||
else:
|
||||
# favor the best stat so it will tend to clump
|
||||
t = sum(value ** 2 for _stat, value in self.stats)
|
||||
t = sum(value**2 for _stat, value in self.stats)
|
||||
t = random.below(t)
|
||||
chosen_stat = None
|
||||
for stat, value in self.stats:
|
||||
chosen_stat = stat
|
||||
t -= value ** 2
|
||||
t -= value**2
|
||||
if t < 0:
|
||||
break
|
||||
|
||||
@@ -423,38 +487,38 @@ class Simulation:
|
||||
def __init__(self, player: Player) -> None:
|
||||
self.player = player
|
||||
self.last_tick = datetime.datetime.now()
|
||||
self.elapsed = 0.0
|
||||
|
||||
def tick(self, elapsed: float = 100.0) -> None:
|
||||
self.player.elapsed += elapsed
|
||||
|
||||
if self.player.task is None:
|
||||
self.player.set_task(RegularTask("Loading", 2_000))
|
||||
self.player.set_task(RegularTask("Loading", 2000))
|
||||
self.player.queue += [
|
||||
RegularTask(
|
||||
"Experiencing an enigmatic and foreboding night vision",
|
||||
10_000,
|
||||
10000,
|
||||
),
|
||||
RegularTask(
|
||||
"Much is revealed about that wise old bastard "
|
||||
"you'd underestimated",
|
||||
6_000,
|
||||
6000,
|
||||
),
|
||||
RegularTask(
|
||||
"A shocking series of events leaves you "
|
||||
"alone and bewildered, but resolute",
|
||||
6_000,
|
||||
6000,
|
||||
),
|
||||
RegularTask(
|
||||
"Drawing upon an unrealized reserve of determination, "
|
||||
"you set out on a long and dangerous journey",
|
||||
4_000,
|
||||
4000,
|
||||
),
|
||||
PlotTask(f"Loading {act_name(1)}", 2_000),
|
||||
PlotTask(f"Loading {act_name(1)}", 2000),
|
||||
]
|
||||
self.player.quest_book.plot_bar.reset(28)
|
||||
return
|
||||
|
||||
if not self.player.task_bar.done:
|
||||
self.elapsed += elapsed
|
||||
self.player.task_bar.increment(elapsed)
|
||||
return
|
||||
|
||||
@@ -528,7 +592,7 @@ class Simulation:
|
||||
self.player.set_task(
|
||||
SellTask(
|
||||
"Selling " + indefinite(item.name, item.quantity),
|
||||
1_000,
|
||||
1000,
|
||||
)
|
||||
)
|
||||
break
|
||||
@@ -541,21 +605,19 @@ class Simulation:
|
||||
self.player.set_task(self.player.queue.pop(0))
|
||||
elif self.player.inventory.encum_bar.done:
|
||||
self.player.set_task(
|
||||
HeadingToMarketTask(
|
||||
"Heading to market to sell loot", 4_000
|
||||
)
|
||||
HeadingToMarketTask("Heading to market to sell loot", 4000)
|
||||
)
|
||||
elif not isinstance(old, (KillTask, HeadingToKillingFieldsTask)):
|
||||
if self.player.inventory.gold > self.player.equip_price():
|
||||
self.player.set_task(
|
||||
BuyTask(
|
||||
"Negotiating purchase of better equipment", 5_000
|
||||
"Negotiating purchase of better equipment", 5000
|
||||
)
|
||||
)
|
||||
else:
|
||||
self.player.set_task(
|
||||
HeadingToKillingFieldsTask(
|
||||
"Heading to the killing fields", 4_000
|
||||
"Heading to the killing fields", 4000
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -612,7 +674,7 @@ class Simulation:
|
||||
raise AssertionError
|
||||
|
||||
self.player.quest_book.add_quest(caption)
|
||||
self.player.quest_book.emit("complete_quest")
|
||||
self.player.quest_book.emit("start_quest", caption)
|
||||
|
||||
def interplot_cinematic(self) -> None:
|
||||
def enqueue(task: BaseTask) -> None:
|
||||
@@ -625,19 +687,19 @@ class Simulation:
|
||||
RegularTask(
|
||||
"Exhausted, you arrive at a friendly oasis "
|
||||
"in a hostile land",
|
||||
1_000,
|
||||
1000,
|
||||
)
|
||||
)
|
||||
enqueue(
|
||||
RegularTask("You greet old friends and meet new allies", 2_000)
|
||||
RegularTask("You greet old friends and meet new allies", 2000)
|
||||
)
|
||||
enqueue(
|
||||
RegularTask(
|
||||
"You are privy to a council of powerful do-gooders", 2_000
|
||||
"You are privy to a council of powerful do-gooders", 2000
|
||||
)
|
||||
)
|
||||
enqueue(
|
||||
RegularTask("There is much to be done. You are chosen!", 1_000)
|
||||
RegularTask("There is much to be done. You are chosen!", 1000)
|
||||
)
|
||||
|
||||
elif choice == 1:
|
||||
@@ -645,7 +707,7 @@ class Simulation:
|
||||
RegularTask(
|
||||
"Your quarry is in sight, "
|
||||
"but a mighty enemy bars your path!",
|
||||
1_000,
|
||||
1000,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -653,7 +715,7 @@ class Simulation:
|
||||
|
||||
enqueue(
|
||||
RegularTask(
|
||||
f"A desperate struggle commences with {nemesis}", 4_000
|
||||
f"A desperate struggle commences with {nemesis}", 4000
|
||||
)
|
||||
)
|
||||
|
||||
@@ -665,20 +727,20 @@ class Simulation:
|
||||
if s % 3 == 0:
|
||||
enqueue(
|
||||
RegularTask(
|
||||
f"Locked in grim combat with {nemesis}", 2_000
|
||||
f"Locked in grim combat with {nemesis}", 2000
|
||||
)
|
||||
)
|
||||
elif s % 3 == 1:
|
||||
enqueue(
|
||||
RegularTask(
|
||||
f"{nemesis} seems to have the upper hand", 2_000
|
||||
f"{nemesis} seems to have the upper hand", 2000
|
||||
)
|
||||
)
|
||||
elif s % 3 == 2:
|
||||
enqueue(
|
||||
RegularTask(
|
||||
f"You seem to gain the advantage over {nemesis}",
|
||||
2_000,
|
||||
2000,
|
||||
)
|
||||
)
|
||||
else:
|
||||
@@ -687,13 +749,13 @@ class Simulation:
|
||||
enqueue(
|
||||
RegularTask(
|
||||
f"Victory! {nemesis} is slain! "
|
||||
"Exhausted, you lose conciousness",
|
||||
3_000,
|
||||
"Exhausted, you lose consciousness",
|
||||
3000,
|
||||
)
|
||||
)
|
||||
enqueue(
|
||||
RegularTask(
|
||||
"You awake in a friendly place, but the road awaits", 2_000
|
||||
"You awake in a friendly place, but the road awaits", 2000
|
||||
)
|
||||
)
|
||||
|
||||
@@ -703,37 +765,35 @@ class Simulation:
|
||||
RegularTask(
|
||||
"Oh sweet relief! "
|
||||
f"You've reached the protection of the good {nemesis}",
|
||||
2_000,
|
||||
2000,
|
||||
)
|
||||
)
|
||||
enqueue(
|
||||
RegularTask(
|
||||
"There is rejoicing, "
|
||||
f"and an unnerving encouter with {nemesis} in private",
|
||||
3_000,
|
||||
f"and an unnerving encounter with {nemesis} in private",
|
||||
3000,
|
||||
)
|
||||
)
|
||||
enqueue(
|
||||
RegularTask(
|
||||
f"You forget your {boring_item()} and go back to get it",
|
||||
2_000,
|
||||
2000,
|
||||
)
|
||||
)
|
||||
enqueue(
|
||||
RegularTask(
|
||||
"What's this!? You overhear something shocking!", 2_000
|
||||
"What's this!? You overhear something shocking!", 2000
|
||||
)
|
||||
)
|
||||
enqueue(
|
||||
RegularTask(
|
||||
f"Could {nemesis} be a dirty double-dealer?", 2_000
|
||||
)
|
||||
RegularTask(f"Could {nemesis} be a dirty double-dealer?", 2000)
|
||||
)
|
||||
enqueue(
|
||||
RegularTask(
|
||||
"Who can possibly be trusted with this news!? ... "
|
||||
"Oh yes, of course",
|
||||
3_000,
|
||||
3000,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -742,7 +802,7 @@ class Simulation:
|
||||
|
||||
enqueue(
|
||||
PlotTask(
|
||||
f"Loading {act_name(self.player.quest_book.act + 1)}", 1_000
|
||||
f"Loading {act_name(self.player.quest_book.act + 1)}", 1000
|
||||
)
|
||||
)
|
||||
|
||||
@@ -892,7 +952,8 @@ class StatsBuilder:
|
||||
return stats
|
||||
|
||||
def unroll(self) -> Stats:
|
||||
self.history.pop()
|
||||
if len(self.history) > 1:
|
||||
self.history.pop()
|
||||
return self.history[-1]
|
||||
|
||||
|
||||
@@ -900,7 +961,7 @@ def create_player(
|
||||
name: str, race: Race, class_: Class, stats: Stats
|
||||
) -> Player:
|
||||
now = datetime.datetime.now()
|
||||
random.seed(now)
|
||||
random.seed(str(now))
|
||||
player = Player(
|
||||
birthday=now, name=name, race=race, class_=class_, stats=stats
|
||||
)
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@ import random
|
||||
import typing as T
|
||||
|
||||
|
||||
def seed(source: T.Any) -> None:
|
||||
random.seed(source)
|
||||
def seed(source: str) -> None:
|
||||
random.seed(source.encode())
|
||||
|
||||
|
||||
def choice(source: T.Sequence[T.Any]) -> T.Any:
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import logging
|
||||
import pickle
|
||||
import typing as T
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pqcli.mechanic import Player
|
||||
@@ -9,6 +11,7 @@ class Roster:
|
||||
def __init__(self, path: Path, players: T.List[Player]) -> None:
|
||||
self.path = path
|
||||
self.players = players
|
||||
self._last_save = datetime.now()
|
||||
|
||||
@staticmethod
|
||||
def load(path: T.Union[str, Path]) -> "Roster":
|
||||
@@ -20,10 +23,18 @@ class Roster:
|
||||
return Roster(real_path, players=[])
|
||||
|
||||
def save(self) -> None:
|
||||
self._last_save = datetime.now()
|
||||
old_path = self.path.with_name(self.path.name + ".old")
|
||||
tmp_path = self.path.with_name(self.path.name + ".new")
|
||||
tmp_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path.write_bytes(pickle.dumps(self.players))
|
||||
if self.path.exists():
|
||||
if old_path.exists():
|
||||
old_path.unlink()
|
||||
self.path.rename(old_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 = 1664525
|
||||
SEED_B = 2141
|
||||
SEED_C = 1013904223
|
||||
|
||||
|
||||
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,142 +0,0 @@
|
||||
import argparse
|
||||
import typing as T
|
||||
|
||||
import urwid
|
||||
|
||||
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", ""),
|
||||
]
|
||||
|
||||
|
||||
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}"
|
||||
urwid.command_map["tab"] = f"cursor down"
|
||||
urwid.command_map["shift tab"] = f"cursor up"
|
||||
|
||||
|
||||
class Ui:
|
||||
def __init__(self, roster: Roster, args: argparse.Namespace) -> None:
|
||||
bind_commands()
|
||||
|
||||
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:
|
||||
self.roster.save()
|
||||
self.loop.set_alarm_in(SAVE_FREQ, self.on_save)
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import abc
|
||||
import argparse
|
||||
import typing as T
|
||||
|
||||
from pqcli.mechanic import Player
|
||||
from pqcli.roster import Roster
|
||||
|
||||
|
||||
class BaseUserInterface(abc.ABC):
|
||||
def __init__(
|
||||
self,
|
||||
roster: Roster,
|
||||
player: T.Optional[Player],
|
||||
args: argparse.Namespace,
|
||||
) -> None:
|
||||
self.roster = roster
|
||||
self.args = args
|
||||
self.player = player
|
||||
|
||||
@abc.abstractmethod
|
||||
def run(self) -> None:
|
||||
raise NotImplementedError("not implemented")
|
||||
@@ -0,0 +1,231 @@
|
||||
import argparse
|
||||
import enum
|
||||
import logging
|
||||
import signal
|
||||
import sys
|
||||
import time
|
||||
import typing as T
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from pqcli import lingo
|
||||
from pqcli.config import CLASSES, PRIME_STATS, RACES
|
||||
from pqcli.mechanic import Player, Simulation, StatsBuilder, create_player
|
||||
from pqcli.roster import Roster
|
||||
from pqcli.ui.base import BaseUserInterface
|
||||
|
||||
LOGO = r"""
|
||||
____ ___ _
|
||||
| _ \ _ __ ___ __ _ _ __ ___ ___ ___ / _ \ _ _ ___ ___| |_
|
||||
| |_) | '__/ _ \ / _` | '__/ _ \/ __/ __| | | | | | |/ _ \/ __| __|
|
||||
| __/| | | (_) | (_| | | | __/\__ \__ \ |_| | |_| | __/\__ \ |_
|
||||
|_| |_| \___/ \__, |_| \___||___/___/\__\_\\__,_|\___||___/\__|
|
||||
|___/
|
||||
"""
|
||||
|
||||
|
||||
class MainMenu(enum.IntEnum):
|
||||
create = 1
|
||||
play = 2
|
||||
info = 3
|
||||
delete = 4
|
||||
quit = 5
|
||||
|
||||
|
||||
class BasicUserInterface(BaseUserInterface):
|
||||
def __init__(
|
||||
self,
|
||||
roster: Roster,
|
||||
player: T.Optional[Player],
|
||||
args: argparse.Namespace,
|
||||
) -> None:
|
||||
super().__init__(roster, player, args)
|
||||
|
||||
def signal_handler(sig: T.Any, frame: T.Any) -> 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.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.quit:
|
||||
self.quit()
|
||||
break
|
||||
|
||||
def create_player(self, *, auto_play: bool = True) -> T.Optional[Player]:
|
||||
name = input("Name your new character: ")
|
||||
if not name:
|
||||
print("Cancelled.")
|
||||
return None
|
||||
|
||||
race = self.menu([(race, race.name) for race in RACES])
|
||||
class_ = self.menu([(class_, class_.name) for class_ in 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
|
||||
)
|
||||
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)
|
||||
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 choose_player(self) -> T.Optional[Player]:
|
||||
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: T.Sequence[T.Tuple[T.Any, str]],
|
||||
title: T.Optional[str] = None,
|
||||
) -> T.Any:
|
||||
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,44 +0,0 @@
|
||||
import typing as T
|
||||
|
||||
import urwid
|
||||
|
||||
from pqcli.ui.custom_button import CustomButton
|
||||
from pqcli.ui.custom_line_box import CustomLineBox
|
||||
|
||||
|
||||
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.ListBox(
|
||||
urwid.SimpleFocusListWalker([question, no_btn, yes_btn])
|
||||
)
|
||||
)
|
||||
|
||||
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")
|
||||
@@ -0,0 +1,203 @@
|
||||
import argparse
|
||||
import curses
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
import typing as T
|
||||
|
||||
from pqcli import lingo
|
||||
from pqcli.config import Class, Race
|
||||
from pqcli.mechanic import Player, Stats, create_player
|
||||
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,
|
||||
RosterView,
|
||||
)
|
||||
|
||||
|
||||
class StopMainLoop(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class CursesUserInterface(BaseUserInterface):
|
||||
def __init__(
|
||||
self, roster: Roster, player: Player, args: argparse.Namespace
|
||||
) -> None:
|
||||
super().__init__(roster, player, args)
|
||||
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: T.Any, frame: T.Any) -> 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_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 = T.cast(int, 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: T.Optional[str] = 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: T.Optional[Race] = None
|
||||
) -> None:
|
||||
view = ChooseCharacterRaceView(self._screen, 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_: T.Optional[Class] = None
|
||||
) -> None:
|
||||
view = ChooseCharacterClassView(self._screen, 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: self._create_character(
|
||||
player_name, race, class_, stats
|
||||
)
|
||||
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_game_view(self, player: Player) -> None:
|
||||
view = GameView(self._screen, self.roster, player, self.args)
|
||||
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
|
||||
) -> None:
|
||||
player = create_player(name, race, class_, stats)
|
||||
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,14 @@
|
||||
import typing as T
|
||||
|
||||
|
||||
class EventHandler:
|
||||
def __init__(self) -> None:
|
||||
self.callbacks: T.List[T.Callable[..., T.Any]] = []
|
||||
|
||||
def __call__(self, *args: T.Any, **kwargs: T.Any) -> None:
|
||||
for callback in self.callbacks:
|
||||
callback(*args, **kwargs)
|
||||
|
||||
def __iadd__(self, callback: T.Callable[..., T.Any]) -> "EventHandler":
|
||||
self.callbacks.append(callback)
|
||||
return self
|
||||
@@ -0,0 +1,22 @@
|
||||
import curses
|
||||
import curses.ascii
|
||||
import typing as T
|
||||
from dataclasses import dataclass
|
||||
|
||||
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: T.List[int]
|
||||
desc: str
|
||||
callback: T.Callable[..., T.Any]
|
||||
|
||||
|
||||
def first(source: T.Iterable[T.Any], default: T.Any = None) -> T.Any:
|
||||
return next(iter(source), default)
|
||||
@@ -0,0 +1,23 @@
|
||||
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 .roster_view import RosterView
|
||||
|
||||
__all__ = [
|
||||
"BaseView",
|
||||
"ChooseCharacterView",
|
||||
"ConfirmView",
|
||||
"ChooseCharacterClassView",
|
||||
"ChooseCharacterNameView",
|
||||
"ChooseCharacterRaceView",
|
||||
"ChooseCharacterStatsView",
|
||||
"GameView",
|
||||
"RosterView",
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
import typing as T
|
||||
|
||||
|
||||
class BaseView:
|
||||
def __init__(self, screen: T.Any) -> 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,53 @@
|
||||
import functools
|
||||
import typing as T
|
||||
|
||||
from pqcli.lingo import act_name, to_roman
|
||||
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):
|
||||
def __init__(self, screen: T.Any, roster: Roster, title: str) -> None:
|
||||
super().__init__(screen, title)
|
||||
|
||||
for y, player in enumerate(roster.players, 1):
|
||||
key: T.Optional[str] = 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 typing as T
|
||||
|
||||
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: T.Any, title: str) -> None:
|
||||
super().__init__(screen)
|
||||
|
||||
self.title = f"{title}:"
|
||||
self.on_cancel = EventHandler()
|
||||
self.on_confirm = EventHandler()
|
||||
|
||||
self.choices: T.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,35 @@
|
||||
import functools
|
||||
import typing as T
|
||||
|
||||
from pqcli.config import CLASSES, Class
|
||||
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
|
||||
from pqcli.ui.curses.views.menu_view import MenuView
|
||||
|
||||
|
||||
class ChooseCharacterClassView(MenuView):
|
||||
def __init__(
|
||||
self, screen: T.Any, class_: T.Optional[Class] = 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: T.Optional[str] = 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,120 @@
|
||||
import curses
|
||||
import curses.ascii
|
||||
import typing as T
|
||||
|
||||
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: T.Any, character_name: T.Optional[str] = None
|
||||
) -> None:
|
||||
super().__init__(screen)
|
||||
|
||||
self.on_cancel = EventHandler()
|
||||
self.on_confirm = EventHandler()
|
||||
|
||||
self._active_widget = 0
|
||||
self._win: T.Optional[T.Any] = None
|
||||
self._text_win: T.Optional[T.Any] = 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,33 @@
|
||||
import functools
|
||||
import typing as T
|
||||
|
||||
from pqcli.config import RACES, Race
|
||||
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
|
||||
from pqcli.ui.curses.views.menu_view import MenuView
|
||||
|
||||
|
||||
class ChooseCharacterRaceView(MenuView):
|
||||
def __init__(self, screen: T.Any, race: T.Optional[Race] = 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: T.Optional[str] = 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,111 @@
|
||||
import curses.ascii
|
||||
import typing as T
|
||||
|
||||
from pqcli.config import PRIME_STATS
|
||||
from pqcli.mechanic import StatsBuilder
|
||||
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: T.Any) -> None:
|
||||
super().__init__(screen)
|
||||
|
||||
self.on_cancel = EventHandler()
|
||||
self.on_confirm = EventHandler()
|
||||
|
||||
self._active_widget = 0
|
||||
self._win: T.Optional[T.Any] = None
|
||||
self._stats_win: T.Optional[T.Any] = 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)
|
||||
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)
|
||||
|
||||
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,263 @@
|
||||
import argparse
|
||||
import curses
|
||||
import datetime
|
||||
import typing as T
|
||||
|
||||
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, Widget
|
||||
|
||||
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 GameView(BaseView):
|
||||
def __init__(
|
||||
self,
|
||||
screen: T.Any,
|
||||
roster: Roster,
|
||||
player: Player,
|
||||
args: argparse.Namespace,
|
||||
) -> None:
|
||||
super().__init__(screen)
|
||||
self.on_exit = EventHandler()
|
||||
|
||||
self._roster = roster
|
||||
self._player = player
|
||||
self._args = args
|
||||
|
||||
self._simulation = Simulation(player)
|
||||
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 hasattr(focused, "scroll_page_up"):
|
||||
focused.scroll_page_up()
|
||||
|
||||
elif key in {curses.KEY_NPAGE}:
|
||||
if hasattr(focused, "scroll_page_down"):
|
||||
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
|
||||
old = SignalMixin.emit
|
||||
SignalMixin.emit = lambda *_: None
|
||||
for _ in range(10000):
|
||||
self._simulation.tick()
|
||||
SignalMixin.emit = old
|
||||
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,
|
||||
)
|
||||
|
||||
self._focusable_children: T.List[Focusable] = [
|
||||
self._char_sheet_win,
|
||||
self._spell_book_win,
|
||||
self._equipment_win,
|
||||
self._inventory_win,
|
||||
self._plot_win,
|
||||
self._quest_book_win,
|
||||
]
|
||||
self._children: T.List[Widget] = self._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,66 @@
|
||||
import typing as T
|
||||
|
||||
from pqcli.mechanic import Player, StatType
|
||||
from pqcli.ui.curses.widgets import Focusable
|
||||
|
||||
from .progress_bar_window import DataTableProgressBarWindow
|
||||
|
||||
|
||||
class CharacterSheetWindow(Focusable, DataTableProgressBarWindow):
|
||||
def __init__(
|
||||
self, player: Player, parent: T.Any, 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,59 @@
|
||||
import typing as T
|
||||
|
||||
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: T.Any, 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: T.Optional[str]
|
||||
) -> 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,106 @@
|
||||
import typing as T
|
||||
|
||||
from pqcli.mechanic import InventoryItem, Player
|
||||
from pqcli.ui.curses.widgets import Focusable
|
||||
|
||||
from .progress_bar_window import DataTableProgressBarWindow
|
||||
|
||||
|
||||
class InventoryWindow(Focusable, DataTableProgressBarWindow):
|
||||
def __init__(
|
||||
self, player: Player, parent: T.Any, 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,69 @@
|
||||
import typing as T
|
||||
|
||||
from pqcli.lingo import act_name
|
||||
from pqcli.mechanic import Player
|
||||
from pqcli.ui.curses.widgets import Focusable
|
||||
|
||||
from .progress_bar_window import ListBoxProgressBarWindow
|
||||
|
||||
|
||||
class PlotWindow(Focusable, ListBoxProgressBarWindow):
|
||||
cutoff = 100
|
||||
|
||||
def __init__(
|
||||
self, player: Player, parent: T.Any, 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,158 @@
|
||||
import curses
|
||||
import typing as T
|
||||
|
||||
from pqcli.ui.curses.widgets import (
|
||||
DataTable,
|
||||
Focusable,
|
||||
ListBox,
|
||||
ProgressBar,
|
||||
WindowWrapper,
|
||||
)
|
||||
|
||||
|
||||
class ProgressBarWindow(WindowWrapper):
|
||||
def __init__(
|
||||
self,
|
||||
parent: T.Any,
|
||||
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 Focusable.focus_standout(self, 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: T.Any,
|
||||
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 Focusable.focus_standout(self, 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: T.Any,
|
||||
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 Focusable.focus_standout(self, 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,66 @@
|
||||
import typing as T
|
||||
|
||||
from pqcli.mechanic import Player
|
||||
from pqcli.ui.curses.widgets import Focusable
|
||||
|
||||
from .progress_bar_window import ListBoxProgressBarWindow
|
||||
|
||||
|
||||
class QuestBookWindow(Focusable, ListBoxProgressBarWindow):
|
||||
cutoff = 100
|
||||
|
||||
def __init__(
|
||||
self, player: Player, parent: T.Any, 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,68 @@
|
||||
import typing as T
|
||||
|
||||
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: T.Any, 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,50 @@
|
||||
import typing as T
|
||||
|
||||
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: T.Any, 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,41 @@
|
||||
import typing as T
|
||||
|
||||
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
|
||||
|
||||
|
||||
class MenuView(BaseView):
|
||||
def __init__(
|
||||
self, screen: T.Any, title: str, active_choice: int = 0
|
||||
) -> None:
|
||||
super().__init__(screen)
|
||||
|
||||
self.on_cancel = EventHandler()
|
||||
self.on_confirm = EventHandler()
|
||||
|
||||
self._title = f"{title}:"
|
||||
self._choices: T.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,99 @@
|
||||
import curses
|
||||
import typing as T
|
||||
|
||||
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: T.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,
|
||||
)
|
||||
if has_colors():
|
||||
for y in range(len(self._header_lines)):
|
||||
self._pad.chgat(y, 0, curses.color_pair(COLOR_LOGO))
|
||||
self._pad.chgat(3, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
|
||||
self._pad.chgat(4, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
|
||||
self._pad.chgat(5, 32, 4, curses.color_pair(COLOR_LOGO_ALT))
|
||||
self._pad.chgat(5, 27, 4, curses.color_pair(COLOR_LOGO_ALT))
|
||||
self._pad.chgat(6, 34, 2, curses.color_pair(COLOR_LOGO_ALT))
|
||||
self._pad.chgat(6, 27, 2, curses.color_pair(COLOR_LOGO_ALT))
|
||||
|
||||
|
||||
class RosterView(BaseView):
|
||||
def __init__(self, screen: T.Any) -> None:
|
||||
super().__init__(screen)
|
||||
|
||||
self.on_create = EventHandler()
|
||||
self.on_play = EventHandler()
|
||||
self.on_delete = EventHandler()
|
||||
self.on_quit = EventHandler()
|
||||
|
||||
self.choices: T.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(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,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,41 @@
|
||||
import curses
|
||||
import typing as T
|
||||
|
||||
|
||||
class Widget:
|
||||
def __del__(self) -> None:
|
||||
self.stop()
|
||||
|
||||
def stop(self) -> None:
|
||||
pass
|
||||
|
||||
|
||||
class WindowWrapper(Widget):
|
||||
def __init__(
|
||||
self, parent: T.Optional[T.Any], h: int, w: int, y: int, x: int
|
||||
) -> None:
|
||||
self._parent = parent
|
||||
self._win: T.Optional[T.Any]
|
||||
|
||||
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) -> T.Tuple[int, int]:
|
||||
if not self._win:
|
||||
return (0, 0)
|
||||
return self._win.getbegyx()
|
||||
|
||||
def getmaxyx(self) -> T.Tuple[int, int]:
|
||||
if not self._win:
|
||||
return (0, 0)
|
||||
return self._win.getmaxyx()
|
||||
@@ -0,0 +1,81 @@
|
||||
import curses
|
||||
import typing as T
|
||||
|
||||
from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors
|
||||
|
||||
from .scrollable import Scrollable
|
||||
|
||||
|
||||
class DataTable(Scrollable):
|
||||
def __init__(
|
||||
self,
|
||||
parent: T.Any,
|
||||
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._items: T.List[T.Tuple[str, str]]
|
||||
self._selected: T.Optional[int] = 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: T.Optional[str]) -> None:
|
||||
self._selected = None if text is None else self.get_idx(text)
|
||||
|
||||
def get_idx(self, text: str) -> T.Optional[int]:
|
||||
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,42 @@
|
||||
import contextlib
|
||||
import curses
|
||||
import typing as T
|
||||
|
||||
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: T.Any) -> T.Generator:
|
||||
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: T.Any, **kwargs: T.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: T.Any) -> T.Generator:
|
||||
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,50 @@
|
||||
import curses
|
||||
import typing as T
|
||||
|
||||
from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors
|
||||
|
||||
from .scrollable import Scrollable
|
||||
|
||||
|
||||
class ListBox(Scrollable):
|
||||
_items: T.List[str]
|
||||
|
||||
def __init__(self, parent: T.Any, h: int, w: int, y: int, x: int) -> None:
|
||||
super().__init__(parent, h, w, y, x)
|
||||
self._selected: T.Optional[int] = 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) -> T.Optional[str]:
|
||||
if not self._items:
|
||||
return None
|
||||
if idx < 0:
|
||||
idx %= len(self._items)
|
||||
try:
|
||||
return self._items[idx]
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
def delete(self, idx: int, count: int = 1) -> None:
|
||||
del self._items[idx : idx + count]
|
||||
|
||||
def select(self, idx: T.Optional[int]) -> None:
|
||||
if idx < 0 and len(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
|
||||
import typing as T
|
||||
|
||||
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: T.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
|
||||
self._pad: T.Optional[T.Any] = curses.newpad(h, w)
|
||||
|
||||
for y, line in enumerate(self._header_lines):
|
||||
self._pad.move(y, 0)
|
||||
self._pad.addstr(line)
|
||||
|
||||
self._active_choice = active_choice
|
||||
|
||||
def stop(self) -> None:
|
||||
del self._pad
|
||||
self._pad = None
|
||||
|
||||
def getmaxyx(self) -> T.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,65 @@
|
||||
import curses
|
||||
import datetime
|
||||
import typing as T
|
||||
|
||||
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: T.Any, 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: T.Optional[T.Tuple[datetime.datetime, float]] = None
|
||||
|
||||
@property
|
||||
def time_left(self) -> T.Optional[datetime.timedelta]:
|
||||
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,116 @@
|
||||
import curses
|
||||
import typing as T
|
||||
|
||||
from pqcli.ui.curses.colors import (
|
||||
COLOR_SCROLLBAR_THUMB,
|
||||
COLOR_SCROLLBAR_TRACK,
|
||||
has_colors,
|
||||
)
|
||||
|
||||
from .base import WindowWrapper
|
||||
|
||||
|
||||
class Scrollable(WindowWrapper):
|
||||
def __init__(self, parent: T.Any, h: int, w: int, y: int, x: int) -> None:
|
||||
super().__init__(parent, h, w, y, x)
|
||||
self._scroll_pos = 0
|
||||
self._pad: T.Optional[T.Any] = curses.newpad(1, 1)
|
||||
self._items: T.List[T.Any] = []
|
||||
|
||||
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(
|
||||
[
|
||||
("pack", urwid.Text("< ")),
|
||||
urwid.SelectableIcon([label], _MAGIC),
|
||||
(
|
||||
"pack",
|
||||
urwid.Text(f"[{hint}]" if hint else "", align="right"),
|
||||
),
|
||||
("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,14 +0,0 @@
|
||||
import typing as T
|
||||
|
||||
import urwid
|
||||
|
||||
|
||||
class CustomListWalker(urwid.SimpleFocusListWalker):
|
||||
def append(self, item: T.Any) -> None:
|
||||
super().append(item)
|
||||
self.set_focus(len(self) - 1)
|
||||
|
||||
|
||||
class CustomListBox(urwid.ListBox):
|
||||
def __init__(self, widgets: T.List[urwid.Widget]) -> None:
|
||||
super().__init__(CustomListWalker(widgets))
|
||||
@@ -1,68 +0,0 @@
|
||||
import typing as T
|
||||
|
||||
import urwid
|
||||
|
||||
# workaround for https://github.com/urwid/urwid/issues/317
|
||||
|
||||
|
||||
class CustomProgressBar(urwid.Widget):
|
||||
_sizing = frozenset(["flow"])
|
||||
text_align = "center"
|
||||
eighths = u" ▏▎▍▌▋▊▉"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.position = 0.0
|
||||
self.max_ = 1.0
|
||||
self.normal = "progressbar-normal"
|
||||
self.complete = "progressbar-done"
|
||||
self.smooth = "progressbar-smooth"
|
||||
|
||||
def set_completion(self, value: float) -> None:
|
||||
self.position = value
|
||||
self._invalidate()
|
||||
|
||||
def set_max(self, value: T.Union[int, float]) -> None:
|
||||
self.max_ = value
|
||||
self._invalidate()
|
||||
|
||||
def rows(self, size: T.Tuple[int, int], focus: bool = False) -> int:
|
||||
return 1
|
||||
|
||||
def get_text(self) -> str:
|
||||
percent = min(100, max(0, int(self.position * 100 / self.max_)))
|
||||
return str(percent) + " %"
|
||||
|
||||
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,32 +0,0 @@
|
||||
import urwid
|
||||
|
||||
|
||||
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__(
|
||||
urwid.Pile([self.top_line_box, ("pack", self.bottom_line_box)]),
|
||||
"linebox",
|
||||
"linebox-focus",
|
||||
)
|
||||
|
||||
def set_bottom_title(self, text: str) -> None:
|
||||
self.bottom_line_box.set_title(text)
|
||||
@@ -1,463 +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_list_box import CustomListBox
|
||||
from pqcli.ui.custom_progress_bar import CustomProgressBar
|
||||
from pqcli.ui.double_line_box import DoubleLineBox
|
||||
from pqcli.ui.read_only_check_box import ReadOnlyCheckBox
|
||||
|
||||
|
||||
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()
|
||||
|
||||
self.list_box = CustomListBox(
|
||||
[
|
||||
urwid.Columns([urwid.Text("Name"), urwid.Text(player.name)]),
|
||||
urwid.Columns(
|
||||
[urwid.Text("Race"), urwid.Text(player.race.name)]
|
||||
),
|
||||
urwid.Columns(
|
||||
[urwid.Text("Class"), urwid.Text(player.class_.name)]
|
||||
),
|
||||
urwid.Columns([urwid.Text("Level"), self.level_text]),
|
||||
urwid.Divider(),
|
||||
]
|
||||
+ [
|
||||
urwid.Columns([urwid.Text(stat.value), self.stat_texts[stat]])
|
||||
for stat in StatType
|
||||
]
|
||||
)
|
||||
|
||||
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=self.list_box,
|
||||
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.set_completion(cur)
|
||||
self.exp_bar.set_max(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.list_box = CustomListBox([])
|
||||
|
||||
self.player.spell_book.connect("add", self.sync_spell_add)
|
||||
self.player.spell_book.connect("change", self.sync_spell_change)
|
||||
|
||||
super().__init__(self.list_box, title="Spell Book")
|
||||
self.sync()
|
||||
|
||||
def sync(self) -> None:
|
||||
del self.list_box.body[:]
|
||||
for spell in self.player.spell_book:
|
||||
self.sync_spell_add(spell)
|
||||
|
||||
def sync_spell_add(self, spell: Spell) -> None:
|
||||
self.list_box.body.append(
|
||||
urwid.Columns(
|
||||
[
|
||||
("weight", 3, urwid.Text(spell.name)),
|
||||
urwid.Text(to_roman(spell.level)),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def sync_spell_change(self, spell: Spell) -> None:
|
||||
for widget in self.list_box.body:
|
||||
if widget.contents[0][0].text == spell.name:
|
||||
widget.contents[1][0].set_text(to_roman(spell.level))
|
||||
|
||||
|
||||
class EquipmentView(CustomLineBox):
|
||||
def __init__(self, player: Player) -> None:
|
||||
self.player = player
|
||||
|
||||
self.value_texts = {
|
||||
equipment_type: urwid.Text("") for equipment_type in EquipmentType
|
||||
}
|
||||
self.list_box = CustomListBox(
|
||||
[
|
||||
urwid.Columns(
|
||||
[
|
||||
urwid.Text(equipment_type.value),
|
||||
("weight", 3, self.value_texts[equipment_type]),
|
||||
]
|
||||
)
|
||||
for equipment_type in EquipmentType
|
||||
]
|
||||
)
|
||||
|
||||
self.player.equipment.connect("change", self.sync_equipment_change)
|
||||
|
||||
super().__init__(self.list_box, 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("")
|
||||
self.list_box = CustomListBox(
|
||||
[
|
||||
urwid.Columns(
|
||||
[("weight", 3, urwid.Text("Gold")), self.gold_text]
|
||||
)
|
||||
]
|
||||
)
|
||||
self.encumbrance_bar = CustomProgressBar()
|
||||
|
||||
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=self.list_box,
|
||||
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:
|
||||
del self.list_box.body[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.set_completion(cur)
|
||||
self.encumbrance_bar.set_max(max_)
|
||||
self.set_bottom_title(f"Encumbrance ({cur:.0f}/{max_} cubits)")
|
||||
|
||||
def sync_item_add(self, item: InventoryItem) -> None:
|
||||
self.list_box.body.append(
|
||||
urwid.Columns(
|
||||
[
|
||||
("weight", 3, urwid.Text(item.name)),
|
||||
urwid.Text(str(item.quantity)),
|
||||
]
|
||||
)
|
||||
)
|
||||
|
||||
def sync_item_del(self, item: InventoryItem) -> None:
|
||||
idx = self.get_item_idx_by_name(item.name)
|
||||
if idx is not None:
|
||||
del self.list_box.body[idx]
|
||||
|
||||
def sync_item_change(self, item: InventoryItem) -> None:
|
||||
idx = self.get_item_idx_by_name(item.name)
|
||||
if idx is not None:
|
||||
self.list_box.body[idx].contents[1][0].set_text(str(item.quantity))
|
||||
|
||||
def get_item_idx_by_name(self, name: str) -> T.Optional[int]:
|
||||
for i, column_widget in enumerate(self.list_box.body):
|
||||
if column_widget.contents[0][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.list_box.set_focus(0)
|
||||
|
||||
|
||||
class PlotView(DoubleLineBox):
|
||||
cutoff = 100
|
||||
|
||||
def __init__(self, player: Player) -> None:
|
||||
self.player = player
|
||||
|
||||
self.list_box = CustomListBox([])
|
||||
self.plot_bar = CustomProgressBar()
|
||||
|
||||
self.player.quest_book.connect("complete_act", self.sync_act_add)
|
||||
self.player.quest_book.plot_bar.connect("change", self.sync_position)
|
||||
|
||||
super().__init__(
|
||||
top_widget=self.list_box,
|
||||
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:
|
||||
del self.list_box.body[:]
|
||||
for act_number in range(
|
||||
max(0, self.player.quest_book.act - self.cutoff),
|
||||
self.player.quest_book.act,
|
||||
):
|
||||
self.list_box.body.append(
|
||||
ReadOnlyCheckBox(act_name(act_number), state=True)
|
||||
)
|
||||
self.list_box.body.append(
|
||||
ReadOnlyCheckBox(act_name(self.player.quest_book.act), state=False)
|
||||
)
|
||||
|
||||
def sync_act_add(self) -> None:
|
||||
del self.list_box.body[: -self.cutoff]
|
||||
if self.list_box.body:
|
||||
self.list_box.body[-1].set_state(True)
|
||||
self.list_box.body.append(
|
||||
ReadOnlyCheckBox(act_name(self.player.quest_book.act), state=False)
|
||||
)
|
||||
|
||||
def sync_position(self) -> None:
|
||||
self.plot_bar.set_completion(self.player.quest_book.plot_bar.position)
|
||||
self.plot_bar.set_max(self.player.quest_book.plot_bar.max_)
|
||||
|
||||
|
||||
class QuestBookView(DoubleLineBox):
|
||||
cutoff = 100
|
||||
|
||||
def __init__(self, player: Player) -> None:
|
||||
self.player = player
|
||||
|
||||
self.list_box = CustomListBox([])
|
||||
self.quest_bar = CustomProgressBar()
|
||||
|
||||
self.player.quest_book.connect("complete_quest", self.sync_quest_add)
|
||||
self.player.quest_book.quest_bar.connect("change", self.sync_position)
|
||||
|
||||
super().__init__(
|
||||
top_widget=self.list_box,
|
||||
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:
|
||||
del self.list_box.body[:]
|
||||
for quest_name in self.player.quest_book.quests[-self.cutoff :]:
|
||||
self.list_box.body.append(ReadOnlyCheckBox(quest_name, state=True))
|
||||
if self.list_box.body:
|
||||
self.list_box.body[-1].set_state(False)
|
||||
|
||||
def sync_quest_add(self) -> None:
|
||||
del self.list_box.body[: -self.cutoff]
|
||||
if self.list_box.body:
|
||||
self.list_box.body[-1].set_state(True)
|
||||
self.list_box.body.append(
|
||||
ReadOnlyCheckBox(self.player.quest_book.quests[-1], state=False)
|
||||
)
|
||||
|
||||
def sync_position(self) -> None:
|
||||
self.quest_bar.set_completion(
|
||||
self.player.quest_book.quest_bar.position
|
||||
)
|
||||
self.quest_bar.set_max(self.player.quest_book.quest_bar.max_)
|
||||
|
||||
|
||||
class TaskView(urwid.Pile):
|
||||
def __init__(self, player: Player) -> None:
|
||||
self.player = player
|
||||
|
||||
self.current_task_text = urwid.Text("...")
|
||||
self.current_task_bar = CustomProgressBar()
|
||||
|
||||
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.set_completion(self.player.task_bar.position)
|
||||
self.current_task_bar.set_max(self.player.task_bar.max_)
|
||||
|
||||
def pack(self, size: T.Tuple[int, int]) -> T.Tuple[int, int]:
|
||||
return (size[0], 2)
|
||||
|
||||
|
||||
class GameView(urwid.Pile):
|
||||
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)
|
||||
|
||||
super().__init__(
|
||||
[
|
||||
urwid.Columns(
|
||||
[
|
||||
(
|
||||
"weight",
|
||||
1,
|
||||
urwid.Pile(
|
||||
[
|
||||
(19, self.character_sheet_view),
|
||||
self.spell_book_view,
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"weight",
|
||||
2,
|
||||
urwid.Pile(
|
||||
[
|
||||
(15, self.equipment_view),
|
||||
self.inventory_view,
|
||||
]
|
||||
),
|
||||
),
|
||||
(
|
||||
"weight",
|
||||
2,
|
||||
urwid.Pile(
|
||||
[(15, self.plot_view), self.quest_book_view]
|
||||
),
|
||||
),
|
||||
]
|
||||
),
|
||||
("pack", self.task_view),
|
||||
]
|
||||
)
|
||||
|
||||
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 unhandled_input(self, key: str) -> bool:
|
||||
if key == "esc":
|
||||
self.cancel()
|
||||
return True
|
||||
|
||||
if self.args.cheats:
|
||||
if key == "e":
|
||||
self.simulation.player.exp_bar.increment(1)
|
||||
return True
|
||||
|
||||
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 True
|
||||
|
||||
return False
|
||||
|
||||
@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,221 +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
|
||||
|
||||
|
||||
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.ListBox(
|
||||
[
|
||||
urwid.Columns(
|
||||
[urwid.Pile(label_texts), urwid.Pile(value_texts)]
|
||||
),
|
||||
urwid.Divider(),
|
||||
urwid.Columns([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(urwid.Pile):
|
||||
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(
|
||||
urwid.Pile(
|
||||
[
|
||||
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,
|
||||
)
|
||||
),
|
||||
),
|
||||
urwid.Columns(
|
||||
[
|
||||
("weight", 2, self.race_box),
|
||||
("weight", 2, self.class_box),
|
||||
urwid.Pile([(13, self.stats_box), buttons_box]),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
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,116 +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
|
||||
|
||||
|
||||
class RosterView(urwid.Filler):
|
||||
signals = ["load_game", "new_game", "delete_game", "exit"]
|
||||
|
||||
def __init__(self, roster: Roster) -> None:
|
||||
self.roster = roster
|
||||
|
||||
logo = urwid.BigText("ProgressQuest", 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.value} {player.stats[player.stats.best]}"
|
||||
)
|
||||
|
||||
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(
|
||||
urwid.Columns(
|
||||
[
|
||||
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(
|
||||
urwid.Pile(
|
||||
[urwid.Padding(logo, width="clip"), urwid.Divider()]
|
||||
+ buttons
|
||||
+ [
|
||||
urwid.Divider(),
|
||||
urwid.Text("Use arrow keys to move around."),
|
||||
]
|
||||
),
|
||||
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
|
||||
@@ -0,0 +1,51 @@
|
||||
[project]
|
||||
name = "pqcli"
|
||||
version = "1.0.4"
|
||||
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",
|
||||
"pre-commit>=4.5.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"
|
||||
|
||||
[tool.uv]
|
||||
dev-dependencies = ["pre-commit>=2.16.0"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 79
|
||||
|
||||
[tool.isort]
|
||||
known_first_party = "pqcli"
|
||||
multi_line_output = 3
|
||||
include_trailing_comma = true
|
||||
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 |
@@ -1,27 +0,0 @@
|
||||
from setuptools import find_packages, setup
|
||||
|
||||
|
||||
setup(
|
||||
author="rr-",
|
||||
author_email="rr-@sakuya.pl",
|
||||
name="pqcli",
|
||||
long_description="ProgressQuest: the CLI edition",
|
||||
version="0.1",
|
||||
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,250 @@
|
||||
version = 1
|
||||
revision = 3
|
||||
requires-python = ">=3.10, <4.0"
|
||||
|
||||
[[package]]
|
||||
name = "cfgv"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/b5/721b8799b04bf9afe054a3899c6cf4e880fcf8563cc71c15610242490a0c/cfgv-3.5.0.tar.gz", hash = "sha256:d5b1034354820651caa73ede66a6294d6e95c1b00acc5e9b098e917404669132", size = 7334, upload-time = "2025-11-19T20:55:51.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/db/3c/33bac158f8ab7f89b2e59426d5fe2e4f63f7ed25df84c036890172b412b5/cfgv-3.5.0-py2.py3-none-any.whl", hash = "sha256:a8dc6b26ad22ff227d2634a65cb388215ce6cc96bbcc5cfde7641ae87e8dacc0", size = 7445, upload-time = "2025-11-19T20:55:50.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "distlib"
|
||||
version = "0.4.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/8e/709914eb2b5749865801041647dc7f4e6d00b549cfe88b65ca192995f07c/distlib-0.4.0.tar.gz", hash = "sha256:feec40075be03a04501a973d81f633735b4b69f98b05450592310c0f401a4e0d", size = 614605, upload-time = "2025-07-17T16:52:00.465Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/33/6b/e0547afaf41bf2c42e52430072fa5658766e3d65bd4b03a563d1b6336f57/distlib-0.4.0-py2.py3-none-any.whl", hash = "sha256:9659f7d87e46584a30b5780e43ac7a2143098441670ff0a49d5f9034c54a6c16", size = 469047, upload-time = "2025-07-17T16:51:58.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filelock"
|
||||
version = "3.20.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/65/ce7f1b70157833bf3cb851b556a37d4547ceafc158aa9b34b36782f23696/filelock-3.20.3.tar.gz", hash = "sha256:18c57ee915c7ec61cff0ecf7f0f869936c7c30191bb0cf406f1341778d0834e1", size = 19485, upload-time = "2026-01-09T17:55:05.421Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/36/7fb70f04bf00bc646cd5bb45aa9eddb15e19437a28b8fb2b4a5249fac770/filelock-3.20.3-py3-none-any.whl", hash = "sha256:4b0dda527ee31078689fc205ec4f1c1bf7d56cf88b6dc9426c4f230e46c2dce1", size = 16701, upload-time = "2026-01-09T17:55:04.334Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "identify"
|
||||
version = "2.6.15"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/e7/685de97986c916a6d93b3876139e00eef26ad5bbbd61925d670ae8013449/identify-2.6.15.tar.gz", hash = "sha256:e4f4864b96c6557ef2a1e1c951771838f4edc9df3a72ec7118b338801b11c7bf", size = 99311, upload-time = "2025-10-02T17:43:40.631Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0f/1c/e5fd8f973d4f375adb21565739498e2e9a1e54c858a97b9a8ccfdc81da9b/identify-2.6.15-py2.py3-none-any.whl", hash = "sha256:1181ef7608e00704db228516541eb83a88a9f94433a8c80bb9b5bd54b1d81757", size = 99183, upload-time = "2025-10-02T17:43:39.137Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.9.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/43/16/fc88b08840de0e0a72a2f9d8c6bae36be573e475a6326ae854bcc549fc45/nodeenv-1.9.1.tar.gz", hash = "sha256:6ec12890a2dab7946721edbfbcd91f3319c6ccc9aec47be7c7e6b7011ee6645f", size = 47437, upload-time = "2024-06-04T18:44:11.171Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/1d/1b658dbd2b9fa9c4c9f32accbfc0205d532c8c6194dc0f2a4c0428e7128a/nodeenv-1.9.1-py2.py3-none-any.whl", hash = "sha256:ba11c9782d29c27c70ffbdda2d7415098754709be8a7056d79a737cd901155c9", size = 22314, upload-time = "2024-06-04T18:44:08.352Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cf/86/0248f086a84f01b37aaec0fa567b397df1a119f73c16f6c7a9aac73ea309/platformdirs-4.5.1.tar.gz", hash = "sha256:61d5cdcc6065745cdd94f0f878977f8de9437be93de97c1c12f853c9c0cdcbda", size = 21715, upload-time = "2025-12-05T13:52:58.638Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/28/3bfe2fa5a7b9c46fe7e13c97bda14c895fb10fa2ebf1d0abb90e0cea7ee1/platformdirs-4.5.1-py3-none-any.whl", hash = "sha256:d03afa3963c806a9bed9d5125c8f4cb2fdaf74a55ab60e5d59b3fde758104d31", size = 18731, upload-time = "2025-12-05T13:52:56.823Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pqcli"
|
||||
version = "1.0.4"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "pre-commit" },
|
||||
{ name = "urwid" },
|
||||
{ name = "urwid-readline" },
|
||||
{ name = "xdg-base-dirs" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
windows = [
|
||||
{ name = "windows-curses" },
|
||||
]
|
||||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "pre-commit" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "pre-commit", specifier = ">=4.5.0" },
|
||||
{ name = "urwid", specifier = ">=2.1.2,<3.0.0" },
|
||||
{ name = "urwid-readline", specifier = ">=0.13,<1.0.0" },
|
||||
{ name = "windows-curses", marker = "extra == 'windows'", specifier = ">=2.3.0,<3.0.0" },
|
||||
{ name = "xdg-base-dirs", specifier = ">=6.0.0,<7.0.0" },
|
||||
]
|
||||
provides-extras = ["windows"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [{ name = "pre-commit", specifier = ">=2.16.0" }]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cfgv" },
|
||||
{ name = "identify" },
|
||||
{ name = "nodeenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "virtualenv" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f4/9b/6a4ffb4ed980519da959e1cf3122fc6cb41211daa58dbae1c73c0e519a37/pre_commit-4.5.0.tar.gz", hash = "sha256:dc5a065e932b19fc1d4c653c6939068fe54325af8e741e74e88db4d28a4dd66b", size = 198428, upload-time = "2025-11-22T21:02:42.304Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/c4/b2d28e9d2edf4f1713eb3c29307f1a63f3d67cf09bdda29715a36a68921a/pre_commit-4.5.0-py2.py3-none-any.whl", hash = "sha256:25e2ce09595174d9c97860a95609f9f852c0614ba602de3561e267547f2335e1", size = 226429, upload-time = "2025-11-22T21:02:40.836Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/a0/39350dd17dd6d6c6507025c0e53aef67a9293a6d37d3511f23ea510d5800/pyyaml-6.0.3-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b", size = 184227, upload-time = "2025-09-25T21:31:46.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/14/52d505b5c59ce73244f59c7a50ecf47093ce4765f116cdb98286a71eeca2/pyyaml-6.0.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956", size = 174019, upload-time = "2025-09-25T21:31:47.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/f7/0e6a5ae5599c838c696adb4e6330a59f463265bfa1e116cfd1fbb0abaaae/pyyaml-6.0.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8", size = 740646, upload-time = "2025-09-25T21:31:49.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/3a/61b9db1d28f00f8fd0ae760459a5c4bf1b941baf714e207b6eb0657d2578/pyyaml-6.0.3-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198", size = 840793, upload-time = "2025-09-25T21:31:50.735Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1e/7acc4f0e74c4b3d9531e24739e0ab832a5edf40e64fbae1a9c01941cabd7/pyyaml-6.0.3-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b", size = 770293, upload-time = "2025-09-25T21:31:51.828Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ef/abd085f06853af0cd59fa5f913d61a8eab65d7639ff2a658d18a25d6a89d/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0", size = 732872, upload-time = "2025-09-25T21:31:53.282Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/15/2bc9c8faf6450a8b3c9fc5448ed869c599c0a74ba2669772b1f3a0040180/pyyaml-6.0.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69", size = 758828, upload-time = "2025-09-25T21:31:54.807Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/00/531e92e88c00f4333ce359e50c19b8d1de9fe8d581b1534e35ccfbc5f393/pyyaml-6.0.3-cp310-cp310-win32.whl", hash = "sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e", size = 142415, upload-time = "2025-09-25T21:31:55.885Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/fa/926c003379b19fca39dd4634818b00dec6c62d87faf628d1394e137354d4/pyyaml-6.0.3-cp310-cp310-win_amd64.whl", hash = "sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c", size = 158561, upload-time = "2025-09-25T21:31:57.406Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.15.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urwid"
|
||||
version = "2.6.16"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "wcwidth" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/21/ad23c9e961b2d36d57c63686a6f86768dd945d406323fb58c84f09478530/urwid-2.6.16.tar.gz", hash = "sha256:93ad239939e44c385e64aa00027878b9e5c486d59e855ec8ab5b1e1adcdb32a2", size = 848179, upload-time = "2024-10-15T16:07:24.297Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/cb/271a4f5a1bf4208dbdc96d85b9eae744cf4e5e11ac73eda76dc98c8fd2d7/urwid-2.6.16-py3-none-any.whl", hash = "sha256:de14896c6df9eb759ed1fd93e0384a5279e51e0dde8f621e4083f7a8368c0797", size = 297196, upload-time = "2024-10-15T16:07:22.521Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urwid-readline"
|
||||
version = "0.15.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "urwid" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ad/70/be318554495555eba7d8ff6e489f6f74ddb225b24086ba4af62a82e723fd/urwid_readline-0.15.1.tar.gz", hash = "sha256:9301444b86d58f7d26388506b704f142cefd193888488b4070d3a0fdfcfc0f84", size = 9007, upload-time = "2024-09-22T17:51:55.144Z" }
|
||||
|
||||
[[package]]
|
||||
name = "virtualenv"
|
||||
version = "20.35.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "distlib" },
|
||||
{ name = "filelock" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/20/28/e6f1a6f655d620846bd9df527390ecc26b3805a0c5989048c210e22c5ca9/virtualenv-20.35.4.tar.gz", hash = "sha256:643d3914d73d3eeb0c552cbb12d7e82adf0e504dbf86a3182f8771a153a1971c", size = 6028799, upload-time = "2025-10-29T06:57:40.511Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wcwidth"
|
||||
version = "0.2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/24/30/6b0809f4510673dc723187aeaf24c7f5459922d01e2f794277a3dfb90345/wcwidth-0.2.14.tar.gz", hash = "sha256:4d478375d31bc5395a3c55c40ccdf3354688364cd61c4f6adacaa9215d0b3605", size = 102293, upload-time = "2025-09-22T16:29:53.023Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b5/123f13c975e9f27ab9c0770f514345bd406d0e8d3b7a0723af9d43f710af/wcwidth-0.2.14-py2.py3-none-any.whl", hash = "sha256:a7bb560c8aee30f9957e5f9895805edd20602f2d7f720186dfd906e82b4982e1", size = 37286, upload-time = "2025-09-22T16:29:51.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-curses"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/34/e70815e7ae6f157c00121180a607dbcdc82acfd949c70bba28c45d206dec/windows_curses-2.4.1-cp310-cp310-win32.whl", hash = "sha256:53d711e07194d0d3ff7ceff29e0955b35479bc01465d46c3041de67b8141db2f", size = 71136, upload-time = "2025-01-11T00:26:15.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/1d/1bb512c445af24fb0a3dccf9537c8727bba5920e69539227b87f5351f608/windows_curses-2.4.1-cp310-cp310-win_amd64.whl", hash = "sha256:325439cd4f37897a1de8a9c068a5b4c432f9244bf9c855ee2fbeb3fa721a770c", size = 81418, upload-time = "2025-01-11T00:26:16.963Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/b3/46a2508fff83b5affb5a311797c584c494b4b0c4c8796a1afa2c1b1e03ac/windows_curses-2.4.1-cp311-cp311-win32.whl", hash = "sha256:4fa1a176bfcf098d0c9bb7bc03dce6e83a4257fc0c66ad721f5745ebf0c00746", size = 71129, upload-time = "2025-01-11T00:26:19.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/29/fd7c0c80177d8df55f841504a5f332b95b0002c80f82055913b6caac94e6/windows_curses-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:fd7d7a9cf6c1758f46ed76b8c67f608bc5fcd5f0ca91f1580fd2d84cf41c7f4f", size = 81431, upload-time = "2025-01-11T00:26:20.359Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/99/60f34e51514c82631aa5c6d21eab88ce1701562de9844608411e39462a46/windows_curses-2.4.1-cp312-cp312-win32.whl", hash = "sha256:bdbe7d58747408aef8a9128b2654acf6fbd11c821b91224b9a046faba8c6b6ca", size = 71489, upload-time = "2025-01-11T00:26:22.726Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/8f/d908bcab1954375b156a9300fa86ceccb110f39457cd6fd59c72777626ab/windows_curses-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c9c2635faf171a229caca80e1dd760ab00db078e2a285ba2f667bbfcc31777c", size = 81777, upload-time = "2025-01-11T00:26:25.273Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/a0/e8d074f013117633f6b502ca123ecfc377fe0bd36818fe65e8935c91ca9c/windows_curses-2.4.1-cp313-cp313-win32.whl", hash = "sha256:05d1ca01e5199a435ccb6c8c2978df4a169cdff1ec99ab15f11ded9de8e5be26", size = 71390, upload-time = "2025-01-11T00:26:27.66Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/4b/2838a829b074a68c570d54ae0ae8539979657d3e619a4dc5a4b03eb69745/windows_curses-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8cf653f8928af19c103ae11cfed38124f418dcdd92643c4cd17239c0cec2f9da", size = 81636, upload-time = "2025-01-11T00:26:29.595Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "xdg-base-dirs"
|
||||
version = "6.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bf/d0/bbe05a15347538aaf9fa5b51ac3b97075dfb834931fcb77d81fbdb69e8f6/xdg_base_dirs-6.0.2.tar.gz", hash = "sha256:950504e14d27cf3c9cb37744680a43bf0ac42efefc4ef4acf98dc736cab2bced", size = 4085, upload-time = "2024-10-19T14:35:08.114Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/03/030b47fd46b60fc87af548e57ff59c2ca84b2a1dadbe721bb0ce33896b2e/xdg_base_dirs-6.0.2-py3-none-any.whl", hash = "sha256:3c01d1b758ed4ace150ac960ac0bd13ce4542b9e2cdf01312dcda5012cfebabe", size = 4747, upload-time = "2024-10-19T14:35:05.931Z" },
|
||||
]
|
||||
Reference in New Issue
Block a user