60 Commits
Author SHA1 Message Date
Dash 4dd2cdf1ad misc: release 1.1.0 2026-10-17 18:56:55 +02:00
Dash 56a954e6c5 build: drop pre-commit from runtime dependencies
The Docker commit added pre-commit to [project] dependencies, so every
install of pqcli pulled it in. It is a development tool, and the hooks
run through prek from the dev group now.
2026-10-17 18:56:54 +02:00
Dash cee0626827 docs: add a changelog
Lists the unreleased changes since 1.0.4, grouped the way the TRX
changelog groups them.
2026-10-17 18:56:54 +02:00
Dash 804b0d8b38 ui: import and export .pqw files from the menus
The basic interface gets two main menu entries that ask for a path.
The curses roster gets [I] Import and [E] Export, with a new text input
view for the path, prefilled with NAME.pqw on export, and a message
view for the outcome and any warnings. The README explains how to move
characters to and from the web version.

Resolves #21
2026-10-17 15:56:03 +02:00
Dash 612b223a59 pqcli: add --import-save and --export-save
--import-save FILE adds the character in a .pqw file to the roster;
it can be given more than once, and if any file fails, nothing is
imported. --export-save NUM FILE writes a character out. Export never
overwrites an existing file. The file handling lives in pqw.read_file
and pqw.write_file, so the interfaces can share it.
2026-10-17 15:56:02 +02:00
Dash 0beb550548 pqw: import and export web saves
A .pqw file is what Progress Quest web's roster page downloads: base64
of one character's JSON. pqcli.pqw converts between that and Player,
keeping the generator state, so a character carries on exactly as it
would have where it came from. Saves from before 2022, which were not
UTF-8, are read as Latin-1, and upstream's two spell renames are merged
the way its loader does. Online realm fields are dropped with a
warning.

The upstream tests check both directions against the real JS: a save
made by upstream, at a task boundary or mid-task, is imported and
played on for 3000 tasks next to upstream; then the same the other way.
2026-10-17 15:56:02 +02:00
Dash fced929d77 lingo: add to_arabic, the inverse of to_roman
Ported from upstream's toArabic, including the T, MT, A and MA
numerals. Web saves store spell levels as roman numerals, so the .pqw
importer needs it.
2026-10-17 15:56:01 +02:00
Dash ed5c5cbdc3 tests: type queued tasks in the upstream bridge
Player.queue holds BaseTask instances, so the bridge's queue encoder
takes BaseTask too. ty flagged this once it covered the tests.
2026-10-17 15:56:00 +02:00
Dash fd4a7d151d misc: replace Any with precise types
Curses screens, windows and pads are typed as curses.window, and the
widgets that may get no parent window take curses.window | None.

- EventHandler is generic over a ParamSpec, so each handler declares
  the arguments it passes, and MenuView is generic over the item its
  on_confirm hands out. Choice callbacks take no arguments.
- Scrollable is generic over its items, which DataTable and ListBox
  fix to tuple[str, str] and str.
- random.choice() and choice_low() are generic over the sequence, so
  the casts at their call sites are gone. BasicUserInterface.menu()
  and util.first() are generic too.
- Signal callbacks and their payloads are Callable[..., object] and
  object, since emit() only forwards them.
- JSON values in game_data are object and narrowed by isinstance,
  and data files are Traversable.
- Signal handlers take an int and a FrameType | None, and
  list_players() takes a TextIO.
- The focus_standout() context managers return
  Generator[None, None, None].

Any remains in three places: Player.__setstate__, whose pickled
__dict__ holds values of every type; Focusable.__init__, a mixin that
passes *args and **kwargs through to the next class; and the values
load_game_data() collects, whose element type depends on the field
each one fills.
2026-10-17 15:55:59 +02:00
Dash 00794aca37 build: type-check with ty in a pre-commit hook
ty joins the dev dependency group, locked at 0.0.89, and a local
hook runs `uv run ty check` over the whole project whenever Python
files change. [tool.ty.src] limits the check to pqcli/ and tests/,
so agent worktrees under .claude/ are not checked as duplicate
copies of the project.
2026-10-17 15:55:58 +02:00
Dash 9155ef418b misc: fix all ty diagnostics
ty reported 20 diagnostics, and 0.0.89 adds a few more; this clears
them all without suppressions.

- The quest monster is held in a local before being stored on the
  quest book, so its name no longer reads from an Optional.
- PgUp/PgDn narrow the focused window with isinstance against a
  runtime-checkable PageScrollable protocol instead of hasattr.
- The ^t cheat used to monkeypatch SignalMixin.emit. SignalMixin now
  has a muted() context manager that emit() checks, and it restores
  emission even if a tick raises.
- GameView keeps its children as GameWindow, a protocol declaring
  stop() and sync(), so the cheat's resync type-checks.
- curses.ascii is imported explicitly where it is used.
- ProgressBarWindow is now Focusable itself, instead of calling
  Focusable.focus_standout() on a class that is not Focusable. Its
  four subclasses drop the duplicate Focusable base; the MRO and
  initialisation order are unchanged.
- MainMenu narrows the Optional pad before colouring the logo.
- The tests that record tasks assert player.task is set.
- Context managers are annotated as Generator rather than Iterator,
  which ty flags as deprecated for @contextmanager.
- The redundant cast() around getch() is gone.
- The frozen GameData test assigns through setattr(), since the
  direct assignment is an error by design.

Bug fixes:

- ListBox.select(None) crashed on `None < 0`; it now clears the
  selection, as DataTable.select(None) already does.
- ListBox.get() caught ValueError, which indexing never raises; it
  now catches IndexError, so an out-of-range index returns None as
  intended.
2026-10-17 15:55:58 +02:00
Dash 0c1d5185a4 tests: compare pqcli with upstream in node
`just test` now fetches Progress Quest web's game logic from the public
site into $XDG_CACHE_HOME/pqcli/pq-web, pinned by SHA-256 so a change on
the site fails loudly. Nothing from upstream is committed.

tests/upstream/harness.js runs that code headless. test_upstream.py
checks the word lists, MonsterTask and the other generators from 200
generator states, and three whole games of 10000 tasks each, comparing
all state including the generator after every task. Without node or the
fetched files, these tests are skipped.
2026-10-17 15:55:57 +02:00
Dash 9ad3bee59b mechanic: start acts as their loading task does
Upstream calls CompleteAct() when it dequeues the plot task, so the act
number, the plot bar and the act rewards change as "Loading Act N"
begins. pqcli did all that when the task ended. The random sequence is
the same either way, so the parity traces are unchanged; only the 1-2
seconds of the loading task differ.
2026-10-17 15:55:56 +02:00
Dash 0a4358e2b8 misc: drop typing as T, use builtin generics
Python 3.10 is the minimum, so annotations use `X | None`, `list[X]`
and friends, enforced by ruff's pyupgrade rules. The remaining typing
names are imported directly, with abstract types coming from
collections.abc. first() is now generic over the item and default.
2026-10-17 15:55:55 +02:00
Dash ada8d5b6ed build: switch hooks to prek, check 50/72
prek replaces pre-commit as the hook runner and reads the same
config. A new commit-msg hook in .prek/ rejects subjects over 50
characters, subjects without a module prefix, and body lines over
72 characters. Install the hooks with `uv run prek install`, and run
them with `just lint`.
2026-10-17 15:55:55 +02:00
Dash 4d620bc384 build: replace black and isort with ruff
ruff format keeps black's style at the same line length, and ruff
check covers import sorting plus pycodestyle and pyflakes. Three
comments and a test regex are wrapped to fit 79 columns.
2026-10-17 15:55:51 +02:00
Dash 9c44d69633 random: port Alea, one generator per character
pqcli used Python's global Mersenne Twister, seeded once from the clock.
Upstream uses Johannes Baagøe's Alea, with Random(n) = uint32() % n, and
keeps the generator state with each character. The port matches the JS
output bit for bit, including UTF-16 seeding of non-BMP characters.

Each Player now owns an Alea that is pickled with the save, and
Simulation.tick draws from it. Saves without one get a fresh generator.
As upstream does, a new character's game continues from the state the
kept stat roll was made from.

The parity traces are re-recorded, since every random number changes.
2026-10-17 15:55:50 +02:00
Dash f1cdff8140 mechanic: fill the plot bar during the Prologue 2026-10-17 15:55:49 +02:00
Dash c25f9dc057 lingo: extend roman numerals past 4000 2026-10-17 15:55:49 +02:00
Dash 48f48aec58 mechanic: keep at most 100 quests in the log 2026-10-17 15:55:48 +02:00
Dash fa31045052 lingo: use "an" before words starting with Ü 2026-10-17 15:55:47 +02:00
Dash 0f1100d4be mechanic: sync impressive guys and cinematics 2026-10-17 15:55:46 +02:00
Dash 9942091af7 mechanic: cap new item wins on huge inventories 2026-10-17 15:55:46 +02:00
Dash 17e1b91a18 mechanic: clump stat gains on prime stats only 2026-10-17 15:55:45 +02:00
Dash ab179c1eb0 mechanic: shorten quests as upstream does 2026-10-17 15:55:44 +02:00
Dash bec6171483 mechanic: use upstream's exponential level curve 2026-10-17 15:55:43 +02:00
Dash f03394f680 tests: add a recipe to re-record parity traces 2026-10-17 15:55:43 +02:00
Dash b69231f321 docs: update install and contributing for uv 2026-10-17 15:55:42 +02:00
Dash 1a6cd720ac build: move dev dependencies to dependency-groups 2026-10-17 15:55:41 +02:00
Dash c0c6c8aeba mechanic: move word lists to data files
The word lists (monsters, spells, equipment, races, classes, titles and
so on) now live in pqcli/data/*.json instead of pqcli/config.py. A
GameData object loaded at startup is passed to the simulation and UIs.

Users can replace any list by putting NAME.json in
$XDG_CONFIG_HOME/pqcli/data/ or a directory given with --data-dir.
--dump-data DIR copies the built-in files out for editing. Malformed
files are reported and pqcli exits before starting the UI.

Combat task descriptions come from the new combat_verbs list, with
{target} standing for the enemy. The built-in list holds only "Executing
{target}", and a single verb consumes no random roll, so stock gameplay
is unchanged. Recorded traces in tests/fixtures pin this down.

Monster, Race and Class stay in pqcli.config so existing saves still
load.

Resolves #3
2026-10-17 15:55:41 +02:00
Dash 5337d94f82 build: add justfile 2026-10-17 15:55:29 +02:00
Dash da60c6f792 build: remove poetry.lock 2026-10-17 15:55:28 +02:00
Jakub StastkaandJakub Stastka 696fad78b3 build: add Docker builds
Co-authored-by: Jakub Stastka <jakub@stastka.org>
2026-04-19 09:58:04 +09:00
Dash 7426924170 misc: release 1.0.4 2025-12-11 14:59:18 +01:00
Dash 9609e9a3f2 misc: fix style issues 2025-12-11 14:58:55 +01:00
Dash 7e52a74065 misc: update author info 2025-12-11 14:58:55 +01:00
Dash 4d5b070949 build: migrate to uv 2025-12-11 14:58:55 +01:00
Dash 326b9297c1 build: migrate to uv 2025-12-11 14:49:15 +01:00
Owen Cochell 7790e52a6d misc: fix crash on Linux due to zero length addnstr call 2025-04-10 08:20:21 +02:00
Owen Cochell e6d18352c5 misc: fix empty character sheet on Linux 2025-04-04 20:26:08 +02:00
dependabot[bot] 4122e936c8 build(deps-dev): bump setuptools from 69.5.1 to 70.0.0
Bumps [setuptools](https://github.com/pypa/setuptools) from 69.5.1 to 70.0.0.
- [Release notes](https://github.com/pypa/setuptools/releases)
- [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst)
- [Commits](https://github.com/pypa/setuptools/compare/v69.5.1...v70.0.0)

---
updated-dependencies:
- dependency-name: setuptools
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-15 21:57:21 +02:00
Marcin Kurczewski 803eba416e misc: fix xdg-base-dirs refactor 2024-07-02 15:41:50 +02:00
Marcin Kurczewski 07cae1a4e8 misc: use xdg-base-dirs
Resolves #26
2024-05-04 12:18:07 +02:00
Marcin Kurczewski 9eaf4397df misc: fix syntax warning 2024-04-26 01:27:33 +02:00
anakojm 077016f7a0 misc: fix seed type issue 2024-04-26 01:26:41 +02:00
txtsd e51a14c081 misc: Set correct python version in classifiers
Signed-off-by: txtsd <code@ihavea.quest>
2022-11-11 08:02:27 +01:00
txtsd 77391e7888 misc: Remove unused imports
Signed-off-by: txtsd <code@ihavea.quest>
2022-11-11 08:02:27 +01:00
txtsd 69ad5e7299 misc: Fix grammar
Signed-off-by: txtsd <code@ihavea.quest>
2022-11-11 08:02:27 +01:00
txtsd 04e0611d45 misc: Fix typos
Signed-off-by: txtsd <code@ihavea.quest>
2022-11-11 08:02:27 +01:00
txtsd c0395e9447 misc: Update .gitignore
Fixes directory format
Adds __pycache__

Signed-off-by: txtsd <code@ihavea.quest>
2022-11-11 08:02:27 +01:00
rr- 3b2d9db2e2 build: switch to Poetry 2021-12-20 23:29:37 +01:00
Marcin Kurczewski bbe3842a2f ui: allow loading characters directly from cli (#16) 2020-09-30 20:20:29 +02:00
rr- 1ad563ad93 misc: release 1.0.2 2020-08-17 09:12:56 +02:00
rr- 29207c1d4c misc: remove unused imports 2020-08-17 09:12:56 +02:00
CJ Kucera 67c3888f82 docs: add troubleshooting section 2020-06-22 21:57:43 +02:00
rr- d4675c747a misc: require windows-curses for Windows 2020-05-07 15:36:41 +02:00
Petr Gašparík c5f0c60ec8 roster: fix overwriting old saves on Windows 2020-05-07 15:27:56 +02:00
rr- fc4dcff9e3 ui/curses: fix division by zero 2020-05-07 15:04:58 +02:00
rr- f8a030a648 misc: release 1.0.1 2019-04-10 20:09:47 +02:00
rr- 82c8d21395 ui/curses: fix division by zero 2019-04-10 20:09:22 +02:00
89 changed files with 10450 additions and 1081 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
dist/
build/
__pycache__
+25
View File
@@ -0,0 +1,25 @@
default_install_hook_types: [pre-commit, commit-msg]
default_stages: [pre-commit]
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.17.1
hooks:
- id: ruff-check
args: [--fix]
- id: ruff-format
- repo: local
hooks:
- id: ty
name: ty
entry: uv run ty check
language: system
pass_filenames: false
types: [python]
- id: commit-msg-50-72
name: Commit message 50/72 rule
entry: python3 .prek/check_commit_msg
language: system
stages: [commit-msg]
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Fail the commit if its message breaks the 50/72 rule.
Subject line <= 50 chars, a blank line, then body wrapped at <= 72 chars.
The subject also has to open with a module prefix. Wired in as a
prek/pre-commit commit-msg hook so it is enforced mechanically instead of by
memory.
"""
import re
import sys
from collections.abc import Sequence
from pathlib import Path
MAX_SUBJECT = 50
MAX_BODY = 72
# `module-prefix: description`, the prefix at the very start of the subject.
# A prefix names a file, so it spells the name as the file does: underscores
# within a name, `/` between folders, `+` between two modules.
SUBJECT_RE = re.compile(r"^[a-z0-9][a-z0-9_+./]*: \S")
# Subjects git or a rebase writes itself, which the rule is not about.
EXEMPT_PREFIXES = ("Merge ", "Revert ", "fixup! ", "squash! ", "amend! ")
def main(argv: Sequence[str]) -> int:
if len(argv) != 2:
sys.stderr.write(f"Usage: {argv[0]} COMMIT_MSG_FILE\n")
return 2
message_path = Path(argv[1])
with message_path.open(encoding="utf-8") as handle:
raw_lines = handle.read().splitlines()
# `git commit -v` appends a diff below a scissors line; ignore it.
cut = next(
(
i
for i, line in enumerate(raw_lines)
if ">8" in line and line[:1] == "#"
),
None,
)
if cut is not None:
raw_lines = raw_lines[:cut]
lines = [line for line in raw_lines if not line.startswith("#")]
while lines and not lines[-1].strip():
lines.pop()
if not lines:
return 0
errors: list[str] = []
subject = lines[0]
if not subject.startswith(EXEMPT_PREFIXES):
if len(subject) > MAX_SUBJECT:
errors.append(
f"subject is {len(subject)} chars (max {MAX_SUBJECT})"
)
if not SUBJECT_RE.match(subject):
head = subject.split(":")[0]
if "," in head:
errors.append(
f"prefix `{head}` separates modules with a comma; join"
" them with `+`"
)
elif "-" in head:
errors.append(
f"prefix `{head}` uses a dash; spell the module as its"
f" file does (`{head.replace('-', '_')}`)"
)
else:
errors.append(
"subject must open with a lowercase module prefix, as in"
" `mechanic: shorten quests`"
)
if len(lines) > 1 and lines[1].strip():
errors.append("second line must be blank (subject, blank, body)")
for number, line in enumerate(lines[2:], start=3):
if len(line) > MAX_BODY:
errors.append(
f"body line {number} is {len(line)} chars (max {MAX_BODY})"
)
if errors:
sys.stderr.write("Commit message breaks the 50/72 rule:\n")
for error in errors:
sys.stderr.write(f" - {error}\n")
return 1
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv))
+39
View File
@@ -0,0 +1,39 @@
# Changelog
## [Unreleased](https://github.com/rr-/pq-cli/compare/1.1.0...main) - ××××-××-××
## [1.1.0](https://github.com/rr-/pq-cli/compare/1.0.4...1.1.0) - 2026-10-17
**Gameplay**
- Changed the game mechanics to match the current web version of Progress Quest, which pqcli now plays exactly like, random rolls included (#21):
- Changed the time needed to level up to grow exponentially: levels come much faster early on and much slower past level 49
- Changed quests to be much shorter
- Changed stat gains that favor the best stat to only consider the six prime stats
- Changed item rewards on inventories past 250 kinds of items to sometimes add one more of something already carried
- Changed impressive characters in cinematics to be named in the new styles, such as "the Queen of the Half Halflings"
- Changed the cinematic texts to match the web version
- Changed acts to begin as their loading task starts
- Added the Chancellor, Baroness and Inquistor titles
- Changed the random number generator to the one the web version uses, stored with each character; existing characters get a new one on first load
- Changed roman numerals past 4000 to use the web version's T and A numerals
- Fixed the plot bar staying empty during the Prologue
- Fixed the quest log keeping 101 quests instead of 100
- Fixed "an" not being used before words starting with Ü
**Customization**
- Added data files for all word lists (monsters, spells, equipment, races, classes, titles and more), which can be replaced in `$XDG_CONFIG_HOME/pqcli/data/` or a directory given with `--data-dir` (#3)
- Added customizable combat descriptions (`combat_verbs.json`), which say "Executing" by default (#3)
- Added `--dump-data DIR` to copy the built-in word lists out for editing (#3)
**Saves**
- Added importing and exporting characters as `.pqw` files, the format of the web version, from both interfaces and with `--import-save` and `--export-save` (#21)
**Installation**
- Added a `Dockerfile` and `docker-compose.yml` to run the game in a container, with saves kept in a persistent volume
- Added character creation on first run when there are no saved characters yet
**Development**
- Added tests, including gameplay traces and a comparison with the web version's own code (`just test`)
- Added a `justfile`
- Changed the hooks to prek, with ruff in place of black and isort, ty for type checking, and a 50/72 commit message check
- Removed `poetry.lock`
+12
View File
@@ -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"]
+137 -3
View File
@@ -30,14 +30,148 @@ Basic interface:
## How to install ## How to install
If you have Python 3.7, just run `pip install --user pqcli` and you're good to go! If you have Python 3.10 or newer, just run `pip install --user pqcli` and you're good to go!
Then type `pqcli` to run the game. Then type `pqcli` to run the game.
In case if you want to use the git version, the process is just a little bit In case if you want to use the git version, the process is just a bit more complex:
more complex:
```console ```console
$ git clone https://github.com/rr-/pq-cli.git $ git clone https://github.com/rr-/pq-cli.git
$ cd pq-cli $ cd pq-cli
$ pip install --user . $ pip install --user .
``` ```
## Docker / Docker Compose
The repository includes a `Dockerfile` and `docker-compose.yml` that run the
game with `--basic` and keep save data in a persistent named volume.
There is no registry pipeline required for this setup: build locally, then run
with Compose.
```console
# Build image locally from the Dockerfile
docker compose build
# First run (interactive): create your character in the shared save volume
docker compose run --rm pqcli-init
# Later runs on a server (detached, default save slot 1):
docker compose up -d pqcli
# List existing saves in the same volume:
docker compose run --rm pqcli-init pqcli --basic --list-saves
# Change detached slot if needed (example: slot 2):
PQCLI_SAVE_SLOT=2 docker compose up -d pqcli
# Follow logs / stop detached container:
docker compose logs -f pqcli
docker compose stop pqcli
```
On first run, if no save data exists in the mounted volume, `pqcli` will
automatically launch an interactive character-creation bootstrap in CLI mode
before starting the normal interface.
For server usage, run first-time setup without a forced slot (`pqcli-init`),
then run the long-lived detached service (`pqcli`) that loads slot `1` by
default.
## Customizing the adventure
All word lists (monsters, spells, equipment, races, classes, titles and so on)
live in JSON files. To change them, copy the built-in files somewhere and edit
them:
```console
$ pqcli --dump-data ~/.config/pqcli/data
```
On startup, pqcli reads `$XDG_CONFIG_HOME/pqcli/data/` (or the directory
given with `--data-dir DIR`). Each `NAME.json` there replaces the built-in
list of the same name. Files you leave out fall back to the built-in lists,
so you can delete the copies you don't change. If a file is malformed, pqcli
prints what is wrong and refuses to start.
The available files are `spells`, `offense_attrib`, `defense_attrib`,
`offense_bad`, `defense_bad`, `shields`, `armors`, `weapons`, `specials`,
`item_attrib`, `item_ofs`, `boring_items`, `monsters`, `races`, `classes`,
`titles`, `impressive_titles` and `combat_verbs`.
For example, to vary the combat descriptions, create `combat_verbs.json`.
`{target}` is replaced with the enemy:
```json
[
"Executing {target}",
"In a fierce battle to thwart {target}",
"Fighting bravely against {target}",
"Kicking the butt of {target}"
]
```
A few things to keep in mind:
- Order matters in `spells.json`: spells are learned roughly from the top
down, so keep weaker spells first.
- Titles near the top of `titles.json` show up more often.
- Stats in `races.json` and `classes.json` are written as `STR`, `CON`,
`DEX`, `INT`, `WIS`, `CHA`, `HP Max` or `MP Max`.
- Existing characters keep their race and class even if you remove them from
the lists.
## Moving characters to and from the web version
pqcli reads and writes the `.pqw` files that the
[web version](http://progressquest.com/play/)'s roster page saves and
loads. A character keeps its random number generator state either way, so
it carries on exactly as it would have where it came from.
- In either interface, pick *Import* or *Export* from the main menu.
- From the command line:
```console
$ pqcli --import-save Grumdrig.pqw
$ pqcli --list-saves
1. Grumdrig
$ pqcli --export-save 1 Grumdrig.pqw
```
Export never overwrites an existing file. pqcli plays offline only, so
online realm, guild and motto settings are dropped on import, with a
warning.
## Contributing
```sh
# Clone the repository:
git clone https://github.com/rr-/pq-cli.git
cd pq-cli
# Install to a local venv:
uv sync
# Install the git hooks (formatting, linting, commit messages):
uv run prek install
# Run the game and the tests:
just run
just test
```
This project uses [uv](https://docs.astral.sh/uv/) for packaging.
Install instructions are available at [uv#installation](https://docs.astral.sh/uv/getting-started/installation/).
The `justfile` needs [just](https://github.com/casey/just); without it, use
`uv run pqcli` and `uv run pytest`.
## Troubleshooting
### `_curses.error: init_pair() returned ERR`
If running on Linux and you get the error `_curses.error: init_pair() returned ERR`,
try making sure that your `$TERM` variable is set to a value which supports
256 colors, such as via the following:
TERM=xterm-256color pqcli
+26
View File
@@ -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:
+29
View File
@@ -0,0 +1,29 @@
default: run
# Run the game; extra arguments go to pqcli
run *args:
uv run pqcli {{args}}
# Run with the curses interface
curses *args:
uv run pqcli --curses {{args}}
# Run with the basic interface
basic *args:
uv run pqcli --basic {{args}}
# Run the formatters and linters on all files
lint:
uv run prek run -a
# Download Progress Quest web's game logic for the upstream tests
fetch-upstream:
uv run python tests/upstream/fetch.py
# Run the test suite; extra arguments go to pytest
test *args: fetch-upstream
uv run pytest {{args}}
# Re-record the gameplay parity fixtures (only when gameplay should change)
record-traces:
uv run python tests/game_trace.py
+156 -4
View File
@@ -1,13 +1,24 @@
import argparse import argparse
import sys
from pathlib import Path from pathlib import Path
from typing import TextIO
import xdg from xdg_base_dirs import xdg_config_home
from pqcli import pqw
from pqcli.game_data import (
DataError,
GameData,
dump_builtin_data,
load_game_data,
)
from pqcli.mechanic import Player
from pqcli.roster import Roster from pqcli.roster import Roster
from pqcli.ui.basic import BasicUserInterface from pqcli.ui.basic import BasicUserInterface
from pqcli.ui.curses import CursesUserInterface from pqcli.ui.curses import CursesUserInterface
SAVE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "save.dat" SAVE_PATH = Path(xdg_config_home()) / "pqcli" / "save.dat"
DATA_PATH = Path(xdg_config_home()) / "pqcli" / "data"
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
@@ -44,15 +55,156 @@ def parse_args() -> argparse.Namespace:
help=argparse.SUPPRESS, help=argparse.SUPPRESS,
) )
parser.add_argument("--cheats", action="store_true", help="???") parser.add_argument("--cheats", action="store_true", help="???")
parser.add_argument(
"--list-saves",
action="store_true",
help="list saved characters and exit",
)
parser.add_argument(
"--load-save",
type=int,
metavar="NUM",
help="play chosen character",
)
parser.add_argument(
"--data-dir",
type=Path,
default=DATA_PATH,
metavar="DIR",
help=(
"directory with custom word lists; each NAME.json replaces "
f"the built-in list of that name (default: {DATA_PATH})"
),
)
parser.add_argument(
"--dump-data",
type=Path,
metavar="DIR",
help="copy the built-in word lists to DIR for editing and exit",
)
parser.add_argument(
"--import-save",
type=Path,
action="append",
metavar="FILE",
help=(
"add the character in a Progress Quest web save (.pqw) to the "
"roster and exit; can be given more than once"
),
)
parser.add_argument(
"--export-save",
nargs=2,
metavar=("NUM", "FILE"),
help=(
"write the chosen character to a Progress Quest web save "
"(.pqw) and exit"
),
)
return parser.parse_args() return parser.parse_args()
def list_players(roster: Roster, file: TextIO | None = None) -> None:
for i, player in enumerate(roster.players, start=1):
print(f"{i}. {player.name}", file=file)
def bootstrap_first_run(
roster: Roster, args: argparse.Namespace, data: GameData
) -> None:
if roster.players or args.list_saves or args.load_save:
return
print("No saved characters found. Starting first-run character creation.")
ui = BasicUserInterface(roster, None, args, data)
while not roster.players:
player = ui.create_player(auto_play=False)
if player:
return
if not ui.confirm("No character created. Do you want to try again?"):
print("A character is required for first run.", file=sys.stderr)
raise SystemExit(1)
def import_saves(
roster: Roster, data: GameData, paths: list[Path], use_saves: bool
) -> None:
imported = []
for path in paths:
try:
player, warnings = pqw.read_file(path, data)
except pqw.PqwError as ex:
print(f"pqcli: {ex}", file=sys.stderr)
exit(1)
for warning in warnings:
print(f"pqcli: {path}: {warning}", file=sys.stderr)
imported.append(player)
roster.players.extend(imported)
if use_saves:
roster.save()
for player in imported:
print(f"Imported {player.name}")
def export_save(roster: Roster, data: GameData, num: str, file: str) -> None:
if not num.isdigit() or not 1 <= int(num) <= len(roster.players):
print("Invalid player. Available players:", file=sys.stderr)
list_players(roster, file=sys.stderr)
exit(1)
player = roster.players[int(num) - 1]
try:
pqw.write_file(player, Path(file), data)
except pqw.PqwError as ex:
print(f"pqcli: {ex}", file=sys.stderr)
exit(1)
print(f"Exported {player.name} to {file}")
def main() -> None: def main() -> None:
args = parse_args() args = parse_args()
roster = Roster.load(SAVE_PATH)
if args.dump_data:
try:
paths = dump_builtin_data(args.dump_data)
except DataError as ex:
print(f"pqcli: {ex}", file=sys.stderr)
exit(1)
print(f"Wrote {len(paths)} files to {args.dump_data}")
exit(0)
try: try:
ui = args.ui(roster, args) data = load_game_data(args.data_dir)
except DataError as ex:
print(f"pqcli: {ex}", file=sys.stderr)
exit(1)
roster = Roster.load(SAVE_PATH)
if args.list_saves:
list_players(roster)
exit(0)
if args.import_save:
import_saves(roster, data, args.import_save, args.use_saves)
exit(0)
if args.export_save:
export_save(roster, data, *args.export_save)
exit(0)
player: Player | None = None
if args.load_save:
try:
player = list(roster.players)[args.load_save - 1]
except IndexError:
print("Invalid player. Available players:", file=sys.stderr)
list_players(roster, file=sys.stderr)
exit(1)
bootstrap_first_run(roster, args, data)
try:
ui = args.ui(roster, player, args, data)
ui.run() ui.run()
finally: finally:
if args.use_saves: if args.use_saves:
+6 -680
View File
@@ -1,5 +1,4 @@
import enum import enum
import typing as T
from dataclasses import dataclass from dataclasses import dataclass
@@ -39,56 +38,9 @@ PRIME_STATS = [
ALL_STATS = PRIME_STATS + [StatType.hp_max, StatType.mp_max] ALL_STATS = PRIME_STATS + [StatType.hp_max, StatType.mp_max]
# The word lists live in pqcli/data/*.json (see pqcli.game_data).
SPELLS = [ # Saves pickle Monster, Race and Class instances by import path, so these
"Slime Finger", # classes must stay in this module.
"Rabbit Punch",
"Hastiness",
"Good Move",
"Sadness",
"Seasick",
"Shoelaces",
"Innoculate",
"Cone of Annoyance",
"Magnetic Orb",
"Invisible Hands",
"Revolting Cloud",
"Aqueous Humor",
"Spectral Miasma",
"Clever Fellow",
"Lockjaw",
"History Lesson",
"Hydrophobia",
"Big Sister",
"Cone of Paste",
"Mulligan",
"Nestor's Bright Idea",
"Holy Batpole",
"Tumor (Benign)",
"Braingate",
"Summon a Bitch",
"Nonplus",
"Animate Nightstand",
"Eye of the Troglodyte",
"Curse Name",
"Dropsy",
"Vitreous Humor",
"Roger's Grand Illusion",
"Covet",
"Black Idaho",
"Astral Miasma",
"Spectral Oyster",
"Acrid Hands",
"Angioplasty",
"Grognor's Big Day Off",
"Tumor (Malignant)",
"Animate Tunic",
"Ursine Armor",
"Holy Roller",
"Tonsilectomy",
"Curse Family",
"Infinite Confusion",
]
@dataclass @dataclass
@@ -97,652 +49,26 @@ class Modifier:
quality: int quality: int
OFFENSE_ATTRIB = [
Modifier("Polished", 1),
Modifier("Serrated", 1),
Modifier("Heavy", 1),
Modifier("Pronged", 2),
Modifier("Steely", 2),
Modifier("Vicious", 3),
Modifier("Venomed", 4),
Modifier("Stabbity", 4),
Modifier("Dancing", 5),
Modifier("Invisible", 6),
Modifier("Vorpal", 7),
]
DEFENSE_ATTRIB = [
Modifier("Studded", 1),
Modifier("Banded", 2),
Modifier("Gilded", 2),
Modifier("Festooned", 3),
Modifier("Holy", 4),
Modifier("Cambric", 1),
Modifier("Fine", 4),
Modifier("Impressive", 5),
Modifier("Custom", 3),
]
OFFENSE_BAD = [
Modifier("Dull", -2),
Modifier("Tarnished", -1),
Modifier("Rusty", -3),
Modifier("Padded", -5),
Modifier("Bent", -4),
Modifier("Mini", -4),
Modifier("Rubber", -6),
Modifier("Nerf", -7),
Modifier("Unbalanced", -2),
]
DEFENSE_BAD = [
Modifier("Holey", -1),
Modifier("Patched", -1),
Modifier("Threadbare", -2),
Modifier("Faded", -1),
Modifier("Rusty", -3),
Modifier("Motheaten", -3),
Modifier("Mildewed", -2),
Modifier("Torn", -3),
Modifier("Dented", -3),
Modifier("Cursed", -5),
Modifier("Plastic", -4),
Modifier("Cracked", -4),
Modifier("Warped", -3),
Modifier("Corroded", -3),
]
@dataclass @dataclass
class EquipmentPreset: class EquipmentPreset:
name: str name: str
quality: int quality: int
SHIELDS = [
EquipmentPreset("Parasol", 0),
EquipmentPreset("Pie Plate", 1),
EquipmentPreset("Garbage Can Lid", 2),
EquipmentPreset("Buckler", 3),
EquipmentPreset("Plexiglass", 4),
EquipmentPreset("Fender", 4),
EquipmentPreset("Round Shield", 5),
EquipmentPreset("Carapace", 5),
EquipmentPreset("Scutum", 6),
EquipmentPreset("Propugner", 6),
EquipmentPreset("Kite Shield", 7),
EquipmentPreset("Pavise", 8),
EquipmentPreset("Tower Shield", 9),
EquipmentPreset("Baroque Shield", 11),
EquipmentPreset("Aegis", 12),
EquipmentPreset("Magnetic Field", 18),
]
ARMORS = [
EquipmentPreset("Lace", 1),
EquipmentPreset("Macrame", 2),
EquipmentPreset("Burlap", 3),
EquipmentPreset("Canvas", 4),
EquipmentPreset("Flannel", 5),
EquipmentPreset("Chamois", 6),
EquipmentPreset("Pleathers", 7),
EquipmentPreset("Leathers", 8),
EquipmentPreset("Bearskin", 9),
EquipmentPreset("Ringmail", 10),
EquipmentPreset("Scale Mail", 12),
EquipmentPreset("Chainmail", 14),
EquipmentPreset("Splint Mail", 15),
EquipmentPreset("Platemail", 16),
EquipmentPreset("ABS", 17),
EquipmentPreset("Kevlar", 18),
EquipmentPreset("Titanium", 19),
EquipmentPreset("Mithril Mail", 20),
EquipmentPreset("Diamond Mail", 25),
EquipmentPreset("Plasma", 30),
]
WEAPONS = [
EquipmentPreset("Stick", 0),
EquipmentPreset("Broken Bottle", 1),
EquipmentPreset("Shiv", 1),
EquipmentPreset("Sprig", 1),
EquipmentPreset("Oxgoad", 1),
EquipmentPreset("Eelspear", 2),
EquipmentPreset("Bowie Knife", 2),
EquipmentPreset("Claw Hammer", 2),
EquipmentPreset("Handpeen", 2),
EquipmentPreset("Andiron", 3),
EquipmentPreset("Hatchet", 3),
EquipmentPreset("Tomahawk", 3),
EquipmentPreset("Hackbarm", 3),
EquipmentPreset("Crowbar", 4),
EquipmentPreset("Mace", 4),
EquipmentPreset("Battleadze", 4),
EquipmentPreset("Leafmace", 5),
EquipmentPreset("Shortsword", 5),
EquipmentPreset("Longiron", 5),
EquipmentPreset("Poachard", 5),
EquipmentPreset("Baselard", 5),
EquipmentPreset("Whinyard", 6),
EquipmentPreset("Blunderbuss", 6),
EquipmentPreset("Longsword", 6),
EquipmentPreset("Crankbow", 6),
EquipmentPreset("Blibo", 7),
EquipmentPreset("Broadsword", 7),
EquipmentPreset("Kreen", 7),
EquipmentPreset("Warhammer", 7),
EquipmentPreset("Morning Star", 8),
EquipmentPreset("Pole-adze", 8),
EquipmentPreset("Spontoon", 8),
EquipmentPreset("Bastard Sword", 9),
EquipmentPreset("Peen-arm", 9),
EquipmentPreset("Culverin", 10),
EquipmentPreset("Lance", 10),
EquipmentPreset("Halberd", 11),
EquipmentPreset("Poleax", 12),
EquipmentPreset("Bandyclef", 15),
]
SPECIALS = [
"Diadem",
"Festoon",
"Gemstone",
"Phial",
"Tiara",
"Scabbard",
"Arrow",
"Lens",
"Lamp",
"Hymnal",
"Fleece",
"Laurel",
"Brooch",
"Gimlet",
"Cobble",
"Albatross",
"Brazier",
"Bandolier",
"Tome",
"Garnet",
"Amethyst",
"Candelabra",
"Corset",
"Sphere",
"Sceptre",
"Ankh",
"Talisman",
"Orb",
"Gammel",
"Ornament",
"Brocade",
"Galoon",
"Bijou",
"Spangle",
"Gimcrack",
"Hood",
"Vulpeculum",
]
ITEM_ATTRIB = [
"Golden",
"Gilded",
"Spectral",
"Astral",
"Garlanded",
"Precious",
"Crafted",
"Dual",
"Filigreed",
"Cruciate",
"Arcane",
"Blessed",
"Reverential",
"Lucky",
"Enchanted",
"Gleaming",
"Grandiose",
"Sacred",
"Legendary",
"Mythic",
"Crystalline",
"Austere",
"Ostentatious",
"One True",
"Proverbial",
"Fearsome",
"Deadly",
"Benevolent",
"Unearthly",
"Magnificent",
"Iron",
"Ormolu",
"Puissant",
]
ITEM_OFS = [
"Foreboding",
"Foreshadowing",
"Nervousness",
"Happiness",
"Torpor",
"Danger",
"Craft",
"Silence",
"Invisibility",
"Rapidity",
"Pleasure",
"Practicality",
"Hurting",
"Joy",
"Petulance",
"Intrusion",
"Chaos",
"Suffering",
"Extroversion",
"Frenzy",
"Sisu",
"Solitude",
"Punctuality",
"Efficiency",
"Comfort",
"Patience",
"Internment",
"Incarceration",
"Misapprehension",
"Loyalty",
"Envy",
"Acrimony",
"Worry",
"Fear",
"Awe",
"Guile",
"Prurience",
"Fortune",
"Perspicacity",
"Domination",
"Submission",
"Fealty",
"Hunger",
"Despair",
"Cruelty",
"Grob",
"Dignard",
"Ra",
"the Bone",
"Diamonique",
"Electrum",
"Hydragyrum",
]
BORING_ITEMS = [
"nail",
"lunchpail",
"sock",
"I.O.U.",
"cookie",
"pint",
"toothpick",
"writ",
"newspaper",
"letter",
"plank",
"hat",
"egg",
"coin",
"needle",
"bucket",
"ladder",
"chicken",
"twig",
"dirtclod",
"counterpane",
"vest",
"teratoma",
"bunny",
"rock",
"pole",
"carrot",
"canoe",
"inkwell",
"hoe",
"bandage",
"trowel",
"towel",
"planter box",
"anvil",
"axle",
"tuppence",
"casket",
"nosegay",
"trinket",
"credenza",
"writ",
]
@dataclass @dataclass
class Monster: class Monster:
name: str name: str
level: int level: int
item: T.Optional[str] item: str | None
MONSTERS = [
Monster("Anhkheg", 6, "chitin"),
Monster("Ant", 0, "antenna"),
Monster("Ape", 4, "ass"),
Monster("Baluchitherium", 14, "ear"),
Monster("Beholder", 10, "eyestalk"),
Monster("Black Pudding", 10, "saliva"),
Monster("Blink Dog", 4, "eyelid"),
Monster("Cub Scout", 1, "neckerchief"),
Monster("Girl Scout", 2, "cookie"),
Monster("Boy Scout", 3, "merit badge"),
Monster("Eagle Scout", 4, "merit badge"),
Monster("Bugbear", 3, "skin"),
Monster("Bugboar", 3, "tusk"),
Monster("Boogie", 3, "slime"),
Monster("Camel", 2, "hump"),
Monster("Carrion Crawler", 3, "egg"),
Monster("Catoblepas", 6, "neck"),
Monster("Centaur", 4, "rib"),
Monster("Centipede", 0, "leg"),
Monster("Cockatrice", 5, "wattle"),
Monster("Couatl", 9, "wing"),
Monster("Crayfish", 0, "antenna"),
Monster("Demogorgon", 53, "tentacle"),
Monster("Jubilex", 17, "gel"),
Monster("Manes", 1, "tooth"),
Monster("Orcus", 27, "wand"),
Monster("Succubus", 6, "bra"),
Monster("Vrock", 8, "neck"),
Monster("Hezrou", 9, "leg"),
Monster("Glabrezu", 10, "collar"),
Monster("Nalfeshnee", 11, "tusk"),
Monster("Marilith", 7, "arm"),
Monster("Balor", 8, "whip"),
Monster("Yeenoghu", 25, "flail"),
Monster("Asmodeus", 52, "leathers"),
Monster("Baalzebul", 43, "pants"),
Monster("Barbed Devil", 8, "flame"),
Monster("Bone Devil", 9, "hook"),
Monster("Dispater", 30, "matches"),
Monster("Erinyes", 6, "thong"),
Monster("Geryon", 30, "cornucopia"),
Monster("Malebranche", 5, "fork"),
Monster("Ice Devil", 11, "snow"),
Monster("Lemure", 3, "blob"),
Monster("Pit Fiend", 13, "seed"),
Monster("Ankylosaurus", 9, "tail"),
Monster("Brontosaurus", 30, "brain"),
Monster("Diplodocus", 24, "fin"),
Monster("Elasmosaurus", 15, "neck"),
Monster("Gorgosaurus", 13, "arm"),
Monster("Iguanadon", 6, "thumb"),
Monster("Megalosaurus", 12, "jaw"),
Monster("Monoclonius", 8, "horn"),
Monster("Pentasaurus", 12, "head"),
Monster("Stegosaurus", 18, "plate"),
Monster("Triceratops", 16, "horn"),
Monster("Tyranosauraus Rex", 18, "forearm"),
Monster("Djinn", 7, "lamp"),
Monster("Doppleganger", 4, "face"),
Monster("Black Dragon", 7, None),
Monster("Plaid Dragon", 7, "sporrin"),
Monster("Blue Dragon", 9, None),
Monster("Beige Dragon", 9, None),
Monster("Brass Dragon", 7, "pole"),
Monster("Tin Dragon", 8, None),
Monster("Bronze Dragon", 9, "medal"),
Monster("Chromatic Dragon", 16, "scale"),
Monster("Copper Dragon", 8, "loafer"),
Monster("Gold Dragon", 8, "filling"),
Monster("Green Dragon", 8, None),
Monster("Platinum Dragon", 21, None),
Monster("Red Dragon", 10, "cocktail"),
Monster("Silver Dragon", 10, None),
Monster("White Dragon", 6, "tooth"),
Monster("Dragon Turtle", 13, "shell"),
Monster("Dryad", 2, "acorn"),
Monster("Dwarf", 1, "drawers"),
Monster("Eel", 2, "sashimi"),
Monster("Efreet", 10, "cinder"),
Monster("Sand Elemental", 8, "glass"),
Monster("Bacon Elemental", 10, "bit"),
Monster("Porn Elemental", 12, "lube"),
Monster("Cheese Elemental", 14, "curd"),
Monster("Hair Elemental", 16, "follicle"),
Monster("Swamp Elf", 1, "lilypad"),
Monster("Brown Elf", 1, "tusk"),
Monster("Sea Elf", 1, "jerkin"),
Monster("Ettin", 10, "fur"),
Monster("Frog", 0, "leg"),
Monster("Violet Fungi", 3, "spore"),
Monster("Gargoyle", 4, "gravel"),
Monster("Gelatinous Cube", 4, "jam"),
Monster("Ghast", 4, "vomit"),
Monster("Ghost", 10, None),
Monster("Ghoul", 2, "muscle"),
Monster("Humidity Giant", 12, "drops"),
Monster("Beef Giant", 11, "steak"),
Monster("Quartz Giant", 10, "crystal"),
Monster("Porcelain Giant", 9, "fixture"),
Monster("Rice Giant", 8, "grain"),
Monster("Cloud Giant", 12, "condensation"),
Monster("Fire Giant", 11, "cigarettes"),
Monster("Frost Giant", 10, "snowman"),
Monster("Hill Giant", 8, "corpse"),
Monster("Stone Giant", 9, "hatchling"),
Monster("Storm Giant", 15, "barometer"),
Monster("Mini Giant", 4, "pompadour"),
Monster("Gnoll", 2, "collar"),
Monster("Gnome", 1, "hat"),
Monster("Goblin", 1, "ear"),
Monster("Grid Bug", 1, "carapace"),
Monster("Jellyrock", 9, "seedling"),
Monster("Beer Golem", 15, "foam"),
Monster("Oxygen Golem", 17, "platelet"),
Monster("Cardboard Golem", 14, "recycling"),
Monster("Rubber Golem", 16, "ball"),
Monster("Leather Golem", 15, "fob"),
Monster("Gorgon", 8, "testicle"),
Monster("Gray Ooze", 3, "gravy"),
Monster("Green Slime", 2, "sample"),
Monster("Griffon", 7, "nest"),
Monster("Banshee", 7, "larynx"),
Monster("Harpy", 3, "mascara"),
Monster("Hell Hound", 5, "tongue"),
Monster("Hippocampus", 4, "mane"),
Monster("Hippogriff", 3, "egg"),
Monster("Hobgoblin", 1, "patella"),
Monster("Homonculus", 2, "fluid"),
Monster("Hydra", 8, "gyrum"),
Monster("Imp", 2, "tail"),
Monster("Invisible Stalker", 8, None),
Monster("Iron Peasant", 3, "chaff"),
Monster("Jumpskin", 3, "shin"),
Monster("Kobold", 1, "penis"),
Monster("Leprechaun", 1, "wallet"),
Monster("Leucrotta", 6, "hoof"),
Monster("Lich", 11, "crown"),
Monster("Lizard Man", 2, "tail"),
Monster("Lurker", 10, "sac"),
Monster("Manticore", 6, "spike"),
Monster("Mastodon", 12, "tusk"),
Monster("Medusa", 6, "eye"),
Monster("Multicell", 2, "dendrite"),
Monster("Pirate", 1, "booty"),
Monster("Berserker", 1, "shirt"),
Monster("Caveman", 2, "club"),
Monster("Dervish", 1, "robe"),
Monster("Merman", 1, "trident"),
Monster("Mermaid", 1, "gills"),
Monster("Mimic", 9, "hinge"),
Monster("Mind Flayer", 8, "tentacle"),
Monster("Minotaur", 6, "map"),
Monster("Yellow Mold", 1, "spore"),
Monster("Morkoth", 7, "teeth"),
Monster("Mummy", 6, "gauze"),
Monster("Naga", 9, "rattle"),
Monster("Nebbish", 1, "belly"),
Monster("Neo-Otyugh", 11, "organ "),
Monster("Nixie", 1, "webbing"),
Monster("Nymph", 3, "hanky"),
Monster("Ochre Jelly", 6, "nucleus"),
Monster("Octopus", 2, "beak"),
Monster("Ogre", 4, "talon"),
Monster("Ogre Mage", 5, "apparel"),
Monster("Orc", 1, "snout"),
Monster("Otyugh", 7, "organ"),
Monster("Owlbear", 5, "feather"),
Monster("Pegasus", 4, "aileron"),
Monster("Peryton", 4, "antler"),
Monster("Piercer", 3, "tip"),
Monster("Pixie", 1, "dust"),
Monster("Man-o-war", 3, "tentacle"),
Monster("Purple Worm", 15, "dung"),
Monster("Quasit", 3, "tail"),
Monster("Rakshasa", 7, "pajamas"),
Monster("Rat", 0, "tail"),
Monster("Remorhaz", 11, "protrusion"),
Monster("Roc", 18, "wing"),
Monster("Roper", 11, "twine"),
Monster("Rot Grub", 1, "eggsac"),
Monster("Rust Monster", 5, "shavings"),
Monster("Satyr", 5, "hoof"),
Monster("Sea Hag", 3, "wart"),
Monster("Silkie", 3, "fur"),
Monster("Shadow", 3, "silhouette"),
Monster("Shambling Mound", 10, "mulch"),
Monster("Shedu", 9, "hoof"),
Monster("Shrieker", 3, "stalk"),
Monster("Skeleton", 1, "clavicle"),
Monster("Spectre", 7, "vestige"),
Monster("Sphinx", 10, "paw"),
Monster("Spider", 0, "web"),
Monster("Sprite", 1, "can"),
Monster("Stirge", 1, "proboscis"),
Monster("Stun Bear", 5, "tooth"),
Monster("Stun Worm", 2, "trode"),
Monster("Su-monster", 5, "tail"),
Monster("Sylph", 3, "thigh"),
Monster("Titan", 20, "sandal"),
Monster("Trapper", 12, "shag"),
Monster("Treant", 10, "acorn"),
Monster("Triton", 3, "scale"),
Monster("Troglodyte", 2, "tail"),
Monster("Troll", 6, "hide"),
Monster("Umber Hulk", 8, "claw"),
Monster("Unicorn", 4, "blood"),
Monster("Vampire", 8, "pancreas"),
Monster("Wight", 4, "lung"),
Monster("Will-o'-the-Wisp", 9, "wisp"),
Monster("Wraith", 5, "finger"),
Monster("Wyvern", 7, "wing"),
Monster("Xorn", 7, "jaw"),
Monster("Yeti", 4, "fur"),
Monster("Zombie", 2, "forehead"),
Monster("Wasp", 0, "stinger"),
Monster("Rat", 1, "tail"),
Monster("Bunny", 0, "ear"),
Monster("Moth", 0, "dust"),
Monster("Beagle", 0, "collar"),
Monster("Midge", 0, "corpse"),
Monster("Ostrich", 1, "beak"),
Monster("Billy Goat", 1, "beard"),
Monster("Bat", 1, "wing"),
Monster("Koala", 2, "heart"),
Monster("Wolf", 2, "paw"),
Monster("Whippet", 2, "collar"),
Monster("Uruk", 2, "boot"),
Monster("Poroid", 4, "node"),
Monster("Moakum", 8, "frenum"),
Monster("Fly", 0, None),
Monster("Hogbird", 3, "curl"),
Monster("Wolog", 4, "lemma"),
]
@dataclass @dataclass
class Race: class Race:
name: str name: str
attr: T.List[StatType] attr: list[StatType]
RACES = [
Race("Half Orc", [StatType.hp_max]),
Race("Half Man", [StatType.charisma]),
Race("Half Halfling", [StatType.dexterity]),
Race("Double Hobbit", [StatType.strength]),
Race("Hob-Hobbit", [StatType.dexterity, StatType.condition]),
Race("Low Elf", [StatType.condition]),
Race("Dung Elf", [StatType.wisdom]),
Race("Talking Pony", [StatType.mp_max, StatType.intelligence]),
Race("Gyrognome", [StatType.dexterity]),
Race("Lesser Dwarf", [StatType.condition]),
Race("Crested Dwarf", [StatType.charisma]),
Race("Eel Man", [StatType.dexterity]),
Race("Panda Man", [StatType.condition, StatType.strength]),
Race("Trans-Kobold", [StatType.wisdom]),
Race("Enchanted Motorcycle", [StatType.mp_max]),
Race("Will o' the Wisp", [StatType.wisdom]),
Race("Battle-Finch", [StatType.dexterity, StatType.intelligence]),
Race("Double Wookiee", [StatType.strength]),
Race("Skraeling", [StatType.wisdom]),
Race("Demicanadian", [StatType.condition]),
Race("Land Squid", [StatType.strength, StatType.hp_max]),
]
@dataclass @dataclass
class Class: class Class:
name: str name: str
attr: T.List[StatType] attr: list[StatType]
CLASSES = [
Class("Ur-Paladin", [StatType.wisdom, StatType.condition]),
Class("Voodoo Princess", [StatType.intelligence, StatType.charisma]),
Class("Robot Monk", [StatType.strength]),
Class("Mu-Fu Monk", [StatType.dexterity]),
Class("Mage Illusioner", [StatType.intelligence, StatType.mp_max]),
Class("Shiv-Knight", [StatType.dexterity]),
Class("Inner Mason", [StatType.condition]),
Class("Fighter/Organist", [StatType.charisma, StatType.strength]),
Class("Puma Burgular", [StatType.dexterity]),
Class("Runeloremaster", [StatType.wisdom]),
Class("Hunter Strangler", [StatType.dexterity, StatType.intelligence]),
Class("Battle-Felon", [StatType.strength]),
Class("Tickle-Mimic", [StatType.wisdom, StatType.intelligence]),
Class("Slow Poisoner", [StatType.condition]),
Class("Bastard Lunatic", [StatType.condition]),
Class("Lowling", [StatType.wisdom]),
Class("Birdrider", [StatType.wisdom]),
Class("Vermineer", [StatType.intelligence]),
]
TITLES = [
"Mr.",
"Mrs.",
"Sir",
"Sgt.",
"Ms.",
"Captain",
"Chief",
"Admiral",
"Saint",
]
IMPRESSIVE_TITLES = [
"King",
"Queen",
"Lord",
"Lady",
"Viceroy",
"Mayor",
"Prince",
"Princess",
"Chief",
"Boss",
"Archbishop",
]
+22
View File
@@ -0,0 +1,22 @@
[
{"name": "Lace", "quality": 1},
{"name": "Macrame", "quality": 2},
{"name": "Burlap", "quality": 3},
{"name": "Canvas", "quality": 4},
{"name": "Flannel", "quality": 5},
{"name": "Chamois", "quality": 6},
{"name": "Pleathers", "quality": 7},
{"name": "Leathers", "quality": 8},
{"name": "Bearskin", "quality": 9},
{"name": "Ringmail", "quality": 10},
{"name": "Scale Mail", "quality": 12},
{"name": "Chainmail", "quality": 14},
{"name": "Splint Mail", "quality": 15},
{"name": "Platemail", "quality": 16},
{"name": "ABS", "quality": 17},
{"name": "Kevlar", "quality": 18},
{"name": "Titanium", "quality": 19},
{"name": "Mithril Mail", "quality": 20},
{"name": "Diamond Mail", "quality": 25},
{"name": "Plasma", "quality": 30}
]
+44
View File
@@ -0,0 +1,44 @@
[
"nail",
"lunchpail",
"sock",
"I.O.U.",
"cookie",
"pint",
"toothpick",
"writ",
"newspaper",
"letter",
"plank",
"hat",
"egg",
"coin",
"needle",
"bucket",
"ladder",
"chicken",
"twig",
"dirtclod",
"counterpane",
"vest",
"teratoma",
"bunny",
"rock",
"pole",
"carrot",
"canoe",
"inkwell",
"hoe",
"bandage",
"trowel",
"towel",
"planter box",
"anvil",
"axle",
"tuppence",
"casket",
"nosegay",
"trinket",
"credenza",
"writ"
]
+20
View File
@@ -0,0 +1,20 @@
[
{"name": "Ur-Paladin", "attr": ["WIS", "CON"]},
{"name": "Voodoo Princess", "attr": ["INT", "CHA"]},
{"name": "Robot Monk", "attr": ["STR"]},
{"name": "Mu-Fu Monk", "attr": ["DEX"]},
{"name": "Mage Illusioner", "attr": ["INT", "MP Max"]},
{"name": "Shiv-Knight", "attr": ["DEX"]},
{"name": "Inner Mason", "attr": ["CON"]},
{"name": "Fighter/Organist", "attr": ["CHA", "STR"]},
{"name": "Puma Burgular", "attr": ["DEX"]},
{"name": "Runeloremaster", "attr": ["WIS"]},
{"name": "Hunter Strangler", "attr": ["DEX", "INT"]},
{"name": "Battle-Felon", "attr": ["STR"]},
{"name": "Tickle-Mimic", "attr": ["WIS", "INT"]},
{"name": "Slow Poisoner", "attr": ["CON"]},
{"name": "Bastard Lunatic", "attr": ["CON"]},
{"name": "Lowling", "attr": ["WIS"]},
{"name": "Birdrider", "attr": ["WIS"]},
{"name": "Vermineer", "attr": ["INT"]}
]
+3
View File
@@ -0,0 +1,3 @@
[
"Executing {target}"
]
+11
View File
@@ -0,0 +1,11 @@
[
{"name": "Studded", "quality": 1},
{"name": "Banded", "quality": 2},
{"name": "Gilded", "quality": 2},
{"name": "Festooned", "quality": 3},
{"name": "Holy", "quality": 4},
{"name": "Cambric", "quality": 1},
{"name": "Fine", "quality": 4},
{"name": "Impressive", "quality": 5},
{"name": "Custom", "quality": 3}
]
+16
View File
@@ -0,0 +1,16 @@
[
{"name": "Holey", "quality": -1},
{"name": "Patched", "quality": -1},
{"name": "Threadbare", "quality": -2},
{"name": "Faded", "quality": -1},
{"name": "Rusty", "quality": -3},
{"name": "Motheaten", "quality": -3},
{"name": "Mildewed", "quality": -2},
{"name": "Torn", "quality": -3},
{"name": "Dented", "quality": -3},
{"name": "Cursed", "quality": -5},
{"name": "Plastic", "quality": -4},
{"name": "Cracked", "quality": -4},
{"name": "Warped", "quality": -3},
{"name": "Corroded", "quality": -3}
]
+16
View File
@@ -0,0 +1,16 @@
[
"King",
"Queen",
"Lord",
"Lady",
"Viceroy",
"Mayor",
"Prince",
"Princess",
"Chief",
"Boss",
"Archbishop",
"Chancellor",
"Baroness",
"Inquistor"
]
+35
View File
@@ -0,0 +1,35 @@
[
"Golden",
"Gilded",
"Spectral",
"Astral",
"Garlanded",
"Precious",
"Crafted",
"Dual",
"Filigreed",
"Cruciate",
"Arcane",
"Blessed",
"Reverential",
"Lucky",
"Enchanted",
"Gleaming",
"Grandiose",
"Sacred",
"Legendary",
"Mythic",
"Crystalline",
"Austere",
"Ostentatious",
"One True",
"Proverbial",
"Fearsome",
"Deadly",
"Benevolent",
"Unearthly",
"Magnificent",
"Iron",
"Ormolu",
"Puissant"
]
+54
View File
@@ -0,0 +1,54 @@
[
"Foreboding",
"Foreshadowing",
"Nervousness",
"Happiness",
"Torpor",
"Danger",
"Craft",
"Silence",
"Invisibility",
"Rapidity",
"Pleasure",
"Practicality",
"Hurting",
"Joy",
"Petulance",
"Intrusion",
"Chaos",
"Suffering",
"Extroversion",
"Frenzy",
"Sisu",
"Solitude",
"Punctuality",
"Efficiency",
"Comfort",
"Patience",
"Internment",
"Incarceration",
"Misapprehension",
"Loyalty",
"Envy",
"Acrimony",
"Worry",
"Fear",
"Awe",
"Guile",
"Prurience",
"Fortune",
"Perspicacity",
"Domination",
"Submission",
"Fealty",
"Hunger",
"Despair",
"Cruelty",
"Grob",
"Dignard",
"Ra",
"the Bone",
"Diamonique",
"Electrum",
"Hydragyrum"
]
+234
View File
@@ -0,0 +1,234 @@
[
{"name": "Anhkheg", "level": 6, "item": "chitin"},
{"name": "Ant", "level": 0, "item": "antenna"},
{"name": "Ape", "level": 4, "item": "ass"},
{"name": "Baluchitherium", "level": 14, "item": "ear"},
{"name": "Beholder", "level": 10, "item": "eyestalk"},
{"name": "Black Pudding", "level": 10, "item": "saliva"},
{"name": "Blink Dog", "level": 4, "item": "eyelid"},
{"name": "Cub Scout", "level": 1, "item": "neckerchief"},
{"name": "Girl Scout", "level": 2, "item": "cookie"},
{"name": "Boy Scout", "level": 3, "item": "merit badge"},
{"name": "Eagle Scout", "level": 4, "item": "merit badge"},
{"name": "Bugbear", "level": 3, "item": "skin"},
{"name": "Bugboar", "level": 3, "item": "tusk"},
{"name": "Boogie", "level": 3, "item": "slime"},
{"name": "Camel", "level": 2, "item": "hump"},
{"name": "Carrion Crawler", "level": 3, "item": "egg"},
{"name": "Catoblepas", "level": 6, "item": "neck"},
{"name": "Centaur", "level": 4, "item": "rib"},
{"name": "Centipede", "level": 0, "item": "leg"},
{"name": "Cockatrice", "level": 5, "item": "wattle"},
{"name": "Couatl", "level": 9, "item": "wing"},
{"name": "Crayfish", "level": 0, "item": "antenna"},
{"name": "Demogorgon", "level": 53, "item": "tentacle"},
{"name": "Jubilex", "level": 17, "item": "gel"},
{"name": "Manes", "level": 1, "item": "tooth"},
{"name": "Orcus", "level": 27, "item": "wand"},
{"name": "Succubus", "level": 6, "item": "bra"},
{"name": "Vrock", "level": 8, "item": "neck"},
{"name": "Hezrou", "level": 9, "item": "leg"},
{"name": "Glabrezu", "level": 10, "item": "collar"},
{"name": "Nalfeshnee", "level": 11, "item": "tusk"},
{"name": "Marilith", "level": 7, "item": "arm"},
{"name": "Balor", "level": 8, "item": "whip"},
{"name": "Yeenoghu", "level": 25, "item": "flail"},
{"name": "Asmodeus", "level": 52, "item": "leathers"},
{"name": "Baalzebul", "level": 43, "item": "pants"},
{"name": "Barbed Devil", "level": 8, "item": "flame"},
{"name": "Bone Devil", "level": 9, "item": "hook"},
{"name": "Dispater", "level": 30, "item": "matches"},
{"name": "Erinyes", "level": 6, "item": "thong"},
{"name": "Geryon", "level": 30, "item": "cornucopia"},
{"name": "Malebranche", "level": 5, "item": "fork"},
{"name": "Ice Devil", "level": 11, "item": "snow"},
{"name": "Lemure", "level": 3, "item": "blob"},
{"name": "Pit Fiend", "level": 13, "item": "seed"},
{"name": "Ankylosaurus", "level": 9, "item": "tail"},
{"name": "Brontosaurus", "level": 30, "item": "brain"},
{"name": "Diplodocus", "level": 24, "item": "fin"},
{"name": "Elasmosaurus", "level": 15, "item": "neck"},
{"name": "Gorgosaurus", "level": 13, "item": "arm"},
{"name": "Iguanadon", "level": 6, "item": "thumb"},
{"name": "Megalosaurus", "level": 12, "item": "jaw"},
{"name": "Monoclonius", "level": 8, "item": "horn"},
{"name": "Pentasaurus", "level": 12, "item": "head"},
{"name": "Stegosaurus", "level": 18, "item": "plate"},
{"name": "Triceratops", "level": 16, "item": "horn"},
{"name": "Tyrannosaurus Rex", "level": 18, "item": "forearm"},
{"name": "Djinn", "level": 7, "item": "lamp"},
{"name": "Doppelganger", "level": 4, "item": "face"},
{"name": "Black Dragon", "level": 7, "item": null},
{"name": "Plaid Dragon", "level": 7, "item": "sporrin"},
{"name": "Blue Dragon", "level": 9, "item": null},
{"name": "Beige Dragon", "level": 9, "item": null},
{"name": "Brass Dragon", "level": 7, "item": "pole"},
{"name": "Tin Dragon", "level": 8, "item": null},
{"name": "Bronze Dragon", "level": 9, "item": "medal"},
{"name": "Chromatic Dragon", "level": 16, "item": "scale"},
{"name": "Copper Dragon", "level": 8, "item": "loafer"},
{"name": "Gold Dragon", "level": 8, "item": "filling"},
{"name": "Green Dragon", "level": 8, "item": null},
{"name": "Platinum Dragon", "level": 21, "item": null},
{"name": "Red Dragon", "level": 10, "item": "cocktail"},
{"name": "Silver Dragon", "level": 10, "item": null},
{"name": "White Dragon", "level": 6, "item": "tooth"},
{"name": "Dragon Turtle", "level": 13, "item": "shell"},
{"name": "Dryad", "level": 2, "item": "acorn"},
{"name": "Dwarf", "level": 1, "item": "drawers"},
{"name": "Eel", "level": 2, "item": "sashimi"},
{"name": "Efreet", "level": 10, "item": "cinder"},
{"name": "Sand Elemental", "level": 8, "item": "glass"},
{"name": "Bacon Elemental", "level": 10, "item": "bit"},
{"name": "Porn Elemental", "level": 12, "item": "lube"},
{"name": "Cheese Elemental", "level": 14, "item": "curd"},
{"name": "Hair Elemental", "level": 16, "item": "follicle"},
{"name": "Swamp Elf", "level": 1, "item": "lilypad"},
{"name": "Brown Elf", "level": 1, "item": "tusk"},
{"name": "Sea Elf", "level": 1, "item": "jerkin"},
{"name": "Ettin", "level": 10, "item": "fur"},
{"name": "Frog", "level": 0, "item": "leg"},
{"name": "Violet Fungi", "level": 3, "item": "spore"},
{"name": "Gargoyle", "level": 4, "item": "gravel"},
{"name": "Gelatinous Cube", "level": 4, "item": "jam"},
{"name": "Ghast", "level": 4, "item": "vomit"},
{"name": "Ghost", "level": 10, "item": null},
{"name": "Ghoul", "level": 2, "item": "muscle"},
{"name": "Humidity Giant", "level": 12, "item": "drops"},
{"name": "Beef Giant", "level": 11, "item": "steak"},
{"name": "Quartz Giant", "level": 10, "item": "crystal"},
{"name": "Porcelain Giant", "level": 9, "item": "fixture"},
{"name": "Rice Giant", "level": 8, "item": "grain"},
{"name": "Cloud Giant", "level": 12, "item": "condensation"},
{"name": "Fire Giant", "level": 11, "item": "cigarettes"},
{"name": "Frost Giant", "level": 10, "item": "snowman"},
{"name": "Hill Giant", "level": 8, "item": "corpse"},
{"name": "Stone Giant", "level": 9, "item": "hatchling"},
{"name": "Storm Giant", "level": 15, "item": "barometer"},
{"name": "Mini Giant", "level": 4, "item": "pompadour"},
{"name": "Gnoll", "level": 2, "item": "collar"},
{"name": "Gnome", "level": 1, "item": "hat"},
{"name": "Goblin", "level": 1, "item": "ear"},
{"name": "Grid Bug", "level": 1, "item": "carapace"},
{"name": "Jellyrock", "level": 9, "item": "seedling"},
{"name": "Beer Golem", "level": 15, "item": "foam"},
{"name": "Oxygen Golem", "level": 17, "item": "platelet"},
{"name": "Cardboard Golem", "level": 14, "item": "recycling"},
{"name": "Rubber Golem", "level": 16, "item": "ball"},
{"name": "Leather Golem", "level": 15, "item": "fob"},
{"name": "Gorgon", "level": 8, "item": "testicle"},
{"name": "Gray Ooze", "level": 3, "item": "gravy"},
{"name": "Green Slime", "level": 2, "item": "sample"},
{"name": "Griffon", "level": 7, "item": "nest"},
{"name": "Banshee", "level": 7, "item": "larynx"},
{"name": "Harpy", "level": 3, "item": "mascara"},
{"name": "Hell Hound", "level": 5, "item": "tongue"},
{"name": "Hippocampus", "level": 4, "item": "mane"},
{"name": "Hippogriff", "level": 3, "item": "egg"},
{"name": "Hobgoblin", "level": 1, "item": "patella"},
{"name": "Homunculus", "level": 2, "item": "fluid"},
{"name": "Hydra", "level": 8, "item": "gyrum"},
{"name": "Imp", "level": 2, "item": "tail"},
{"name": "Invisible Stalker", "level": 8, "item": null},
{"name": "Iron Peasant", "level": 3, "item": "chaff"},
{"name": "Jumpskin", "level": 3, "item": "shin"},
{"name": "Kobold", "level": 1, "item": "penis"},
{"name": "Leprechaun", "level": 1, "item": "wallet"},
{"name": "Leucrotta", "level": 6, "item": "hoof"},
{"name": "Lich", "level": 11, "item": "crown"},
{"name": "Lizard Man", "level": 2, "item": "tail"},
{"name": "Lurker", "level": 10, "item": "sac"},
{"name": "Manticore", "level": 6, "item": "spike"},
{"name": "Mastodon", "level": 12, "item": "tusk"},
{"name": "Medusa", "level": 6, "item": "eye"},
{"name": "Multicell", "level": 2, "item": "dendrite"},
{"name": "Pirate", "level": 1, "item": "booty"},
{"name": "Berserker", "level": 1, "item": "shirt"},
{"name": "Caveman", "level": 2, "item": "club"},
{"name": "Dervish", "level": 1, "item": "robe"},
{"name": "Merman", "level": 1, "item": "trident"},
{"name": "Mermaid", "level": 1, "item": "gills"},
{"name": "Mimic", "level": 9, "item": "hinge"},
{"name": "Mind Flayer", "level": 8, "item": "tentacle"},
{"name": "Minotaur", "level": 6, "item": "map"},
{"name": "Yellow Mold", "level": 1, "item": "spore"},
{"name": "Morkoth", "level": 7, "item": "teeth"},
{"name": "Mummy", "level": 6, "item": "gauze"},
{"name": "Naga", "level": 9, "item": "rattle"},
{"name": "Nebbish", "level": 1, "item": "belly"},
{"name": "Neo-Otyugh", "level": 11, "item": "organ "},
{"name": "Nixie", "level": 1, "item": "webbing"},
{"name": "Nymph", "level": 3, "item": "hanky"},
{"name": "Ochre Jelly", "level": 6, "item": "nucleus"},
{"name": "Octopus", "level": 2, "item": "beak"},
{"name": "Ogre", "level": 4, "item": "talon"},
{"name": "Ogre Mage", "level": 5, "item": "apparel"},
{"name": "Orc", "level": 1, "item": "snout"},
{"name": "Otyugh", "level": 7, "item": "organ"},
{"name": "Owlbear", "level": 5, "item": "feather"},
{"name": "Pegasus", "level": 4, "item": "aileron"},
{"name": "Peryton", "level": 4, "item": "antler"},
{"name": "Piercer", "level": 3, "item": "tip"},
{"name": "Pixie", "level": 1, "item": "dust"},
{"name": "Man-o-war", "level": 3, "item": "tentacle"},
{"name": "Purple Worm", "level": 15, "item": "dung"},
{"name": "Quasit", "level": 3, "item": "tail"},
{"name": "Rakshasa", "level": 7, "item": "pajamas"},
{"name": "Rat", "level": 0, "item": "tail"},
{"name": "Remorhaz", "level": 11, "item": "protrusion"},
{"name": "Roc", "level": 18, "item": "wing"},
{"name": "Roper", "level": 11, "item": "twine"},
{"name": "Rot Grub", "level": 1, "item": "eggsac"},
{"name": "Rust Monster", "level": 5, "item": "shavings"},
{"name": "Satyr", "level": 5, "item": "hoof"},
{"name": "Sea Hag", "level": 3, "item": "wart"},
{"name": "Silkie", "level": 3, "item": "fur"},
{"name": "Shadow", "level": 3, "item": "silhouette"},
{"name": "Shambling Mound", "level": 10, "item": "mulch"},
{"name": "Shedu", "level": 9, "item": "hoof"},
{"name": "Shrieker", "level": 3, "item": "stalk"},
{"name": "Skeleton", "level": 1, "item": "clavicle"},
{"name": "Spectre", "level": 7, "item": "vestige"},
{"name": "Sphinx", "level": 10, "item": "paw"},
{"name": "Spider", "level": 0, "item": "web"},
{"name": "Sprite", "level": 1, "item": "can"},
{"name": "Stirge", "level": 1, "item": "proboscis"},
{"name": "Stun Bear", "level": 5, "item": "tooth"},
{"name": "Stun Worm", "level": 2, "item": "trode"},
{"name": "Su-monster", "level": 5, "item": "tail"},
{"name": "Sylph", "level": 3, "item": "thigh"},
{"name": "Titan", "level": 20, "item": "sandal"},
{"name": "Trapper", "level": 12, "item": "shag"},
{"name": "Treant", "level": 10, "item": "acorn"},
{"name": "Triton", "level": 3, "item": "scale"},
{"name": "Troglodyte", "level": 2, "item": "tail"},
{"name": "Troll", "level": 6, "item": "hide"},
{"name": "Umber Hulk", "level": 8, "item": "claw"},
{"name": "Unicorn", "level": 4, "item": "blood"},
{"name": "Vampire", "level": 8, "item": "pancreas"},
{"name": "Wight", "level": 4, "item": "lung"},
{"name": "Will-o'-the-Wisp", "level": 9, "item": "wisp"},
{"name": "Wraith", "level": 5, "item": "finger"},
{"name": "Wyvern", "level": 7, "item": "wing"},
{"name": "Xorn", "level": 7, "item": "jaw"},
{"name": "Yeti", "level": 4, "item": "fur"},
{"name": "Zombie", "level": 2, "item": "forehead"},
{"name": "Wasp", "level": 0, "item": "stinger"},
{"name": "Rat", "level": 1, "item": "tail"},
{"name": "Bunny", "level": 0, "item": "ear"},
{"name": "Moth", "level": 0, "item": "dust"},
{"name": "Beagle", "level": 0, "item": "collar"},
{"name": "Midge", "level": 0, "item": "corpse"},
{"name": "Ostrich", "level": 1, "item": "beak"},
{"name": "Billy Goat", "level": 1, "item": "beard"},
{"name": "Bat", "level": 1, "item": "wing"},
{"name": "Koala", "level": 2, "item": "heart"},
{"name": "Wolf", "level": 2, "item": "paw"},
{"name": "Whippet", "level": 2, "item": "collar"},
{"name": "Uruk", "level": 2, "item": "boot"},
{"name": "Poroid", "level": 4, "item": "node"},
{"name": "Moakum", "level": 8, "item": "frenum"},
{"name": "Fly", "level": 0, "item": null},
{"name": "Hogbird", "level": 3, "item": "curl"},
{"name": "Wolog", "level": 4, "item": "lemma"}
]
+13
View File
@@ -0,0 +1,13 @@
[
{"name": "Polished", "quality": 1},
{"name": "Serrated", "quality": 1},
{"name": "Heavy", "quality": 1},
{"name": "Pronged", "quality": 2},
{"name": "Steely", "quality": 2},
{"name": "Vicious", "quality": 3},
{"name": "Venomed", "quality": 4},
{"name": "Stabbity", "quality": 4},
{"name": "Dancing", "quality": 5},
{"name": "Invisible", "quality": 6},
{"name": "Vorpal", "quality": 7}
]
+11
View File
@@ -0,0 +1,11 @@
[
{"name": "Dull", "quality": -2},
{"name": "Tarnished", "quality": -1},
{"name": "Rusty", "quality": -3},
{"name": "Padded", "quality": -5},
{"name": "Bent", "quality": -4},
{"name": "Mini", "quality": -4},
{"name": "Rubber", "quality": -6},
{"name": "Nerf", "quality": -7},
{"name": "Unbalanced", "quality": -2}
]
+23
View File
@@ -0,0 +1,23 @@
[
{"name": "Half Orc", "attr": ["HP Max"]},
{"name": "Half Man", "attr": ["CHA"]},
{"name": "Half Halfling", "attr": ["DEX"]},
{"name": "Double Hobbit", "attr": ["STR"]},
{"name": "Hob-Hobbit", "attr": ["DEX", "CON"]},
{"name": "Low Elf", "attr": ["CON"]},
{"name": "Dung Elf", "attr": ["WIS"]},
{"name": "Talking Pony", "attr": ["MP Max", "INT"]},
{"name": "Gyrognome", "attr": ["DEX"]},
{"name": "Lesser Dwarf", "attr": ["CON"]},
{"name": "Crested Dwarf", "attr": ["CHA"]},
{"name": "Eel Man", "attr": ["DEX"]},
{"name": "Panda Man", "attr": ["CON", "STR"]},
{"name": "Trans-Kobold", "attr": ["WIS"]},
{"name": "Enchanted Motorcycle", "attr": ["MP Max"]},
{"name": "Will o' the Wisp", "attr": ["WIS"]},
{"name": "Battle-Finch", "attr": ["DEX", "INT"]},
{"name": "Double Wookiee", "attr": ["STR"]},
{"name": "Skraeling", "attr": ["WIS"]},
{"name": "Demicanadian", "attr": ["CON"]},
{"name": "Land Squid", "attr": ["STR", "HP Max"]}
]
+18
View File
@@ -0,0 +1,18 @@
[
{"name": "Parasol", "quality": 0},
{"name": "Pie Plate", "quality": 1},
{"name": "Garbage Can Lid", "quality": 2},
{"name": "Buckler", "quality": 3},
{"name": "Plexiglass", "quality": 4},
{"name": "Fender", "quality": 4},
{"name": "Round Shield", "quality": 5},
{"name": "Carapace", "quality": 5},
{"name": "Scutum", "quality": 6},
{"name": "Propugner", "quality": 6},
{"name": "Kite Shield", "quality": 7},
{"name": "Pavise", "quality": 8},
{"name": "Tower Shield", "quality": 9},
{"name": "Baroque Shield", "quality": 11},
{"name": "Aegis", "quality": 12},
{"name": "Magnetic Field", "quality": 18}
]
+39
View File
@@ -0,0 +1,39 @@
[
"Diadem",
"Festoon",
"Gemstone",
"Phial",
"Tiara",
"Scabbard",
"Arrow",
"Lens",
"Lamp",
"Hymnal",
"Fleece",
"Laurel",
"Brooch",
"Gimlet",
"Cobble",
"Albatross",
"Brazier",
"Bandolier",
"Tome",
"Garnet",
"Amethyst",
"Candelabra",
"Corset",
"Sphere",
"Sceptre",
"Ankh",
"Talisman",
"Orb",
"Gammel",
"Ornament",
"Brocade",
"Galoon",
"Bijou",
"Spangle",
"Gimcrack",
"Hood",
"Vulpeculum"
]
+49
View File
@@ -0,0 +1,49 @@
[
"Slime Finger",
"Rabbit Punch",
"Hastiness",
"Good Move",
"Sadness",
"Seasick",
"Shoelaces",
"Inoculate",
"Cone of Annoyance",
"Magnetic Orb",
"Invisible Hands",
"Revolting Cloud",
"Aqueous Humor",
"Spectral Miasma",
"Clever Fellow",
"Lockjaw",
"History Lesson",
"Hydrophobia",
"Big Sister",
"Cone of Paste",
"Mulligan",
"Nestor's Bright Idea",
"Holy Batpole",
"Tumor (Benign)",
"Braingate",
"Summon a Bitch",
"Nonplus",
"Animate Nightstand",
"Eye of the Troglodyte",
"Curse Name",
"Dropsy",
"Vitreous Humor",
"Roger's Grand Illusion",
"Covet",
"Black Idaho",
"Astral Miasma",
"Spectral Oyster",
"Acrid Hands",
"Angioplasty",
"Grognor's Big Day Off",
"Tumor (Malignant)",
"Animate Tunic",
"Ursine Armor",
"Holy Roller",
"Tonsillectomy",
"Curse Family",
"Infinite Confusion"
]
+11
View File
@@ -0,0 +1,11 @@
[
"Mr.",
"Mrs.",
"Sir",
"Sgt.",
"Ms.",
"Captain",
"Chief",
"Admiral",
"Saint"
]
+41
View File
@@ -0,0 +1,41 @@
[
{"name": "Stick", "quality": 0},
{"name": "Broken Bottle", "quality": 1},
{"name": "Shiv", "quality": 1},
{"name": "Sprig", "quality": 1},
{"name": "Oxgoad", "quality": 1},
{"name": "Eelspear", "quality": 2},
{"name": "Bowie Knife", "quality": 2},
{"name": "Claw Hammer", "quality": 2},
{"name": "Handpeen", "quality": 2},
{"name": "Andiron", "quality": 3},
{"name": "Hatchet", "quality": 3},
{"name": "Tomahawk", "quality": 3},
{"name": "Hackbarm", "quality": 3},
{"name": "Crowbar", "quality": 4},
{"name": "Mace", "quality": 4},
{"name": "Battleadze", "quality": 4},
{"name": "Leafmace", "quality": 5},
{"name": "Shortsword", "quality": 5},
{"name": "Longiron", "quality": 5},
{"name": "Poachard", "quality": 5},
{"name": "Baselard", "quality": 5},
{"name": "Whinyard", "quality": 6},
{"name": "Blunderbuss", "quality": 6},
{"name": "Longsword", "quality": 6},
{"name": "Crankbow", "quality": 6},
{"name": "Blibo", "quality": 7},
{"name": "Broadsword", "quality": 7},
{"name": "Kreen", "quality": 7},
{"name": "Warhammer", "quality": 7},
{"name": "Morning Star", "quality": 8},
{"name": "Pole-adze", "quality": 8},
{"name": "Spontoon", "quality": 8},
{"name": "Bastard Sword", "quality": 9},
{"name": "Peen-arm", "quality": 9},
{"name": "Culverin", "quality": 10},
{"name": "Lance", "quality": 10},
{"name": "Halberd", "quality": 11},
{"name": "Poleax", "quality": 12},
{"name": "Bandyclef", "quality": 15}
]
+257
View File
@@ -0,0 +1,257 @@
import json
import sys
from collections.abc import Callable, Sequence
from dataclasses import dataclass, fields
from importlib import resources
from pathlib import Path
from typing import Any
from pqcli.config import (
Class,
EquipmentPreset,
Modifier,
Monster,
Race,
StatType,
)
if sys.version_info >= (3, 11):
from importlib.resources.abc import Traversable
else:
from importlib.abc import Traversable
TARGET_PLACEHOLDER = "{target}"
class DataError(Exception):
pass
@dataclass(frozen=True)
class GameData:
spells: tuple[str, ...]
offense_attrib: tuple[Modifier, ...]
defense_attrib: tuple[Modifier, ...]
offense_bad: tuple[Modifier, ...]
defense_bad: tuple[Modifier, ...]
shields: tuple[EquipmentPreset, ...]
armors: tuple[EquipmentPreset, ...]
weapons: tuple[EquipmentPreset, ...]
specials: tuple[str, ...]
item_attrib: tuple[str, ...]
item_ofs: tuple[str, ...]
boring_items: tuple[str, ...]
monsters: tuple[Monster, ...]
races: tuple[Race, ...]
classes: tuple[Class, ...]
titles: tuple[str, ...]
impressive_titles: tuple[str, ...]
combat_verbs: tuple[str, ...]
DATA_FILES = tuple(field.name for field in fields(GameData))
class _Context:
def __init__(self, path: str) -> None:
self.path = path
def error(self, message: str) -> DataError:
return DataError(f"{self.path}: {message}")
def _check_keys(
ctx: _Context, where: str, obj: object, keys: Sequence[str]
) -> dict[str, object]:
if not isinstance(obj, dict):
raise ctx.error(f"{where} must be an object")
missing = [key for key in keys if key not in obj]
if missing:
raise ctx.error(f"{where} is missing {', '.join(map(repr, missing))}")
extra = [key for key in obj if key not in keys]
if extra:
raise ctx.error(f"{where} has unknown {', '.join(map(repr, extra))}")
return obj
def _str(ctx: _Context, where: str, value: object) -> str:
if not isinstance(value, str):
raise ctx.error(f"{where} must be a string")
return value
def _int(ctx: _Context, where: str, value: object) -> int:
if isinstance(value, bool) or not isinstance(value, int):
raise ctx.error(f"{where} must be an integer")
return value
def _stat(ctx: _Context, where: str, value: object) -> StatType:
name = _str(ctx, where, value)
for stat in StatType:
if name.lower() in (stat.value.lower(), stat.name):
return stat
allowed = ", ".join(stat.value for stat in StatType)
raise ctx.error(f"{where}: unknown stat {value!r} (use one of {allowed})")
def _parse_str(ctx: _Context, where: str, item: object) -> str:
return _str(ctx, where, item)
def _parse_verb(ctx: _Context, where: str, item: object) -> str:
verb = _str(ctx, where, item)
if TARGET_PLACEHOLDER not in verb:
raise ctx.error(f"{where} must contain {TARGET_PLACEHOLDER}")
return verb
def _parse_modifier(ctx: _Context, where: str, item: object) -> Modifier:
obj = _check_keys(ctx, where, item, ["name", "quality"])
return Modifier(
_str(ctx, f"{where}.name", obj["name"]),
_int(ctx, f"{where}.quality", obj["quality"]),
)
def _parse_preset(ctx: _Context, where: str, item: object) -> EquipmentPreset:
obj = _check_keys(ctx, where, item, ["name", "quality"])
return EquipmentPreset(
_str(ctx, f"{where}.name", obj["name"]),
_int(ctx, f"{where}.quality", obj["quality"]),
)
def _parse_monster(ctx: _Context, where: str, item: object) -> Monster:
obj = _check_keys(ctx, where, item, ["name", "level", "item"])
loot = obj["item"]
return Monster(
_str(ctx, f"{where}.name", obj["name"]),
_int(ctx, f"{where}.level", obj["level"]),
None if loot is None else _str(ctx, f"{where}.item", loot),
)
def _parse_attrs(ctx: _Context, where: str, value: object) -> list[StatType]:
if not isinstance(value, list):
raise ctx.error(f"{where} must be a list of stats")
return [_stat(ctx, f"{where}[{i}]", stat) for i, stat in enumerate(value)]
def _parse_race(ctx: _Context, where: str, item: object) -> Race:
obj = _check_keys(ctx, where, item, ["name", "attr"])
return Race(
_str(ctx, f"{where}.name", obj["name"]),
_parse_attrs(ctx, f"{where}.attr", obj["attr"]),
)
def _parse_class(ctx: _Context, where: str, item: object) -> Class:
obj = _check_keys(ctx, where, item, ["name", "attr"])
return Class(
_str(ctx, f"{where}.name", obj["name"]),
_parse_attrs(ctx, f"{where}.attr", obj["attr"]),
)
_PARSERS: dict[str, Callable[[_Context, str, object], object]] = {
"spells": _parse_str,
"offense_attrib": _parse_modifier,
"defense_attrib": _parse_modifier,
"offense_bad": _parse_modifier,
"defense_bad": _parse_modifier,
"shields": _parse_preset,
"armors": _parse_preset,
"weapons": _parse_preset,
"specials": _parse_str,
"item_attrib": _parse_str,
"item_ofs": _parse_str,
"boring_items": _parse_str,
"monsters": _parse_monster,
"races": _parse_race,
"classes": _parse_class,
"titles": _parse_str,
"impressive_titles": _parse_str,
"combat_verbs": _parse_verb,
}
assert set(_PARSERS) == set(DATA_FILES)
def _parse(name: str, path: str, text: str) -> tuple[object, ...]:
ctx = _Context(path)
try:
value = json.loads(text)
except json.JSONDecodeError as ex:
raise ctx.error(f"invalid JSON: {ex}") from ex
if not isinstance(value, list):
raise ctx.error("must be a list")
if not value:
raise ctx.error("must not be empty")
parser = _PARSERS[name]
return tuple(parser(ctx, f"[{i}]", item) for i, item in enumerate(value))
def _builtin_dir() -> Traversable:
return resources.files("pqcli") / "data"
def _read_text(path: Traversable, display: str) -> str:
try:
return path.read_text(encoding="utf-8")
except UnicodeDecodeError as ex:
raise DataError(f"{display}: not valid UTF-8: {ex}") from ex
except OSError as ex:
raise DataError(f"{display}: cannot read: {ex}") from ex
def load_game_data(override_dir: Path | None = None) -> GameData:
"""Load the built-in word lists, replacing any that override_dir has.
A missing override_dir is fine. Each <name>.json in it replaces the
whole built-in list of that name.
"""
overrides: set[str] = set()
if override_dir is not None and override_dir.exists():
if not override_dir.is_dir():
raise DataError(f"{override_dir}: not a directory")
for path in sorted(override_dir.glob("*.json")):
if path.stem in DATA_FILES:
overrides.add(path.stem)
else:
print(
f"pqcli: ignoring {path}: not a known data file "
f"(known: {', '.join(DATA_FILES)})",
file=sys.stderr,
)
# The element type of each tuple depends on the GameData field it
# fills, so it is only checked when GameData receives it.
values: dict[str, tuple[Any, ...]] = {}
for name in DATA_FILES:
if name in overrides:
assert override_dir is not None
source: Traversable = override_dir / f"{name}.json"
display = str(source)
else:
source = _builtin_dir() / f"{name}.json"
display = f"<built-in>/{name}.json"
values[name] = _parse(name, display, _read_text(source, display))
return GameData(**values)
def dump_builtin_data(target_dir: Path) -> list[Path]:
"""Copy the built-in data files to target_dir, never overwriting."""
targets = [target_dir / f"{name}.json" for name in DATA_FILES]
existing = [path for path in targets if path.exists()]
if existing:
raise DataError(
"refusing to overwrite "
+ ", ".join(str(path) for path in existing)
)
target_dir.mkdir(parents=True, exist_ok=True)
for name, path in zip(DATA_FILES, targets):
source = _builtin_dir() / f"{name}.json"
path.write_text(
_read_text(source, f"<built-in>/{name}.json"), encoding="utf-8"
)
return targets
+44 -3
View File
@@ -1,5 +1,4 @@
import datetime import datetime
import typing as T
from pqcli import random from pqcli import random
@@ -55,6 +54,11 @@ def to_roman(num: int) -> str:
ret = "-" ret = "-"
num = -num num = -num
while _rome(10000, "T"):
pass
_rome(9000, "MT")
_rome(5000, "A")
_rome(4000, "MA")
while _rome(1000, "M"): while _rome(1000, "M"):
pass pass
_rome(900, "CM") _rome(900, "CM")
@@ -75,6 +79,43 @@ def to_roman(num: int) -> str:
return ret return ret
def to_arabic(roman: str) -> int:
num = 0
rest = roman.upper()
def _arab(ds: str, dn: int) -> bool:
nonlocal num, rest
if not rest.startswith(ds):
return False
rest = rest[len(ds) :]
num += dn
return True
while _arab("T", 10000):
pass
_arab("MT", 9000)
_arab("A", 5000)
_arab("MA", 4000)
while _arab("M", 1000):
pass
_arab("CM", 900)
_arab("D", 500)
_arab("CD", 400)
while _arab("C", 100):
pass
_arab("XC", 90)
_arab("L", 50)
_arab("XL", 40)
while _arab("X", 10):
pass
_arab("IX", 9)
_arab("V", 5)
_arab("IV", 4)
while _arab("I", 1):
pass
return num
def act_name(act: int) -> str: def act_name(act: int) -> str:
if act == 0: if act == 0:
return "Prologue" return "Prologue"
@@ -97,7 +138,7 @@ def plural(subject: str) -> str:
def indefinite(subject: str, qty: int) -> str: def indefinite(subject: str, qty: int) -> str:
if qty == 1: if qty == 1:
if subject.startswith(tuple("AEIOU?aeiou?")): if subject.startswith(tuple("AEIOUÜaeiouü")):
return "an " + subject return "an " + subject
return "a " + subject return "a " + subject
return str(qty) + " " + plural(subject) return str(qty) + " " + plural(subject)
@@ -109,7 +150,7 @@ def definite(subject: str, qty: int) -> str:
return "the " + subject return "the " + subject
def prefix(a: T.List[str], m: int, subject: str, sep: str = " ") -> str: def prefix(a: list[str], m: int, subject: str, sep: str = " ") -> str:
m = abs(m) m = abs(m)
if m < 1 or m > len(a): if m < 1 or m > len(a):
return subject return subject
+243 -127
View File
@@ -1,35 +1,73 @@
import contextlib
import datetime import datetime
import functools
import itertools import itertools
import logging import logging
import math import math
import typing as T
from collections import defaultdict from collections import defaultdict
from collections.abc import Callable, Generator, Iterator, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
from pqcli import random from pqcli import random
from pqcli.config import * from pqcli.config import (
from pqcli.lingo import * PRIME_STATS,
Class,
EquipmentPreset,
EquipmentType,
Modifier,
Monster,
Race,
StatType,
)
from pqcli.game_data import TARGET_PLACEHOLDER, GameData
from pqcli.lingo import (
act_name,
big,
definite,
generate_name,
indefinite,
plural,
sick,
special,
young,
)
from pqcli.random import Alea, RngState
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SIGNALS: T.Dict[ _SIGNALS: dict[tuple["SignalMixin", str], list[Callable[..., object]]] = (
T.Tuple["SignalMixin", str], T.List[T.Callable[..., T.Any]] defaultdict(list)
] = defaultdict(list) )
class SignalMixin: class SignalMixin:
def emit(self, signal_name: str, *user_data: T.Any) -> None: _muted = 0
@staticmethod
@contextlib.contextmanager
def muted() -> Generator[None, None, None]:
"""Suppress all signal emission for the duration of the block."""
SignalMixin._muted += 1
try:
yield
finally:
SignalMixin._muted -= 1
def emit(self, signal_name: str, *user_data: object) -> None:
if SignalMixin._muted:
return
for callback in _SIGNALS[self, signal_name]: for callback in _SIGNALS[self, signal_name]:
callback(*user_data) callback(*user_data)
def connect( def connect(
self, signal_name: str, callback: T.Callable[..., T.Any] self, signal_name: str, callback: Callable[..., object]
) -> None: ) -> None:
_SIGNALS[self, signal_name].append(callback) _SIGNALS[self, signal_name].append(callback)
def disconnect( def disconnect(
self, signal_name: str, callback: T.Callable[..., T.Any] self, signal_name: str, callback: Callable[..., object]
) -> None: ) -> None:
try: try:
idx = _SIGNALS[self, signal_name].index(callback) idx = _SIGNALS[self, signal_name].index(callback)
@@ -39,8 +77,9 @@ class SignalMixin:
def level_up_time(level: int) -> int: def level_up_time(level: int) -> int:
# seconds # seconds: 20 minutes for level 1, exponential increase after that.
return 20 * level * 60 # floor(x + 0.5) mirrors JS Math.round, unlike Python's round().
return math.floor((20 + 1.15**level) * 60 + 0.5)
class Bar(SignalMixin): class Bar(SignalMixin):
@@ -85,10 +124,10 @@ class Bar(SignalMixin):
class Stats(SignalMixin): class Stats(SignalMixin):
signals = ["change"] signals = ["change"]
def __init__(self, values: T.Dict[StatType, int]) -> None: def __init__(self, values: dict[StatType, int]) -> None:
self._values = values self._values = values
def __iter__(self) -> T.Iterator[T.Tuple[StatType, int]]: def __iter__(self) -> Iterator[tuple[StatType, int]]:
return iter(self._values.items()) return iter(self._values.items())
def __getitem__(self, stat: StatType) -> int: def __getitem__(self, stat: StatType) -> int:
@@ -114,22 +153,22 @@ class QuestBook(SignalMixin):
signals = ["start_act", "start_quest"] signals = ["start_act", "start_quest"]
def __init__(self) -> None: def __init__(self) -> None:
self._quests: T.List[str] = [] self._quests: list[str] = []
self._act = 0 self._act = 0
self.plot_bar = Bar(max_=1) self.plot_bar = Bar(max_=1)
self.quest_bar = Bar(max_=1) self.quest_bar = Bar(max_=1)
self.monster: T.Optional[Monster] = None self.monster: Monster | None = None
@property @property
def act(self) -> int: def act(self) -> int:
return self._act return self._act
@property @property
def quests(self) -> T.List[str]: def quests(self) -> list[str]:
return self._quests return self._quests
@property @property
def current_quest(self) -> T.Optional[str]: def current_quest(self) -> str | None:
if self._quests: if self._quests:
return self._quests[-1] return self._quests[-1]
return None return None
@@ -140,7 +179,7 @@ class QuestBook(SignalMixin):
def add_quest(self, name: str) -> None: def add_quest(self, name: str) -> None:
logger.info("Commencing quest: %s", name) logger.info("Commencing quest: %s", name)
self._quests = self._quests[-100:] self._quests = self._quests[-99:]
self._quests.append(name) self._quests.append(name)
@@ -155,7 +194,7 @@ class Inventory(SignalMixin):
def __init__(self, capacity: int = 0) -> None: def __init__(self, capacity: int = 0) -> None:
self._gold = 0 self._gold = 0
self._items: T.List[InventoryItem] = [] self._items: list[InventoryItem] = []
self.encum_bar = Bar(max_=capacity) self.encum_bar = Bar(max_=capacity)
@property @property
@@ -165,7 +204,7 @@ class Inventory(SignalMixin):
def __len__(self) -> int: def __len__(self) -> int:
return len(self._items) return len(self._items)
def __iter__(self) -> T.Iterator[InventoryItem]: def __iter__(self) -> Iterator[InventoryItem]:
return iter(self._items) return iter(self._items)
def __getitem__(self, idx: int) -> InventoryItem: def __getitem__(self, idx: int) -> InventoryItem:
@@ -211,16 +250,16 @@ class Equipment(SignalMixin):
signals = ["change"] signals = ["change"]
def __init__(self) -> None: def __init__(self) -> None:
self._items: T.Dict[EquipmentType, str] = { self._items: dict[EquipmentType, str] = {
EquipmentType.weapon: "Sharp Rock", EquipmentType.weapon: "Sharp Rock",
EquipmentType.hauberk: "-3 Burlap", EquipmentType.hauberk: "-3 Burlap",
} }
self.best = self[EquipmentType.weapon] self.best = self[EquipmentType.weapon]
def __iter__(self) -> T.Iterator[T.Tuple[EquipmentType, str]]: def __iter__(self) -> Iterator[tuple[EquipmentType, str]]:
return iter(self._items.items()) return iter(self._items.items())
def __getitem__(self, equipment_type: EquipmentType) -> T.Optional[str]: def __getitem__(self, equipment_type: EquipmentType) -> str | None:
return self._items.get(equipment_type, None) return self._items.get(equipment_type, None)
def put(self, equipment_type: EquipmentType, item_name: str) -> None: def put(self, equipment_type: EquipmentType, item_name: str) -> None:
@@ -244,9 +283,9 @@ class SpellBook(SignalMixin):
signals = ["add", "change"] signals = ["add", "change"]
def __init__(self) -> None: def __init__(self) -> None:
self._spells: T.List[Spell] = [] self._spells: list[Spell] = []
def __iter__(self) -> T.Iterator[Spell]: def __iter__(self) -> Iterator[Spell]:
return iter(self._spells) return iter(self._spells)
def __bool__(self) -> bool: def __bool__(self) -> bool:
@@ -269,7 +308,7 @@ class SpellBook(SignalMixin):
self.emit("add", spell) self.emit("add", spell)
@property @property
def best(self) -> T.Optional[Spell]: def best(self) -> Spell | None:
if not self._spells: if not self._spells:
return None return None
return max(self._spells, key=lambda spell: spell.level) return max(self._spells, key=lambda spell: spell.level)
@@ -283,7 +322,7 @@ class BaseTask:
@dataclass @dataclass
class KillTask(BaseTask): class KillTask(BaseTask):
monster: T.Optional[Monster] = None monster: Monster | None = None
class BuyTask(BaseTask): class BuyTask(BaseTask):
@@ -310,6 +349,10 @@ class PlotTask(RegularTask):
pass pass
class LoadingTask(RegularTask):
pass
class Player(SignalMixin): class Player(SignalMixin):
signals = ["new_task", "level_up"] signals = ["new_task", "level_up"]
@@ -320,12 +363,14 @@ class Player(SignalMixin):
race: Race, race: Race,
class_: Class, class_: Class,
stats: Stats, stats: Stats,
rng: Alea | None = None,
) -> None: ) -> None:
self.name: str = name self.name: str = name
self.birthday: datetime.datetime = birthday self.birthday: datetime.datetime = birthday
self.race: Race = race self.race: Race = race
self.class_: Class = class_ self.class_: Class = class_
self.stats: Stats = stats self.stats: Stats = stats
self.rng: Alea = rng or Alea()
self.elapsed = 0.0 self.elapsed = 0.0
self.exp_bar = Bar(max_=level_up_time(1)) self.exp_bar = Bar(max_=level_up_time(1))
@@ -337,12 +382,14 @@ class Player(SignalMixin):
self.spell_book = SpellBook() self.spell_book = SpellBook()
self.task_bar = Bar(max_=1) self.task_bar = Bar(max_=1)
self.task: T.Optional[BaseTask] = None self.task: BaseTask | None = None
self.queue: T.List[BaseTask] = [] self.queue: list[BaseTask] = []
def __setstate__(self, obj: T.Any) -> None: def __setstate__(self, obj: dict[str, Any]) -> None:
self.__dict__.update(obj) self.__dict__.update(obj)
self.elapsed = obj.get("elapsed", 0.0) self.elapsed = obj.get("elapsed", 0.0)
if "rng" not in obj:
self.rng = Alea()
def set_task(self, task: BaseTask) -> None: def set_task(self, task: BaseTask) -> None:
self.task = task self.task = task
@@ -353,7 +400,7 @@ class Player(SignalMixin):
def equip_price(self) -> int: def equip_price(self) -> int:
return 5 * (self.level**2) + 10 * self.level + 20 return 5 * (self.level**2) + 10 * self.level + 20
def level_up(self) -> None: def level_up(self, data: GameData) -> None:
self.level += 1 self.level += 1
logger.info("Leveled up to level %d!", self.level) logger.info("Leveled up to level %d!", self.level)
self.stats.increment( self.stats.increment(
@@ -366,23 +413,23 @@ class Player(SignalMixin):
) )
self.win_stat() self.win_stat()
self.win_stat() self.win_stat()
self.win_spell() self.win_spell(data)
self.exp_bar.reset(level_up_time(self.level)) self.exp_bar.reset(level_up_time(self.level))
self.emit("level_up") self.emit("level_up")
def win_stat(self) -> bool: def win_stat(self) -> bool:
chosen_stat: T.Optional[StatType] = None chosen_stat: StatType | None = None
if random.odds(1, 2): if random.odds(1, 2):
chosen_stat = random.choice(list(StatType)) chosen_stat = random.choice(list(StatType))
else: else:
# favor the best stat so it will tend to clump # favor the best stat so it will tend to clump
t = sum(value ** 2 for _stat, value in self.stats) t = sum(self.stats[stat] ** 2 for stat in PRIME_STATS)
t = random.below(t) t = random.below(t)
chosen_stat = None chosen_stat = None
for stat, value in self.stats: for stat in PRIME_STATS:
chosen_stat = stat chosen_stat = stat
t -= value ** 2 t -= self.stats[stat] ** 2
if t < 0: if t < 0:
break break
@@ -392,31 +439,34 @@ class Player(SignalMixin):
self.inventory.set_capacity(10 + self.stats[StatType.strength]) self.inventory.set_capacity(10 + self.stats[StatType.strength])
return True return True
def win_spell(self) -> None: def win_spell(self, data: GameData) -> None:
spells = data.spells
self.spell_book.add( self.spell_book.add(
SPELLS[ spells[
random.below_low( random.below_low(
min(self.stats[StatType.wisdom] + self.level, len(SPELLS)) min(self.stats[StatType.wisdom] + self.level, len(spells))
) )
], ],
1, 1,
) )
def win_equipment(self) -> None: def win_equipment(self, data: GameData) -> None:
choice = random.choice(list(EquipmentType)) choice = random.choice(list(EquipmentType))
stuff: T.List[EquipmentPreset] stuff: Sequence[EquipmentPreset]
better: T.List[Modifier] better: Sequence[Modifier]
worse: T.List[Modifier] worse: Sequence[Modifier]
if choice == EquipmentType.weapon: if choice == EquipmentType.weapon:
stuff = WEAPONS stuff = data.weapons
better = OFFENSE_ATTRIB better = data.offense_attrib
worse = OFFENSE_BAD worse = data.offense_bad
else: else:
stuff = SHIELDS if choice == EquipmentType.shield else ARMORS stuff = (
better = DEFENSE_ATTRIB data.shields if choice == EquipmentType.shield else data.armors
worse = DEFENSE_BAD )
better = data.defense_attrib
worse = data.defense_bad
equipment = pick_equipment(stuff, self.level) equipment = pick_equipment(stuff, self.level)
name = equipment.name name = equipment.name
@@ -444,20 +494,36 @@ class Player(SignalMixin):
logger.info("Gained %s %s", name, choice.value) logger.info("Gained %s %s", name, choice.value)
self.equipment.put(choice, name) self.equipment.put(choice, name)
def win_item(self) -> None: def win_item(self, data: GameData) -> None:
self.inventory.add(special_item(), 1) # Past 250 rows, sometimes add one more of something already carried.
# Upstream counts the Gold row too, and picking it yields 1 gold.
rows = len(self.inventory) + 1
if max(250, random.below(999)) < rows:
index = random.below(rows)
if index == 0:
self.inventory.add_gold(1)
else:
self.inventory.add(self.inventory[index - 1].name, 1)
else:
self.inventory.add(special_item(data), 1)
class Simulation: class Simulation:
def __init__(self, player: Player) -> None: def __init__(self, player: Player, data: GameData) -> None:
self.player = player self.player = player
self.data = data
self.last_tick = datetime.datetime.now() self.last_tick = datetime.datetime.now()
def tick(self, elapsed: float = 100.0) -> None: def tick(self, elapsed: float = 100.0) -> None:
# each character plays on its own generator, saved along with it
with random.using(self.player.rng):
self._tick(elapsed)
def _tick(self, elapsed: float) -> None:
self.player.elapsed += elapsed self.player.elapsed += elapsed
if self.player.task is None: if self.player.task is None:
self.player.set_task(RegularTask("Loading", 2000)) self.player.set_task(LoadingTask("Loading", 2000))
self.player.queue += [ self.player.queue += [
RegularTask( RegularTask(
"Experiencing an enigmatic and foreboding night vision", "Experiencing an enigmatic and foreboding night vision",
@@ -480,7 +546,7 @@ class Simulation:
), ),
PlotTask(f"Loading {act_name(1)}", 2000), PlotTask(f"Loading {act_name(1)}", 2000),
] ]
self.player.quest_book.plot_bar.reset(28) self.player.quest_book.plot_bar.reset(26)
return return
if not self.player.task_bar.done: if not self.player.task_bar.done:
@@ -491,7 +557,7 @@ class Simulation:
gain = self.player.task and isinstance(self.player.task, KillTask) gain = self.player.task and isinstance(self.player.task, KillTask)
if gain: if gain:
if self.player.exp_bar.done: if self.player.exp_bar.done:
self.player.level_up() self.player.level_up(self.data)
else: else:
self.player.exp_bar.increment(self.player.task_bar.max_ / 1000) self.player.exp_bar.increment(self.player.task_bar.max_ / 1000)
@@ -515,6 +581,13 @@ class Simulation:
self.player.quest_book.plot_bar.increment( self.player.quest_book.plot_bar.increment(
self.player.task_bar.max_ / 1000 self.player.task_bar.max_ / 1000
) )
elif self.player.quest_book.act == 0 and not isinstance(
self.player.task, LoadingTask | PlotTask
):
# the Prologue story tasks fill the plot bar
self.player.quest_book.plot_bar.increment(
self.player.task_bar.max_ / 1000
)
self.dequeue() self.dequeue()
@@ -526,7 +599,7 @@ class Simulation:
or self.player.task.monster.item is None or self.player.task.monster.item is None
): ):
# npc # npc
self.player.win_item() self.player.win_item(self.data)
elif self.player.task.monster.item: elif self.player.task.monster.item:
self.player.inventory.add( self.player.inventory.add(
( (
@@ -540,9 +613,9 @@ class Simulation:
elif isinstance(self.player.task, BuyTask): elif isinstance(self.player.task, BuyTask):
# buy some equipment # buy some equipment
self.player.inventory.add_gold(-self.player.equip_price()) self.player.inventory.add_gold(-self.player.equip_price())
self.player.win_equipment() self.player.win_equipment(self.data)
elif isinstance(self.player.task, (HeadingToMarketTask, SellTask)): elif isinstance(self.player.task, HeadingToMarketTask | SellTask):
if isinstance(self.player.task, SellTask): if isinstance(self.player.task, SellTask):
item = self.player.inventory[0] item = self.player.inventory[0]
amount = item.quantity * self.player.level amount = item.quantity * self.player.level
@@ -562,17 +635,22 @@ class Simulation:
) )
break break
elif isinstance(self.player.task, PlotTask):
self.complete_act()
old = self.player.task old = self.player.task
if self.player.queue: if self.player.queue:
self.player.set_task(self.player.queue.pop(0)) task = self.player.queue.pop(0)
if isinstance(task, PlotTask):
# the act starts as its loading task does, as upstream
self.complete_act()
task = PlotTask(
f"Loading {act_name(self.player.quest_book.act)}",
task.duration,
)
self.player.set_task(task)
elif self.player.inventory.encum_bar.done: elif self.player.inventory.encum_bar.done:
self.player.set_task( self.player.set_task(
HeadingToMarketTask("Heading to market to sell loot", 4000) HeadingToMarketTask("Heading to market to sell loot", 4000)
) )
elif not isinstance(old, (KillTask, HeadingToKillingFieldsTask)): elif not isinstance(old, KillTask | HeadingToKillingFieldsTask):
if self.player.inventory.gold > self.player.equip_price(): if self.player.inventory.gold > self.player.equip_price():
self.player.set_task( self.player.set_task(
BuyTask( BuyTask(
@@ -588,7 +666,9 @@ class Simulation:
else: else:
self.player.set_task( self.player.set_task(
monster_task( monster_task(
self.player.level, self.player.quest_book.monster self.data,
self.player.level,
self.player.quest_book.monster,
) )
) )
@@ -598,21 +678,21 @@ class Simulation:
60 * 60 * (1 + 5 * self.player.quest_book.act) 60 * 60 * (1 + 5 * self.player.quest_book.act)
) )
if self.player.quest_book.act > 1: if self.player.quest_book.act > 1:
self.player.win_item() self.player.win_item(self.data)
self.player.win_equipment() self.player.win_equipment(self.data)
def complete_quest(self) -> None: def complete_quest(self) -> None:
self.player.quest_book.quest_bar.reset(50 + random.below_low(1000)) self.player.quest_book.quest_bar.reset(50 + random.below(100))
if self.player.quest_book.current_quest: if self.player.quest_book.current_quest:
logger.info( logger.info(
"Quest completed: %s", self.player.quest_book.current_quest "Quest completed: %s", self.player.quest_book.current_quest
) )
random.choice( random.choice(
[ [
self.player.win_spell, functools.partial(self.player.win_spell, self.data),
self.player.win_equipment, functools.partial(self.player.win_equipment, self.data),
self.player.win_stat, self.player.win_stat,
self.player.win_item, functools.partial(self.player.win_item, self.data),
] ]
)() )()
@@ -620,20 +700,21 @@ class Simulation:
caption = "" caption = ""
choice = random.below(5) choice = random.below(5)
if choice == 0: if choice == 0:
self.player.quest_book.monster = unnamed_monster( monster = unnamed_monster(
self.player.level, iterations=3 self.data, self.player.level, iterations=3
)
caption = "Exterminate " + definite(
self.player.quest_book.monster.name, 2
) )
self.player.quest_book.monster = monster
caption = "Exterminate " + definite(monster.name, 2)
elif choice == 1: elif choice == 1:
caption = "Seek " + definite(interesting_item(), 1) caption = "Seek " + definite(interesting_item(self.data), 1)
elif choice == 2: elif choice == 2:
caption = "Deliver this " + boring_item() caption = "Deliver this " + boring_item(self.data)
elif choice == 3: elif choice == 3:
caption = "Fetch me " + indefinite(boring_item(), 1) caption = "Fetch me " + indefinite(boring_item(self.data), 1)
elif choice == 4: elif choice == 4:
monster = unnamed_monster(self.player.level, iterations=1) monster = unnamed_monster(
self.data, self.player.level, iterations=1
)
caption = "Placate " + definite(monster.name, 2) caption = "Placate " + definite(monster.name, 2)
else: else:
raise AssertionError raise AssertionError
@@ -676,7 +757,7 @@ class Simulation:
) )
) )
nemesis = named_monster(self.player.level + 3) nemesis = named_monster(self.data, self.player.level + 3)
enqueue( enqueue(
RegularTask( RegularTask(
@@ -714,7 +795,7 @@ class Simulation:
enqueue( enqueue(
RegularTask( RegularTask(
f"Victory! {nemesis} is slain! " f"Victory! {nemesis} is slain! "
"Exhausted, you lose conciousness", "Exhausted, you lose consciousness",
3000, 3000,
) )
) )
@@ -725,24 +806,25 @@ class Simulation:
) )
elif choice == 2: elif choice == 2:
nemesis = impressive_guy() nemesis = impressive_guy(self.data)
enqueue( enqueue(
RegularTask( RegularTask(
"Oh sweet relief! " "Oh sweet relief! "
f"You've reached the protection of the good {nemesis}", f"You've reached the kind protection of {nemesis}",
2000, 2000,
) )
) )
enqueue( enqueue(
RegularTask( RegularTask(
"There is rejoicing, " "There is rejoicing, "
f"and an unnerving encouter with {nemesis} in private", f"and an unnerving encounter with {nemesis} in private",
3000, 3000,
) )
) )
enqueue( enqueue(
RegularTask( RegularTask(
f"You forget your {boring_item()} and go back to get it", f"You forget your {boring_item(self.data)} "
"and go back to get it",
2000, 2000,
) )
) )
@@ -756,7 +838,7 @@ class Simulation:
) )
enqueue( enqueue(
RegularTask( RegularTask(
"Who can possibly be trusted with this news!? ... " "Who can possibly be trusted with this news!? -- "
"Oh yes, of course", "Oh yes, of course",
3000, 3000,
) )
@@ -772,57 +854,54 @@ class Simulation:
) )
def special_item() -> str: def special_item(data: GameData) -> str:
return interesting_item() + " of " + T.cast(str, random.choice(ITEM_OFS)) return interesting_item(data) + " of " + random.choice(data.item_ofs)
def interesting_item() -> str: def interesting_item(data: GameData) -> str:
return ( return random.choice(data.item_attrib) + " " + random.choice(data.specials)
T.cast(str, random.choice(ITEM_ATTRIB))
+ " "
+ T.cast(str, random.choice(SPECIALS))
)
def boring_item() -> str: def boring_item(data: GameData) -> str:
return T.cast(str, random.choice(BORING_ITEMS)) return random.choice(data.boring_items)
def impressive_guy() -> str: def impressive_guy(data: GameData) -> str:
return T.cast(str, random.choice(IMPRESSIVE_TITLES)) + ( if random.below(2):
" of the " + T.cast(Race, random.choice(RACES)).name title = random.choice(data.impressive_titles)
if random.below(2) race = random.choice(data.races)
else " of " + generate_name() return f"the {title} of the {plural(race.name)}"
) title = random.choice(data.impressive_titles)
return f"{title} {generate_name()} of {generate_name()}"
def unnamed_monster(level: int, iterations: int) -> Monster: def unnamed_monster(data: GameData, level: int, iterations: int) -> Monster:
result = T.cast(Monster, random.choice(MONSTERS)) result = random.choice(data.monsters)
for _ in range(iterations): for _ in range(iterations):
alternative = T.cast(Monster, random.choice(MONSTERS)) alternative = random.choice(data.monsters)
if abs(level - alternative.level) < abs(level - result.level): if abs(level - alternative.level) < abs(level - result.level):
result = alternative result = alternative
return result return result
def named_monster(level: int) -> str: def named_monster(data: GameData, level: int) -> str:
monster = unnamed_monster(level, iterations=4) monster = unnamed_monster(data, level, iterations=4)
return generate_name() + " the " + monster.name return generate_name() + " the " + monster.name
def pick_equipment( def pick_equipment(
source: T.List[EquipmentPreset], goal: int source: Sequence[EquipmentPreset], goal: int
) -> EquipmentPreset: ) -> EquipmentPreset:
result = T.cast(EquipmentPreset, random.choice(source)) result = random.choice(source)
for _ in range(5): for _ in range(5):
alternative = T.cast(EquipmentPreset, random.choice(source)) alternative = random.choice(source)
if abs(goal - alternative.quality) < abs(goal - result.quality): if abs(goal - alternative.quality) < abs(goal - result.quality):
result = alternative result = alternative
return result return result
def monster_task( def monster_task(
player_level: int, quest_monster: T.Optional[Monster] data: GameData, player_level: int, quest_monster: Monster | None
) -> KillTask: ) -> KillTask:
level = player_level level = player_level
for _ in range(level): for _ in range(level):
@@ -832,15 +911,17 @@ def monster_task(
level = 1 level = 1
is_definite = False is_definite = False
monster: T.Optional[Monster] = None monster: Monster | None = None
if random.odds(1, 25): if random.odds(1, 25):
# use an NPC every once in a while # use an NPC every once in a while
race = random.choice(RACES) race = random.choice(data.races)
if random.odds(1, 2): if random.odds(1, 2):
result = "passing " + race.name + " " + random.choice(CLASSES).name result = (
"passing " + race.name + " " + random.choice(data.classes).name
)
else: else:
result = ( result = (
random.choice_low(TITLES) random.choice_low(data.titles)
+ " " + " "
+ generate_name() + generate_name()
+ " the " + " the "
@@ -854,8 +935,9 @@ def monster_task(
result = monster.name result = monster.name
lev = monster.level lev = monster.level
else: else:
# pick the monster out of so many random ones closest to the level we want # pick the monster out of so many random ones closest to the level
monster = unnamed_monster(level, iterations=5) # we want
monster = unnamed_monster(data, level, iterations=5)
result = monster.name result = monster.name
lev = monster.level lev = monster.level
@@ -894,15 +976,41 @@ def monster_task(
result = indefinite(result, qty) result = indefinite(result, qty)
duration = (2 * 3 * level * 1000) // player_level duration = (2 * 3 * level * 1000) // player_level
return KillTask(f"Executing {result}", duration, monster=monster) # with a single verb, skip the roll so the stock RNG stream is unchanged
verb = (
data.combat_verbs[0]
if len(data.combat_verbs) == 1
else random.choice(data.combat_verbs)
)
description = verb.replace(TARGET_PLACEHOLDER, result)
return KillTask(description, duration, monster=monster)
@dataclass
class _Roll:
stats: Stats
seed: RngState # generator state before the roll
after: RngState
class StatsBuilder: class StatsBuilder:
def __init__(self) -> None: def __init__(self, rng: Alea | None = None) -> None:
self.history: T.List[Stats] = [] self._rng = rng or Alea()
self.history: list[_Roll] = []
@property
def seed(self) -> RngState:
"""Generator state the current stats were rolled from.
Like upstream, a new character's game continues from this state,
so its first rolls replay the stat roll's numbers.
"""
return self.history[-1].seed
def roll(self) -> Stats: def roll(self) -> Stats:
values: T.Dict[StatType, int] = { seed = self._rng.state()
with random.using(self._rng):
values: dict[StatType, int] = {
stat: 3 + random.below(6) + random.below(6) + random.below(6) stat: 3 + random.below(6) + random.below(6) + random.below(6)
for stat in PRIME_STATS for stat in PRIME_STATS
} }
@@ -913,21 +1021,29 @@ class StatsBuilder:
random.below(8) + values[StatType.intelligence] // 6 random.below(8) + values[StatType.intelligence] // 6
) )
stats = Stats(values) stats = Stats(values)
self.history.append(stats) self.history.append(_Roll(stats, seed, self._rng.state()))
return stats return stats
def unroll(self) -> Stats: def unroll(self) -> Stats:
if len(self.history) > 1: if len(self.history) > 1:
self.history.pop() self.history.pop()
return self.history[-1] self._rng = Alea.from_state(self.history[-1].after)
return self.history[-1].stats
def create_player( def create_player(
name: str, race: Race, class_: Class, stats: Stats name: str,
race: Race,
class_: Class,
stats: Stats,
seed: RngState | None = None,
) -> Player: ) -> Player:
now = datetime.datetime.now()
random.seed(now)
player = Player( player = Player(
birthday=now, name=name, race=race, class_=class_, stats=stats birthday=datetime.datetime.now(),
name=name,
race=race,
class_=class_,
stats=stats,
rng=Alea.from_state(seed) if seed is not None else None,
) )
return player return player
+380
View File
@@ -0,0 +1,380 @@
"""Import and export Progress Quest web saves (.pqw files).
A .pqw file is base64 of one character's JSON, as the web version's roster
page downloads it. Characters keep their generator state both ways, so they
carry on exactly as they would have where they came from.
"""
import base64
import binascii
import copy
import datetime
import json
from pathlib import Path
from typing import Any, cast
from pqcli.config import Class, EquipmentType, Monster, Race, StatType
from pqcli.game_data import GameData
from pqcli.lingo import act_name, to_arabic, to_roman
from pqcli.mechanic import (
BaseTask,
BuyTask,
HeadingToKillingFieldsTask,
HeadingToMarketTask,
InventoryItem,
KillTask,
LoadingTask,
Player,
PlotTask,
RegularTask,
SellTask,
Simulation,
Spell,
Stats,
)
from pqcli.random import Alea, RngState
# Spells upstream renamed; its loader merges old saves the same way.
SPELL_RENAMES = [
("Innoculate", "Inoculate"),
("Tonsilectomy", "Tonsillectomy"),
]
# Fields of the web save that pqcli has no use for.
ONLINE_FIELDS = ("online", "guild", "motto")
LOADING_CAPTION = "Loading...."
class PqwError(Exception):
pass
def decode(text: str) -> dict[str, Any]:
"""Turn the contents of a .pqw file into the web save's game object."""
try:
raw = base64.b64decode("".join(text.split()), validate=True)
except (binascii.Error, ValueError) as ex:
raise PqwError(f"not a .pqw file: {ex}") from ex
try:
decoded = raw.decode("utf-8")
except UnicodeDecodeError:
# saves from before 2022 were base64 of the raw JS string
decoded = raw.decode("latin-1")
try:
game = json.loads(decoded)
except json.JSONDecodeError as ex:
raise PqwError(f"not a .pqw file: {ex}") from ex
if not isinstance(game, dict) or "Traits" not in game:
raise PqwError("not a .pqw file: no character in it")
return game
def encode(game: dict[str, Any]) -> str:
"""Turn a web save's game object into the contents of a .pqw file."""
text = json.dumps(game, ensure_ascii=False, separators=(",", ":"))
return base64.b64encode(text.encode("utf-8")).decode("ascii")
def _field(game: dict[str, Any], *path: str | int) -> Any:
value: Any = game
for key in path:
try:
value = value[key]
except (KeyError, IndexError, TypeError) as ex:
name = ".".join(map(str, path))
raise PqwError(f"missing field {name}") from ex
return value
def _int(game: dict[str, Any], *path: str | int) -> int:
value = _field(game, *path)
try:
return int(value)
except (TypeError, ValueError) as ex:
name = ".".join(map(str, path))
raise PqwError(f"field {name} is not a number: {value!r}") from ex
def _bar(game: dict[str, Any], name: str) -> tuple[int, float]:
position = _field(game, name, "position")
if not isinstance(position, int | float):
raise PqwError(f"field {name}.position is not a number")
return _int(game, name, "max"), position
def _parse_monster(text: str) -> Monster | None:
if not text:
return None
parts = text.split("|")
if len(parts) != 3:
raise PqwError(f"cannot read monster {text!r}")
name, level, item = parts
try:
return Monster(name, int(level), None if item == "*" else item)
except ValueError as ex:
raise PqwError(f"cannot read monster {text!r}") from ex
def _encode_monster(monster: Monster) -> str:
return f"{monster.name}|{monster.level}|{monster.item or '*'}"
def _parse_task(game: dict[str, Any]) -> BaseTask:
kind = str(_field(game, "task"))
caption = str(_field(game, "kill"))
duration = _bar(game, "TaskBar")[0]
if kind.startswith("kill|"):
monster = _parse_monster(kind.removeprefix("kill|"))
return KillTask(caption.removesuffix("..."), duration, monster)
if caption == LOADING_CAPTION:
return LoadingTask("Loading", duration)
task_class: type[BaseTask] = {
"buying": BuyTask,
"market": HeadingToMarketTask,
"sell": SellTask,
"heading": HeadingToKillingFieldsTask,
"": RegularTask,
}.get(kind, RegularTask)
return task_class(caption.removesuffix("..."), duration)
def _encode_task(task: BaseTask) -> tuple[str, str]:
if isinstance(task, LoadingTask):
return "", LOADING_CAPTION
caption = task.description + "..."
if isinstance(task, KillTask):
monster = task.monster or Monster("", 0, None)
return "kill|" + _encode_monster(monster), caption
for task_class, kind in [
(BuyTask, "buying"),
(HeadingToMarketTask, "market"),
(SellTask, "sell"),
(HeadingToKillingFieldsTask, "heading"),
]:
if isinstance(task, task_class):
return kind, caption
return "", caption
def _parse_queue_entry(entry: Any) -> RegularTask:
parts = str(entry).split("|", 2)
if len(parts) != 3 or parts[0] not in {"task", "plot"}:
raise PqwError(f"cannot read queued task {entry!r}")
try:
duration = int(parts[1]) * 1000
except ValueError as ex:
raise PqwError(f"cannot read queued task {entry!r}") from ex
if parts[0] == "plot":
# renamed after the act it starts once it comes up
return PlotTask("Loading", duration)
return RegularTask(parts[2], duration)
def _encode_queue_entry(task: BaseTask) -> str:
seconds = task.duration // 1000
if isinstance(task, PlotTask):
return f"plot|{seconds}|Loading"
return f"task|{seconds}|{task.description}"
def _parse_spells(game: dict[str, Any]) -> list[Spell]:
rows = [(str(row[0]), str(row[1])) for row in _field(game, "Spells")]
levels = {name: to_arabic(roman) for name, roman in rows}
spells = []
for name, _roman in rows:
renamed = dict(SPELL_RENAMES).get(name)
if renamed is not None:
levels[renamed] = levels.get(renamed, 0) + levels[name]
name = renamed
elif any(new == name and old in levels for old, new in SPELL_RENAMES):
continue # merged into the old spell's place
spells.append(Spell(name, levels[name]))
return spells
def _find_by_name(
items: tuple[Race, ...] | tuple[Class, ...], name: str
) -> Race | Class | None:
return next((item for item in items if item.name == name), None)
def import_game(
game: dict[str, Any], data: GameData
) -> tuple[Player, list[str]]:
"""Build a pqcli character from a web save, plus any warnings."""
warnings = []
for field in ONLINE_FIELDS:
if game.get(field):
warnings.append(f"dropped {field!r}: pqcli plays offline only")
name = str(_field(game, "Traits", "Name"))
race_name = str(_field(game, "Traits", "Race"))
class_name = str(_field(game, "Traits", "Class"))
race = _find_by_name(data.races, race_name) or Race(race_name, [])
class_ = _find_by_name(data.classes, class_name) or Class(class_name, [])
stats = Stats({stat: _int(game, "Stats", stat.value) for stat in StatType})
seed = _field(game, "seed")
if not (isinstance(seed, list) and len(seed) == 4):
raise PqwError("field seed is not a generator state")
stamp = game.get("birthstamp")
birthday = (
datetime.datetime.fromtimestamp(stamp / 1000)
if isinstance(stamp, int | float)
else datetime.datetime.now()
)
player = Player(
name=name,
birthday=birthday,
race=cast(Race, race),
class_=cast(Class, class_),
stats=stats,
rng=Alea.from_state(cast(RngState, tuple(seed))),
)
player.level = _int(game, "Traits", "Level")
player.elapsed = float(game.get("elapsed", 0)) * 1000
player.exp_bar.reset(*_bar(game, "ExpBar"))
player.task_bar.reset(*_bar(game, "TaskBar"))
player.quest_book.plot_bar.reset(*_bar(game, "PlotBar"))
player.quest_book.quest_bar.reset(*_bar(game, "QuestBar"))
player.quest_book._act = _int(game, "act")
player.quest_book._quests = [str(q) for q in _field(game, "Quests")]
player.quest_book.monster = _parse_monster(
str(game.get("questmonster", ""))
)
for kind in EquipmentType:
item = str(_field(game, "Equips").get(kind.value, ""))
if item:
player.equipment.put(kind, item)
player.equipment.best = str(game.get("bestequip", player.equipment.best))
rows = _field(game, "Inventory")
if not rows or rows[0][0] != "Gold":
raise PqwError("field Inventory does not start with Gold")
player.inventory._gold = _int(game, "Inventory", 0, 1)
player.inventory._items = [
InventoryItem(str(row[0]), int(row[1])) for row in rows[1:]
]
player.inventory.encum_bar.reset(*_bar(game, "EncumBar"))
player.spell_book._spells = _parse_spells(game)
player.task = _parse_task(game)
player.queue = [_parse_queue_entry(e) for e in _field(game, "queue")]
return player, warnings
def export_game(player: Player, data: GameData) -> dict[str, Any]:
"""Build a web save from a pqcli character."""
if player.task is None:
# never played: set up the Prologue first, on a copy
player = copy.deepcopy(player)
Simulation(player, data).tick(0)
assert player.task is not None
seed = list(player.rng.state())
birthstamp = int(player.birthday.timestamp() * 1000)
now = datetime.datetime.now()
kind, caption = _encode_task(player.task)
best_stat = player.stats.best_prime
best_spell = player.spell_book.best
quest_monster = player.quest_book.monster
# upstream picks the quest monster by its index in the monster list
quest_monster_index = (
data.monsters.index(quest_monster)
if quest_monster in data.monsters
else 0
)
def bar(position: float, max_: int) -> dict[str, float]:
return {"position": position, "max": max_}
return {
"Traits": {
"Name": player.name,
"Race": player.race.name,
"Class": player.class_.name,
"Level": player.level,
},
"dna": seed,
"seed": seed,
"birthday": player.birthday.strftime("%a %b %d %Y %H:%M:%S"),
"birthstamp": birthstamp,
"date": now.strftime("%a %b %d %Y %H:%M:%S"),
"stamp": int(now.timestamp() * 1000),
"Stats": {stat.value: player.stats[stat] for stat in StatType},
"beststat": f"{best_stat.value} {player.stats[best_stat]}",
"task": kind,
"tasks": 0,
"elapsed": int(player.elapsed // 1000),
"bestequip": player.equipment.best,
"Equips": {
kind.value: player.equipment[kind] or "" for kind in EquipmentType
},
"Inventory": [["Gold", player.inventory.gold]]
+ [[item.name, item.quantity] for item in player.inventory],
"Spells": [
[spell.name, to_roman(spell.level)] for spell in player.spell_book
],
"bestspell": (
f"{best_spell.name} {to_roman(best_spell.level)}"
if best_spell
else ""
),
"act": player.quest_book.act,
"bestplot": act_name(player.quest_book.act),
"Quests": list(player.quest_book.quests),
"bestquest": player.quest_book.current_quest or "",
"questmonster": (
_encode_monster(quest_monster) if quest_monster else ""
),
"questmonsterindex": quest_monster_index,
"kill": caption,
"ExpBar": bar(player.exp_bar.position, player.exp_bar.max_),
"EncumBar": bar(
player.inventory.encum_bar.position,
player.inventory.encum_bar.max_,
),
"PlotBar": bar(
player.quest_book.plot_bar.position,
player.quest_book.plot_bar.max_,
),
"QuestBar": bar(
player.quest_book.quest_bar.position,
player.quest_book.quest_bar.max_,
),
"TaskBar": bar(player.task_bar.position, player.task_bar.max_),
"queue": [_encode_queue_entry(task) for task in player.queue],
}
def read_file(path: Path, data: GameData) -> tuple[Player, list[str]]:
"""Import the character in a .pqw file, plus any warnings."""
try:
text = path.read_text(encoding="ascii", errors="replace")
except OSError as ex:
raise PqwError(f"cannot read {path}: {ex.strerror}") from ex
return import_game(decode(text), data)
def write_file(player: Player, path: Path, data: GameData) -> None:
"""Export a character to a .pqw file, never overwriting one."""
text = encode(export_game(player, data))
try:
with path.open("x", encoding="ascii") as handle:
handle.write(text)
except FileExistsError as ex:
raise PqwError(f"{path} already exists") from ex
except OSError as ex:
raise PqwError(f"cannot write {path}: {ex.strerror}") from ex
def default_file_name(player: Player) -> str:
# the web version names its downloads the same way
return f"{player.name}.pqw"
+105 -7
View File
@@ -1,21 +1,119 @@
import random import contextlib
import typing as T import time
from collections.abc import Callable, Generator, Sequence
from typing import TypeVar
RngState = tuple[float, float, float, int]
T = TypeVar("T")
_2_POW_32 = 0x100000000
_2_POW_MINUS_32 = 2.3283064365386963e-10
def seed(source: T.Any) -> None: def _to_uint32(value: float) -> float:
random.seed(source) # JS `value >>> 0` for the non-negative values Mash produces
return float(int(value) % _2_POW_32)
def choice(source: T.Sequence[T.Any]) -> T.Any: def _mash() -> Callable[[str], float]:
n = float(0xEFC8249D)
def mash(data: str) -> float:
nonlocal n
# JS strings are UTF-16, and charCodeAt() returns code units
units = data.encode("utf-16-le")
for i in range(0, len(units), 2):
n += units[i] | (units[i + 1] << 8)
h = 0.02519603282416938 * n
n = _to_uint32(h)
h -= n
h *= n
n = _to_uint32(h)
h -= n
n += h * _2_POW_32
return _to_uint32(n) * _2_POW_MINUS_32
return mash
class Alea:
"""Johannes Baagøe's Alea PRNG (MIT), as used by Progress Quest web.
Ported bit for bit so that games seeded alike play out alike. Saves
pickle this class by import path, so it must stay in this module.
"""
def __init__(self, *args: str) -> None:
if not args:
args = (str(int(time.time() * 1000)),)
mash = _mash()
self._s0 = mash(" ")
self._s1 = mash(" ")
self._s2 = mash(" ")
self._c = 1
for arg in args:
self._s0 -= mash(arg)
if self._s0 < 0:
self._s0 += 1
self._s1 -= mash(arg)
if self._s1 < 0:
self._s1 += 1
self._s2 -= mash(arg)
if self._s2 < 0:
self._s2 += 1
@classmethod
def from_state(cls, state: RngState) -> "Alea":
rng = cls.__new__(cls)
rng._s0, rng._s1, rng._s2, rng._c = state
return rng
def state(self) -> RngState:
return (self._s0, self._s1, self._s2, self._c)
def random(self) -> float:
t = 2091639 * self._s0 + self._c * _2_POW_MINUS_32
self._s0 = self._s1
self._s1 = self._s2
self._c = int(t)
self._s2 = t - self._c
return self._s2
def uint32(self) -> int:
return int(self.random() * _2_POW_32)
_current = Alea()
def seed(source: str) -> None:
global _current
_current = Alea(source)
@contextlib.contextmanager
def using(rng: Alea) -> Generator[None, None, None]:
"""Draw from rng instead of the shared generator inside the block."""
global _current
previous = _current
_current = rng
try:
yield
finally:
_current = previous
def choice(source: Sequence[T]) -> T:
return source[below(len(source))] return source[below(len(source))]
def choice_low(source: T.Sequence[T.Any]) -> T.Any: def choice_low(source: Sequence[T]) -> T:
return source[below_low(len(source))] return source[below_low(len(source))]
def below(num: int) -> int: def below(num: int) -> int:
return random.randint(0, num - 1) return _current.uint32() % num
def below_low(num: int) -> int: def below_low(num: int) -> int:
+4 -3
View File
@@ -1,6 +1,5 @@
import logging import logging
import pickle import pickle
import typing as T
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -8,13 +7,13 @@ from pqcli.mechanic import Player
class Roster: class Roster:
def __init__(self, path: Path, players: T.List[Player]) -> None: def __init__(self, path: Path, players: list[Player]) -> None:
self.path = path self.path = path
self.players = players self.players = players
self._last_save = datetime.now() self._last_save = datetime.now()
@staticmethod @staticmethod
def load(path: T.Union[str, Path]) -> "Roster": def load(path: str | Path) -> "Roster":
real_path = Path(path) real_path = Path(path)
if real_path.exists(): if real_path.exists():
return Roster( return Roster(
@@ -29,6 +28,8 @@ class Roster:
tmp_path.parent.mkdir(parents=True, exist_ok=True) tmp_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_bytes(pickle.dumps(self.players)) tmp_path.write_bytes(pickle.dumps(self.players))
if self.path.exists(): if self.path.exists():
if old_path.exists():
old_path.unlink()
self.path.rename(old_path) self.path.rename(old_path)
tmp_path.rename(self.path) tmp_path.rename(self.path)
+11 -1
View File
@@ -1,13 +1,23 @@
import abc import abc
import argparse import argparse
from pqcli.game_data import GameData
from pqcli.mechanic import Player
from pqcli.roster import Roster from pqcli.roster import Roster
class BaseUserInterface(abc.ABC): class BaseUserInterface(abc.ABC):
def __init__(self, roster: Roster, args: argparse.Namespace) -> None: def __init__(
self,
roster: Roster,
player: Player | None,
args: argparse.Namespace,
data: GameData,
) -> None:
self.roster = roster self.roster = roster
self.data = data
self.args = args self.args = args
self.player = player
@abc.abstractmethod @abc.abstractmethod
def run(self) -> None: def run(self) -> None:
+86 -21
View File
@@ -4,38 +4,53 @@ import logging
import signal import signal
import sys import sys
import time import time
import typing as T from collections.abc import Sequence
from datetime import datetime, timedelta from datetime import datetime, timedelta
from pathlib import Path
from types import FrameType
from typing import TypeVar
from pqcli import lingo from pqcli import lingo, pqw
from pqcli.config import CLASSES, PRIME_STATS, RACES from pqcli.config import PRIME_STATS
from pqcli.game_data import GameData
from pqcli.mechanic import Player, Simulation, StatsBuilder, create_player from pqcli.mechanic import Player, Simulation, StatsBuilder, create_player
from pqcli.roster import Roster from pqcli.roster import Roster
from pqcli.ui.base import BaseUserInterface from pqcli.ui.base import BaseUserInterface
LOGO = """ LOGO = r"""
____ ___ _ ____ ___ _
| _ \ _ __ ___ __ _ _ __ ___ ___ ___ / _ \ _ _ ___ ___| |_ | _ \ _ __ ___ __ _ _ __ ___ ___ ___ / _ \ _ _ ___ ___| |_
| |_) | '__/ _ \ / _` | '__/ _ \/ __/ __| | | | | | |/ _ \/ __| __| | |_) | '__/ _ \ / _` | '__/ _ \/ __/ __| | | | | | |/ _ \/ __| __|
| __/| | | (_) | (_| | | | __/\__ \__ \ |_| | |_| | __/\__ \ |_ | __/| | | (_) | (_| | | | __/\__ \__ \ |_| | |_| | __/\__ \ |_
|_| |_| \___/ \__, |_| \___||___/___/\__\_\\\\__,_|\___||___/\__| |_| |_| \___/ \__, |_| \___||___/___/\__\_\\__,_|\___||___/\__|
|___/ |___/
""" """
T = TypeVar("T")
class MainMenu(enum.IntEnum): class MainMenu(enum.IntEnum):
create = 1 create = 1
play = 2 play = 2
info = 3 info = 3
delete = 4 delete = 4
quit = 5 import_ = 5
export = 6
quit = 7
class BasicUserInterface(BaseUserInterface): class BasicUserInterface(BaseUserInterface):
def __init__(self, roster: Roster, args: argparse.Namespace) -> None: def __init__(
super().__init__(roster, args) self,
roster: Roster,
player: Player | None,
args: argparse.Namespace,
data: GameData,
) -> None:
super().__init__(roster, player, args, data)
def signal_handler(sig: T.Any, frame: T.Any) -> None: def signal_handler(sig: int, frame: FrameType | None) -> None:
logging.info("Quitting") logging.info("Quitting")
if self.args.use_saves: if self.args.use_saves:
self.roster.save() self.roster.save()
@@ -51,6 +66,9 @@ class BasicUserInterface(BaseUserInterface):
) )
self.logo() self.logo()
if self.player:
self.play(self.player)
else:
self.main_menu() self.main_menu()
def logo(self) -> None: def logo(self) -> None:
@@ -64,6 +82,8 @@ class BasicUserInterface(BaseUserInterface):
(MainMenu.play, "Play as character"), (MainMenu.play, "Play as character"),
(MainMenu.info, "Query character info"), (MainMenu.info, "Query character info"),
(MainMenu.delete, "Delete a character"), (MainMenu.delete, "Delete a character"),
(MainMenu.import_, "Import a character (.pqw)"),
(MainMenu.export, "Export a character (.pqw)"),
(MainMenu.quit, "Quit"), (MainMenu.quit, "Quit"),
] ]
) )
@@ -89,18 +109,29 @@ class BasicUserInterface(BaseUserInterface):
continue continue
self.delete_player(player) self.delete_player(player)
elif choice == MainMenu.import_:
self.import_player()
elif choice == MainMenu.export:
player = self.choose_player()
if not player:
continue
self.export_player(player)
elif choice == MainMenu.quit: elif choice == MainMenu.quit:
self.quit() self.quit()
break break
def create_player(self) -> T.Optional[Player]: def create_player(self, *, auto_play: bool = True) -> Player | None:
name = input("Name your new character: ") name = input("Name your new character: ")
if not name: if not name:
print("Cancelled.") print("Cancelled.")
return None return None
race = self.menu([(race, race.name) for race in RACES]) race = self.menu([(race, race.name) for race in self.data.races])
class_ = self.menu([(class_, class_.name) for class_ in CLASSES]) class_ = self.menu(
[(class_, class_.name) for class_ in self.data.classes]
)
stats_builder = StatsBuilder() stats_builder = StatsBuilder()
while True: while True:
@@ -112,17 +143,22 @@ class BasicUserInterface(BaseUserInterface):
break break
player = create_player( player = create_player(
name=name, race=race, class_=class_, stats=stats name=name,
race=race,
class_=class_,
stats=stats,
seed=stats_builder.seed,
) )
self.roster.players.append(player) self.roster.players.append(player)
if self.confirm("Do you want to play as your new character?"): if auto_play and self.confirm(
"Do you want to play as your new character?"
):
self.play(player) self.play(player)
return player return player
def play(self, player: Player) -> None: def play(self, player: Player) -> None:
print(f"Playing as {player.name}") print(f"Playing as {player.name}")
simulation = Simulation(player) simulation = Simulation(player, self.data)
last_save = datetime.now()
last_tick = datetime.now() last_tick = datetime.now()
last_level = 0 last_level = 0
while True: while True:
@@ -179,7 +215,36 @@ class BasicUserInterface(BaseUserInterface):
if self.args.use_saves: if self.args.use_saves:
self.roster.save() self.roster.save()
def choose_player(self) -> T.Optional[Player]: def import_player(self) -> None:
path = input("Import from .pqw file: ").strip()
if not path:
print("Cancelled.")
return
try:
player, warnings = pqw.read_file(
Path(path).expanduser(), self.data
)
except pqw.PqwError as ex:
print(f"Cannot import: {ex}")
return
for warning in warnings:
print(f"Warning: {warning}")
self.roster.players.append(player)
if self.args.use_saves:
self.roster.save()
print(f"Imported {player.name}.")
def export_player(self, player: Player) -> None:
default = pqw.default_file_name(player)
path = input(f"Export to .pqw file [{default}]: ").strip() or default
try:
pqw.write_file(player, Path(path).expanduser(), self.data)
except pqw.PqwError as ex:
print(f"Cannot export: {ex}")
return
print(f"Exported {player.name} to {path}.")
def choose_player(self) -> Player | None:
if not self.roster.players: if not self.roster.players:
print("No characters to choose from!") print("No characters to choose from!")
return None return None
@@ -199,9 +264,9 @@ class BasicUserInterface(BaseUserInterface):
def menu( def menu(
self, self,
options: T.Sequence[T.Tuple[T.Any, str]], options: Sequence[tuple[T, str]],
title: T.Optional[str] = None, title: str | None = None,
) -> T.Any: ) -> T:
print() print()
if title: if title:
print(f"{title}:") print(f"{title}:")
@@ -217,6 +282,6 @@ class BasicUserInterface(BaseUserInterface):
print("Not a number") print("Not a number")
continue continue
if num not in range(1, len(options) + 1): if num not in range(1, len(options) + 1):
print(f"Expected a number betwen 1..{len(options)}") print(f"Expected a number between 1..{len(options)}")
continue continue
return options[num - 1][0] return options[num - 1][0]
+95 -18
View File
@@ -3,11 +3,14 @@ import curses
import os import os
import signal import signal
import time import time
import typing as T from pathlib import Path
from types import FrameType
from pqcli import lingo from pqcli import lingo, pqw
from pqcli.config import Class, Race from pqcli.config import Class, Race
from pqcli.game_data import GameData
from pqcli.mechanic import Player, Stats, create_player from pqcli.mechanic import Player, Stats, create_player
from pqcli.random import RngState
from pqcli.roster import Roster from pqcli.roster import Roster
from pqcli.ui.base import BaseUserInterface from pqcli.ui.base import BaseUserInterface
from pqcli.ui.curses.colors import ( from pqcli.ui.curses.colors import (
@@ -30,7 +33,9 @@ from pqcli.ui.curses.views import (
ChooseCharacterView, ChooseCharacterView,
ConfirmView, ConfirmView,
GameView, GameView,
MessageView,
RosterView, RosterView,
TextInputView,
) )
@@ -39,8 +44,14 @@ class StopMainLoop(Exception):
class CursesUserInterface(BaseUserInterface): class CursesUserInterface(BaseUserInterface):
def __init__(self, roster: Roster, args: argparse.Namespace) -> None: def __init__(
super().__init__(roster, args) self,
roster: Roster,
player: Player,
args: argparse.Namespace,
data: GameData,
) -> None:
super().__init__(roster, player, args, data)
os.environ.setdefault("ESCDELAY", "25") os.environ.setdefault("ESCDELAY", "25")
self._screen = curses.initscr() self._screen = curses.initscr()
self._screen.nodelay(True) self._screen.nodelay(True)
@@ -60,7 +71,7 @@ class CursesUserInterface(BaseUserInterface):
curses.init_pair(COLOR_PROGRESSBAR, 0, 9) curses.init_pair(COLOR_PROGRESSBAR, 0, 9)
curses.init_pair(COLOR_HIGHLIGHT, 3, -1) curses.init_pair(COLOR_HIGHLIGHT, 3, -1)
def signal_handler(sig: T.Any, frame: T.Any) -> None: def signal_handler(sig: int, frame: FrameType | None) -> None:
if self.args.use_saves: if self.args.use_saves:
self.roster.save() self.roster.save()
exit(0) exit(0)
@@ -71,16 +82,21 @@ class CursesUserInterface(BaseUserInterface):
self._roster_view.on_create += self._switch_to_create_char_name_view 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_delete += self._switch_to_delete_char_view
self._roster_view.on_play += self._switch_to_play_view self._roster_view.on_play += self._switch_to_play_view
self._roster_view.on_import += self._switch_to_import_view
self._roster_view.on_export += self._switch_to_export_char_view
self._roster_view.on_quit += self._quit self._roster_view.on_quit += self._quit
self._view: BaseView = self._roster_view self._view: BaseView = self._roster_view
self._view.start() self._view.start()
def run(self) -> None: def run(self) -> None:
try: try:
if self.player:
self._switch_to_game_view(self.player)
else:
self._switch_to_roster_view() self._switch_to_roster_view()
while True: while True:
key = T.cast(int, self._screen.getch()) key = self._screen.getch()
if key == curses.ERR: if key == curses.ERR:
time.sleep(0.1) time.sleep(0.1)
@@ -104,7 +120,7 @@ class CursesUserInterface(BaseUserInterface):
self._switch_view(self._roster_view) self._switch_view(self._roster_view)
def _switch_to_create_char_name_view( def _switch_to_create_char_name_view(
self, player_name: T.Optional[str] = None self, player_name: str | None = None
) -> None: ) -> None:
view = ChooseCharacterNameView(self._screen, player_name) view = ChooseCharacterNameView(self._screen, player_name)
view.on_cancel += self._switch_to_roster_view view.on_cancel += self._switch_to_roster_view
@@ -112,9 +128,9 @@ class CursesUserInterface(BaseUserInterface):
self._switch_view(view) self._switch_view(view)
def _switch_to_create_char_race_view( def _switch_to_create_char_race_view(
self, player_name: str, race: T.Optional[Race] = None self, player_name: str, race: Race | None = None
) -> None: ) -> None:
view = ChooseCharacterRaceView(self._screen, race) view = ChooseCharacterRaceView(self._screen, self.data.races, race)
view.on_cancel += lambda: self._switch_to_create_char_name_view( view.on_cancel += lambda: self._switch_to_create_char_name_view(
player_name player_name
) )
@@ -124,14 +140,16 @@ class CursesUserInterface(BaseUserInterface):
self._switch_view(view) self._switch_view(view)
def _switch_to_create_char_class_view( def _switch_to_create_char_class_view(
self, player_name: str, race: Race, class_: T.Optional[Class] = None self, player_name: str, race: Race, class_: Class | None = None
) -> None: ) -> None:
view = ChooseCharacterClassView(self._screen, class_) view = ChooseCharacterClassView(
self._screen, self.data.classes, class_
)
view.on_cancel += lambda: self._switch_to_create_char_race_view( view.on_cancel += lambda: self._switch_to_create_char_race_view(
player_name, race player_name, race
) )
view.on_confirm += lambda class_: self._switch_to_create_char_stats_view( view.on_confirm += lambda class_: (
player_name, race, class_ self._switch_to_create_char_stats_view(player_name, race, class_)
) )
self._switch_view(view) self._switch_view(view)
@@ -142,8 +160,8 @@ class CursesUserInterface(BaseUserInterface):
view.on_cancel += lambda: self._switch_to_create_char_class_view( view.on_cancel += lambda: self._switch_to_create_char_class_view(
player_name, race, class_ player_name, race, class_
) )
view.on_confirm += lambda stats: self._create_character( view.on_confirm += lambda stats, seed: self._create_character(
player_name, race, class_, stats player_name, race, class_, stats, seed
) )
self._switch_view(view) self._switch_view(view)
@@ -169,8 +187,62 @@ class CursesUserInterface(BaseUserInterface):
view.on_confirm += self._switch_to_game_view view.on_confirm += self._switch_to_game_view
self._switch_view(view) self._switch_view(view)
def _switch_to_import_view(self) -> None:
view = TextInputView(self._screen, "Import from .pqw file:", "")
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._import_character
self._switch_view(view)
def _switch_to_export_char_view(self) -> None:
view = ChooseCharacterView(
self._screen, self.roster, "Choose character to export"
)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_export_file_view
self._switch_view(view)
def _switch_to_export_file_view(self, player: Player) -> None:
view = TextInputView(
self._screen,
f"Export {player.name} to .pqw file:",
pqw.default_file_name(player),
)
view.on_cancel += self._switch_to_export_char_view
view.on_confirm += lambda path: self._export_character(player, path)
self._switch_view(view)
def _switch_to_message_view(self, message: str) -> None:
view = MessageView(self._screen, message)
view.on_close += self._switch_to_roster_view
self._switch_view(view)
def _import_character(self, path: str) -> None:
try:
player, warnings = pqw.read_file(
Path(path).expanduser(), self.data
)
except pqw.PqwError as ex:
self._switch_to_message_view(f"Cannot import: {ex}")
return
self.roster.players.append(player)
if self.args.use_saves:
self.roster.save()
self._switch_to_message_view(
"\n".join([f"Imported {player.name}.", *warnings])
)
def _export_character(self, player: Player, path: str) -> None:
try:
pqw.write_file(player, Path(path).expanduser(), self.data)
except pqw.PqwError as ex:
self._switch_to_message_view(f"Cannot export: {ex}")
return
self._switch_to_message_view(f"Exported {player.name} to {path}.")
def _switch_to_game_view(self, player: Player) -> None: def _switch_to_game_view(self, player: Player) -> None:
view = GameView(self._screen, self.roster, player, self.args) view = GameView(
self._screen, self.roster, player, self.args, self.data
)
view.on_exit += self._switch_to_roster_view view.on_exit += self._switch_to_roster_view
self._switch_view(view) self._switch_view(view)
@@ -180,9 +252,14 @@ class CursesUserInterface(BaseUserInterface):
self._view.start() self._view.start()
def _create_character( def _create_character(
self, name: str, race: Race, class_: Class, stats: Stats self,
name: str,
race: Race,
class_: Class,
stats: Stats,
seed: RngState,
) -> None: ) -> None:
player = create_player(name, race, class_, stats) player = create_player(name, race, class_, stats, seed)
self.roster.players.append(player) self.roster.players.append(player)
if self.args.use_saves: if self.args.use_saves:
self.roster.save() self.roster.save()
+8 -5
View File
@@ -1,14 +1,17 @@
import typing as T from collections.abc import Callable
from typing import Generic, ParamSpec
P = ParamSpec("P")
class EventHandler: class EventHandler(Generic[P]):
def __init__(self) -> None: def __init__(self) -> None:
self.callbacks: T.List[T.Callable[..., T.Any]] = [] self.callbacks: list[Callable[P, object]] = []
def __call__(self, *args: T.Any, **kwargs: T.Any) -> None: def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None:
for callback in self.callbacks: for callback in self.callbacks:
callback(*args, **kwargs) callback(*args, **kwargs)
def __iadd__(self, callback: T.Callable[..., T.Any]) -> "EventHandler": def __iadd__(self, callback: Callable[P, object]) -> "EventHandler[P]":
self.callbacks.append(callback) self.callbacks.append(callback)
return self return self
+19 -4
View File
@@ -1,7 +1,8 @@
import curses import curses
import curses.ascii import curses.ascii
import typing as T from collections.abc import Callable, Iterable
from dataclasses import dataclass from dataclasses import dataclass
from typing import TypeVar, overload
KEYS_CYCLE = {curses.ascii.TAB} KEYS_CYCLE = {curses.ascii.TAB}
KEYS_DOWN = set(map(ord, "jJ")) | {curses.KEY_DOWN} KEYS_DOWN = set(map(ord, "jJ")) | {curses.KEY_DOWN}
@@ -13,10 +14,24 @@ KEYS_CANCEL = set(map(ord, "qQ")) | {curses.ascii.ESC}
@dataclass @dataclass
class Choice: class Choice:
keys: T.List[int] keys: list[int]
desc: str desc: str
callback: T.Callable[..., T.Any] callback: Callable[[], object]
def first(source: T.Iterable[T.Any], default: T.Any = None) -> T.Any: ItemT = TypeVar("ItemT")
DefaultT = TypeVar("DefaultT")
@overload
def first(source: Iterable[ItemT]) -> ItemT | None: ...
@overload
def first(source: Iterable[ItemT], default: DefaultT) -> ItemT | DefaultT: ...
def first(
source: Iterable[ItemT], default: DefaultT | None = None
) -> ItemT | DefaultT | None:
return next(iter(source), default) return next(iter(source), default)
+16
View File
@@ -8,4 +8,20 @@ from .create_character_view import (
ChooseCharacterStatsView, ChooseCharacterStatsView,
) )
from .game_view import GameView from .game_view import GameView
from .message_view import MessageView
from .roster_view import RosterView from .roster_view import RosterView
from .text_input_view import TextInputView
__all__ = [
"BaseView",
"ChooseCharacterView",
"ConfirmView",
"ChooseCharacterClassView",
"ChooseCharacterNameView",
"ChooseCharacterRaceView",
"ChooseCharacterStatsView",
"GameView",
"MessageView",
"RosterView",
"TextInputView",
]
+2 -2
View File
@@ -1,8 +1,8 @@
import typing as T import curses
class BaseView: class BaseView:
def __init__(self, screen: T.Any) -> None: def __init__(self, screen: curses.window) -> None:
self.screen = screen self.screen = screen
def start(self) -> None: def start(self) -> None:
@@ -1,20 +1,21 @@
import curses
import functools import functools
import typing as T
from pqcli.lingo import act_name, to_roman from pqcli.lingo import act_name, to_roman
from pqcli.mechanic import Player
from pqcli.roster import Roster from pqcli.roster import Roster
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import KEYS_CANCEL, Choice from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.views.menu_view import MenuView from pqcli.ui.curses.views.menu_view import MenuView
from pqcli.ui.curses.widgets import Menu
class ChooseCharacterView(MenuView): class ChooseCharacterView(MenuView[Player]):
def __init__(self, screen: T.Any, roster: Roster, title: str) -> None: def __init__(
self, screen: curses.window, roster: Roster, title: str
) -> None:
super().__init__(screen, title) super().__init__(screen, title)
for y, player in enumerate(roster.players, 1): for y, player in enumerate(roster.players, 1):
key: T.Optional[str] = str(y % 10) if y <= 10 else None key: str | None = str(y % 10) if y <= 10 else None
best_equip = player.equipment.best best_equip = player.equipment.best
+6 -6
View File
@@ -1,4 +1,4 @@
import typing as T import curses
from pqcli.ui.curses.event_handler import EventHandler from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import Choice from pqcli.ui.curses.util import Choice
@@ -8,17 +8,17 @@ from .base_view import BaseView
class ConfirmView(BaseView): class ConfirmView(BaseView):
def __init__(self, screen: T.Any, title: str) -> None: def __init__(self, screen: curses.window, title: str) -> None:
super().__init__(screen) super().__init__(screen)
self.title = f"{title}:" self.title = f"{title}:"
self.on_cancel = EventHandler() self.on_cancel = EventHandler[[]]()
self.on_confirm = EventHandler() self.on_confirm = EventHandler[[]]()
self.choices: T.List[Choice] = [ self.choices: list[Choice] = [
Choice( Choice(
keys=list(map(ord, "yY")), keys=list(map(ord, "yY")),
desc=f"[Y] Yes", desc="[Y] Yes",
callback=self.on_confirm, callback=self.on_confirm,
), ),
Choice( Choice(
@@ -2,3 +2,10 @@ from .choose_character_class_view import ChooseCharacterClassView
from .choose_character_name_view import ChooseCharacterNameView from .choose_character_name_view import ChooseCharacterNameView
from .choose_character_race_view import ChooseCharacterRaceView from .choose_character_race_view import ChooseCharacterRaceView
from .choose_character_stats_view import ChooseCharacterStatsView from .choose_character_stats_view import ChooseCharacterStatsView
__all__ = [
"ChooseCharacterClassView",
"ChooseCharacterNameView",
"ChooseCharacterRaceView",
"ChooseCharacterStatsView",
]
@@ -1,23 +1,27 @@
import curses
import functools import functools
import typing as T from collections.abc import Sequence
from pqcli.config import CLASSES, Class from pqcli.config import Class
from pqcli.ui.curses.util import KEYS_CANCEL, Choice from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.views.menu_view import MenuView from pqcli.ui.curses.views.menu_view import MenuView
class ChooseCharacterClassView(MenuView): class ChooseCharacterClassView(MenuView[Class]):
def __init__( def __init__(
self, screen: T.Any, class_: T.Optional[Class] = None self,
screen: curses.window,
classes: Sequence[Class],
class_: Class | None = None,
) -> None: ) -> None:
super().__init__( super().__init__(
screen, screen,
"Choose character class", "Choose character class",
CLASSES.index(class_) if class_ is not None else 0, classes.index(class_) if class_ is not None else 0,
) )
for y, class_ in enumerate(CLASSES, 1): for y, class_ in enumerate(classes, 1):
key: T.Optional[str] = str(y % 10) if y <= 10 else None key: str | None = str(y % 10) if y <= 10 else None
self._choices.append( self._choices.append(
Choice( Choice(
keys=[ord(key)] if key is not None else [], keys=[ord(key)] if key is not None else [],
@@ -1,6 +1,5 @@
import curses import curses
import curses.ascii import curses.ascii
import typing as T
from pqcli.mechanic import generate_name from pqcli.mechanic import generate_name
from pqcli.ui.curses.event_handler import EventHandler from pqcli.ui.curses.event_handler import EventHandler
@@ -11,16 +10,16 @@ from pqcli.ui.curses.widgets.focusable import focus_standout
class ChooseCharacterNameView(BaseView): class ChooseCharacterNameView(BaseView):
def __init__( def __init__(
self, screen: T.Any, character_name: T.Optional[str] = None self, screen: curses.window, character_name: str | None = None
) -> None: ) -> None:
super().__init__(screen) super().__init__(screen)
self.on_cancel = EventHandler() self.on_cancel = EventHandler[[]]()
self.on_confirm = EventHandler() self.on_confirm = EventHandler[[str]]()
self._active_widget = 0 self._active_widget = 0
self._win: T.Optional[T.Any] = None self._win: curses.window | None = None
self._text_win: T.Optional[T.Any] = None self._text_win: curses.window | None = None
self._text = character_name or generate_name() self._text = character_name or generate_name()
def start(self) -> None: def start(self) -> None:
@@ -1,21 +1,27 @@
import curses
import functools import functools
import typing as T from collections.abc import Sequence
from pqcli.config import RACES, Race from pqcli.config import Race
from pqcli.ui.curses.util import KEYS_CANCEL, Choice from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.views.menu_view import MenuView from pqcli.ui.curses.views.menu_view import MenuView
class ChooseCharacterRaceView(MenuView): class ChooseCharacterRaceView(MenuView[Race]):
def __init__(self, screen: T.Any, race: T.Optional[Race] = None) -> None: def __init__(
self,
screen: curses.window,
races: Sequence[Race],
race: Race | None = None,
) -> None:
super().__init__( super().__init__(
screen, screen,
"Choose character race", "Choose character race",
RACES.index(race) if race is not None else 0, races.index(race) if race is not None else 0,
) )
for y, race in enumerate(RACES, 1): for y, race in enumerate(races, 1):
key: T.Optional[str] = str(y % 10) if y <= 10 else None key: str | None = str(y % 10) if y <= 10 else None
self._choices.append( self._choices.append(
Choice( Choice(
keys=[ord(key)] if key is not None else [], keys=[ord(key)] if key is not None else [],
@@ -1,8 +1,9 @@
import curses
import curses.ascii import curses.ascii
import typing as T
from pqcli.config import PRIME_STATS from pqcli.config import PRIME_STATS
from pqcli.mechanic import StatsBuilder from pqcli.mechanic import Stats, StatsBuilder
from pqcli.random import RngState
from pqcli.ui.curses.event_handler import EventHandler 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.util import KEYS_CANCEL, KEYS_CYCLE, KEYS_DOWN, KEYS_UP
from pqcli.ui.curses.views.base_view import BaseView from pqcli.ui.curses.views.base_view import BaseView
@@ -10,15 +11,15 @@ from pqcli.ui.curses.widgets.focusable import focus_standout
class ChooseCharacterStatsView(BaseView): class ChooseCharacterStatsView(BaseView):
def __init__(self, screen: T.Any) -> None: def __init__(self, screen: curses.window) -> None:
super().__init__(screen) super().__init__(screen)
self.on_cancel = EventHandler() self.on_cancel = EventHandler[[]]()
self.on_confirm = EventHandler() self.on_confirm = EventHandler[[Stats, RngState]]()
self._active_widget = 0 self._active_widget = 0
self._win: T.Optional[T.Any] = None self._win: curses.window | None = None
self._stats_win: T.Optional[T.Any] = None self._stats_win: curses.window | None = None
self._stats_builder = StatsBuilder() self._stats_builder = StatsBuilder()
self._stats = self._stats_builder.roll() self._stats = self._stats_builder.roll()
@@ -55,7 +56,7 @@ class ChooseCharacterStatsView(BaseView):
elif self._active_widget == 1: elif self._active_widget == 1:
self._stats = self._stats_builder.unroll() self._stats = self._stats_builder.unroll()
elif self._active_widget == 2: elif self._active_widget == 2:
self.on_confirm(self._stats) self.on_confirm(self._stats, self._stats_builder.seed)
elif self._active_widget == 3: elif self._active_widget == 3:
self.on_cancel() self.on_cancel()
else: else:
@@ -68,7 +69,7 @@ class ChooseCharacterStatsView(BaseView):
self._stats = self._stats_builder.unroll() self._stats = self._stats_builder.unroll()
elif key == curses.KEY_F10: elif key == curses.KEY_F10:
self.on_confirm(self._stats) self.on_confirm(self._stats, self._stats_builder.seed)
elif key in KEYS_CYCLE: elif key in KEYS_CYCLE:
self._active_widget = (self._active_widget + 1) % 4 self._active_widget = (self._active_widget + 1) % 4
+30 -14
View File
@@ -1,8 +1,10 @@
import argparse import argparse
import curses import curses
import curses.ascii
import datetime import datetime
import typing as T from typing import Protocol, runtime_checkable
from pqcli.game_data import GameData
from pqcli.mechanic import Player, SignalMixin, Simulation from pqcli.mechanic import Player, SignalMixin, Simulation
from pqcli.roster import Roster from pqcli.roster import Roster
from pqcli.ui.curses.event_handler import EventHandler from pqcli.ui.curses.event_handler import EventHandler
@@ -15,7 +17,7 @@ from pqcli.ui.curses.util import (
KEYS_UP, KEYS_UP,
) )
from pqcli.ui.curses.views.base_view import BaseView from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets import Focusable, Scrollable, Widget from pqcli.ui.curses.widgets import Focusable
from .character_sheet_window import CharacterSheetWindow from .character_sheet_window import CharacterSheetWindow
from .equipment_window import EquipmentWindow from .equipment_window import EquipmentWindow
@@ -26,22 +28,36 @@ from .spell_book_window import SpellBookWindow
from .task_progress_window import TaskProgressWindow from .task_progress_window import TaskProgressWindow
class GameWindow(Protocol):
def stop(self) -> None: ...
def sync(self) -> None: ...
@runtime_checkable
class PageScrollable(Protocol):
def scroll_page_up(self) -> None: ...
def scroll_page_down(self) -> None: ...
class GameView(BaseView): class GameView(BaseView):
def __init__( def __init__(
self, self,
screen: T.Any, screen: curses.window,
roster: Roster, roster: Roster,
player: Player, player: Player,
args: argparse.Namespace, args: argparse.Namespace,
data: GameData,
) -> None: ) -> None:
super().__init__(screen) super().__init__(screen)
self.on_exit = EventHandler() self.on_exit = EventHandler[[]]()
self._roster = roster self._roster = roster
self._player = player self._player = player
self._args = args self._args = args
self._simulation = Simulation(player) self._simulation = Simulation(player, data)
self._last_tick = datetime.datetime.now() self._last_tick = datetime.datetime.now()
def keypress(self, key: int) -> None: def keypress(self, key: int) -> None:
@@ -51,11 +67,11 @@ class GameView(BaseView):
self.on_exit() self.on_exit()
elif key in {curses.KEY_PPAGE}: elif key in {curses.KEY_PPAGE}:
if hasattr(focused, "scroll_page_up"): if isinstance(focused, PageScrollable):
focused.scroll_page_up() focused.scroll_page_up()
elif key in {curses.KEY_NPAGE}: elif key in {curses.KEY_NPAGE}:
if hasattr(focused, "scroll_page_down"): if isinstance(focused, PageScrollable):
focused.scroll_page_down() focused.scroll_page_down()
elif key in KEYS_CYCLE: elif key in KEYS_CYCLE:
@@ -116,11 +132,9 @@ class GameView(BaseView):
self._simulation.tick() self._simulation.tick()
elif self._args.cheats and key == curses.ascii.DC4: # ^t elif self._args.cheats and key == curses.ascii.DC4: # ^t
old = SignalMixin.emit with SignalMixin.muted():
SignalMixin.emit = lambda *_: None
for _ in range(10000): for _ in range(10000):
self._simulation.tick() self._simulation.tick()
SignalMixin.emit = old
for child in self._children: for child in self._children:
child.sync() child.sync()
@@ -223,16 +237,18 @@ class GameView(BaseView):
x=0, x=0,
) )
self._focusable_children: T.List[Focusable] = [ focusable_children = (
self._char_sheet_win, self._char_sheet_win,
self._spell_book_win, self._spell_book_win,
self._equipment_win, self._equipment_win,
self._inventory_win, self._inventory_win,
self._plot_win, self._plot_win,
self._quest_book_win, self._quest_book_win,
] )
self._children: T.List[Widget] = self._focusable_children + [ self._focusable_children: list[Focusable] = list(focusable_children)
self._task_win self._children: list[GameWindow] = [
*focusable_children,
self._task_win,
] ]
def stop(self) -> None: def stop(self) -> None:
@@ -1,14 +1,19 @@
import typing as T import curses
from pqcli.mechanic import Player, StatType from pqcli.mechanic import Player, StatType
from pqcli.ui.curses.widgets import Focusable
from .progress_bar_window import DataTableProgressBarWindow from .progress_bar_window import DataTableProgressBarWindow
class CharacterSheetWindow(Focusable, DataTableProgressBarWindow): class CharacterSheetWindow(DataTableProgressBarWindow):
def __init__( def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None: ) -> None:
super().__init__( super().__init__(
parent, parent,
@@ -1,4 +1,4 @@
import typing as T import curses
from pqcli.mechanic import EquipmentType, Player from pqcli.mechanic import EquipmentType, Player
from pqcli.ui.curses.widgets import DataTable, Focusable, WindowWrapper from pqcli.ui.curses.widgets import DataTable, Focusable, WindowWrapper
@@ -6,7 +6,13 @@ from pqcli.ui.curses.widgets import DataTable, Focusable, WindowWrapper
class EquipmentWindow(Focusable, WindowWrapper): class EquipmentWindow(Focusable, WindowWrapper):
def __init__( def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None: ) -> None:
super().__init__(parent, h, w, y, x) super().__init__(parent, h, w, y, x)
self._on_focus_change += self._render self._on_focus_change += self._render
@@ -39,7 +45,7 @@ class EquipmentWindow(Focusable, WindowWrapper):
self._render() self._render()
def _sync_equipment_change( def _sync_equipment_change(
self, equipment_type: EquipmentType, item_name: T.Optional[str] self, equipment_type: EquipmentType, item_name: str | None
) -> None: ) -> None:
self._data_table.set(equipment_type.value, item_name or "") self._data_table.set(equipment_type.value, item_name or "")
self._data_table.select(equipment_type.value) self._data_table.select(equipment_type.value)
@@ -1,21 +1,19 @@
import curses import curses
import typing as T
from pqcli.lingo import to_roman
from pqcli.mechanic import InventoryItem, Player from pqcli.mechanic import InventoryItem, Player
from pqcli.ui.curses.widgets import (
DataTable,
Focusable,
ProgressBar,
WindowWrapper,
)
from .progress_bar_window import DataTableProgressBarWindow from .progress_bar_window import DataTableProgressBarWindow
class InventoryWindow(Focusable, DataTableProgressBarWindow): class InventoryWindow(DataTableProgressBarWindow):
def __init__( def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None: ) -> None:
super().__init__( super().__init__(
parent, parent,
@@ -1,17 +1,22 @@
import typing as T import curses
from pqcli.lingo import act_name from pqcli.lingo import act_name
from pqcli.mechanic import Player from pqcli.mechanic import Player
from pqcli.ui.curses.widgets import Focusable, ListBox, WindowWrapper
from .progress_bar_window import ListBoxProgressBarWindow from .progress_bar_window import ListBoxProgressBarWindow
class PlotWindow(Focusable, ListBoxProgressBarWindow): class PlotWindow(ListBoxProgressBarWindow):
cutoff = 100 cutoff = 100
def __init__( def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None: ) -> None:
super().__init__( super().__init__(
parent, h, w, y, x, " Plot Development ", show_time=True parent, h, w, y, x, " Plot Development ", show_time=True
@@ -1,5 +1,4 @@
import curses import curses
import typing as T
from pqcli.ui.curses.widgets import ( from pqcli.ui.curses.widgets import (
DataTable, DataTable,
@@ -10,10 +9,10 @@ from pqcli.ui.curses.widgets import (
) )
class ProgressBarWindow(WindowWrapper): class ProgressBarWindow(Focusable, WindowWrapper):
def __init__( def __init__(
self, self,
parent: T.Any, parent: curses.window,
h: int, h: int,
w: int, w: int,
y: int, y: int,
@@ -51,7 +50,7 @@ class ProgressBarWindow(WindowWrapper):
self._progress_bar_win.erase() self._progress_bar_win.erase()
with Focusable.focus_standout(self, self._progress_bar_win): with self.focus_standout(self._progress_bar_win):
self._progress_bar_win.border( self._progress_bar_win.border(
curses.ACS_VLINE, curses.ACS_VLINE,
curses.ACS_VLINE, curses.ACS_VLINE,
@@ -65,13 +64,16 @@ class ProgressBarWindow(WindowWrapper):
text = self._progress_title text = self._progress_title
x = max(0, (self.getmaxyx()[1] - len(text)) // 2) 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( self._progress_bar_win.addnstr(
0, 0,
x, x,
text, text,
min(len(text), self._progress_bar_win.getmaxyx()[1]), nwrite,
) )
self._progress_bar.set_position(self._cur_pos, self._max_pos) self._progress_bar.set_position(self._cur_pos, self._max_pos)
self._progress_bar_win.noutrefresh() self._progress_bar_win.noutrefresh()
@@ -79,7 +81,7 @@ class ProgressBarWindow(WindowWrapper):
class DataTableProgressBarWindow(ProgressBarWindow): class DataTableProgressBarWindow(ProgressBarWindow):
def __init__( def __init__(
self, self,
parent: T.Any, parent: curses.window,
h: int, h: int,
w: int, w: int,
y: int, y: int,
@@ -102,7 +104,7 @@ class DataTableProgressBarWindow(ProgressBarWindow):
if not self._win: if not self._win:
return return
with Focusable.focus_standout(self, self._win): with self.focus_standout(self._win):
self._win.box() self._win.box()
x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2) x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2)
self._win.addnstr( self._win.addnstr(
@@ -120,7 +122,7 @@ class DataTableProgressBarWindow(ProgressBarWindow):
class ListBoxProgressBarWindow(ProgressBarWindow): class ListBoxProgressBarWindow(ProgressBarWindow):
def __init__( def __init__(
self, self,
parent: T.Any, parent: curses.window,
h: int, h: int,
w: int, w: int,
y: int, y: int,
@@ -140,7 +142,7 @@ class ListBoxProgressBarWindow(ProgressBarWindow):
if not self._win: if not self._win:
return return
with Focusable.focus_standout(self, self._win): with self.focus_standout(self._win):
self._win.box() self._win.box()
x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2) x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2)
self._win.addnstr( self._win.addnstr(
@@ -1,16 +1,21 @@
import typing as T import curses
from pqcli.mechanic import Player from pqcli.mechanic import Player
from pqcli.ui.curses.widgets import Focusable, ListBox, WindowWrapper
from .progress_bar_window import ListBoxProgressBarWindow from .progress_bar_window import ListBoxProgressBarWindow
class QuestBookWindow(Focusable, ListBoxProgressBarWindow): class QuestBookWindow(ListBoxProgressBarWindow):
cutoff = 100 cutoff = 100
def __init__( def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None: ) -> None:
super().__init__(parent, h, w, y, x, " Quests ", show_time=True) super().__init__(parent, h, w, y, x, " Quests ", show_time=True)
self._on_focus_change += self._render self._on_focus_change += self._render
@@ -1,4 +1,4 @@
import typing as T import curses
from pqcli.lingo import to_roman from pqcli.lingo import to_roman
from pqcli.mechanic import Player, Spell from pqcli.mechanic import Player, Spell
@@ -7,7 +7,13 @@ from pqcli.ui.curses.widgets import DataTable, Focusable, WindowWrapper
class SpellBookWindow(Focusable, WindowWrapper): class SpellBookWindow(Focusable, WindowWrapper):
def __init__( def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None: ) -> None:
super().__init__(parent, h, w, y, x) super().__init__(parent, h, w, y, x)
self._on_focus_change += self._render self._on_focus_change += self._render
@@ -1,4 +1,4 @@
import typing as T import curses
from pqcli.mechanic import Player from pqcli.mechanic import Player
from pqcli.ui.curses.widgets import ( from pqcli.ui.curses.widgets import (
@@ -11,7 +11,13 @@ from pqcli.ui.curses.widgets import (
class TaskProgressWindow(Focusable, WindowWrapper): class TaskProgressWindow(Focusable, WindowWrapper):
def __init__( def __init__(
self, player: Player, parent: T.Any, h: int, w: int, y: int, x: int self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None: ) -> None:
super().__init__(parent, h, w, y, x) super().__init__(parent, h, w, y, x)
+9 -7
View File
@@ -1,23 +1,25 @@
import functools import curses
import typing as T from typing import Generic, TypeVar
from pqcli.ui.curses.event_handler import EventHandler from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import Choice from pqcli.ui.curses.util import Choice
from pqcli.ui.curses.views.base_view import BaseView from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets import Menu from pqcli.ui.curses.widgets import Menu
ItemT = TypeVar("ItemT")
class MenuView(BaseView):
class MenuView(BaseView, Generic[ItemT]):
def __init__( def __init__(
self, screen: T.Any, title: str, active_choice: int = 0 self, screen: curses.window, title: str, active_choice: int = 0
) -> None: ) -> None:
super().__init__(screen) super().__init__(screen)
self.on_cancel = EventHandler() self.on_cancel = EventHandler[[]]()
self.on_confirm = EventHandler() self.on_confirm = EventHandler[[ItemT]]()
self._title = f"{title}:" self._title = f"{title}:"
self._choices: T.List[Choice] = [] self._choices: list[Choice] = []
self._active_choice = active_choice self._active_choice = active_choice
def start(self) -> None: def start(self) -> None:
+53
View File
@@ -0,0 +1,53 @@
import curses
import curses.ascii
import textwrap
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.widgets import Menu
from .base_view import BaseView
class MessageView(BaseView):
"""Show a message until it is dismissed."""
def __init__(self, screen: curses.window, message: str) -> None:
super().__init__(screen)
self.message = message
self.on_close = EventHandler[[]]()
self.choices: list[Choice] = [
Choice(
keys=[curses.ascii.NL, *KEYS_CANCEL],
desc="[Enter] OK",
callback=self.on_close,
),
]
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
self.screen.erase()
self.screen.noutrefresh()
width = max(20, scr_width - 6)
header = "\n".join(
"\n".join(textwrap.wrap(line, width) or [""])
for line in self.message.split("\n")
)
self.main_menu = Menu(
header=header,
choices=self.choices,
active_choice=0,
scr_height=scr_height,
scr_width=scr_width,
)
self.main_menu.render()
def stop(self) -> None:
self.main_menu.stop()
def keypress(self, key: int) -> None:
self.main_menu.keypress(key)
self.main_menu.render()
+29 -18
View File
@@ -1,5 +1,4 @@
import curses import curses
import typing as T
from pqcli.ui.curses.colors import COLOR_LOGO, COLOR_LOGO_ALT, has_colors 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.event_handler import EventHandler
@@ -24,14 +23,12 @@ LOGO = """
█▄▄▀ █▀▀ ▄▀▀▄ ▄▀▀▄ █▀▀ ▄▀▀▄ ▄▀▀▀ ▄▀▀▀ █ █ █ █ ▄▀▀▄ ▄▀▀▀ ▀█▀ █▄▄▀ █▀▀ ▄▀▀▄ ▄▀▀▄ █▀▀ ▄▀▀▄ ▄▀▀▀ ▄▀▀▀ █ █ █ █ ▄▀▀▄ ▄▀▀▀ ▀█▀
█ █ █ █ ▀▄▄█ █ █▀▀ ▀▀▄ ▀▀▄ █ ▌█ █ █ █▀▀ ▀▀▄ █ █ █ █ █ ▀▄▄█ █ █▀▀ ▀▀▄ ▀▀▄ █ ▌█ █ █ █▀▀ ▀▀▄ █
▀ ▀ ▀▀ ▄▄▀ ▀ ▀▀ ▀▀▀ ▀▀▀ ▀▀▌ ▀▀ ▀▀ ▀▀▀ ▀ ▀ ▀ ▀▀ ▄▄▀ ▀ ▀▀ ▀▀▀ ▀▀▀ ▀▀▌ ▀▀ ▀▀ ▀▀▀ ▀
""".strip( """.strip("\n")
"\n"
)
class MainMenu(Menu): class MainMenu(Menu):
def __init__( def __init__(
self, choices: T.List[Choice], scr_height: int, scr_width: int self, choices: list[Choice], scr_height: int, scr_width: int
) -> None: ) -> None:
super().__init__( super().__init__(
header=LOGO, header=LOGO,
@@ -40,27 +37,31 @@ class MainMenu(Menu):
scr_height=scr_height, scr_height=scr_height,
scr_width=scr_width, scr_width=scr_width,
) )
pad = self._pad
assert pad is not None
if has_colors(): if has_colors():
for y in range(len(self._header_lines)): for y in range(len(self._header_lines)):
self._pad.chgat(y, 0, curses.color_pair(COLOR_LOGO)) pad.chgat(y, 0, curses.color_pair(COLOR_LOGO))
self._pad.chgat(3, 29, 5, curses.color_pair(COLOR_LOGO_ALT)) pad.chgat(3, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(4, 29, 5, curses.color_pair(COLOR_LOGO_ALT)) pad.chgat(4, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(5, 32, 4, curses.color_pair(COLOR_LOGO_ALT)) pad.chgat(5, 32, 4, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(5, 27, 4, curses.color_pair(COLOR_LOGO_ALT)) pad.chgat(5, 27, 4, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(6, 34, 2, curses.color_pair(COLOR_LOGO_ALT)) pad.chgat(6, 34, 2, curses.color_pair(COLOR_LOGO_ALT))
self._pad.chgat(6, 27, 2, curses.color_pair(COLOR_LOGO_ALT)) pad.chgat(6, 27, 2, curses.color_pair(COLOR_LOGO_ALT))
class RosterView(BaseView): class RosterView(BaseView):
def __init__(self, screen: T.Any) -> None: def __init__(self, screen: curses.window) -> None:
super().__init__(screen) super().__init__(screen)
self.on_create = EventHandler() self.on_create = EventHandler[[]]()
self.on_play = EventHandler() self.on_play = EventHandler[[]]()
self.on_delete = EventHandler() self.on_delete = EventHandler[[]]()
self.on_quit = EventHandler() self.on_import = EventHandler[[]]()
self.on_export = EventHandler[[]]()
self.on_quit = EventHandler[[]]()
self.choices: T.List[Choice] = [ self.choices: list[Choice] = [
Choice( Choice(
keys=list(map(ord, "cC")), keys=list(map(ord, "cC")),
desc="[C] Create new character", desc="[C] Create new character",
@@ -76,6 +77,16 @@ class RosterView(BaseView):
desc="[D] Delete character", desc="[D] Delete character",
callback=self.on_delete, callback=self.on_delete,
), ),
Choice(
keys=list(map(ord, "iI")),
desc="[I] Import character (.pqw)",
callback=self.on_import,
),
Choice(
keys=list(map(ord, "eE")),
desc="[E] Export character (.pqw)",
callback=self.on_export,
),
Choice( Choice(
keys=list(KEYS_CANCEL), desc="[Q] Quit", callback=self.on_quit keys=list(KEYS_CANCEL), desc="[Q] Quit", callback=self.on_quit
), ),
+73
View File
@@ -0,0 +1,73 @@
import curses
import curses.ascii
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.views.base_view import BaseView
class TextInputView(BaseView):
"""Ask for one line of text, such as a file name."""
def __init__(self, screen: curses.window, title: str, text: str) -> None:
super().__init__(screen)
self.on_cancel = EventHandler[[]]()
self.on_confirm = EventHandler[[str]]()
self._title = title
self._text = text
self._win: curses.window | None = None
self._text_win: curses.window | None = None
def start(self) -> None:
self.screen.erase()
self.screen.noutrefresh()
scr_height, scr_width = self.screen.getmaxyx()
h = 6
w = min(60, scr_width - 4)
y = (scr_height - h - 2) // 2
x = (scr_width - w - 2) // 2
self._win = curses.newwin(h + 2, w + 2, y, x)
self._text_win = curses.newwin(3, w + 2, y + 2, x - 1)
self._render()
def stop(self) -> None:
self._win = None
self._text_win = None
def keypress(self, key: int) -> None:
if key == curses.ascii.ESC:
self.on_cancel()
return
if key == curses.ascii.NL:
text = self._text.strip()
if text:
self.on_confirm(text)
else:
self.on_cancel()
return
if key in {curses.KEY_BACKSPACE, curses.ascii.DEL}:
self._text = self._text[:-1]
elif key == curses.ascii.ETB: # ^w
self._text = ""
elif curses.ascii.isprint(key):
self._text += chr(key)
self._render()
def _render(self) -> None:
if not self._win or not self._text_win:
return
self._win.erase()
self._win.addstr(0, 0, self._title)
self._win.addstr(5, 0, "[Enter] Continue")
self._win.addstr(6, 0, "[Esc ] Cancel")
self._win.refresh()
width = self._text_win.getmaxyx()[1] - 2
self._text_win.erase()
self._text_win.box()
# keep the end of long paths in view
self._text_win.addnstr(1, 1, self._text[-width + 1 :], width)
self._text_win.refresh()
+12
View File
@@ -6,3 +6,15 @@ from .list_box import ListBox
from .menu import Menu from .menu import Menu
from .progress_bar import ProgressBar from .progress_bar import ProgressBar
from .scrollable import Scrollable from .scrollable import Scrollable
__all__ = [
"Widget",
"WindowWrapper",
"DataTable",
"Focusable",
"Label",
"ListBox",
"Menu",
"ProgressBar",
"Scrollable",
]
+4 -5
View File
@@ -1,5 +1,4 @@
import curses import curses
import typing as T
class Widget: class Widget:
@@ -12,10 +11,10 @@ class Widget:
class WindowWrapper(Widget): class WindowWrapper(Widget):
def __init__( def __init__(
self, parent: T.Optional[T.Any], h: int, w: int, y: int, x: int self, parent: curses.window | None, h: int, w: int, y: int, x: int
) -> None: ) -> None:
self._parent = parent self._parent = parent
self._win: T.Optional[T.Any] self._win: curses.window | None
if not parent: if not parent:
self._win = None self._win = None
@@ -30,12 +29,12 @@ class WindowWrapper(Widget):
del self._win del self._win
self._win = None self._win = None
def getbegyx(self) -> T.Tuple[int, int]: def getbegyx(self) -> tuple[int, int]:
if not self._win: if not self._win:
return (0, 0) return (0, 0)
return self._win.getbegyx() return self._win.getbegyx()
def getmaxyx(self) -> T.Tuple[int, int]: def getmaxyx(self) -> tuple[int, int]:
if not self._win: if not self._win:
return (0, 0) return (0, 0)
return self._win.getmaxyx() return self._win.getmaxyx()
+11 -12
View File
@@ -1,16 +1,14 @@
import curses import curses
import enum
import typing as T
from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors
from .scrollable import Scrollable from .scrollable import Scrollable
class DataTable(Scrollable): class DataTable(Scrollable[tuple[str, str]]):
def __init__( def __init__(
self, self,
parent: T.Any, parent: curses.window | None,
h: int, h: int,
w: int, w: int,
y: int, y: int,
@@ -20,8 +18,7 @@ class DataTable(Scrollable):
super().__init__(parent, h, w, y, x) super().__init__(parent, h, w, y, x)
self._align_right = align_right self._align_right = align_right
self._items: T.List[T.Tuple[str, str]] self._selected: int | None = None
self._selected: T.Optional[int] = None
def add(self, text: str, value: str) -> None: def add(self, text: str, value: str) -> None:
self._items.append((text, value)) self._items.append((text, value))
@@ -41,10 +38,10 @@ class DataTable(Scrollable):
del self._items[idx] del self._items[idx]
self.scroll_to_item(idx) self.scroll_to_item(idx)
def select(self, text: T.Optional[str]) -> None: def select(self, text: str | None) -> None:
self._selected = None if text is None else self.get_idx(text) self._selected = None if text is None else self.get_idx(text)
def get_idx(self, text: str) -> T.Optional[int]: def get_idx(self, text: str) -> int | None:
for idx, row in enumerate(self._items): for idx, row in enumerate(self._items):
if row[0].strip() == text.strip(): if row[0].strip() == text.strip():
return idx return idx
@@ -70,10 +67,12 @@ class DataTable(Scrollable):
if y == self._selected and has_colors(): if y == self._selected and has_colors():
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT)) self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
self._pad.addnstr(y, 0, row[0], min(len(row[0]), w)) self._pad.addnstr(y, 0, row[0], min(len(row[0]), w))
if col2_x < w: # Determine the number of characters to write
self._pad.addnstr( nwrite = min(len(row[1]), w - col2_x)
y, col2_x, row[1], 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(): if y == self._selected and has_colors():
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT)) self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
+10 -5
View File
@@ -1,13 +1,16 @@
import contextlib import contextlib
import curses import curses
import typing as T from collections.abc import Generator
from typing import Any
from pqcli.ui.curses.colors import COLOR_FOCUSED, has_colors from pqcli.ui.curses.colors import COLOR_FOCUSED, has_colors
from pqcli.ui.curses.event_handler import EventHandler from pqcli.ui.curses.event_handler import EventHandler
@contextlib.contextmanager @contextlib.contextmanager
def focus_standout(focused: bool, win: T.Any) -> T.Generator: def focus_standout(
focused: bool, win: curses.window
) -> Generator[None, None, None]:
if focused: if focused:
if has_colors(): if has_colors():
win.attron(curses.color_pair(COLOR_FOCUSED)) win.attron(curses.color_pair(COLOR_FOCUSED))
@@ -22,9 +25,9 @@ def focus_standout(focused: bool, win: T.Any) -> T.Generator:
class Focusable: class Focusable:
def __init__(self, *args: T.Any, **kwargs: T.Any) -> None: def __init__(self, *args: Any, **kwargs: Any) -> None:
self._focused = False self._focused = False
self._on_focus_change = EventHandler() self._on_focus_change = EventHandler[[]]()
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@property @property
@@ -37,6 +40,8 @@ class Focusable:
self._on_focus_change() self._on_focus_change()
@contextlib.contextmanager @contextlib.contextmanager
def focus_standout(self, win: T.Any) -> T.Generator: def focus_standout(
self, win: curses.window
) -> Generator[None, None, None]:
with focus_standout(self._focused, win): with focus_standout(self._focused, win):
yield yield
+9 -11
View File
@@ -1,18 +1,16 @@
import curses import curses
import enum
import typing as T
from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors
from .scrollable import Scrollable from .scrollable import Scrollable
class ListBox(Scrollable): class ListBox(Scrollable[str]):
_items: T.List[str] def __init__(
self, parent: curses.window | None, h: int, w: int, y: int, x: int
def __init__(self, parent: T.Any, h: int, w: int, y: int, x: int) -> None: ) -> None:
super().__init__(parent, h, w, y, x) super().__init__(parent, h, w, y, x)
self._selected: T.Optional[int] = None self._selected: int | None = None
def add(self, text: str) -> None: def add(self, text: str) -> None:
self._items.append(text) self._items.append(text)
@@ -23,21 +21,21 @@ class ListBox(Scrollable):
idx %= len(self._items) idx %= len(self._items)
self._items[idx] = value self._items[idx] = value
def get(self, idx: int) -> T.Optional[str]: def get(self, idx: int) -> str | None:
if not self._items: if not self._items:
return None return None
if idx < 0: if idx < 0:
idx %= len(self._items) idx %= len(self._items)
try: try:
return self._items[idx] return self._items[idx]
except ValueError: except IndexError:
return None return None
def delete(self, idx: int, count: int = 1) -> None: def delete(self, idx: int, count: int = 1) -> None:
del self._items[idx : idx + count] del self._items[idx : idx + count]
def select(self, idx: T.Optional[int]) -> None: def select(self, idx: int | None) -> None:
if idx < 0: if idx is not None and idx < 0 and self._items:
idx %= len(self._items) idx %= len(self._items)
self._selected = idx self._selected = idx
+6 -6
View File
@@ -1,6 +1,5 @@
import curses import curses
import curses.ascii import curses.ascii
import typing as T
from pqcli.ui.curses.util import KEYS_CYCLE, KEYS_DOWN, KEYS_UP, Choice from pqcli.ui.curses.util import KEYS_CYCLE, KEYS_DOWN, KEYS_UP, Choice
from pqcli.ui.curses.widgets.focusable import focus_standout from pqcli.ui.curses.widgets.focusable import focus_standout
@@ -12,7 +11,7 @@ class Menu(Widget):
def __init__( def __init__(
self, self,
header: str, header: str,
choices: T.List[Choice], choices: list[Choice],
active_choice: int, active_choice: int,
scr_height: int, scr_height: int,
scr_width: int, scr_width: int,
@@ -28,11 +27,12 @@ class Menu(Widget):
w = max(map(len, all_lines)) + 1 w = max(map(len, all_lines)) + 1
h = len(all_lines) + 1 h = len(all_lines) + 1
self._pad: T.Optional[T.Any] = curses.newpad(h, w) pad = curses.newpad(h, w)
self._pad: curses.window | None = pad
for y, line in enumerate(self._header_lines): for y, line in enumerate(self._header_lines):
self._pad.move(y, 0) pad.move(y, 0)
self._pad.addstr(line) pad.addstr(line)
self._active_choice = active_choice self._active_choice = active_choice
@@ -40,7 +40,7 @@ class Menu(Widget):
del self._pad del self._pad
self._pad = None self._pad = None
def getmaxyx(self) -> T.Tuple[int, int]: def getmaxyx(self) -> tuple[int, int]:
if not self._pad: if not self._pad:
return (0, 0) return (0, 0)
return self._pad.getmaxyx() return self._pad.getmaxyx()
+16 -6
View File
@@ -1,6 +1,5 @@
import curses import curses
import datetime import datetime
import typing as T
from pqcli.lingo import format_timespan from pqcli.lingo import format_timespan
from pqcli.ui.curses.colors import COLOR_PROGRESSBAR, has_colors from pqcli.ui.curses.colors import COLOR_PROGRESSBAR, has_colors
@@ -9,7 +8,13 @@ from pqcli.ui.curses.widgets.base import WindowWrapper
class ProgressBar(WindowWrapper): class ProgressBar(WindowWrapper):
def __init__( def __init__(
self, parent: T.Any, h: int, w: int, y: int, x: int, show_time: bool self,
parent: curses.window | None,
h: int,
w: int,
y: int,
x: int,
show_time: bool,
) -> None: ) -> None:
super().__init__(parent, h, w, y, x) super().__init__(parent, h, w, y, x)
@@ -17,15 +22,18 @@ class ProgressBar(WindowWrapper):
self._max_pos = 1.0 self._max_pos = 1.0
self._show_time = show_time self._show_time = show_time
self._last_tick: T.Optional[T.Tuple[datetime.datetime, float]] = None self._last_tick: tuple[datetime.datetime, float] | None = None
@property @property
def time_left(self) -> T.Optional[datetime.timedelta]: def time_left(self) -> datetime.timedelta | None:
if self._last_tick is None: if self._last_tick is None:
return None return None
time_then, pos_then = self._last_tick time_then, pos_then = self._last_tick
time_now, pos_now = datetime.datetime.now(), self._cur_pos time_now, pos_now = datetime.datetime.now(), self._cur_pos
speed = (pos_now - pos_then) / (time_now - time_then).total_seconds() div = (time_now - time_then).total_seconds()
if div == 0:
return None
speed = (pos_now - pos_then) / div
if not speed: if not speed:
return None return None
return datetime.timedelta(seconds=(self._max_pos - pos_now) / speed) return datetime.timedelta(seconds=(self._max_pos - pos_now) / speed)
@@ -51,9 +59,11 @@ class ProgressBar(WindowWrapper):
self._win.chgat( self._win.chgat(
0, 0,
0, 0,
(
curses.color_pair(COLOR_PROGRESSBAR) curses.color_pair(COLOR_PROGRESSBAR)
if has_colors() if has_colors()
else curses.A_REVERSE, else curses.A_REVERSE
),
) )
if x < self.getmaxyx()[1]: if x < self.getmaxyx()[1]:
self._win.chgat(0, x, curses.A_NORMAL) self._win.chgat(0, x, curses.A_NORMAL)
+12 -6
View File
@@ -1,5 +1,5 @@
import curses import curses
import typing as T from typing import Generic, TypeVar
from pqcli.ui.curses.colors import ( from pqcli.ui.curses.colors import (
COLOR_SCROLLBAR_THUMB, COLOR_SCROLLBAR_THUMB,
@@ -9,13 +9,17 @@ from pqcli.ui.curses.colors import (
from .base import WindowWrapper from .base import WindowWrapper
ItemT = TypeVar("ItemT")
class Scrollable(WindowWrapper):
def __init__(self, parent: T.Any, h: int, w: int, y: int, x: int) -> None: class Scrollable(WindowWrapper, Generic[ItemT]):
def __init__(
self, parent: curses.window | None, h: int, w: int, y: int, x: int
) -> None:
super().__init__(parent, h, w, y, x) super().__init__(parent, h, w, y, x)
self._scroll_pos = 0 self._scroll_pos = 0
self._pad: T.Optional[T.Any] = curses.newpad(1, 1) self._pad: curses.window | None = curses.newpad(1, 1)
self._items: T.List[T.Any] = [] self._items: list[ItemT] = []
def clear(self) -> None: def clear(self) -> None:
self._items = [] self._items = []
@@ -91,9 +95,11 @@ class Scrollable(WindowWrapper):
self._win.chgat( self._win.chgat(
win_y, win_y,
w - 1, w - 1,
(
attr_thumb attr_thumb
if thumb_y1 <= win_y <= thumb_y2 if thumb_y1 <= win_y <= thumb_y2
else attr_track, else attr_track
),
) )
self._win.noutrefresh() self._win.noutrefresh()
+55 -7
View File
@@ -1,8 +1,56 @@
[tool.black] [project]
line-length = 79 name = "pqcli"
py36 = true version = "1.1.0"
description = "Progress Quest: the CLI edition"
authors = [{ name = "Laura Kurczewska", email = "dash@wind.garden" }]
license = "MIT"
readme = "README.md"
requires-python = ">=3.10,<4.0"
classifiers = [
"Environment :: Console",
"Development Status :: 4 - Beta",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Topic :: Games/Entertainment :: Role-Playing",
"Topic :: Games/Entertainment :: Simulation",
]
[tool.isort] dependencies = [
known_first_party = "pqcli" "xdg-base-dirs>=6.0.0,<7.0.0",
multi_line_output = 3 "urwid>=2.1.2,<3.0.0",
include_trailing_comma = true "urwid-readline>=0.13,<1.0.0",
]
[project.optional-dependencies]
windows = ["windows-curses>=2.3.0,<3.0.0"]
[project.urls]
Repository = "https://github.com/rr-/pq-cli.git"
[project.scripts]
pqcli = "pqcli.__main__:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = ["prek>=0.2", "pytest>=8.0", "ty>=0.0.89"]
[tool.ty.src]
# Only the package and its tests; agent worktrees under .claude/ would
# otherwise be checked as duplicate copies of the project.
include = ["pqcli", "tests"]
[tool.ruff]
line-length = 79
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP"]
[tool.ruff.lint.isort]
known-first-party = ["pqcli"]
-26
View File
@@ -1,26 +0,0 @@
from setuptools import find_packages, setup
setup(
author="rr-",
author_email="rr-@sakuya.pl",
name="pqcli",
long_description="Progress Quest: the CLI edition",
version="1.0",
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",
],
)
+4
View File
@@ -0,0 +1,4 @@
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
+1902
View File
File diff suppressed because it is too large. Load diff
+1900
View File
File diff suppressed because it is too large. Load diff
+1901
View File
File diff suppressed because it is too large. Load diff
+86
View File
@@ -0,0 +1,86 @@
import datetime
from pathlib import Path
from pqcli import random
from pqcli.config import StatType
from pqcli.game_data import load_game_data
from pqcli.mechanic import Player, Simulation, Stats, monster_task
from pqcli.random import Alea
STATS = {
StatType.strength: 12,
StatType.condition: 11,
StatType.dexterity: 10,
StatType.intelligence: 13,
StatType.wisdom: 14,
StatType.charisma: 9,
StatType.hp_max: 6,
StatType.mp_max: 5,
}
def run_trace(seed: str, ticks: int = 3000) -> list[str]:
"""Play a deterministic game and return everything that happened."""
data = load_game_data()
player = Player(
name="Tester",
birthday=datetime.datetime(2000, 1, 1),
race=data.races[0],
class_=data.classes[0],
stats=Stats(dict(STATS)),
rng=Alea(seed),
)
random.seed(seed)
simulation = Simulation(player, data)
lines: list[str] = []
def record_task() -> None:
assert player.task is not None
lines.append(f"task: {player.task.description}")
player.connect("new_task", record_task)
for _ in range(ticks):
simulation.tick(100000.0)
with random.using(player.rng):
# plot cinematics and quests are rare in a short run, so force them
for _ in range(30):
simulation.interplot_cinematic()
simulation.complete_quest()
lines.extend(f"queued: {task.description}" for task in player.queue)
player.queue.clear()
# high level monsters are unreachable in a short run
for level in range(1, 100):
task = monster_task(data, level, player.quest_book.monster)
lines.append(f"monster: {task.description} {task.duration}")
lines.append(f"level: {player.level}")
lines.append(f"act: {player.quest_book.act}")
lines.append(f"gold: {player.inventory.gold}")
for stat, value in player.stats:
lines.append(f"stat: {stat.value} {value}")
for equipment_type, item in player.equipment:
lines.append(f"equipment: {equipment_type.value} {item}")
for inventory_item in player.inventory:
lines.append(
f"inventory: {inventory_item.name} {inventory_item.quantity}"
)
for spell in player.spell_book:
lines.append(f"spell: {spell.name} {spell.level}")
for quest in player.quest_book.quests:
lines.append(f"quest: {quest}")
return lines
SEEDS = ["alpha", "beta", "gamma"]
FIXTURES = Path(__file__).parent / "fixtures"
if __name__ == "__main__":
# Re-record the parity fixtures. Only do this on purpose, when a change
# is meant to alter gameplay.
for seed in SEEDS:
lines = run_trace(seed)
(FIXTURES / f"parity_{seed}.txt").write_text("\n".join(lines) + "\n")
+57
View File
@@ -0,0 +1,57 @@
import os
import subprocess
import sys
from pathlib import Path
from test_pqw import played
from pqcli.game_data import load_game_data
from pqcli.pqw import read_file, write_file
def pqcli(
config: Path, *args: str, cwd: Path | None = None
) -> subprocess.CompletedProcess[str]:
env = {**os.environ, "XDG_CONFIG_HOME": str(config)}
return subprocess.run(
[sys.executable, "-m", "pqcli", *args],
capture_output=True,
text=True,
env=env,
cwd=cwd,
)
def test_import_then_export(tmp_path: Path) -> None:
data = load_game_data()
source = tmp_path / "in.pqw"
write_file(played(3000).player, source, data)
result = pqcli(tmp_path, "--import-save", str(source))
assert result.returncode == 0, result.stderr
assert result.stdout == "Imported Exportée\n"
assert (tmp_path / "pqcli" / "save.dat").exists()
listed = pqcli(tmp_path, "--list-saves")
assert listed.stdout == "1. Exportée\n"
target = tmp_path / "out.pqw"
result = pqcli(tmp_path, "--export-save", "1", str(target))
assert result.returncode == 0, result.stderr
assert read_file(target, data)[0].name == "Exportée"
def test_import_bad_file_changes_nothing(tmp_path: Path) -> None:
bad = tmp_path / "bad.pqw"
bad.write_text("garbage!")
result = pqcli(tmp_path, "--import-save", str(bad))
assert result.returncode == 1
assert "not a .pqw file" in result.stderr
assert not (tmp_path / "pqcli" / "save.dat").exists()
def test_export_rejects_unknown_character(tmp_path: Path) -> None:
result = pqcli(tmp_path, "--export-save", "3", str(tmp_path / "x.pqw"))
assert result.returncode == 1
assert "Invalid player" in result.stderr
assert not (tmp_path / "x.pqw").exists()
+181
View File
@@ -0,0 +1,181 @@
import datetime
import json
import pickle
from pathlib import Path
import pytest
from pqcli import random
from pqcli.config import Class, Monster, Race, StatType
from pqcli.game_data import (
DATA_FILES,
DataError,
GameData,
dump_builtin_data,
load_game_data,
)
from pqcli.mechanic import Player, Stats, monster_task
def write(directory: Path, name: str, value: object) -> Path:
path = directory / f"{name}.json"
path.write_text(json.dumps(value), encoding="utf-8")
return path
def test_builtin_data_loads() -> None:
data = load_game_data()
assert data.spells[0] == "Slime Finger"
assert data.monsters[0] == Monster("Anhkheg", 6, "chitin")
assert data.races[0] == Race("Half Orc", [StatType.hp_max])
assert data.classes[-1] == Class("Vermineer", [StatType.intelligence])
assert data.combat_verbs == ("Executing {target}",)
assert all(getattr(data, name) for name in DATA_FILES)
def test_builtin_data_keeps_quirks() -> None:
data = load_game_data()
names = [monster.name for monster in data.monsters]
assert names.count("Rat") == 2
assert Monster("Neo-Otyugh", 11, "organ ") in data.monsters
assert any(monster.item is None for monster in data.monsters)
def test_missing_override_dir_uses_builtin(tmp_path: Path) -> None:
assert load_game_data(tmp_path / "nope") == load_game_data()
def test_override_replaces_only_that_list(tmp_path: Path) -> None:
write(tmp_path, "spells", ["Zap", "Bonk"])
write(
tmp_path,
"races",
[{"name": "Robot", "attr": ["STR", "hp_max", "Wisdom"]}],
)
builtin = load_game_data()
data = load_game_data(tmp_path)
assert data.spells == ("Zap", "Bonk")
assert data.races == (
Race("Robot", [StatType.strength, StatType.hp_max, StatType.wisdom]),
)
assert data.monsters == builtin.monsters
assert data.classes == builtin.classes
def test_override_accepts_all_shapes(tmp_path: Path) -> None:
write(tmp_path, "monsters", [{"name": "Bob", "level": 0, "item": None}])
write(tmp_path, "weapons", [{"name": "Spoon", "quality": -1}])
write(tmp_path, "offense_bad", [{"name": "Soggy", "quality": -2}])
data = load_game_data(tmp_path)
assert data.monsters == (Monster("Bob", 0, None),)
assert data.weapons[0].name == "Spoon"
assert data.offense_bad[0].quality == -2
def test_unknown_file_warns(
tmp_path: Path, capsys: pytest.CaptureFixture[str]
) -> None:
write(tmp_path, "monster", [])
load_game_data(tmp_path)
assert "monster.json" in capsys.readouterr().err
@pytest.mark.parametrize(
"name,value,message",
[
("spells", [], "must not be empty"),
("spells", {"a": 1}, "must be a list"),
("spells", ["ok", 3], "[1]"),
("monsters", [{"name": "Imp", "level": "2", "item": "x"}], "level"),
("monsters", [{"name": "Imp", "level": True, "item": "x"}], "level"),
("monsters", [{"name": "Imp", "level": 2}], "item"),
("monsters", [{"name": "Imp", "level": 2, "item": 1}], "item"),
("weapons", [{"name": "Spoon", "quality": 1, "x": 1}], "x"),
("races", [{"name": "Elf", "attr": ["LUCK"]}], "LUCK"),
("races", [{"name": "Elf", "attr": "STR"}], "attr"),
("combat_verbs", ["Hitting"], "{target}"),
],
)
def test_invalid_override_raises(
tmp_path: Path, name: str, value: object, message: str
) -> None:
write(tmp_path, name, value)
with pytest.raises(DataError) as excinfo:
load_game_data(tmp_path)
assert f"{name}.json" in str(excinfo.value)
assert message in str(excinfo.value)
def test_malformed_json_raises(tmp_path: Path) -> None:
(tmp_path / "spells.json").write_text("[oops", encoding="utf-8")
with pytest.raises(DataError, match="spells.json"):
load_game_data(tmp_path)
def test_non_utf8_raises(tmp_path: Path) -> None:
(tmp_path / "spells.json").write_bytes(b'["\xff"]')
with pytest.raises(DataError, match="spells.json"):
load_game_data(tmp_path)
def test_override_dir_must_be_directory(tmp_path: Path) -> None:
path = write(tmp_path, "spells", ["Zap"])
with pytest.raises(DataError, match="not a directory"):
load_game_data(path)
def test_combat_verbs_are_used(tmp_path: Path) -> None:
verbs = [
"Fighting bravely against {target}",
"Kicking the butt of {target}",
]
write(tmp_path, "combat_verbs", verbs)
data = load_game_data(tmp_path)
random.seed("verbs")
seen = set()
for _ in range(200):
description = monster_task(data, 5, None).description
prefix = description.split(" ")[0]
assert prefix in {"Fighting", "Kicking"}
assert "{target}" not in description
seen.add(prefix)
assert seen == {"Fighting", "Kicking"}
def test_dump_builtin_data_round_trips(tmp_path: Path) -> None:
written = dump_builtin_data(tmp_path / "out")
assert sorted(path.stem for path in written) == sorted(DATA_FILES)
assert load_game_data(tmp_path / "out") == load_game_data()
def test_dump_builtin_data_refuses_to_overwrite(tmp_path: Path) -> None:
existing = write(tmp_path, "spells", ["Mine"])
with pytest.raises(DataError, match="spells.json"):
dump_builtin_data(tmp_path)
assert json.loads(existing.read_text()) == ["Mine"]
assert list(tmp_path.iterdir()) == [existing]
def test_saved_player_survives_removed_race(tmp_path: Path) -> None:
data = load_game_data()
player = Player(
name="Saved",
birthday=datetime.datetime(2000, 1, 1),
race=data.races[3],
class_=data.classes[2],
stats=Stats({stat: 10 for stat in StatType}),
)
blob = pickle.dumps(player)
write(tmp_path, "races", [{"name": "Robot", "attr": ["STR"]}])
load_game_data(tmp_path)
restored = pickle.loads(blob)
assert restored.race == Race("Double Hobbit", [StatType.strength])
assert type(restored.race).__module__ == "pqcli.config"
def test_game_data_is_immutable() -> None:
data = load_game_data()
assert isinstance(data, GameData)
with pytest.raises(AttributeError):
setattr(data, "spells", ())
+28
View File
@@ -0,0 +1,28 @@
from pqcli.lingo import indefinite, to_arabic, to_roman
def test_indefinite_article() -> None:
assert indefinite("Orc", 1) == "an Orc"
assert indefinite("Ürn", 1) == "an Ürn"
assert indefinite("über-Orc", 1) == "an über-Orc"
assert indefinite("?", 1) == "a ?"
assert indefinite("Rat", 1) == "a Rat"
def test_to_roman() -> None:
assert to_roman(0) == "N"
assert to_roman(-4) == "-IV"
assert to_roman(1994) == "MCMXCIV"
# pq-web aa37e4a: T = 10000, A = 5000
assert to_roman(4000) == "MA"
assert to_roman(5000) == "A"
assert to_roman(9000) == "MT"
assert to_roman(14999) == "TMACMXCIX"
assert to_roman(23456) == "TTMMMCDLVI"
def test_to_arabic_inverts_to_roman() -> None:
for num in [*range(1, 5000), 9000, 9999, 14999, 23456]:
assert to_arabic(to_roman(num)) == num
assert to_arabic("N") == 0
assert to_arabic("iv") == 4
+249
View File
@@ -0,0 +1,249 @@
import datetime
import pickle
import re
import pytest
from pqcli import random
from pqcli.config import PRIME_STATS, StatType
from pqcli.game_data import load_game_data
from pqcli.lingo import plural
from pqcli.mechanic import (
Player,
Simulation,
Stats,
StatsBuilder,
create_player,
impressive_guy,
level_up_time,
)
from pqcli.random import Alea
def test_level_up_time_matches_upstream() -> None:
# Math.round((20 + Math.pow(1.15, level)) * 60), pq-web 5cd39f1
assert level_up_time(1) == 1269
assert level_up_time(10) == 1443
assert level_up_time(30) == 5173
assert level_up_time(100) == 70460007
RACE = load_game_data().races[0]
CLASS = load_game_data().classes[0]
def make_simulation() -> Simulation:
data = load_game_data()
player = Player(
name="Tester",
birthday=datetime.datetime(2000, 1, 1),
race=data.races[0],
class_=data.classes[0],
stats=Stats({stat: 10 for stat in StatType}),
rng=Alea("test"),
)
return Simulation(player, data)
def test_quest_length_matches_upstream() -> None:
# QuestBar.reset(50 + Random(100)), pq-web f7d5e83
random.seed("quests")
simulation = make_simulation()
lengths = set()
for _ in range(2000):
simulation.complete_quest()
lengths.add(simulation.player.quest_book.quest_bar.max_)
assert min(lengths) == 50
assert max(lengths) == 149
def test_weighted_stat_gain_skips_hp_and_mp(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# the "favor the best stat" branch only covers prime stats, pq-web 48c42e6
monkeypatch.setattr(random, "odds", lambda chance, out_of: False)
random.seed("stats")
player = make_simulation().player
player.stats.increment(StatType.hp_max, 1000)
for _ in range(500):
player.win_stat()
assert player.stats[StatType.hp_max] == 1010
assert player.stats[StatType.mp_max] == 10
assert sum(player.stats[stat] for stat in PRIME_STATS) == 60 + 500
def fake_below(monkeypatch: pytest.MonkeyPatch, *values: int) -> None:
queue = list(values)
monkeypatch.setattr(random, "below", lambda num: queue.pop(0) % num)
def full_inventory(rows: int) -> Simulation:
simulation = make_simulation()
for i in range(rows):
simulation.player.inventory.add(f"item {i}", 1)
return simulation
def test_win_item_adds_new_item_below_cap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
simulation = full_inventory(10)
random.seed("items")
simulation.player.win_item(simulation.data)
assert len(simulation.player.inventory) == 11
def test_win_item_repeats_existing_item_past_cap(
monkeypatch: pytest.MonkeyPatch,
) -> None:
# Max(250, Random(999)) < rows: add one of a random row, pq-web 3cbd386
simulation = full_inventory(300)
inventory = simulation.player.inventory
fake_below(monkeypatch, 260, 6) # rows include Gold, so 6 is "item 5"
simulation.player.win_item(simulation.data)
assert len(inventory) == 300
assert inventory[5].quantity == 2
def test_win_item_can_pick_gold_row(monkeypatch: pytest.MonkeyPatch) -> None:
simulation = full_inventory(300)
fake_below(monkeypatch, 0, 0)
simulation.player.win_item(simulation.data)
assert simulation.player.inventory.gold == 1
assert len(simulation.player.inventory) == 300
def test_win_item_past_cap_can_still_add_new_item(
monkeypatch: pytest.MonkeyPatch,
) -> None:
simulation = full_inventory(300)
random.seed("items")
fake_below(monkeypatch, 301, 0, 0, 0)
simulation.player.win_item(simulation.data)
assert len(simulation.player.inventory) == 301
def test_impressive_guy_formats() -> None:
# pq-web cea0d8c
data = load_game_data()
titles = "|".join(re.escape(title) for title in data.impressive_titles)
races = "|".join(re.escape(plural(race.name)) for race in data.races)
pattern = re.compile(
rf"the ({titles}) of the ({races})"
rf"|({titles}) [A-Z][a-z]+ of [A-Z][a-z]+"
)
random.seed("guys")
guys = [impressive_guy(data) for _ in range(200)]
for guy in guys:
assert pattern.fullmatch(guy), guy
assert any(guy.startswith("the ") for guy in guys)
assert not all(guy.startswith("the ") for guy in guys)
def test_impressive_titles_match_upstream() -> None:
assert load_game_data().impressive_titles[-3:] == (
"Chancellor",
"Baroness",
"Inquistor", # sic, as upstream
)
def test_cinematic_text_matches_upstream() -> None:
random.seed("cinematics")
simulation = make_simulation()
for _ in range(60):
simulation.interplot_cinematic()
texts = [task.description for task in simulation.player.queue]
assert any("You've reached the kind protection of " in t for t in texts)
assert any(t.endswith("news!? -- Oh yes, of course") for t in texts)
def test_quest_log_keeps_100_quests() -> None:
quest_book = make_simulation().player.quest_book
for i in range(150):
quest_book.add_quest(f"quest {i}")
assert len(quest_book.quests) == 100
assert quest_book.quests[-1] == "quest 149"
def test_prologue_fills_plot_bar() -> None:
# upstream advances the plot bar on any task while act == 0, and starts
# act I as its loading task starts
simulation = make_simulation()
player = simulation.player
seen: list[tuple[str, float, int, int]] = []
def record() -> None:
assert player.task is not None
bar = player.quest_book.plot_bar
seen.append(
(
player.task.description,
bar.position,
bar.max_,
player.quest_book.act,
)
)
player.connect("new_task", record)
while len(seen) < 7:
simulation.tick(100000.0)
assert [(position, act) for _, position, _, act in seen[:5]] == [
(0, 0),
(0, 0),
(10, 0),
(16, 0),
(22, 0),
]
assert seen[5] == ("Loading Act I", 0, 21600, 1)
assert seen[6][0] == "Heading to the killing fields"
def test_player_rng_survives_save_and_load() -> None:
simulation = make_simulation()
for _ in range(50):
simulation.tick(100000.0)
restored = pickle.loads(pickle.dumps(simulation.player))
assert restored.rng.state() == simulation.player.rng.state()
def test_old_save_without_rng_gets_one() -> None:
player = make_simulation().player
state = player.__dict__.copy()
del state["rng"]
restored = Player.__new__(Player)
restored.__setstate__(state)
assert isinstance(restored.rng, Alea)
def test_tick_draws_from_player_rng_only() -> None:
random.seed("global")
expected = Alea("global").uint32() % 1000
simulation = make_simulation()
before = simulation.player.rng.state()
for _ in range(50):
simulation.tick(100000.0)
assert simulation.player.rng.state() != before
assert random.below(1000) == expected
def test_new_game_replays_stat_roll_numbers() -> None:
# upstream seeds the game with the state from before the stat roll
builder = StatsBuilder(Alea("roll"))
builder.roll()
stats = builder.roll()
player = create_player("New", RACE, CLASS, stats, builder.seed)
assert player.rng.state() == builder.history[-1].seed
with random.using(player.rng):
rerolled = StatsBuilder(player.rng).roll()
assert dict(rerolled) == dict(stats)
def test_unroll_restores_previous_stats_and_generator() -> None:
builder = StatsBuilder(Alea("roll"))
first = builder.roll()
after_first = builder.history[-1].after
builder.roll()
assert builder.unroll() is first
assert builder.seed == builder.history[-1].seed
assert builder._rng.state() == after_first
+10
View File
@@ -0,0 +1,10 @@
import pytest
from game_trace import FIXTURES, SEEDS, run_trace
@pytest.mark.parametrize("seed", SEEDS)
def test_gameplay_matches_recorded_trace(seed: str) -> None:
# The fixtures pin down gameplay, RNG stream included. When a change is
# meant to alter gameplay, re-record them with `just record-traces`.
expected = (FIXTURES / f"parity_{seed}.txt").read_text().splitlines()
assert run_trace(seed) == expected
+164
View File
@@ -0,0 +1,164 @@
import base64
import datetime
import json
from pathlib import Path
import pytest
from upstream import bridge
from pqcli.config import StatType
from pqcli.game_data import load_game_data
from pqcli.mechanic import Player, Simulation, Stats
from pqcli.pqw import (
PqwError,
decode,
default_file_name,
encode,
export_game,
import_game,
read_file,
write_file,
)
from pqcli.random import Alea
DATA = load_game_data()
def played(ticks: int, seed: str = "pqw") -> Simulation:
player = Player(
name="Exportée",
birthday=datetime.datetime(2020, 2, 2, 12, 0, 0),
race=DATA.races[5],
class_=DATA.classes[7],
stats=Stats({stat: 9 for stat in StatType}),
rng=Alea(seed),
)
simulation = Simulation(player, DATA)
simulation.tick(0)
for _ in range(ticks):
simulation.tick(100)
return simulation
def test_encode_decode_round_trip() -> None:
game = {"Traits": {"Name": "Zoë 🐉"}, "act": 3}
assert decode(encode(game)) == game
def test_decode_ignores_whitespace() -> None:
text = encode({"Traits": {"Name": "x"}})
assert decode("\n".join(text[i : i + 10] for i in range(0, 99, 10)))
def test_decode_reads_old_latin1_saves() -> None:
# before 2022 the web version stored base64 of the raw JS string
raw = json.dumps({"Traits": {"Name": "Jürgen"}}, ensure_ascii=False)
text = base64.b64encode(raw.encode("latin-1")).decode()
assert decode(text)["Traits"]["Name"] == "Jürgen"
@pytest.mark.parametrize(
"text",
[
"not base64 at all!",
base64.b64encode(b"not json").decode(),
base64.b64encode(b"[1, 2]").decode(),
base64.b64encode(b'{"act": 1}').decode(),
],
)
def test_decode_rejects_garbage(text: str) -> None:
with pytest.raises(PqwError, match="not a .pqw file"):
decode(text)
def test_import_reports_missing_fields() -> None:
game = export_game(played(500).player, DATA)
del game["ExpBar"]
with pytest.raises(PqwError, match="ExpBar"):
import_game(game, DATA)
def test_import_rejects_bad_numbers() -> None:
game = export_game(played(500).player, DATA)
game["Traits"]["Level"] = "lots"
with pytest.raises(PqwError, match="Traits.Level"):
import_game(game, DATA)
def test_import_warns_about_online_play() -> None:
game = export_game(played(500).player, DATA)
game["online"] = {"realm": "Alpaquil", "passkey": 1}
game["motto"] = "hi"
_player, warnings = import_game(game, DATA)
assert len(warnings) == 2
assert "online" in warnings[0]
def test_import_merges_renamed_spells() -> None:
game = export_game(played(500).player, DATA)
game["Spells"] = [
["Inoculate", "II"],
["Slime Finger", "I"],
["Innoculate", "III"],
]
player, _warnings = import_game(game, DATA)
assert [(s.name, s.level) for s in player.spell_book] == [
("Slime Finger", 1),
("Inoculate", 5),
]
def test_import_keeps_unknown_race_and_class() -> None:
game = export_game(played(500).player, DATA)
game["Traits"]["Race"] = "Moon Wombat"
player, _warnings = import_game(game, DATA)
assert player.race.name == "Moon Wombat"
assert player.race.attr == []
def test_export_of_unplayed_character() -> None:
player = Player(
name="Fresh",
birthday=datetime.datetime(2020, 2, 2),
race=DATA.races[0],
class_=DATA.classes[0],
stats=Stats({stat: 9 for stat in StatType}),
)
game = export_game(player, DATA)
assert game["kill"] == "Loading...."
assert len(game["queue"]) == 5
assert player.task is None # exported a copy
@pytest.mark.parametrize("ticks", [0, 1, 25, 3000, 40000, 123457])
def test_round_trip_keeps_playing_identically(ticks: int) -> None:
original = played(ticks)
copy, _warnings = import_game(
decode(encode(export_game(original.player, DATA))), DATA
)
assert bridge.snapshot(copy) == bridge.snapshot(original.player)
resumed = Simulation(copy, DATA)
assert bridge.run(resumed, 10**8, 2000) == bridge.run(
original, 10**8, 2000
)
def test_files_round_trip(tmp_path: Path) -> None:
original = played(3000).player
path = tmp_path / default_file_name(original)
write_file(original, path, DATA)
copy, _warnings = read_file(path, DATA)
assert bridge.snapshot(copy) == bridge.snapshot(original)
def test_write_file_never_overwrites(tmp_path: Path) -> None:
path = tmp_path / "taken.pqw"
path.write_text("mine")
with pytest.raises(PqwError, match="already exists"):
write_file(played(10).player, path, DATA)
assert path.read_text() == "mine"
def test_read_file_reports_missing_file(tmp_path: Path) -> None:
with pytest.raises(PqwError, match="cannot read"):
read_file(tmp_path / "nope.pqw", DATA)
+88
View File
@@ -0,0 +1,88 @@
import pickle
import pytest
from pqcli import random
from pqcli.random import Alea
# First outputs and resulting state of upstream's JS Alea(seed).uint32()
VECTORS = {
"alpha": (
[3768637044, 756790225, 3147161637, 1726577786, 279486812],
(0.732755669625476, 0.4020002172328532, 0.06507309433072805, 368555),
),
"": (
[715789690, 2091287642, 486307, 3866591158, 760028033],
(
0.00011322717182338238,
0.9002609080635011,
0.17695781611837447,
1018452,
),
),
"1697543210123": (
[3110287155, 2843160345, 1794106530, 2361562046, 1804721005],
(
0.41772297816351056,
0.5498440111987293,
0.42019435320980847,
1384612,
),
),
# Mash hashes UTF-16 code units, so the dragon counts as two
"Ünïcødé 🐉": (
[4162081648, 198215584, 541466577, 6418444, 2254846220],
(
0.12607001163996756,
0.0014944104477763176,
0.5249972967430949,
96530,
),
),
}
@pytest.mark.parametrize("seed", VECTORS)
def test_alea_matches_upstream(seed: str) -> None:
first, state = VECTORS[seed]
rng = Alea(seed)
assert [rng.uint32() for _ in first] == first
assert rng.state() == state
def test_alea_state_round_trip() -> None:
rng = Alea("alpha")
rng.uint32()
copy = Alea.from_state(rng.state())
assert [copy.uint32() for _ in range(100)] == [
rng.uint32() for _ in range(100)
]
def test_alea_pickles_with_its_state() -> None:
rng = Alea("alpha")
rng.uint32()
copy = pickle.loads(pickle.dumps(rng))
assert copy.state() == rng.state()
def test_unseeded_alea_differs() -> None:
assert Alea().state() != Alea("alpha").state()
def test_below_is_uint32_modulo() -> None:
# Random(n) = seed.uint32() % n
random.seed("alpha")
assert [random.below(1000) for _ in range(5)] == [
n % 1000 for n in VECTORS["alpha"][0]
]
def test_using_restores_previous_generator() -> None:
random.seed("outer")
expected = Alea("outer").uint32() % 1000
inner = Alea("inner")
with random.using(inner):
random.below(10)
assert inner.state() != Alea("inner").state()
assert random.below(1000) == expected
+201
View File
@@ -0,0 +1,201 @@
"""Compare pqcli with Progress Quest web, run headless in node.
`just test` fetches upstream first; see tests/upstream/fetch.py. Without
node or the fetched files, these tests are skipped.
"""
from typing import Any, cast
import pytest
from upstream import bridge
from pqcli import random
from pqcli.game_data import DATA_FILES, load_game_data
from pqcli.lingo import generate_name
from pqcli.mechanic import (
Simulation,
impressive_guy,
monster_task,
named_monster,
special_item,
)
from pqcli.pqw import decode, encode, export_game, import_game
from pqcli.random import Alea, RngState
pytestmark = pytest.mark.skipif(
bridge.unavailable_reason() is not None,
reason=bridge.unavailable_reason() or "",
)
DATA = load_game_data()
STATES = [list(Alea(f"state {i}").state()) for i in range(200)]
def test_word_lists_match_upstream() -> None:
(lists,) = bridge.run_harness([{"cmd": "lists"}])
for name in DATA_FILES:
if name == "combat_verbs":
continue # pqcli only; upstream always says "Executing"
assert bridge.encode_list(name, DATA) == lists[bridge.LISTS[name]]
def compare_calls(
fn: str,
args: list[Any],
ours: Any,
convert: Any = lambda result: result,
**extra: Any,
) -> None:
requests = [
{"cmd": "call", "fn": fn, "args": args, "rng": state, **extra}
for state in STATES
]
for state, theirs in zip(STATES, bridge.run_harness(requests)):
rng = Alea.from_state(cast(RngState, tuple(state)))
with random.using(rng):
result = ours()
assert (result, list(rng.state())) == (
convert(theirs["result"]),
theirs["rng"],
), f"{fn}({args}) from state {state}"
def kill_task(level: int, monster: Any) -> tuple[str, int]:
task = monster_task(DATA, level, monster)
return task.description, task.duration
def upstream_kill_task(level: int) -> Any:
# Dequeue() turns MonsterTask's result into the task like this
return lambda result: (
"Executing " + result["description"],
(2 * 3 * result["level"] * 1000) // level,
)
@pytest.mark.parametrize("level", [1, 2, 5, 10, 20, 40, 80, 150])
def test_monster_task_matches_upstream(level: int) -> None:
compare_calls(
"MonsterTask",
[level],
lambda: kill_task(level, None),
upstream_kill_task(level),
questmonster="",
)
@pytest.mark.parametrize("level", [1, 10, 40])
def test_monster_task_with_quest_monster_matches_upstream(
level: int,
) -> None:
index = 25
monster = DATA.monsters[index]
compare_calls(
"MonsterTask",
[level],
lambda: kill_task(level, monster),
upstream_kill_task(level),
questmonster=bridge.encode_monster(monster),
questmonsterindex=index,
)
def test_named_monster_matches_upstream() -> None:
compare_calls("NamedMonster", [12], lambda: named_monster(DATA, 12))
def test_impressive_guy_matches_upstream() -> None:
compare_calls("ImpressiveGuy", [], lambda: impressive_guy(DATA))
def test_special_item_matches_upstream() -> None:
compare_calls("SpecialItem", [], lambda: special_item(DATA))
def test_generate_name_matches_upstream() -> None:
compare_calls("GenerateName", [], generate_name)
@pytest.mark.parametrize(
"seed,race,class_",
[
("alpha", "Half Orc", "Ur-Paladin"),
("beta", "Land Squid", "Vermineer"),
("gamma", "Talking Pony", "Mage Illusioner"),
],
)
def test_game_matches_upstream(seed: str, race: str, class_: str) -> None:
# Play both from the same character and generator state, 100 ms per
# tick, and compare the whole state, generator included, each time a
# task completes.
tasks = 10000
ticks = 100 * tasks * 1000
spec = bridge.make_spec(seed, race, class_)
(theirs,) = bridge.run_harness(
[{"cmd": "play", "spec": spec, "ticks": ticks, "tasks": tasks}]
)
ours = bridge.play(DATA, spec, ticks, tasks)
assert len(ours) == tasks
assert_same_games(ours, theirs)
def assert_same_games(
ours: list[dict[str, Any]], theirs: list[dict[str, Any]]
) -> None:
assert len(ours) == len(theirs)
for i, (mine, upstream) in enumerate(zip(ours, theirs)):
if mine != upstream:
previous = [snap["caption"] for snap in ours[max(0, i - 5) : i]]
fields = {
key: (mine[key], upstream[key])
for key in mine
if mine[key] != upstream[key]
}
pytest.fail(
f"diverged at task {i} after {previous}:\n"
+ "\n".join(
f" {key}:\n pqcli: {ours_value}\n"
f" upstream: {theirs_value}"
for key, (ours_value, theirs_value) in fields.items()
)
)
# (tasks, ticks): stop right after a task, or in the middle of one
SAVE_POINTS = [(3000, 10**9), (10**9, 123457), (10**9, 1)]
@pytest.mark.parametrize("tasks,ticks", SAVE_POINTS)
def test_import_from_upstream_plays_on_identically(
tasks: int, ticks: int
) -> None:
spec = bridge.make_spec("import", "Eel Man", "Inner Mason")
(game,) = bridge.run_harness(
[{"cmd": "export", "spec": spec, "ticks": ticks, "tasks": tasks}]
)
(text, theirs) = bridge.run_harness(
[
{"cmd": "encode", "game": game},
{"cmd": "resume", "game": game, "ticks": 10**9, "tasks": 3000},
]
)
player, warnings = import_game(decode(text), DATA)
assert warnings == []
ours = bridge.run(Simulation(player, DATA), 10**9, 3000)
assert_same_games(ours, theirs)
@pytest.mark.parametrize("tasks,ticks", SAVE_POINTS)
def test_export_to_upstream_plays_on_identically(
tasks: int, ticks: int
) -> None:
spec = bridge.make_spec("export", "Panda Man", "Robot Monk")
simulation = bridge.new_simulation(DATA, spec)
bridge.run(simulation, ticks, tasks)
text = encode(export_game(simulation.player, DATA))
(game,) = bridge.run_harness([{"cmd": "decode", "text": text}])
(theirs,) = bridge.run_harness(
[{"cmd": "resume", "game": game, "ticks": 10**9, "tasks": 3000}]
)
ours = bridge.run(simulation, 10**9, 3000)
assert_same_games(ours, theirs)
View File
Whitespace-only changes.
+182
View File
@@ -0,0 +1,182 @@
"""Run upstream's JS and pqcli side by side, producing comparable data."""
import datetime
import json
import shutil
import subprocess
from pathlib import Path
from typing import Any, cast
from pqcli.config import EquipmentType, Monster, StatType
from pqcli.game_data import GameData
from pqcli.lingo import to_roman
from pqcli.mechanic import (
BaseTask,
Player,
PlotTask,
Simulation,
Stats,
)
from pqcli.random import Alea, RngState
from .fetch import cache_dir, is_fetched
HARNESS = Path(__file__).parent / "harness.js"
# pqcli data file -> upstream K.* list
LISTS = {
"spells": "Spells",
"offense_attrib": "OffenseAttrib",
"defense_attrib": "DefenseAttrib",
"offense_bad": "OffenseBad",
"defense_bad": "DefenseBad",
"shields": "Shields",
"armors": "Armors",
"weapons": "Weapons",
"specials": "Specials",
"item_attrib": "ItemAttrib",
"item_ofs": "ItemOfs",
"boring_items": "BoringItems",
"monsters": "Monsters",
"races": "Races",
"classes": "Klasses",
"titles": "Titles",
"impressive_titles": "ImpressiveTitles",
}
def unavailable_reason() -> str | None:
if shutil.which("node") is None:
return "node is not installed"
if not is_fetched(cache_dir()):
return (
f"upstream is not fetched into {cache_dir()}; "
"run `just fetch-upstream`"
)
return None
def run_harness(requests: list[dict[str, Any]]) -> list[Any]:
result = subprocess.run(
["node", str(HARNESS), str(cache_dir())],
input=json.dumps(requests),
capture_output=True,
text=True,
check=True,
)
return json.loads(result.stdout)
def encode_list(name: str, data: GameData) -> list[str]:
"""Write a pqcli word list the way upstream's config.js spells it."""
items: list[Any] = list(getattr(data, name))
if name in {"offense_attrib", "defense_attrib"}:
return [f"{item.name}|{item.quality:+d}" for item in items]
if name in {"offense_bad", "defense_bad", "shields", "armors", "weapons"}:
return [f"{item.name}|{item.quality}" for item in items]
if name == "monsters":
return [encode_monster(item) for item in items]
if name in {"races", "classes"}:
return [
item.name + "|" + ",".join(stat.value for stat in item.attr)
for item in items
]
return items
def encode_monster(monster: Monster | None) -> str:
if monster is None:
return ""
return f"{monster.name}|{monster.level}|{monster.item or '*'}"
def make_spec(seed: str, race: str, class_: str) -> dict[str, Any]:
rng = Alea(seed)
stats = {}
for stat in ["STR", "CON", "DEX", "INT", "WIS", "CHA"]:
stats[stat] = 3 + sum(rng.uint32() % 6 for _ in range(3))
stats["HP Max"] = rng.uint32() % 8 + stats["CON"] // 6
stats["MP Max"] = rng.uint32() % 8 + stats["INT"] // 6
return {
"name": "Tester",
"race": race,
"class": class_,
"stats": stats,
"seed": list(rng.state()),
}
def _queue_entry(task: BaseTask) -> str:
kind = "plot" if isinstance(task, PlotTask) else "task"
text = "Loading" if isinstance(task, PlotTask) else task.description
return f"{kind}|{task.duration // 1000}|{text}"
def snapshot(player: Player) -> dict[str, Any]:
assert player.task is not None
bars = {
"ExpBar": player.exp_bar,
"PlotBar": player.quest_book.plot_bar,
"QuestBar": player.quest_book.quest_bar,
"EncumBar": player.inventory.encum_bar,
"TaskBar": player.task_bar,
}
return {
"caption": player.task.description,
"level": player.level,
"act": player.quest_book.act,
"bars": {name: [bar.position, bar.max_] for name, bar in bars.items()},
"stats": {stat.value: player.stats[stat] for stat in StatType},
"equipment": {
kind.value: player.equipment[kind] or "" for kind in EquipmentType
},
"gold": player.inventory.gold,
"inventory": [[item.name, item.quantity] for item in player.inventory],
"spells": [
[spell.name, to_roman(spell.level)] for spell in player.spell_book
],
"quests": list(player.quest_book.quests),
"questmonster": encode_monster(player.quest_book.monster),
"queue": [_queue_entry(task) for task in player.queue],
"rng": list(player.rng.state()),
}
def play(
data: GameData, spec: dict[str, Any], ticks: int, tasks: int
) -> list[dict[str, Any]]:
"""Play pqcli the way the harness plays upstream: 100 ms ticks."""
return run(new_simulation(data, spec), ticks, tasks)
def new_simulation(data: GameData, spec: dict[str, Any]) -> Simulation:
race = next(race for race in data.races if race.name == spec["race"])
class_ = next(c for c in data.classes if c.name == spec["class"])
seed = cast(RngState, tuple(spec["seed"]))
player = Player(
name=spec["name"],
birthday=datetime.datetime(2000, 1, 1),
race=race,
class_=class_,
stats=Stats({StatType(k): v for k, v in spec["stats"].items()}),
rng=Alea.from_state(seed),
)
simulation = Simulation(player, data)
simulation.tick(0) # sets up the Prologue, as upstream's sold() does
return simulation
def run(
simulation: Simulation, ticks: int, tasks: int
) -> list[dict[str, Any]]:
"""Keep playing in 100 ms ticks, snapshotting after each task."""
player = simulation.player
snapshots = []
for _ in range(ticks):
completing = player.task_bar.done
simulation.tick(100)
if completing:
snapshots.append(snapshot(player))
if len(snapshots) >= tasks:
break
return snapshots
+75
View File
@@ -0,0 +1,75 @@
"""Fetch Progress Quest web's game logic for the upstream comparison tests.
The files are downloaded from the public site into a cache directory, never
into this repository. Their hashes are pinned, so a change on the site fails
loudly instead of silently changing what the tests compare against.
"""
import hashlib
import os
import sys
import urllib.request
from pathlib import Path
BASE_URL = "https://progressquest.com/play/"
# Matches pq-web 3e9431b (2024-03-18) apart from http -> https URL edits.
FILES = {
"config.js": (
"cc76f256057d6ddbf7010f1b94e59913b935b9980138ed5e800bdd1e5ff5ec4e"
),
"main.js": (
"63926cda54b232c5e5be4e97123710ee4ab2dbd1faa213b21ddc01dab54a1ce0"
),
"newguy.js": (
"ac99e6524b1e8602f571515fc24f5c177e11d17ae4c6233dc43ff3aad079c7a5"
),
}
def cache_dir() -> Path:
if "PQ_WEB_DIR" in os.environ:
return Path(os.environ["PQ_WEB_DIR"])
base = os.environ.get("XDG_CACHE_HOME") or Path.home() / ".cache"
return Path(base) / "pqcli" / "pq-web"
def _sha256(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def is_fetched(directory: Path) -> bool:
return all(
(directory / name).is_file()
and _sha256((directory / name).read_bytes()) == digest
for name, digest in FILES.items()
)
def fetch(directory: Path) -> None:
directory.mkdir(parents=True, exist_ok=True)
for name, digest in FILES.items():
path = directory / name
if path.is_file() and _sha256(path.read_bytes()) == digest:
continue
url = BASE_URL + name
with urllib.request.urlopen(url, timeout=30) as response:
data = response.read()
actual = _sha256(data)
if actual != digest:
raise SystemExit(
f"{url} changed upstream: expected sha256 {digest}, "
f"got {actual}. Review the change, then update FILES in "
f"{__file__}."
)
path.write_bytes(data)
if __name__ == "__main__":
target = cache_dir()
try:
fetch(target)
except OSError as ex:
print(f"cannot fetch upstream into {target}: {ex}", file=sys.stderr)
sys.exit(1)
print(target)
+189
View File
@@ -0,0 +1,189 @@
// Runs Progress Quest web's game logic headless, for comparing with pqcli.
//
// Usage: node harness.js UPSTREAM_DIR < requests.json > responses.json
//
// The input is a JSON list of requests; the output is a JSON list with one
// response per request. Upstream's files are loaded from UPSTREAM_DIR
// (see fetch.py) and are not part of this repository.
"use strict";
const fs = require("fs");
const path = require("path");
const vm = require("vm");
const upstreamDir = process.argv[2];
const store = {};
// Just enough browser for upstream to run without a DOM.
global.window = {
location: { href: "#" },
localStorage: {
getItem: (key) => (key in store ? store[key] : null),
setItem: (key, value) => {
store[key] = String(value);
},
removeItem: (key) => {
delete store[key];
},
},
};
Object.defineProperty(global, "navigator", {
value: { userAgent: "node" },
configurable: true,
});
global.document = null;
let expectedAlert = null;
global.alert = (message) => {
if (message === expectedAlert) return;
throw new Error("upstream alert: " + message);
};
// The clock Worker is replaced by explicit ticks, see play().
global.Worker = function () {
this.postMessage = () => {};
this.addEventListener = () => {};
};
const $ = (selector) => (selector === "#multiplayer:checked" ? { length: 0 } : null);
$.isFunction = (obj) => typeof obj === "function";
$.each = (obj, callback) => {
if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
if (callback.call(obj[i], i, obj[i]) === false) break;
}
} else {
for (const key in obj) {
if (callback.call(obj[key], key, obj[key]) === false) break;
}
}
return obj;
};
$.ajax = () => ({ then: () => {}, done: () => {} });
global.$ = $;
for (const name of ["config.js", "main.js", "newguy.js"]) {
const file = path.join(upstreamDir, name);
vm.runInThisContext(fs.readFileSync(file, "utf8"), { filename: file });
}
let now = 0;
global.timeGetTime = () => now;
// FormCreate() tries to load the character named in the URL. There is
// none yet, so upstream alerts and gives up, which is fine here.
expectedAlert = "Error loading game";
FormCreate();
expectedAlert = null;
// Without a DOM, ListBox.rows() has no table to read. WinItem picks a row
// from it once the inventory passes 250 rows, so read the rows from game.
Inventory.rows = () =>
game.Inventory.map((row) => ({ firstChild: { innerText: row[0] } }));
function newGame(spec) {
// Mirrors the character creation form: stats.seed is the generator state
// the stats were rolled from, and the game continues from it.
stats = Object.assign({}, spec.stats, { seed: spec.seed });
traits = { Name: spec.name, Race: spec.race, Class: spec.class };
sold();
LoadGame(JSON.parse(store.roster)[spec.name]);
}
function snapshot() {
const bars = {};
for (const id of ["ExpBar", "PlotBar", "QuestBar", "EncumBar", "TaskBar"]) {
bars[id] = [game[id].position, game[id].max];
}
const statsOut = {};
for (const key of K.Stats) statsOut[key] = game.Stats[key];
return {
caption: game.kill.replace(/\.\.\.$/, ""),
level: game.Traits.Level,
act: game.act,
bars: bars,
stats: statsOut,
equipment: game.Equips,
gold: game.Inventory[0][1],
inventory: game.Inventory.slice(1),
spells: game.Spells,
quests: game.Quests,
questmonster: game.questmonster,
queue: game.queue,
rng: randseed(),
};
}
function play(request) {
newGame(request.spec);
return run(request.ticks, request.tasks);
}
// Play a game to some point, then return its save, as .pqw files hold it.
function exportGame(request) {
newGame(request.spec);
run(request.ticks, request.tasks);
SaveGame();
return JSON.parse(store.roster)[request.spec.name];
}
// Load a save and keep playing it.
function resume(request) {
LoadGame(JSON.parse(JSON.stringify(request.game)));
return run(request.ticks, request.tasks);
}
function run(ticks, tasks) {
const snapshots = [];
for (let i = 0; i < ticks; i++) {
const completing = TaskBar.done();
now += 100;
clock.lasttick = now - 100;
Timer1Timer();
if (completing) {
// copy, since game's lists keep changing after this tick
snapshots.push(JSON.parse(JSON.stringify(snapshot())));
if (snapshots.length >= tasks) break;
}
}
return snapshots;
}
function call(request) {
randseed(request.rng.slice());
if (request.questmonster !== undefined) {
game.questmonster = request.questmonster;
game.questmonsterindex = request.questmonsterindex;
}
const fn = global[request.fn];
const result = fn.apply(null, request.args || []);
return { result: result, rng: randseed() };
}
function lists() {
const result = {};
for (const key of Object.keys(K)) {
if (Array.isArray(K[key])) result[key] = K[key];
}
return result;
}
// The roster page's .pqw encoding, from roster.js.
function encode(request) {
return btoa(unescape(encodeURIComponent(JSON.stringify(request.game))));
}
function decode(request) {
return JSON.parse(decodeURIComponent(escape(atob(request.text))));
}
const handlers = {
encode: encode,
decode: decode,
play: play,
export: exportGame,
resume: resume,
call: call,
lists: lists,
};
const requests = JSON.parse(fs.readFileSync(0, "utf8"));
const responses = requests.map((request) => handlers[request.cmd](request));
process.stdout.write(JSON.stringify(responses));
Generated
+314
View File
@@ -0,0 +1,314 @@
version = 1
revision = 3
requires-python = ">=3.10, <4.0"
resolution-markers = [
"python_full_version >= '3.11'",
"python_full_version < '3.11'",
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "exceptiongroup"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" } },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" },
]
[[package]]
name = "iniconfig"
version = "2.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209, upload-time = "2026-10-06T22:48:38.076Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552, upload-time = "2026-10-06T22:48:36.959Z" },
]
[[package]]
name = "packaging"
version = "26.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7d/fa/3944b40b07da9ce895c0e6303a5ab7d53da063554f534556b134a54d6093/packaging-26.3.tar.gz", hash = "sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79", size = 313412, upload-time = "2026-08-04T18:15:28.737Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956, upload-time = "2026-08-04T18:15:27.159Z" },
]
[[package]]
name = "pluggy"
version = "1.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/bf/db/7fc19e6f2dc92a966727031389fc2e08b558f0f25eb7403c1119ad4713cd/pluggy-1.7.0.tar.gz", hash = "sha256:d1eaa46ebb595891b860ab086b4d09c8588af65ebd4361b8e8f4bb8920b90ba8", size = 123304, upload-time = "2026-10-15T09:50:58.343Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/40/9e/2b38731e0fc536806f16490e1a12d7f0dc2a1235aa8cc07bcc75416a7daa/pluggy-1.7.0-py3-none-any.whl", hash = "sha256:7dd7b0d8832ba3cb632c306926ded123429211b83641b35dc5c41ad2d34f9bec", size = 27082, upload-time = "2026-10-15T09:50:56.808Z" },
]
[[package]]
name = "pqcli"
version = "1.1.0"
source = { editable = "." }
dependencies = [
{ name = "urwid" },
{ name = "urwid-readline" },
{ name = "xdg-base-dirs" },
]
[package.optional-dependencies]
windows = [
{ name = "windows-curses" },
]
[package.dev-dependencies]
dev = [
{ name = "prek" },
{ name = "pytest" },
{ name = "ty" },
]
[package.metadata]
requires-dist = [
{ 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 = "prek", specifier = ">=0.2" },
{ name = "pytest", specifier = ">=8.0" },
{ name = "ty", specifier = ">=0.0.89" },
]
[[package]]
name = "prek"
version = "0.5.5"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9b/7b/11538b8e6cd7e269261feae62a2d08818fdcf8f43c3b1a253cec01a5488d/prek-0.5.5.tar.gz", hash = "sha256:16bf596768bb754779c7d42317e9ca16ac609c0cce7d13bae0829944ab40a26a", size = 570428, upload-time = "2026-10-05T06:08:59.165Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2e/77/189e554ea92baf6c1d33733b3e9d1ed819ab7d77e57eb2e3ead0bb1d8060/prek-0.5.5-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:50fbceb0f728b504023f6e6c9037ac0656436949d94f7a85bac5542e6c1a4a75", size = 6038669, upload-time = "2026-10-05T06:08:39.368Z" },
{ url = "https://files.pythonhosted.org/packages/63/ea/fc857c6b3f1e3fdb4d81bdca2051076de82e986cb1455a01b351ac7a5cd1/prek-0.5.5-py3-none-macosx_11_0_arm64.whl", hash = "sha256:2f610927b95e84b28173a4c810892a7b5d5bb55bee792ddf97178fa97654633e", size = 5548955, upload-time = "2026-10-05T06:08:41.428Z" },
{ url = "https://files.pythonhosted.org/packages/47/c3/2bab54f6393fb9f247225681d476086e24fa6242f329e9ca1e5bd638ec50/prek-0.5.5-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:120ef7d4b6bb58155e74827743c266e48501efb0e4c37ff726b14fe6e86a126b", size = 5848228, upload-time = "2026-10-05T06:08:44.051Z" },
{ url = "https://files.pythonhosted.org/packages/aa/f9/1279e1c83819de5904db1a09f4afc589932a2e62c7063713b1fe3a54ee46/prek-0.5.5-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2f6c453a12c21757b23bbbc97415aef78527841ad3f9e61f52cb35cf012f93e5", size = 6371296, upload-time = "2026-10-05T06:08:46.278Z" },
{ url = "https://files.pythonhosted.org/packages/93/fc/e566be5919eaffef4cc22488f16e4bbfd08961734bd67c06f85d405e4cbc/prek-0.5.5-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:73d509c0d9556171de2502bada4962be7a25871e7b23e782549074e1416dc850", size = 6269922, upload-time = "2026-10-05T06:08:48.645Z" },
{ url = "https://files.pythonhosted.org/packages/d2/39/a48d8e6203055bff7bb734b1f2f9aede4a4539bee6ab4c9273dbc01fa546/prek-0.5.5-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:76c867fdbd4e4f19e6b6b2988bd1e1407d1324170ebcf1428e4e34f8f0751a99", size = 5861518, upload-time = "2026-10-05T06:08:50.808Z" },
{ url = "https://files.pythonhosted.org/packages/b7/03/690fba9543033e25ea2e7b2ae1cca1ae8d8c2e32295f0d66f4b50934b8c7/prek-0.5.5-py3-none-musllinux_1_1_x86_64.whl", hash = "sha256:c69f60c2b3b4d481e38d0eab3b97ac05ec05d7648ae690925ae95ca9d22c7643", size = 6383685, upload-time = "2026-10-05T06:08:52.868Z" },
{ url = "https://files.pythonhosted.org/packages/8c/04/1e9ccf3a61a3bfd4107b83973f7afde9177b6ff8f2d5d4b00125e77c2100/prek-0.5.5-py3-none-win_amd64.whl", hash = "sha256:15bff9039349466ea9bf6b0c173b822ff08f8ae4cfbfdea782492e519c06d874", size = 5784759, upload-time = "2026-10-05T06:08:55.056Z" },
{ url = "https://files.pythonhosted.org/packages/41/d9/bb6811e438d3c74d26cb5d72e6343606e0a7a6283fee87521189f4ba1191/prek-0.5.5-py3-none-win_arm64.whl", hash = "sha256:3db3191d2509830c75438e20956a47c2259867d18a4106064ed75088b968a0c6", size = 5517355, upload-time = "2026-10-05T06:08:57.193Z" },
]
[[package]]
name = "pygments"
version = "2.21.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/49/2e/ced460408999b33da6b31b0021b0f37d329e202d4169aeb164493778f25b/pygments-2.21.0.tar.gz", hash = "sha256:610ca751c9bc2492b38eb9a38a7fbc93edbbb2d7182edaf34e66ae493dee5c8c", size = 5005329, upload-time = "2026-08-17T08:02:48.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
]
[[package]]
name = "pytest"
version = "9.1.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "exceptiongroup", marker = "python_full_version < '3.11'" },
{ name = "iniconfig" },
{ name = "packaging" },
{ name = "pluggy" },
{ name = "pygments" },
{ name = "tomli", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
]
[[package]]
name = "tomli"
version = "2.5.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b0/78/9ad63712633ed3ab5cc1a648d863d7e7da371e9425e209555a0fe711b695/tomli-2.5.0.tar.gz", hash = "sha256:264507556cd8b8c8e7c6ee037cdf443a463f03f4c958e57195e3d369711b8ff6", size = 17662, upload-time = "2026-10-07T12:23:37.892Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/22/a6/ab99b60ee52acd949684febabc3005d0045d0f66bebd9cdebd67372d26dd/tomli-2.5.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c4dc1c1781f2f716de763d1e9a7b34c6a894e167e291c7c5d16c72f7a9538545", size = 163901, upload-time = "2026-10-07T12:22:15.601Z" },
{ url = "https://files.pythonhosted.org/packages/bc/00/ee01b7ed4579180fff07142d290257f25ba786f23f3ec6005f620933c2f5/tomli-2.5.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eff8babca5a7999bc137acbc7482a8b7e17ffca5075ab41f5d770ab408c7bfef", size = 163756, upload-time = "2026-10-07T12:22:16.957Z" },
{ url = "https://files.pythonhosted.org/packages/72/c2/4efebf65372f6583185f79799312109dddb61102d47e5c33dcfd1a297aca/tomli-2.5.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86665cee9c4835b7a7f1e8ec2c719b5258d4dc782887aded5a8ae7352a96843b", size = 268038, upload-time = "2026-10-07T12:22:18.135Z" },
{ url = "https://files.pythonhosted.org/packages/53/07/5850468e925d898abb36038666f9c333a94d2a223e802a8ba5b6d319d23f/tomli-2.5.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7e369fd63331746182360977b1892bfc215476a30d61612d732425311639f56", size = 276422, upload-time = "2026-10-07T12:22:19.567Z" },
{ url = "https://files.pythonhosted.org/packages/b4/87/f293984cdcf83c054196d4fd3dad44fc68ae55b4b8c44bc76cef360c3150/tomli-2.5.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7ad1ea345759240d6463efa0ed1c704402752e49aa21476620738d74d72d8aa1", size = 272616, upload-time = "2026-10-07T12:22:20.794Z" },
{ url = "https://files.pythonhosted.org/packages/ce/ce/db582886b3c1219d3fec93ebd669332482e5aee7a91e0f7838d84f2d1759/tomli-2.5.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:96243987194634bd411066ce40c952e108f86af04db533ecd8ac3ff2a85b1885", size = 276593, upload-time = "2026-10-07T12:22:22.12Z" },
{ url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830, upload-time = "2026-10-07T12:22:23.651Z" },
{ url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742, upload-time = "2026-10-07T12:22:24.972Z" },
{ url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332, upload-time = "2026-10-07T12:22:26.117Z" },
{ url = "https://files.pythonhosted.org/packages/52/ed/7baa86f87493646a594de388c7c1c40a39dd0461f7e9c0359cbeefc91fe8/tomli-2.5.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:1f4a40d03fb9f63424f0979855bdeaf44dd7696b8d59501822c10ed30ba532df", size = 164854, upload-time = "2026-10-07T12:22:27.444Z" },
{ url = "https://files.pythonhosted.org/packages/a5/b1/44c0341f2224397855723c7a8a39f718ea6fcbcc3dacc66e5aeca0f334e3/tomli-2.5.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9ebf8d19b17bd0daeb7b7dec81a946a439b753942fd0210d6e96c532249eea6b", size = 164074, upload-time = "2026-10-07T12:22:28.679Z" },
{ url = "https://files.pythonhosted.org/packages/23/04/e2d5b7d3fba47adedb23de616c16d428ea076c79a3d8e1d95d649ffe197e/tomli-2.5.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bf0b5e8e0f68ebb494356e577c06c139161efd8d3b9050f93b39b7c26cc54ff0", size = 274274, upload-time = "2026-10-07T12:22:29.804Z" },
{ url = "https://files.pythonhosted.org/packages/43/90/6090e706ff27a6f89f4a40578e3324b95c3cd8c4150868aabf33a8f414c3/tomli-2.5.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6cf74416bdc94ae458b14e37286c1073081850ac8459a00d0c5efef5d44294c6", size = 286435, upload-time = "2026-10-07T12:22:31.297Z" },
{ url = "https://files.pythonhosted.org/packages/0a/9e/a2c40768df16c408f22430afb0a73e9d7e5f79c950884954649d1146b74d/tomli-2.5.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:61ea1ebe1e55a34ea8199cc8dbff398d35027b82271c8ac4802fd3a1fd5b1bcc", size = 278119, upload-time = "2026-10-07T12:22:32.601Z" },
{ url = "https://files.pythonhosted.org/packages/12/25/3c0cb485b98e9cfac495629b1c93c87ccf0b72fbe9d2689fd8fe62c6d5a3/tomli-2.5.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ed53f7e89bb04f6d9e8e7799112360b0c4d5cbff067de0814c98c37c39b920f7", size = 286177, upload-time = "2026-10-07T12:22:33.745Z" },
{ url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760, upload-time = "2026-10-07T12:22:34.887Z" },
{ url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722, upload-time = "2026-10-07T12:22:36.162Z" },
{ url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534, upload-time = "2026-10-07T12:22:37.296Z" },
{ url = "https://files.pythonhosted.org/packages/42/e5/51736d70da209350969e15aca5c5ab6e2ce1ea87a0a892a6c13aec172a86/tomli-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:943276cf269e0071948d9ff697159c1735e623c1151d88abb09b74659ef0cbea", size = 163328, upload-time = "2026-10-07T12:22:38.373Z" },
{ url = "https://files.pythonhosted.org/packages/ec/55/086f80dab4ab497602644274e6dea7ec5dd0b4e262e443a8ad3bb7edee2d/tomli-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:463b16086865b97facd8d0b3fb4cb7c544e3f58d2a69dc3113d6db9653fdb043", size = 162246, upload-time = "2026-10-07T12:22:39.673Z" },
{ url = "https://files.pythonhosted.org/packages/aa/eb/3ecc94459f3635c92321f4e7bde571323fdb2267c50e19e3188a281eae3b/tomli-2.5.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1245a6638fc4bb0a60af38a7d45413db34a13842027c77597c712c998c62fdf0", size = 272655, upload-time = "2026-10-07T12:22:41.08Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d7/494fd1f0c37a621f1ad9975c2efadb523e8101f144ed6edb2e7fe64738f2/tomli-2.5.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d8bac3d603c97e6854424e5b2b5b741bdbde387e09f162fb0446812b4a8362b", size = 283595, upload-time = "2026-10-07T12:22:42.222Z" },
{ url = "https://files.pythonhosted.org/packages/70/51/bb8d62b1317e6640866f6949b2d5855e5300f2c99d46de1cd245570bba65/tomli-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:21e4cae4114aba25aa0d4f85cdf486d290fb35c0954d7bba536248da64d43066", size = 276253, upload-time = "2026-10-07T12:22:43.625Z" },
{ url = "https://files.pythonhosted.org/packages/66/f4/f46bd7f0763cd47de2db697dca9257c6a4adfd1a93b018cc75c8190ed5a8/tomli-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bbaefc84548d754be821bba7c4141c4787dda182f9e77f2f87b71213529efa7b", size = 283582, upload-time = "2026-10-07T12:22:44.983Z" },
{ url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628, upload-time = "2026-10-07T12:22:46.508Z" },
{ url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301, upload-time = "2026-10-07T12:22:47.647Z" },
{ url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744, upload-time = "2026-10-07T12:22:48.925Z" },
{ url = "https://files.pythonhosted.org/packages/e4/49/caf6b307766eb9567664a8707e9d6be5fcc0e8903f18781c6677a60d80c7/tomli-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d3182ee2d887e507bd67319a0a61105d1dd33facc111329559a233b772c1a105", size = 162899, upload-time = "2026-10-07T12:22:50.088Z" },
{ url = "https://files.pythonhosted.org/packages/d3/c8/68cfce773a2733a49c74f99d627fb461bd990756860099eac25617889585/tomli-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:521345fd1f19d45b8df87657aaa38b6f2ca3800059fadf428e7ebf479a383646", size = 162080, upload-time = "2026-10-07T12:22:51.558Z" },
{ url = "https://files.pythonhosted.org/packages/7e/b2/e5bb8651fdad593f670501a7d718b1a7f73f064d44dea15e04c04dfef45d/tomli-2.5.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e95c7614e705bfe2b04b27aa124adec59752d15813df37e2156747cab3a006b", size = 273380, upload-time = "2026-10-07T12:22:52.918Z" },
{ url = "https://files.pythonhosted.org/packages/8d/d2/9e2d7f8b1dfe0e2b34c245986ebd55c4c553ea4ce6c47c443b332673253f/tomli-2.5.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7ac2027d37c3afbdf4bdd377f2676f6f1d2122a5be1f1137b49dced590b37e75", size = 283228, upload-time = "2026-10-07T12:22:54.173Z" },
{ url = "https://files.pythonhosted.org/packages/ba/df/ec7b876b7b1a2718bd74a3743c076fff565b04029ba33e8f61fac262739f/tomli-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c414be4ed9d3cac80c42e348fa5a956117d1a48227f48026e31f59cb4a7671eb", size = 277189, upload-time = "2026-10-07T12:22:55.342Z" },
{ url = "https://files.pythonhosted.org/packages/7d/7b/e192d9eed0b9cb80da799f4d77052297fb9a2c3cc9b19f571f56ea88add6/tomli-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9b03d7dc168353b4132965bde20feceabaa470e570c6f59660dfae59b1f9eeb3", size = 283632, upload-time = "2026-10-07T12:22:56.735Z" },
{ url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535, upload-time = "2026-10-07T12:22:58.084Z" },
{ url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621, upload-time = "2026-10-07T12:22:59.2Z" },
{ url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572, upload-time = "2026-10-07T12:23:00.479Z" },
{ url = "https://files.pythonhosted.org/packages/b9/20/1f88f19427d380a40e90a770e087489eaafe4aeee070ae88ed2bbec00acd/tomli-2.5.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e9e15b4a6c7dd6b85b5fbab29488a73f1f70de516942308daa266bf0e0aeb0d4", size = 171814, upload-time = "2026-10-07T12:23:01.914Z" },
{ url = "https://files.pythonhosted.org/packages/d0/56/cbe5079c9f9a54b9b3e27fc82f08f3cb36edee75561679f53d2380c801d6/tomli-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e12bbcd32897272fb05929110362ae9ff4c1b9bb26bd9e971e71dcd3275b4c3d", size = 171324, upload-time = "2026-10-07T12:23:03.18Z" },
{ url = "https://files.pythonhosted.org/packages/2b/30/1d53fd3b0f1cb3ba542e345ec32c26aefdddc4e829e4f3429af8a4f27782/tomli-2.5.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20aa36de8f2cf87237143bc1fa1aae8d6612c09118f4da21c6a684db5dd1f6f9", size = 297441, upload-time = "2026-10-07T12:23:04.345Z" },
{ url = "https://files.pythonhosted.org/packages/66/d9/0800acb6a111686f764c1b91ef15cc42a20a66a46013bb42220f1d2c61c1/tomli-2.5.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22185fad8a1e622f064e78008018a0dd3323550dcb479cb7a1d296888d74024f", size = 307476, upload-time = "2026-10-07T12:23:05.671Z" },
{ url = "https://files.pythonhosted.org/packages/e8/63/30a8f3cd51b5bec37f04744bad0b0dc6160df84aad4f27b0e9283d66f221/tomli-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:984012f71908165449a951de2050d52f276bfe3aa5d5f570f63ddad814370374", size = 296113, upload-time = "2026-10-07T12:23:07.202Z" },
{ url = "https://files.pythonhosted.org/packages/ab/18/0b9ffc597e69c5a1e20a7823cb60d54b39a9f54e91edcb8574f022186758/tomli-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f79203b3965b4000e91808aaa7c040206093f2b8bf86f455982f2274c9ccf442", size = 307725, upload-time = "2026-10-07T12:23:08.508Z" },
{ url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546, upload-time = "2026-10-07T12:23:09.956Z" },
{ url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814, upload-time = "2026-10-07T12:23:11.486Z" },
{ url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188, upload-time = "2026-10-07T12:23:12.728Z" },
{ url = "https://files.pythonhosted.org/packages/66/98/316fdc00f8c0939e6fe50461dd343c162d3ad51d1286eb25b7db54361d50/tomli-2.5.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:a525685c2f97da40762b8695eb7aa0af4c8344ca1905c73e4e29cb04d34607dc", size = 162775, upload-time = "2026-10-07T12:23:13.941Z" },
{ url = "https://files.pythonhosted.org/packages/c5/22/7b10fa5bb01c9539f53f69b619361b19350acc73657772ea7ac70ba309a8/tomli-2.5.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:9dbb18c1cfb2f6517942fc9314437f66aa06d94436ffb1f06102ef3572f35276", size = 161406, upload-time = "2026-10-07T12:23:15.215Z" },
{ url = "https://files.pythonhosted.org/packages/9c/e7/1a069d86dfd20f1f84f71c63faed9f83c1d890bc06c27d82dc7d888fb573/tomli-2.5.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:752e8b1aa6a4367ef8bf6a1a1e005540f7ed055ba36d7193796812ca5404eb52", size = 273855, upload-time = "2026-10-07T12:23:16.471Z" },
{ url = "https://files.pythonhosted.org/packages/ae/83/d1ef43d1687d092ab9c235455c76e6e709483b346b056f086095c7c263a5/tomli-2.5.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c47300f9bf791808f77d82747691c4bb09cb14bdf3060cca99b42cdc4361d5a7", size = 284910, upload-time = "2026-10-07T12:23:18.166Z" },
{ url = "https://files.pythonhosted.org/packages/cc/05/f4d9cf7de61822ece0c3873f30d291e324911c71a378b8bfe5ced13fd9f5/tomli-2.5.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:19b0dd8749f4ea2f112c5fcfb3c5248390c899d7e2e173f1d91abee1fa0ff391", size = 277723, upload-time = "2026-10-07T12:23:19.355Z" },
{ url = "https://files.pythonhosted.org/packages/42/28/78262493141fa543151cf005760c3cb01d09fc28a11f993c05109902cb8c/tomli-2.5.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:57b1c3b01fab802e2899bc3d168dca320e14165e2fd9fd584760fb4ca5826859", size = 285115, upload-time = "2026-10-07T12:23:20.698Z" },
{ url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475, upload-time = "2026-10-07T12:23:21.941Z" },
{ url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589, upload-time = "2026-10-07T12:23:23.098Z" },
{ url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493, upload-time = "2026-10-07T12:23:24.233Z" },
{ url = "https://files.pythonhosted.org/packages/68/14/00853f0b396d8971107ae1921bb5b322fdee1650d2f16bf06c20adb532e5/tomli-2.5.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:2419c2a189551987b59d80e63ec355671283336f41c6b9b89462df679c7d0c57", size = 171380, upload-time = "2026-10-07T12:23:25.512Z" },
{ url = "https://files.pythonhosted.org/packages/89/ad/fa6949321dadee46b27363974fb197b94c911c3b0f7a5fd26d7dc18fc2a0/tomli-2.5.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dc598040da8d42cf20f0be588ed7004f46db12a0ac6c32e03a59dccedaaadcd", size = 170553, upload-time = "2026-10-07T12:23:26.855Z" },
{ url = "https://files.pythonhosted.org/packages/53/aa/3056c919eb3e084df3752b2cf5f865dcc04af0b27dba2f66d7b28af4633a/tomli-2.5.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:49096930c8d886c9bbdab62d2d0d17ce823ddeea522309a190b36245d5b49e01", size = 294428, upload-time = "2026-10-07T12:23:28.132Z" },
{ url = "https://files.pythonhosted.org/packages/96/b2/faeeb5d8769ea3832021d73e892c8391eae7b4b4f8b55a789127bd8b18a9/tomli-2.5.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8ade5023067f99fe72b88accd30d0ea05a158e9e32a11f124e731ea9695313f", size = 304909, upload-time = "2026-10-07T12:23:29.381Z" },
{ url = "https://files.pythonhosted.org/packages/f6/52/f094c09e73fb654b621716d019acb5d29bdfd1be01df80c281d552bda48d/tomli-2.5.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:b69564772b5c8f22ea5f498dff08cfa825045b4d4c4400529000bdf818aa3b2a", size = 293220, upload-time = "2026-10-07T12:23:30.608Z" },
{ url = "https://files.pythonhosted.org/packages/86/f5/0c30541078ca4b505ce3bd76ed931facbfec524dd018535d691d1af0a6d2/tomli-2.5.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:8ff3a2ca028c7eee0c777f9a092038d0a594a9fa04e215f929a22c329e2cb142", size = 305705, upload-time = "2026-10-07T12:23:32.181Z" },
{ url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432, upload-time = "2026-10-07T12:23:33.496Z" },
{ url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281, upload-time = "2026-10-07T12:23:34.648Z" },
{ url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069, upload-time = "2026-10-07T12:23:35.77Z" },
{ url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765, upload-time = "2026-10-07T12:23:36.875Z" },
]
[[package]]
name = "ty"
version = "0.0.89"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/67/ea/dfb9bdbc168c50a568809466596751afa0c7da60da03b8c12906d1decde4/ty-0.0.89.tar.gz", hash = "sha256:bc1027b1ea95cca25c2a3a0b41433c809cf4b86a384d2b2434eba4bcd34548d4", size = 7978241, upload-time = "2026-10-16T10:35:05.249Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c5/84/e199d9a3dc3c94c804ddd9e94b63dbe543ae7f39329574963b6a6a95872f/ty-0.0.89-py3-none-linux_armv6l.whl", hash = "sha256:739b087e8873e177ffda99ff8a3600a4a7704d06523a8e88c4493021eee40b8a", size = 14254879, upload-time = "2026-10-16T10:34:24.813Z" },
{ url = "https://files.pythonhosted.org/packages/e5/d5/5afe09bf5c83c4e3a9e5913386a460e240a7029f2ae5fc96df5b43563a91/ty-0.0.89-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:47ebc98480c8a301955e18a8bdd67ef07090071ad47349b3254f325d3d5ab4e7", size = 13790417, upload-time = "2026-10-16T10:34:27.672Z" },
{ url = "https://files.pythonhosted.org/packages/7e/f9/88648f0ba9400dfd642c1e8dc0aeecb87945b121f3ff51f3902883c016d6/ty-0.0.89-py3-none-macosx_11_0_arm64.whl", hash = "sha256:b0631291af5c850a15007c0ffb906d813966466e4ec12173c44977c0c5a2d51d", size = 12856900, upload-time = "2026-10-16T10:34:29.871Z" },
{ url = "https://files.pythonhosted.org/packages/6a/89/baae2b407101c4cb02f19216123900c147af8fd87cdb1ed7eba139472734/ty-0.0.89-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d768300ae16274ce656006e9199869ad68684b74400d963e1c82deddf3a22ad", size = 13653330, upload-time = "2026-10-16T10:34:32.296Z" },
{ url = "https://files.pythonhosted.org/packages/ed/1d/293516c2e10a77134cfc40d22c3fc08edd8c5eadae9c75531ec8a9263348/ty-0.0.89-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3dd4c57ba58bab8f6c816b2c254db52b58834b5dbf8268287382f7a2f9735c8f", size = 13986215, upload-time = "2026-10-16T10:34:34.506Z" },
{ url = "https://files.pythonhosted.org/packages/2f/c3/da911c8fc22d2664692432570619576b25c7cd0a586401cab08987763094/ty-0.0.89-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1af774c24117d366b69142d4f35edee518617f8b926d13560d0334cbe5aa3f85", size = 14905008, upload-time = "2026-10-16T10:34:36.632Z" },
{ url = "https://files.pythonhosted.org/packages/5c/c3/aed8d920b9bbf7b582e9fc0305acb2f2c748ede788e7dba9d6952e939c78/ty-0.0.89-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a8147811b9b3bc2dc9338cd01c7893ac931fc3fae7aace169047ecbd627e6628", size = 15278217, upload-time = "2026-10-16T10:34:39.144Z" },
{ url = "https://files.pythonhosted.org/packages/96/92/c6b4320f08fca44ffe279e5ddeb3eae12869c6341f596931accdefea1182/ty-0.0.89-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6d5f178c91d50fb62461b5e579028b7d2e92872e1d8f02e96ace26464c2277a6", size = 15206289, upload-time = "2026-10-16T10:34:41.626Z" },
{ url = "https://files.pythonhosted.org/packages/3e/61/da22c714fd4f4dff642d60fb0d8bf8da1985d126f21cc89237f8bf707c92/ty-0.0.89-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a410f10b26cc3861757b626727db9d979deba7d2a3ab59f964fac8f0514fdc", size = 14510395, upload-time = "2026-10-16T10:34:44.063Z" },
{ url = "https://files.pythonhosted.org/packages/f0/32/5f9ce2377a77ad9ff36d8996edf86b1f069c1e8716e37c7d6d668402b110/ty-0.0.89-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:a70ab1a402746d7837407dc169e461fdf90945bc1eef0d3c0f1be543490060bf", size = 14767366, upload-time = "2026-10-16T10:34:46.154Z" },
{ url = "https://files.pythonhosted.org/packages/63/72/2b28c71d6ebd97b5bb1378f1806598c5dd90278f7b99a431dd3879dddcfc/ty-0.0.89-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:b55c28a49bd2676eac9b3540a1dcc4cc053e4671c65dcec9fdad3dfe55fb148c", size = 13670284, upload-time = "2026-10-16T10:34:48.225Z" },
{ url = "https://files.pythonhosted.org/packages/34/cc/68b8ebf98c9bc582fddc3d2b93f6e0f53719d1ddee1c5037dfa2e1cb8467/ty-0.0.89-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:4c9f22edeb8176f8ab189337e8cbd8f92b66744534a2886df9e252b52ade991c", size = 13997891, upload-time = "2026-10-16T10:34:50.556Z" },
{ url = "https://files.pythonhosted.org/packages/cf/4d/a1d61e06092fd5726f48a8711bd32eac6af132308eb222a0027caba4f42d/ty-0.0.89-py3-none-musllinux_1_2_i686.whl", hash = "sha256:0c186185cad57a1469b851096b51345de7b625e353c1f4acd338402daed22fa7", size = 14326247, upload-time = "2026-10-16T10:34:53.005Z" },
{ url = "https://files.pythonhosted.org/packages/f8/d2/466d3762e330504a2c114dbbd0f0383229f9652dccd56277dde008bfe0da/ty-0.0.89-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5e38302ac218d2404758241093b5542ed0345d4f569096589f1810034244e998", size = 14661527, upload-time = "2026-10-16T10:34:55.555Z" },
{ url = "https://files.pythonhosted.org/packages/b1/d5/dfd7ed5e4af472b20a11ef651da4bca55f9dc8be485cd152337f94caddde/ty-0.0.89-py3-none-win32.whl", hash = "sha256:0146637ed2321717280bfe4e4b20a18813b741da280e8039d7df2bf22b0251f1", size = 13224078, upload-time = "2026-10-16T10:34:57.735Z" },
{ url = "https://files.pythonhosted.org/packages/b4/14/1ee663c422e3a2d910c5cc27e7d84bd7709a3df0aca28a713843c6c3fa5d/ty-0.0.89-py3-none-win_amd64.whl", hash = "sha256:9fc56322a1aff29a19088d2025f3c42a6d25a7035668048bb908c9f8b66f480d", size = 13727433, upload-time = "2026-10-16T10:35:00.15Z" },
{ url = "https://files.pythonhosted.org/packages/09/93/af8f7250920823ca6d582bcf0abf803fb92c8b99415ba7f40f2304102c6f/ty-0.0.89-py3-none-win_arm64.whl", hash = "sha256:1d4967c8f7bfd67c9708115b41d7049197c5d20c76fb2e136fe78b0437ada05b", size = 13287101, upload-time = "2026-10-16T10:35:02.918Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.11'",
]
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 = "typing-extensions"
version = "4.16.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version < '3.11'",
]
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" },
]
[[package]]
name = "urwid"
version = "2.6.16"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions", version = "4.15.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "typing-extensions", version = "4.16.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ 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 = "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" },
]