143 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
rr- 746ca551f3 misc: release 1.0 2019-02-02 00:32:48 +01:00
rr- 5d47a0f44c mechanic: fix simulation stopping under certain conditions 2019-01-17 00:47:23 +01:00
rr- 555f386b24 ui/curses: highlight changed and added items 2019-01-14 16:10:26 +01:00
rr- 5f34a85c80 ui/curses: fix new quests and acts not appearing 2019-01-14 14:52:24 +01:00
rr- 97552a711a misc: don't use relative imports 2019-01-14 14:42:29 +01:00
rr- 561f28bd95 readme: add curses title screen 2019-01-13 22:25:47 +01:00
rr- 0bd4583c73 ui/curses: add swords to logo 2019-01-13 22:20:59 +01:00
rr- adc3c7914d ui/curses: use title case in menus 2019-01-13 21:44:26 +01:00
rr- d2c06b08bb ui/curses: cycle widgets on tab 2019-01-13 21:41:24 +01:00
rr- 4dc674816b ui/curses: add focus to character stats view 2019-01-13 21:41:24 +01:00
rr- 8c6b1a2504 ui/curses: add focus to character name view 2019-01-13 21:41:21 +01:00
rr- 8d385523ad ui/curses: split into files 2019-01-13 21:41:00 +01:00
rr- 05c9bcd0e6 ui/curses: improve margins 2019-01-13 20:53:18 +01:00
rr- 2c3da6484b ui/curses: align button hints to left 2019-01-13 20:50:05 +01:00
rr- d4f9326759 ui/curses: make escape work in game view 2019-01-13 20:48:33 +01:00
rr- e85a8d5826 ui/curses: improve character choice 2019-01-13 19:48:54 +01:00
rr- 2da46a44a4 ui/curses: implement cheats 2019-01-13 18:34:09 +01:00
rr- 0ecfc2c1fd ui/urwid: remove urwid ui
It was too similar and inferior to curses.
2019-01-13 18:26:12 +01:00
rr- ffe5734433 ui: default to curses user interface 2019-01-13 18:06:27 +01:00
rr- ae99f1f1a4 ui/urwid: respect --no-colors 2019-01-13 17:46:07 +01:00
rr- a47d6233be ui/urwid: colorize logo 2019-01-13 17:33:19 +01:00
rr- 1980049c6f ui/urwid: drop customizing color palettes 2019-01-13 17:33:11 +01:00
rr- 9fc078ad94 ui/curses: add colors 2019-01-13 17:33:11 +01:00
rr- 7a835c615e ui/urwid: split to files 2019-01-13 16:33:07 +01:00
rr- 9412a03da7 misc: add pyproject.toml 2019-01-04 20:49:33 +01:00
rr- 9012363437 misc: update readme, add new screenshots 2019-01-04 20:48:20 +01:00
rr- 021d7eb602 misc: improve mypy health 2019-01-04 20:13:52 +01:00
rr- 3d8034b285 ui/curses: cancel char creation one step at a time 2019-01-04 20:07:43 +01:00
rr- bd3f196f84 mechanic: fix stat unrolling 2019-01-04 19:49:50 +01:00
rr- 9a90b7b7b4 ui/curses: add character creation wizard 2019-01-04 19:49:50 +01:00
rr- e3ffe51979 ui/curses: let user scroll game view windows 2019-01-04 16:59:29 +01:00
rr- 0133802570 ui/curses: estimate time in progress bars 2019-01-04 16:40:30 +01:00
rr- b20c4b4b34 ui/curses: fix arrows not working in game view 2019-01-04 16:25:58 +01:00
rr- 5bdfc2ed25 ui/curses: fix scrollable visual glitches 2019-01-04 15:44:51 +01:00
rr- 8b38eeecac ui/curses: remove flickering 2019-01-04 00:56:55 +01:00
rr- ed9e513271 mechanic: emit bar signals if significant changes 2019-01-04 00:43:44 +01:00
rr- 97d958d2e7 pqcli: fix signals 2019-01-04 00:43:21 +01:00
rr- 9db61f7191 ui/curses: implement game view widget focusing 2019-01-04 00:22:29 +01:00
rr- 4230c57e73 ui/curses: remove redundant code 2019-01-04 00:22:29 +01:00
rr- 871144bf0c ui/curses: add scrollbars 2019-01-03 23:23:47 +01:00
rr- 3fdc2459ca sim: remove simulator script 2019-01-03 21:44:58 +01:00
rr- e008fc6048 ui: save periodically in every frontend 2019-01-03 21:44:23 +01:00
rr- ca7ab192c5 ui/curses: add curses interface draft 2019-01-03 21:38:19 +01:00
rr- 81f6dbede2 mechanic: remove urwid dependency 2019-01-03 21:32:55 +01:00
rr- 478075347c ui/basic: save periodically 2018-12-28 00:32:53 +01:00
rr- 595538cdca ui/basic: add basic user interface 2018-12-14 00:32:21 +01:00
rr- e27f3470a4 ui: rename Ui class to UrwidUserInterface 2018-12-14 00:32:21 +01:00
rr- 5545e2324a misc: release 0.3 2018-11-13 16:22:32 +01:00
rr- 000b11c26c ui: remove space before % 2018-11-13 16:20:40 +01:00
rr- b9a0934e74 ui: show 1d and 1h rather than 1.0d and 1.0h 2018-11-13 16:06:50 +01:00
rr- a823c9041e ui: show >24h as days 2018-11-13 16:06:20 +01:00
rr- 58758d35d5 docs: update screenshot 2018-11-10 17:24:42 +01:00
rr- e7d5947299 ui: improve predicted times near zero values 2018-11-10 17:24:42 +01:00
rr- 16ec0cfa5d ui: show time left in some progress bars 2018-11-10 17:24:42 +01:00
rr- ce7a422482 ui: add time_left property to progress bar 2018-11-10 17:24:42 +01:00
rr- 3cdafe4ec8 mechanic: remember player age 2018-11-10 17:24:42 +01:00
rr- 9bfbaf1646 ui: change proportions 2018-11-10 17:24:42 +01:00
rr- 4bb0e9250a ui: change paddings in game view 2018-11-09 23:37:40 +01:00
rr- a930b2b3db ui: remove horizontal wraparound from game view 2018-11-09 12:21:25 +01:00
rr- 2372dcdcaa ui: continue ticking on confirmation dialogs 2018-11-09 00:56:50 +01:00
rr- 8e25fbea04 ui: fix scrollbar rendering bugs 2018-11-09 00:56:38 +01:00
rr- 70ab6eab48 ui: show 2 decimal places in progress bars 2018-11-09 00:56:06 +01:00
rr- 37252d2727 ui: count only prime stats as best in roster view 2018-11-09 00:55:34 +01:00
rr- 284f549966 ui: implement tab navigation 2018-11-08 22:07:04 +01:00
rr- 904951acf4 ui: fix arrow navigation 2018-11-08 21:41:20 +01:00
rr- 3b19191120 ui: use urwid constants 2018-11-08 20:14:50 +01:00
rr- 72fe5439b0 ui: remove unused custom list box 2018-11-08 20:14:50 +01:00
rr- 88a936c293 ui: make plot development view scrollable 2018-11-08 20:01:12 +01:00
rr- 0b74d1a36e ui: make equipment scrollable 2018-11-08 19:52:41 +01:00
rr- 14149cd112 ui: make spell book scrollable 2018-11-08 19:15:59 +01:00
rr- bb233b82b8 ui: make character sheet scrollable 2018-11-08 19:15:59 +01:00
rr- 723c4aae88 ui: add padding to scrollbar 2018-11-08 19:15:59 +01:00
rr- 6bd12797f0 ui: create data table widget 2018-11-08 18:22:41 +01:00
rr- 066fc692e4 misc: add undocumented --no-save option 2018-11-08 13:09:37 +01:00
rr- 86f59d8fb2 ui: make inventory scrollable 2018-11-08 13:09:37 +01:00
rr- 78e39e639e ui: add --no-config 2018-11-08 13:09:37 +01:00
rr- 6c2fed9d40 ui: stylize scrollbar 2018-11-08 13:09:37 +01:00
rr- b5b9554f9b ui: make quest book scrollable 2018-11-08 11:35:54 +01:00
rr- 3afa965eeb ui: simplify quest book 2018-11-08 10:46:06 +01:00
rr- b00cf34e8f misc: reformat numbers 2018-11-08 10:07:51 +01:00
rr- c7c5abf7c8 ui: make palette configurable 2018-11-06 15:03:38 +01:00
rr- c8d8d646e8 misc: obscure cheats help 2018-11-06 14:52:51 +01:00
rr- 7692d2e531 misc: correct spelling 2018-11-06 00:25:52 +01:00
106 changed files with 12863 additions and 2189 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"]
+150 -9
View File
@@ -1,5 +1,5 @@
<p align="center"> <p align="center">
<img alt="ProgressQuest" src="http://progressquest.com/pq.png"> <img alt="Progress Quest" src="http://progressquest.com/pq.png">
</p> </p>
Relive the great adventure… this time in the terminal realm! Relive the great adventure… this time in the terminal realm!
@@ -10,27 +10,168 @@ Relive the great adventure… this time in the terminal realm!
## Features ## Features
- Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat`
- Clean terminal interface
- Smooth progress bars using UTF-8 block characters
- Scrollable list boxes
- Faithful port of the game logic - Faithful port of the game logic
- Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat`
- Terminal interface that comes in 2 flavors:
- Rich and colorful (`--curses`, default)
- Minimal, suitable for raw grind (`--basic`)
- Ideal to run on your server - Ideal to run on your server
## How it looks like ## How it looks like
![20181106_001341_mjt](https://user-images.githubusercontent.com/1045476/48032665-d616f300-e158-11e8-8978-4ee3a69e44f8.png) Curses interface:
![Screenshot](screen-curses-logo.png)
![Screenshot](screen-curses.png)
Basic interface:
![Screenshot](screen-basic.png)
## 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
+192 -6
View File
@@ -1,27 +1,213 @@
import argparse import argparse
import sys
from pathlib import Path from pathlib import Path
from typing import TextIO
import xdg from xdg_base_dirs import xdg_config_home
from pqcli import pqw
from pqcli.game_data import (
DataError,
GameData,
dump_builtin_data,
load_game_data,
)
from pqcli.mechanic import Player
from pqcli.roster import Roster from pqcli.roster import Roster
from pqcli.ui import Ui from pqcli.ui.basic import BasicUserInterface
from pqcli.ui.curses import CursesUserInterface
SAVE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "save.dat" SAVE_PATH = Path(xdg_config_home()) / "pqcli" / "save.dat"
DATA_PATH = Path(xdg_config_home()) / "pqcli" / "data"
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(prog="pqcli") parser = argparse.ArgumentParser(prog="pqcli")
parser.add_argument("--cheats", help="enable cheats", action="store_true")
group = parser.add_mutually_exclusive_group()
group.set_defaults(ui=CursesUserInterface)
group.add_argument(
"--basic",
dest="ui",
action="store_const",
const=BasicUserInterface,
help="Use basic user interface (very crude, but uses least CPU)",
)
group.add_argument(
"--curses",
dest="ui",
action="store_const",
const=CursesUserInterface,
help="Use curses user interface (fast, but no colors output)",
)
parser.add_argument(
"--no-colors",
dest="colors",
action="store_false",
help="Disable color highlighting in curses interface",
)
parser.add_argument(
"--no-save",
dest="use_saves",
action="store_false",
help=argparse.SUPPRESS,
)
parser.add_argument("--cheats", action="store_true", help="???")
parser.add_argument(
"--list-saves",
action="store_true",
help="list saved characters and exit",
)
parser.add_argument(
"--load-save",
type=int,
metavar="NUM",
help="play chosen character",
)
parser.add_argument(
"--data-dir",
type=Path,
default=DATA_PATH,
metavar="DIR",
help=(
"directory with custom word lists; each NAME.json replaces "
f"the built-in list of that name (default: {DATA_PATH})"
),
)
parser.add_argument(
"--dump-data",
type=Path,
metavar="DIR",
help="copy the built-in word lists to DIR for editing and exit",
)
parser.add_argument(
"--import-save",
type=Path,
action="append",
metavar="FILE",
help=(
"add the character in a Progress Quest web save (.pqw) to the "
"roster and exit; can be given more than once"
),
)
parser.add_argument(
"--export-save",
nargs=2,
metavar=("NUM", "FILE"),
help=(
"write the chosen character to a Progress Quest web save "
"(.pqw) and exit"
),
)
return parser.parse_args() return parser.parse_args()
def list_players(roster: Roster, file: TextIO | None = None) -> None:
for i, player in enumerate(roster.players, start=1):
print(f"{i}. {player.name}", file=file)
def bootstrap_first_run(
roster: Roster, args: argparse.Namespace, data: GameData
) -> None:
if roster.players or args.list_saves or args.load_save:
return
print("No saved characters found. Starting first-run character creation.")
ui = BasicUserInterface(roster, None, args, data)
while not roster.players:
player = ui.create_player(auto_play=False)
if player:
return
if not ui.confirm("No character created. Do you want to try again?"):
print("A character is required for first run.", file=sys.stderr)
raise SystemExit(1)
def import_saves(
roster: Roster, data: GameData, paths: list[Path], use_saves: bool
) -> None:
imported = []
for path in paths:
try:
player, warnings = pqw.read_file(path, data)
except pqw.PqwError as ex:
print(f"pqcli: {ex}", file=sys.stderr)
exit(1)
for warning in warnings:
print(f"pqcli: {path}: {warning}", file=sys.stderr)
imported.append(player)
roster.players.extend(imported)
if use_saves:
roster.save()
for player in imported:
print(f"Imported {player.name}")
def export_save(roster: Roster, data: GameData, num: str, file: str) -> None:
if not num.isdigit() or not 1 <= int(num) <= len(roster.players):
print("Invalid player. Available players:", file=sys.stderr)
list_players(roster, file=sys.stderr)
exit(1)
player = roster.players[int(num) - 1]
try:
pqw.write_file(player, Path(file), data)
except pqw.PqwError as ex:
print(f"pqcli: {ex}", file=sys.stderr)
exit(1)
print(f"Exported {player.name} to {file}")
def main() -> None: def main() -> None:
args = parse_args() args = parse_args()
roster = Roster.load(SAVE_PATH)
if args.dump_data:
try:
paths = dump_builtin_data(args.dump_data)
except DataError as ex:
print(f"pqcli: {ex}", file=sys.stderr)
exit(1)
print(f"Wrote {len(paths)} files to {args.dump_data}")
exit(0)
try: try:
Ui(roster, args).run() data = load_game_data(args.data_dir)
except DataError as ex:
print(f"pqcli: {ex}", file=sys.stderr)
exit(1)
roster = Roster.load(SAVE_PATH)
if args.list_saves:
list_players(roster)
exit(0)
if args.import_save:
import_saves(roster, data, args.import_save, args.use_saves)
exit(0)
if args.export_save:
export_save(roster, data, *args.export_save)
exit(0)
player: Player | None = None
if args.load_save:
try:
player = list(roster.players)[args.load_save - 1]
except IndexError:
print("Invalid player. Available players:", file=sys.stderr)
list_players(roster, file=sys.stderr)
exit(1)
bootstrap_first_run(roster, args, data)
try:
ui = args.ui(roster, player, args, data)
ui.run()
finally: finally:
if args.use_saves:
roster.save() roster.save()
+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
+71 -3
View File
@@ -1,8 +1,29 @@
import typing as T import datetime
from pqcli import random from pqcli import random
def format_float(num: float) -> str:
ret = f"{num:.01f}"
if ret.endswith("0"):
ret = ret[:-2]
return ret
def format_timespan(timespan: datetime.timedelta) -> str:
num = timespan.total_seconds()
if num < 60.0:
return f"~{int(num)}s"
num /= 60
if num < 60.0:
return f"~{int(num)}m"
num /= 60
if num < 24.0:
return f"~{format_float(num)}h"
num /= 24
return f"~{format_float(num)}d"
def generate_name() -> str: def generate_name() -> str:
parts = [ parts = [
"br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z".split("|"), "br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z".split("|"),
@@ -33,6 +54,11 @@ def to_roman(num: int) -> str:
ret = "-" ret = "-"
num = -num num = -num
while _rome(10000, "T"):
pass
_rome(9000, "MT")
_rome(5000, "A")
_rome(4000, "MA")
while _rome(1000, "M"): while _rome(1000, "M"):
pass pass
_rome(900, "CM") _rome(900, "CM")
@@ -53,6 +79,43 @@ def to_roman(num: int) -> str:
return ret return ret
def to_arabic(roman: str) -> int:
num = 0
rest = roman.upper()
def _arab(ds: str, dn: int) -> bool:
nonlocal num, rest
if not rest.startswith(ds):
return False
rest = rest[len(ds) :]
num += dn
return True
while _arab("T", 10000):
pass
_arab("MT", 9000)
_arab("A", 5000)
_arab("MA", 4000)
while _arab("M", 1000):
pass
_arab("CM", 900)
_arab("D", 500)
_arab("CD", 400)
while _arab("C", 100):
pass
_arab("XC", 90)
_arab("L", 50)
_arab("XL", 40)
while _arab("X", 10):
pass
_arab("IX", 9)
_arab("V", 5)
_arab("IV", 4)
while _arab("I", 1):
pass
return num
def act_name(act: int) -> str: def act_name(act: int) -> str:
if act == 0: if act == 0:
return "Prologue" return "Prologue"
@@ -75,7 +138,7 @@ def plural(subject: str) -> str:
def indefinite(subject: str, qty: int) -> str: def indefinite(subject: str, qty: int) -> str:
if qty == 1: if qty == 1:
if subject.startswith(tuple("AEIOU?aeiou?")): if subject.startswith(tuple("AEIOUÜaeiouü")):
return "an " + subject return "an " + subject
return "a " + subject return "a " + subject
return str(qty) + " " + plural(subject) return str(qty) + " " + plural(subject)
@@ -87,7 +150,7 @@ def definite(subject: str, qty: int) -> str:
return "the " + subject return "the " + subject
def prefix(a: T.List[str], m: int, subject: str, sep: str = " ") -> str: def prefix(a: list[str], m: int, subject: str, sep: str = " ") -> str:
m = abs(m) m = abs(m)
if m < 1 or m > len(a): if m < 1 or m > len(a):
return subject return subject
@@ -122,3 +185,8 @@ def special(m: int, subject: str) -> str:
return prefix( return prefix(
["Battle-", "cursed ", "Were-", "undead ", "demon "], m, subject, "" ["Battle-", "cursed ", "Were-", "undead ", "demon "], m, subject, ""
) )
def terminate_message(player_name: str) -> str:
adjective = random.choice(["faithful", "noble", "loyal", "brave"])
return f"Terminate {adjective} {player_name}?"
+316 -174
View File
@@ -1,40 +1,85 @@
import contextlib
import datetime import datetime
import functools
import itertools import itertools
import logging import logging
import typing as T import math
from collections import defaultdict
from collections.abc import Callable, Generator, Iterator, Sequence
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any
import urwid
from pqcli import random from pqcli import random
from pqcli.config import * from pqcli.config import (
from pqcli.lingo import * PRIME_STATS,
Class,
EquipmentPreset,
EquipmentType,
Modifier,
Monster,
Race,
StatType,
)
from pqcli.game_data import TARGET_PLACEHOLDER, GameData
from pqcli.lingo import (
act_name,
big,
definite,
generate_name,
indefinite,
plural,
sick,
special,
young,
)
from pqcli.random import Alea, RngState
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_SIGNALS: dict[tuple["SignalMixin", str], list[Callable[..., object]]] = (
defaultdict(list)
)
class SignalMixin: class SignalMixin:
def __init_subclass__(cls: T.Any, **kwargs: T.Any) -> None: _muted = 0
urwid.signals.register_signal(cls, cls.signals)
super().__init_subclass__(**kwargs)
def emit(self, signal_name: str, *user_data: T.Any) -> None: @staticmethod
urwid.signals.emit_signal(self, signal_name, *user_data) @contextlib.contextmanager
def muted() -> Generator[None, None, None]:
"""Suppress all signal emission for the duration of the block."""
SignalMixin._muted += 1
try:
yield
finally:
SignalMixin._muted -= 1
def connect(self, signal_name: str, callback: T.Callable) -> None: def emit(self, signal_name: str, *user_data: object) -> None:
urwid.signals.connect_signal(self, signal_name, callback) if SignalMixin._muted:
return
for callback in _SIGNALS[self, signal_name]:
callback(*user_data)
def __getstate__(self) -> T.Any: def connect(
return { self, signal_name: str, callback: Callable[..., object]
key: value ) -> None:
for key, value in self.__dict__.items() _SIGNALS[self, signal_name].append(callback)
if "signal" not in key
} def disconnect(
self, signal_name: str, callback: Callable[..., object]
) -> None:
try:
idx = _SIGNALS[self, signal_name].index(callback)
except ValueError:
return
del _SIGNALS[self, signal_name][idx]
def level_up_time(level: int) -> int: def level_up_time(level: int) -> int:
# seconds # seconds: 20 minutes for level 1, exponential increase after that.
return 20 * level * 60 # floor(x + 0.5) mirrors JS Math.round, unlike Python's round().
return math.floor((20 + 1.15**level) * 60 + 0.5)
class Bar(SignalMixin): class Bar(SignalMixin):
@@ -46,6 +91,9 @@ class Bar(SignalMixin):
self.emit("change") self.emit("change")
def reset(self, new_max: int, position: float = 0.0) -> None: def reset(self, new_max: int, position: float = 0.0) -> None:
if not math.isclose(new_max, self._max) or not math.isclose(
position, self._position
):
self._max = new_max self._max = new_max
self._position = position self._position = position
self.emit("change") self.emit("change")
@@ -66,17 +114,20 @@ class Bar(SignalMixin):
return self._position >= self.max_ return self._position >= self.max_
def reposition(self, new_pos: float) -> None: def reposition(self, new_pos: float) -> None:
self._position = float(min(new_pos, self._max)) old_pos = self._position
new_pos = float(min(new_pos, self._max))
self._position = new_pos
if not math.isclose(new_pos, old_pos):
self.emit("change") self.emit("change")
class Stats(SignalMixin): class Stats(SignalMixin):
signals = ["change"] signals = ["change"]
def __init__(self, values: T.Dict[StatType, int]) -> None: def __init__(self, values: dict[StatType, int]) -> None:
self._values = values self._values = values
def __iter__(self) -> T.Iterator[T.Tuple[StatType, int]]: def __iter__(self) -> Iterator[tuple[StatType, int]]:
return iter(self._values.items()) return iter(self._values.items())
def __getitem__(self, stat: StatType) -> int: def __getitem__(self, stat: StatType) -> int:
@@ -86,6 +137,12 @@ class Stats(SignalMixin):
def best(self) -> StatType: def best(self) -> StatType:
return max(self, key=lambda kv: kv[1])[0] return max(self, key=lambda kv: kv[1])[0]
@property
def best_prime(self) -> StatType:
return max(
(kv for kv in self if kv[0] in PRIME_STATS), key=lambda kv: kv[1]
)[0]
def increment(self, stat: StatType, qty: int = 1) -> None: def increment(self, stat: StatType, qty: int = 1) -> None:
self._values[stat] += qty self._values[stat] += qty
logger.info("Increased %s to %d", stat.value, self[stat]) logger.info("Increased %s to %d", stat.value, self[stat])
@@ -93,36 +150,36 @@ class Stats(SignalMixin):
class QuestBook(SignalMixin): class QuestBook(SignalMixin):
signals = ["complete_act", "complete_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
def increment_act(self) -> None: def increment_act(self) -> None:
self._act += 1 self._act += 1
self.emit("complete_act") self.emit("start_act", self._act)
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)
@@ -137,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
@@ -147,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:
@@ -164,7 +221,7 @@ class Inventory(SignalMixin):
def pop(self, index: int) -> None: def pop(self, index: int) -> None:
item = self._items[index] item = self._items[index]
logger.info("Lost %s (qty=%d)", item.name, item.quantity) logger.info("Lost %s", indefinite(item.name, item.quantity))
self._items.pop(index) self._items.pop(index)
self.sync_encumbrance() self.sync_encumbrance()
self.emit("item_del", item) self.emit("item_del", item)
@@ -193,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:
@@ -226,11 +283,17 @@ class SpellBook(SignalMixin):
signals = ["add", "change"] signals = ["add", "change"]
def __init__(self) -> None: def __init__(self) -> None:
self._spells: T.List[Spell] = [] self._spells: list[Spell] = []
def __iter__(self) -> T.Iterator[Spell]: def __iter__(self) -> Iterator[Spell]:
return iter(self._spells) return iter(self._spells)
def __bool__(self) -> bool:
return bool(self._spells)
def __len__(self) -> int:
return len(self._spells)
def add(self, spell_name: str, level: int) -> None: def add(self, spell_name: str, level: int) -> None:
for spell in self._spells: for spell in self._spells:
if spell.name == spell_name: if spell.name == spell_name:
@@ -245,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)
@@ -259,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):
@@ -286,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"]
@@ -296,12 +363,15 @@ 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.exp_bar = Bar(max_=level_up_time(1)) self.exp_bar = Bar(max_=level_up_time(1))
self.level = 1 self.level = 1
@@ -312,8 +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: dict[str, Any]) -> None:
self.__dict__.update(obj)
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
@@ -322,9 +398,9 @@ class Player(SignalMixin):
self.emit("new_task") self.emit("new_task")
def equip_price(self) -> int: def equip_price(self) -> int:
return 5 * (self.level ** 2) + 10 * self.level + 20 return 5 * (self.level**2) + 10 * self.level + 20
def level_up(self) -> None: def level_up(self, data: GameData) -> None:
self.level += 1 self.level += 1
logger.info("Leveled up to level %d!", self.level) logger.info("Leveled up to level %d!", self.level)
self.stats.increment( self.stats.increment(
@@ -337,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
@@ -363,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
@@ -415,46 +494,62 @@ 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()
self.elapsed = 0.0
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
if self.player.task is None: if self.player.task is None:
self.player.set_task(RegularTask("Loading", 2_000)) 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",
10_000, 10000,
), ),
RegularTask( RegularTask(
"Much is revealed about that wise old bastard " "Much is revealed about that wise old bastard "
"you'd underestimated", "you'd underestimated",
6_000, 6000,
), ),
RegularTask( RegularTask(
"A shocking series of events leaves you " "A shocking series of events leaves you "
"alone and bewildered, but resolute", "alone and bewildered, but resolute",
6_000, 6000,
), ),
RegularTask( RegularTask(
"Drawing upon an unrealized reserve of determination, " "Drawing upon an unrealized reserve of determination, "
"you set out on a long and dangerous journey", "you set out on a long and dangerous journey",
4_000, 4000,
), ),
PlotTask(f"Loading {act_name(1)}", 2_000), PlotTask(f"Loading {act_name(1)}", 2000),
] ]
self.player.quest_book.plot_bar.reset(28) self.player.quest_book.plot_bar.reset(26)
return return
if not self.player.task_bar.done: if not self.player.task_bar.done:
self.elapsed += elapsed
self.player.task_bar.increment(elapsed) self.player.task_bar.increment(elapsed)
return return
@@ -462,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)
@@ -486,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()
@@ -497,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(
( (
@@ -511,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
@@ -528,40 +630,45 @@ class Simulation:
self.player.set_task( self.player.set_task(
SellTask( SellTask(
"Selling " + indefinite(item.name, item.quantity), "Selling " + indefinite(item.name, item.quantity),
1_000, 1000,
) )
) )
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( HeadingToMarketTask("Heading to market to sell loot", 4000)
"Heading to market to sell loot", 4_000
) )
) 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(
"Negotiating purchase of better equipment", 5_000 "Negotiating purchase of better equipment", 5000
) )
) )
else: else:
self.player.set_task( self.player.set_task(
HeadingToKillingFieldsTask( HeadingToKillingFieldsTask(
"Heading to the killing fields", 4_000 "Heading to the killing fields", 4000
) )
) )
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,
) )
) )
@@ -571,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),
] ]
)() )()
@@ -593,26 +700,27 @@ 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
self.player.quest_book.add_quest(caption) self.player.quest_book.add_quest(caption)
self.player.quest_book.emit("complete_quest") self.player.quest_book.emit("start_quest", caption)
def interplot_cinematic(self) -> None: def interplot_cinematic(self) -> None:
def enqueue(task: BaseTask) -> None: def enqueue(task: BaseTask) -> None:
@@ -625,19 +733,19 @@ class Simulation:
RegularTask( RegularTask(
"Exhausted, you arrive at a friendly oasis " "Exhausted, you arrive at a friendly oasis "
"in a hostile land", "in a hostile land",
1_000, 1000,
) )
) )
enqueue( enqueue(
RegularTask("You greet old friends and meet new allies", 2_000) RegularTask("You greet old friends and meet new allies", 2000)
) )
enqueue( enqueue(
RegularTask( RegularTask(
"You are privy to a council of powerful do-gooders", 2_000 "You are privy to a council of powerful do-gooders", 2000
) )
) )
enqueue( enqueue(
RegularTask("There is much to be done. You are chosen!", 1_000) RegularTask("There is much to be done. You are chosen!", 1000)
) )
elif choice == 1: elif choice == 1:
@@ -645,15 +753,15 @@ class Simulation:
RegularTask( RegularTask(
"Your quarry is in sight, " "Your quarry is in sight, "
"but a mighty enemy bars your path!", "but a mighty enemy bars your path!",
1_000, 1000,
) )
) )
nemesis = named_monster(self.player.level + 3) nemesis = named_monster(self.data, self.player.level + 3)
enqueue( enqueue(
RegularTask( RegularTask(
f"A desperate struggle commences with {nemesis}", 4_000 f"A desperate struggle commences with {nemesis}", 4000
) )
) )
@@ -665,20 +773,20 @@ class Simulation:
if s % 3 == 0: if s % 3 == 0:
enqueue( enqueue(
RegularTask( RegularTask(
f"Locked in grim combat with {nemesis}", 2_000 f"Locked in grim combat with {nemesis}", 2000
) )
) )
elif s % 3 == 1: elif s % 3 == 1:
enqueue( enqueue(
RegularTask( RegularTask(
f"{nemesis} seems to have the upper hand", 2_000 f"{nemesis} seems to have the upper hand", 2000
) )
) )
elif s % 3 == 2: elif s % 3 == 2:
enqueue( enqueue(
RegularTask( RegularTask(
f"You seem to gain the advantage over {nemesis}", f"You seem to gain the advantage over {nemesis}",
2_000, 2000,
) )
) )
else: else:
@@ -687,53 +795,52 @@ class Simulation:
enqueue( enqueue(
RegularTask( RegularTask(
f"Victory! {nemesis} is slain! " f"Victory! {nemesis} is slain! "
"Exhausted, you lose conciousness", "Exhausted, you lose consciousness",
3_000, 3000,
) )
) )
enqueue( enqueue(
RegularTask( RegularTask(
"You awake in a friendly place, but the road awaits", 2_000 "You awake in a friendly place, but the road awaits", 2000
) )
) )
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}",
2_000, 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",
3_000, 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)} "
2_000, "and go back to get it",
2000,
) )
) )
enqueue( enqueue(
RegularTask( RegularTask(
"What's this!? You overhear something shocking!", 2_000 "What's this!? You overhear something shocking!", 2000
) )
) )
enqueue( enqueue(
RegularTask( RegularTask(f"Could {nemesis} be a dirty double-dealer?", 2000)
f"Could {nemesis} be a dirty double-dealer?", 2_000
)
) )
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",
3_000, 3000,
) )
) )
@@ -742,62 +849,59 @@ class Simulation:
enqueue( enqueue(
PlotTask( PlotTask(
f"Loading {act_name(self.player.quest_book.act + 1)}", 1_000 f"Loading {act_name(self.player.quest_book.act + 1)}", 1000
) )
) )
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):
@@ -807,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 "
@@ -829,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
@@ -869,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
} }
@@ -888,20 +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:
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:
+13 -3
View File
@@ -1,17 +1,19 @@
import logging
import pickle import pickle
import typing as T from datetime import datetime
from pathlib import Path from pathlib import Path
from pqcli.mechanic import Player from pqcli.mechanic import Player
class Roster: class Roster:
def __init__(self, path: Path, players: T.List[Player]) -> None: def __init__(self, path: Path, players: list[Player]) -> None:
self.path = path self.path = path
self.players = players self.players = players
self._last_save = datetime.now()
@staticmethod @staticmethod
def load(path: T.Union[str, Path]) -> "Roster": def load(path: str | Path) -> "Roster":
real_path = Path(path) real_path = Path(path)
if real_path.exists(): if real_path.exists():
return Roster( return Roster(
@@ -20,10 +22,18 @@ class Roster:
return Roster(real_path, players=[]) return Roster(real_path, players=[])
def save(self) -> None: def save(self) -> None:
self._last_save = datetime.now()
old_path = self.path.with_name(self.path.name + ".old") old_path = self.path.with_name(self.path.name + ".old")
tmp_path = self.path.with_name(self.path.name + ".new") tmp_path = self.path.with_name(self.path.name + ".new")
tmp_path.parent.mkdir(parents=True, exist_ok=True) tmp_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_bytes(pickle.dumps(self.players)) tmp_path.write_bytes(pickle.dumps(self.players))
if self.path.exists(): if self.path.exists():
if old_path.exists():
old_path.unlink()
self.path.rename(old_path) self.path.rename(old_path)
tmp_path.rename(self.path) tmp_path.rename(self.path)
def save_periodically(self) -> None:
if (datetime.now() - self._last_save).total_seconds() >= 300:
logging.info("Saving...")
self.save()
-115
View File
@@ -1,115 +0,0 @@
import argparse
import datetime
import logging
import sys
from pathlib import Path
from signal import SIG_DFL, SIGPIPE, signal
from pqcli import random
from pqcli.config import CLASSES, RACES
from pqcli.mechanic import (
Player,
Simulation,
StatsBuilder,
act_name,
create_player,
generate_name,
)
from pqcli.roster import Roster
signal(SIGPIPE, SIG_DFL)
logging.basicConfig(
stream=sys.stdout, level=logging.DEBUG, format="%(message)s"
)
# make things predictable
SEED_A = 1664525
SEED_B = 2141
SEED_C = 1013904223
def custom_random(num: int) -> int:
global SEED_A, SEED_B, SEED_C
SEED_B = (SEED_A * SEED_B + SEED_C) % 0x100000000
return SEED_B % num
random.below = custom_random
def character_sheet(player: Player) -> None:
print(f"{player.name} the {player.race.name}")
print(f"Level {player.level} {player.class_.name}")
print(f"Plot stage: {act_name(player.quest_book.act)}")
print(f"Quest: {player.quest_book.current_quest}")
print()
print("Stats:")
for stat, value in player.stats:
print(f" {stat.value}: {value}")
print()
print(f"Equipment (best: {player.equipment.best}):")
for equipment, item_name in player.equipment:
print(f" {equipment.value}: {item_name}")
print()
print("Spell Book:")
for spell in player.spell_book:
print(f" {spell.name}: {spell.level}")
print()
print("Inventory:")
print(f" Gold: {player.inventory.gold}")
for item in player.inventory:
print(f" {item.name}: {item.quantity}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("-r", "--roster", type=Path)
parser.add_argument("-l", "--max-level", type=int, default=5)
return parser.parse_args()
def random_player() -> Player:
return create_player(
stats=StatsBuilder().roll(),
name=generate_name(),
race=random.choice(RACES),
class_=random.choice(CLASSES),
)
def main() -> None:
args = parse_args()
if args.roster:
roster = Roster.load(args.roster)
if not roster.players:
roster.players.append(random_player())
roster.save()
player = roster.players[0]
else:
roster = None
player = random_player()
start = datetime.datetime.now()
elapsed = 0
last_level = 0
simulation = Simulation(player)
try:
while True:
if player.level != last_level:
last_level = player.level
character_sheet(player)
if player.level >= args.max_level:
break
simulation.tick()
elapsed += 100
finally:
print("real time: ", (datetime.datetime.now() - start))
print("virtual time: ", datetime.timedelta(milliseconds=elapsed))
if roster:
roster.save()
if __name__ == "__main__":
main()
-142
View File
@@ -1,142 +0,0 @@
import argparse
import typing as T
import urwid
from pqcli import random
from pqcli.mechanic import Player
from pqcli.roster import Roster
from pqcli.ui.confirm_dialog import ConfirmDialog
from pqcli.ui.game_view import GameView
from pqcli.ui.new_game_view import NewGameView
from pqcli.ui.roster_view import RosterView
TICK_FREQ = 0.1
SAVE_FREQ = 60.0
PALETTE: T.List[T.Tuple[str, ...]] = [
("button", "", ""),
("button-focus", "light red", "black"),
("linebox", "", ""),
("linebox-focus", "light red", "black"),
("linebox-content", "", ""),
("linebox-content-focus", "", ""),
("progressbar-normal", "", "", "standout"),
("progressbar-done", "black", "light red"),
("progressbar-smooth", "light red", ""),
]
class ConfirmExitDialog(ConfirmDialog):
def __init__(self, old_view: urwid.Widget) -> None:
super().__init__("Really quit?", old_view)
def bind_commands() -> None:
for key, direction in {
"k": "up",
"j": "down",
"h": "left",
"l": "right",
}.items():
for key_variant in {key, key.upper()}:
urwid.command_map[key_variant] = f"cursor {direction}"
urwid.command_map["tab"] = f"cursor down"
urwid.command_map["shift tab"] = f"cursor up"
class Ui:
def __init__(self, roster: Roster, args: argparse.Namespace) -> None:
bind_commands()
self.roster = roster
self.args = args
self.loop = urwid.MainLoop(
None, PALETTE, unhandled_input=self.unhandled_input
)
self.old_views: T.List[urwid.Widget] = []
self.switch_to_roster_view()
def run(self) -> None:
self.loop.set_alarm_in(TICK_FREQ, self.on_tick)
self.loop.set_alarm_in(SAVE_FREQ, self.on_save)
self.loop.run()
def unhandled_input(self, key: str) -> bool:
callback = getattr(self.loop.widget, "unhandled_input", None)
if callback and callback(key):
return True
if key == "ctrl q":
self.switch_to_exit_view()
return True
return False
def switch_to_roster_view(self) -> None:
self.loop.widget = RosterView(self.roster)
self._connect("load_game", self.switch_to_game_view)
self._connect("new_game", self.switch_to_new_game_view)
self._connect("delete_game", self.switch_to_delete_player_view)
self._connect("exit", self.switch_to_exit_view)
def switch_to_exit_view(self) -> None:
if isinstance(self.loop.widget, ConfirmExitDialog):
return
self.old_views.append(self.loop.widget)
self.loop.widget = ConfirmExitDialog(self.old_views[-1])
self._connect("confirm", self.exit)
self._connect("cancel", self.cancel_dialog)
def switch_to_new_game_view(self) -> None:
self.loop.widget = NewGameView()
self._connect("confirm", self.create_player)
self._connect("cancel", self.switch_to_roster_view)
def create_player(self, player: Player) -> None:
self.roster.players.append(player)
self.switch_to_roster_view()
def delete_player(self, player_idx: int) -> None:
del self.roster.players[player_idx]
self.switch_to_roster_view()
def switch_to_game_view(self, player_idx: int) -> None:
player = self.roster.players[player_idx]
self.loop.widget = GameView(self.loop, player, self.args)
self._connect("cancel", self.switch_to_roster_view)
def switch_to_delete_player_view(self, player_idx: int) -> None:
adjective = random.choice(["faithful", "noble", "loyal", "brave"])
player_name = self.roster.players[player_idx].name
self.old_views.append(self.loop.widget)
self.loop.widget = ConfirmDialog(
f"Terminate {adjective} {player_name}?", self.old_views[-1]
)
self._connect("confirm", lambda: self.delete_player(player_idx))
self._connect("cancel", self.cancel_dialog)
def exit(self) -> None:
raise urwid.ExitMainLoop()
def cancel_dialog(self) -> None:
assert len(self.old_views)
self.loop.widget = self.old_views.pop()
def _connect(self, signal_name: str, callback: T.Callable) -> None:
urwid.signals.connect_signal(
self.loop.widget,
signal_name,
lambda _sender, *args: callback(*args),
)
def on_tick(self, _loop: urwid.MainLoop, _user_data: T.Any) -> None:
if hasattr(self.loop.widget, "tick"):
self.loop.widget.tick()
self.loop.set_alarm_in(TICK_FREQ, self.on_tick)
def on_save(self, _loop: urwid.MainLoop, _user_data: T.Any) -> None:
self.roster.save()
self.loop.set_alarm_in(SAVE_FREQ, self.on_save)
+24
View File
@@ -0,0 +1,24 @@
import abc
import argparse
from pqcli.game_data import GameData
from pqcli.mechanic import Player
from pqcli.roster import Roster
class BaseUserInterface(abc.ABC):
def __init__(
self,
roster: Roster,
player: Player | None,
args: argparse.Namespace,
data: GameData,
) -> None:
self.roster = roster
self.data = data
self.args = args
self.player = player
@abc.abstractmethod
def run(self) -> None:
raise NotImplementedError("not implemented")
+287
View File
@@ -0,0 +1,287 @@
import argparse
import enum
import logging
import signal
import sys
import time
from collections.abc import Sequence
from datetime import datetime, timedelta
from pathlib import Path
from types import FrameType
from typing import TypeVar
from pqcli import lingo, pqw
from pqcli.config import PRIME_STATS
from pqcli.game_data import GameData
from pqcli.mechanic import Player, Simulation, StatsBuilder, create_player
from pqcli.roster import Roster
from pqcli.ui.base import BaseUserInterface
LOGO = r"""
____ ___ _
| _ \ _ __ ___ __ _ _ __ ___ ___ ___ / _ \ _ _ ___ ___| |_
| |_) | '__/ _ \ / _` | '__/ _ \/ __/ __| | | | | | |/ _ \/ __| __|
| __/| | | (_) | (_| | | | __/\__ \__ \ |_| | |_| | __/\__ \ |_
|_| |_| \___/ \__, |_| \___||___/___/\__\_\\__,_|\___||___/\__|
|___/
"""
T = TypeVar("T")
class MainMenu(enum.IntEnum):
create = 1
play = 2
info = 3
delete = 4
import_ = 5
export = 6
quit = 7
class BasicUserInterface(BaseUserInterface):
def __init__(
self,
roster: Roster,
player: Player | None,
args: argparse.Namespace,
data: GameData,
) -> None:
super().__init__(roster, player, args, data)
def signal_handler(sig: int, frame: FrameType | None) -> None:
logging.info("Quitting")
if self.args.use_saves:
self.roster.save()
sys.exit(0)
signal.signal(signal.SIGINT, signal_handler)
def run(self) -> None:
logging.basicConfig(
stream=sys.stdout,
level=logging.INFO,
format="[%(asctime)s] %(message)s",
)
self.logo()
if self.player:
self.play(self.player)
else:
self.main_menu()
def logo(self) -> None:
print(LOGO)
def main_menu(self) -> None:
while True:
choice = self.menu(
[
(MainMenu.create, "Create a new character"),
(MainMenu.play, "Play as character"),
(MainMenu.info, "Query character info"),
(MainMenu.delete, "Delete a character"),
(MainMenu.import_, "Import a character (.pqw)"),
(MainMenu.export, "Export a character (.pqw)"),
(MainMenu.quit, "Quit"),
]
)
if choice == MainMenu.create:
player = self.create_player()
elif choice == MainMenu.play:
player = self.choose_player()
if not player:
continue
self.play(player)
elif choice == MainMenu.info:
player = self.choose_player()
if not player:
continue
self.print_player_info(player)
elif choice == MainMenu.delete:
player = self.choose_player()
if not player:
continue
self.delete_player(player)
elif choice == MainMenu.import_:
self.import_player()
elif choice == MainMenu.export:
player = self.choose_player()
if not player:
continue
self.export_player(player)
elif choice == MainMenu.quit:
self.quit()
break
def create_player(self, *, auto_play: bool = True) -> Player | None:
name = input("Name your new character: ")
if not name:
print("Cancelled.")
return None
race = self.menu([(race, race.name) for race in self.data.races])
class_ = self.menu(
[(class_, class_.name) for class_ in self.data.classes]
)
stats_builder = StatsBuilder()
while True:
stats = stats_builder.roll()
for stat in PRIME_STATS:
print(f"{stat.value}: {stats[stat]}")
print(f"Total: {sum(stats[stat] for stat in PRIME_STATS)}")
if self.confirm("Is this okay?"):
break
player = create_player(
name=name,
race=race,
class_=class_,
stats=stats,
seed=stats_builder.seed,
)
self.roster.players.append(player)
if auto_play and self.confirm(
"Do you want to play as your new character?"
):
self.play(player)
return player
def play(self, player: Player) -> None:
print(f"Playing as {player.name}")
simulation = Simulation(player, self.data)
last_tick = datetime.now()
last_level = 0
while True:
elapsed = datetime.now() - last_tick
simulation.tick(elapsed.total_seconds() * 1000)
last_tick = datetime.now()
wait = (timedelta(milliseconds=100) - elapsed).total_seconds()
wait = max(0, wait)
time.sleep(wait)
if player.level != last_level:
last_level = player.level
self.print_player_info(player)
if self.args.use_saves:
self.roster.save_periodically()
def print_player_info(self, player: Player) -> None:
print("--- Character Sheet ---")
print(f"Name: {player.name}")
print(f"Race: {player.race.name}")
print(f"Class: {player.class_.name}")
print(f"Level: {player.level}")
print()
for stat, value in player.stats:
print(f"{stat.value}: {value}")
print()
print("--- Spell Book ---")
if not player.spell_book:
print("No spells memorized yet.")
else:
for spell in player.spell_book:
print(f"{spell.name} {lingo.to_roman(spell.level)}")
print()
print("--- Equipment ---")
for equipment_type, name in player.equipment:
print(f"{equipment_type.value}: {name}")
print()
print("--- Inventory ---")
print(f"Gold: {player.inventory.gold}")
for item in player.inventory:
print(f"{item.name}: {item.quantity}")
print()
print("--- Plot ---")
print(f"Current act: {lingo.to_roman(player.quest_book.act)}")
print(f"Current quest: {player.quest_book.current_quest or '?'}")
print(
f"Current task: {player.task.description if player.task else '?'}"
)
def delete_player(self, player: Player) -> None:
if self.confirm(lingo.terminate_message(player.name)):
del self.roster.players[self.roster.players.index(player)]
def quit(self) -> None:
if self.args.use_saves:
self.roster.save()
def import_player(self) -> None:
path = input("Import from .pqw file: ").strip()
if not path:
print("Cancelled.")
return
try:
player, warnings = pqw.read_file(
Path(path).expanduser(), self.data
)
except pqw.PqwError as ex:
print(f"Cannot import: {ex}")
return
for warning in warnings:
print(f"Warning: {warning}")
self.roster.players.append(player)
if self.args.use_saves:
self.roster.save()
print(f"Imported {player.name}.")
def export_player(self, player: Player) -> None:
default = pqw.default_file_name(player)
path = input(f"Export to .pqw file [{default}]: ").strip() or default
try:
pqw.write_file(player, Path(path).expanduser(), self.data)
except pqw.PqwError as ex:
print(f"Cannot export: {ex}")
return
print(f"Exported {player.name} to {path}.")
def choose_player(self) -> Player | None:
if not self.roster.players:
print("No characters to choose from!")
return None
return self.menu(
[(player, player.name) for player in self.roster.players],
title="Choose your character",
)
def confirm(self, message: str) -> bool:
while True:
choice = input(f"{message} [y/n] ")
if choice.lower() in {"y", "yes", "1"}:
return True
if choice.lower() in {"n", "no", "0"}:
return False
def menu(
self,
options: Sequence[tuple[T, str]],
title: str | None = None,
) -> T:
print()
if title:
print(f"{title}:")
for i, item in enumerate(options, 1):
_, description = item
print(f"{i}) {description}")
print()
while True:
try:
num = int(input("Your choice: "))
except ValueError:
print("Not a number")
continue
if num not in range(1, len(options) + 1):
print(f"Expected a number between 1..{len(options)}")
continue
return options[num - 1][0]
-44
View File
@@ -1,44 +0,0 @@
import typing as T
import urwid
from pqcli.ui.custom_button import CustomButton
from pqcli.ui.custom_line_box import CustomLineBox
class ConfirmDialog(urwid.Overlay):
signals = ["confirm", "cancel"]
def __init__(self, label: str, parent: urwid.Widget) -> None:
question = urwid.Text(("bold", label), "center")
yes_btn = CustomButton(
"Yes", hint="Y", on_press=lambda _user_data: self.confirm()
)
no_btn = CustomButton(
"No", hint="N", on_press=lambda _user_data: self.cancel()
)
line_box = CustomLineBox(
urwid.ListBox(
urwid.SimpleFocusListWalker([question, no_btn, yes_btn])
)
)
super().__init__(
line_box, parent, "center", len(label) + 6, "middle", 5
)
def keypress(self, size: T.Any, key: str) -> T.Optional[str]:
if key in {"y", "Y"}:
self.confirm()
return None
if key in {"n", "N"}:
self.cancel()
return None
return T.cast(T.Optional[str], super().keypress(size, key))
def confirm(self) -> None:
self._emit("confirm")
def cancel(self) -> None:
self._emit("cancel")
+273
View File
@@ -0,0 +1,273 @@
import argparse
import curses
import os
import signal
import time
from pathlib import Path
from types import FrameType
from pqcli import lingo, pqw
from pqcli.config import Class, Race
from pqcli.game_data import GameData
from pqcli.mechanic import Player, Stats, create_player
from pqcli.random import RngState
from pqcli.roster import Roster
from pqcli.ui.base import BaseUserInterface
from pqcli.ui.curses.colors import (
COLOR_FOCUSED,
COLOR_HIGHLIGHT,
COLOR_LOGO,
COLOR_LOGO_ALT,
COLOR_PROGRESSBAR,
COLOR_SCROLLBAR_THUMB,
COLOR_SCROLLBAR_TRACK,
has_colors,
set_colors,
)
from pqcli.ui.curses.views import (
BaseView,
ChooseCharacterClassView,
ChooseCharacterNameView,
ChooseCharacterRaceView,
ChooseCharacterStatsView,
ChooseCharacterView,
ConfirmView,
GameView,
MessageView,
RosterView,
TextInputView,
)
class StopMainLoop(Exception):
pass
class CursesUserInterface(BaseUserInterface):
def __init__(
self,
roster: Roster,
player: Player,
args: argparse.Namespace,
data: GameData,
) -> None:
super().__init__(roster, player, args, data)
os.environ.setdefault("ESCDELAY", "25")
self._screen = curses.initscr()
self._screen.nodelay(True)
self._screen.keypad(True)
curses.noecho()
curses.curs_set(0)
set_colors(args.colors)
if has_colors():
curses.start_color()
curses.use_default_colors()
curses.init_pair(COLOR_LOGO, -1, -1)
curses.init_pair(COLOR_LOGO_ALT, -1, 9)
curses.init_pair(COLOR_FOCUSED, -1, 8)
curses.init_pair(COLOR_SCROLLBAR_THUMB, -1, 7)
curses.init_pair(COLOR_SCROLLBAR_TRACK, -1, 0)
curses.init_pair(COLOR_PROGRESSBAR, 0, 9)
curses.init_pair(COLOR_HIGHLIGHT, 3, -1)
def signal_handler(sig: int, frame: FrameType | None) -> None:
if self.args.use_saves:
self.roster.save()
exit(0)
signal.signal(signal.SIGINT, signal_handler)
self._roster_view = RosterView(self._screen)
self._roster_view.on_create += self._switch_to_create_char_name_view
self._roster_view.on_delete += self._switch_to_delete_char_view
self._roster_view.on_play += self._switch_to_play_view
self._roster_view.on_import += self._switch_to_import_view
self._roster_view.on_export += self._switch_to_export_char_view
self._roster_view.on_quit += self._quit
self._view: BaseView = self._roster_view
self._view.start()
def run(self) -> None:
try:
if self.player:
self._switch_to_game_view(self.player)
else:
self._switch_to_roster_view()
while True:
key = self._screen.getch()
if key == curses.ERR:
time.sleep(0.1)
self._view.idle()
continue
if key == curses.KEY_RESIZE:
self._view.stop()
self._view.start()
else:
try:
self._view.keypress(key)
except StopMainLoop:
break
finally:
curses.endwin()
curses.curs_set(1)
def _switch_to_roster_view(self) -> None:
self._switch_view(self._roster_view)
def _switch_to_create_char_name_view(
self, player_name: str | None = None
) -> None:
view = ChooseCharacterNameView(self._screen, player_name)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_create_char_race_view
self._switch_view(view)
def _switch_to_create_char_race_view(
self, player_name: str, race: Race | None = None
) -> None:
view = ChooseCharacterRaceView(self._screen, self.data.races, race)
view.on_cancel += lambda: self._switch_to_create_char_name_view(
player_name
)
view.on_confirm += lambda race: self._switch_to_create_char_class_view(
player_name, race
)
self._switch_view(view)
def _switch_to_create_char_class_view(
self, player_name: str, race: Race, class_: Class | None = None
) -> None:
view = ChooseCharacterClassView(
self._screen, self.data.classes, class_
)
view.on_cancel += lambda: self._switch_to_create_char_race_view(
player_name, race
)
view.on_confirm += lambda class_: (
self._switch_to_create_char_stats_view(player_name, race, class_)
)
self._switch_view(view)
def _switch_to_create_char_stats_view(
self, player_name: str, race: Race, class_: Class
) -> None:
view = ChooseCharacterStatsView(self._screen)
view.on_cancel += lambda: self._switch_to_create_char_class_view(
player_name, race, class_
)
view.on_confirm += lambda stats, seed: self._create_character(
player_name, race, class_, stats, seed
)
self._switch_view(view)
def _switch_to_delete_char_view(self) -> None:
view = ChooseCharacterView(
self._screen, self.roster, "Choose character to delete"
)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_confirm_delete_char_view
self._switch_view(view)
def _switch_to_confirm_delete_char_view(self, player: Player) -> None:
view = ConfirmView(self._screen, lingo.terminate_message(player.name))
view.on_cancel += self._switch_to_delete_char_view
view.on_confirm += lambda: self._delete_character(player)
self._switch_view(view)
def _switch_to_play_view(self) -> None:
view = ChooseCharacterView(
self._screen, self.roster, "Choose character to play"
)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_game_view
self._switch_view(view)
def _switch_to_import_view(self) -> None:
view = TextInputView(self._screen, "Import from .pqw file:", "")
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._import_character
self._switch_view(view)
def _switch_to_export_char_view(self) -> None:
view = ChooseCharacterView(
self._screen, self.roster, "Choose character to export"
)
view.on_cancel += self._switch_to_roster_view
view.on_confirm += self._switch_to_export_file_view
self._switch_view(view)
def _switch_to_export_file_view(self, player: Player) -> None:
view = TextInputView(
self._screen,
f"Export {player.name} to .pqw file:",
pqw.default_file_name(player),
)
view.on_cancel += self._switch_to_export_char_view
view.on_confirm += lambda path: self._export_character(player, path)
self._switch_view(view)
def _switch_to_message_view(self, message: str) -> None:
view = MessageView(self._screen, message)
view.on_close += self._switch_to_roster_view
self._switch_view(view)
def _import_character(self, path: str) -> None:
try:
player, warnings = pqw.read_file(
Path(path).expanduser(), self.data
)
except pqw.PqwError as ex:
self._switch_to_message_view(f"Cannot import: {ex}")
return
self.roster.players.append(player)
if self.args.use_saves:
self.roster.save()
self._switch_to_message_view(
"\n".join([f"Imported {player.name}.", *warnings])
)
def _export_character(self, player: Player, path: str) -> None:
try:
pqw.write_file(player, Path(path).expanduser(), self.data)
except pqw.PqwError as ex:
self._switch_to_message_view(f"Cannot export: {ex}")
return
self._switch_to_message_view(f"Exported {player.name} to {path}.")
def _switch_to_game_view(self, player: Player) -> None:
view = GameView(
self._screen, self.roster, player, self.args, self.data
)
view.on_exit += self._switch_to_roster_view
self._switch_view(view)
def _switch_view(self, view: BaseView) -> None:
self._view.stop()
self._view = view
self._view.start()
def _create_character(
self,
name: str,
race: Race,
class_: Class,
stats: Stats,
seed: RngState,
) -> None:
player = create_player(name, race, class_, stats, seed)
self.roster.players.append(player)
if self.args.use_saves:
self.roster.save()
self._switch_to_game_view(player)
def _delete_character(self, player: Player) -> None:
self.roster.players.remove(player)
self._switch_to_roster_view()
def _quit(self) -> None:
raise StopMainLoop
+18
View File
@@ -0,0 +1,18 @@
_HAS_COLORS = False
COLOR_LOGO = 1
COLOR_LOGO_ALT = 2
COLOR_FOCUSED = 3
COLOR_SCROLLBAR_TRACK = 4
COLOR_SCROLLBAR_THUMB = 5
COLOR_PROGRESSBAR = 6
COLOR_HIGHLIGHT = 7
def has_colors() -> bool:
return _HAS_COLORS
def set_colors(has_colors: bool) -> None:
global _HAS_COLORS
_HAS_COLORS = has_colors
+17
View File
@@ -0,0 +1,17 @@
from collections.abc import Callable
from typing import Generic, ParamSpec
P = ParamSpec("P")
class EventHandler(Generic[P]):
def __init__(self) -> None:
self.callbacks: list[Callable[P, object]] = []
def __call__(self, *args: P.args, **kwargs: P.kwargs) -> None:
for callback in self.callbacks:
callback(*args, **kwargs)
def __iadd__(self, callback: Callable[P, object]) -> "EventHandler[P]":
self.callbacks.append(callback)
return self
+37
View File
@@ -0,0 +1,37 @@
import curses
import curses.ascii
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import TypeVar, overload
KEYS_CYCLE = {curses.ascii.TAB}
KEYS_DOWN = set(map(ord, "jJ")) | {curses.KEY_DOWN}
KEYS_LEFT = set(map(ord, "hH")) | {curses.KEY_LEFT}
KEYS_RIGHT = set(map(ord, "lL")) | {curses.KEY_RIGHT}
KEYS_UP = set(map(ord, "kK")) | {curses.KEY_UP}
KEYS_CANCEL = set(map(ord, "qQ")) | {curses.ascii.ESC}
@dataclass
class Choice:
keys: list[int]
desc: str
callback: Callable[[], object]
ItemT = TypeVar("ItemT")
DefaultT = TypeVar("DefaultT")
@overload
def first(source: Iterable[ItemT]) -> ItemT | None: ...
@overload
def first(source: Iterable[ItemT], default: DefaultT) -> ItemT | DefaultT: ...
def first(
source: Iterable[ItemT], default: DefaultT | None = None
) -> ItemT | DefaultT | None:
return next(iter(source), default)
+27
View File
@@ -0,0 +1,27 @@
from .base_view import BaseView
from .choose_character_view import ChooseCharacterView
from .confirm_view import ConfirmView
from .create_character_view import (
ChooseCharacterClassView,
ChooseCharacterNameView,
ChooseCharacterRaceView,
ChooseCharacterStatsView,
)
from .game_view import GameView
from .message_view import MessageView
from .roster_view import RosterView
from .text_input_view import TextInputView
__all__ = [
"BaseView",
"ChooseCharacterView",
"ConfirmView",
"ChooseCharacterClassView",
"ChooseCharacterNameView",
"ChooseCharacterRaceView",
"ChooseCharacterStatsView",
"GameView",
"MessageView",
"RosterView",
"TextInputView",
]
+18
View File
@@ -0,0 +1,18 @@
import curses
class BaseView:
def __init__(self, screen: curses.window) -> None:
self.screen = screen
def start(self) -> None:
pass
def stop(self) -> None:
pass
def idle(self) -> None:
pass
def keypress(self, key: int) -> None:
pass
@@ -0,0 +1,56 @@
import curses
import functools
from pqcli.lingo import act_name, to_roman
from pqcli.mechanic import Player
from pqcli.roster import Roster
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.views.menu_view import MenuView
class ChooseCharacterView(MenuView[Player]):
def __init__(
self, screen: curses.window, roster: Roster, title: str
) -> None:
super().__init__(screen, title)
for y, player in enumerate(roster.players, 1):
key: str | None = str(y % 10) if y <= 10 else None
best_equip = player.equipment.best
best_spell = player.spell_book.best
best_spell_name = (
(best_spell.name + " " + to_roman(best_spell.level))
if best_spell
else "-"
)
best_stat_name = (
f"{player.stats.best_prime.value} "
f"{player.stats[player.stats.best_prime]}"
)
label = (
f"[{key or '-'}] "
f"{player.name} the {player.race.name} "
f"({act_name(player.quest_book.act)})\n"
f" Level {player.level} {player.class_.name}\n"
f" {best_equip} / {best_spell_name} / {best_stat_name}"
)
self._choices.append(
Choice(
keys=[ord(key)] if key is not None else [],
desc=label,
callback=functools.partial(self.on_confirm, player),
)
)
self._choices.append(
Choice(
keys=list(KEYS_CANCEL),
desc="[Q] Cancel",
callback=self.on_cancel,
)
)
+50
View File
@@ -0,0 +1,50 @@
import curses
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import Choice
from pqcli.ui.curses.widgets import Menu
from .base_view import BaseView
class ConfirmView(BaseView):
def __init__(self, screen: curses.window, title: str) -> None:
super().__init__(screen)
self.title = f"{title}:"
self.on_cancel = EventHandler[[]]()
self.on_confirm = EventHandler[[]]()
self.choices: list[Choice] = [
Choice(
keys=list(map(ord, "yY")),
desc="[Y] Yes",
callback=self.on_confirm,
),
Choice(
keys=list(map(ord, "nNqQ\N{ESC}")),
desc="[N] No",
callback=self.on_cancel,
),
]
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
self.screen.erase()
self.screen.noutrefresh()
self.main_menu = Menu(
header=self.title,
choices=self.choices,
active_choice=0,
scr_height=scr_height,
scr_width=scr_width,
)
self.main_menu.render()
def stop(self) -> None:
self.main_menu.stop()
def keypress(self, key: int) -> None:
self.main_menu.keypress(key)
self.main_menu.render()
@@ -0,0 +1,11 @@
from .choose_character_class_view import ChooseCharacterClassView
from .choose_character_name_view import ChooseCharacterNameView
from .choose_character_race_view import ChooseCharacterRaceView
from .choose_character_stats_view import ChooseCharacterStatsView
__all__ = [
"ChooseCharacterClassView",
"ChooseCharacterNameView",
"ChooseCharacterRaceView",
"ChooseCharacterStatsView",
]
@@ -0,0 +1,39 @@
import curses
import functools
from collections.abc import Sequence
from pqcli.config import Class
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.views.menu_view import MenuView
class ChooseCharacterClassView(MenuView[Class]):
def __init__(
self,
screen: curses.window,
classes: Sequence[Class],
class_: Class | None = None,
) -> None:
super().__init__(
screen,
"Choose character class",
classes.index(class_) if class_ is not None else 0,
)
for y, class_ in enumerate(classes, 1):
key: str | None = str(y % 10) if y <= 10 else None
self._choices.append(
Choice(
keys=[ord(key)] if key is not None else [],
desc=f"[{key or '-'}] {class_.name}",
callback=functools.partial(self.on_confirm, class_),
)
)
self._choices.append(
Choice(
keys=list(KEYS_CANCEL),
desc="[Q] Cancel",
callback=self.on_cancel,
)
)
@@ -0,0 +1,119 @@
import curses
import curses.ascii
from pqcli.mechanic import generate_name
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import KEYS_CANCEL, KEYS_CYCLE, KEYS_DOWN, KEYS_UP
from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets.focusable import focus_standout
class ChooseCharacterNameView(BaseView):
def __init__(
self, screen: curses.window, character_name: str | None = None
) -> None:
super().__init__(screen)
self.on_cancel = EventHandler[[]]()
self.on_confirm = EventHandler[[str]]()
self._active_widget = 0
self._win: curses.window | None = None
self._text_win: curses.window | None = None
self._text = character_name or generate_name()
def start(self) -> None:
self.screen.erase()
self.screen.noutrefresh()
scr_height, scr_width = self.screen.getmaxyx()
h = 8
w = 30
y = (scr_height - h - 2) // 2
x = (scr_width - w - 2) // 2
self._text_win = curses.newwin(3, w + 2, y + 2, x - 1)
self._win = curses.newwin(h + 2, w + 2, y, x)
self._render()
def stop(self) -> None:
del self._win
del self._text_win
self._win = None
self._text_win = None
def keypress(self, key: int) -> None:
if key == curses.KEY_F5:
self._text = generate_name()
elif key == curses.KEY_F10:
self._finish()
elif key in KEYS_CYCLE:
self._active_widget = (self._active_widget + 1) % 4
elif self._active_widget == 0:
if key == curses.ascii.ESC:
self.on_cancel()
elif curses.ascii.isprint(key):
self._text += chr(key)
elif key == curses.KEY_BACKSPACE or key == curses.ascii.DEL:
self._text = self._text[:-1]
elif key == curses.ascii.NL:
self._finish()
elif key == curses.ascii.ETB: # ^w
self._text = ""
elif key == curses.KEY_DOWN:
self._active_widget = 1
else:
if key == curses.ascii.NL:
if self._active_widget == 1:
self._text = generate_name()
elif self._active_widget == 2:
self._finish()
elif self._active_widget == 3:
self.on_cancel()
else:
assert False
elif key in KEYS_CANCEL:
self.on_cancel()
elif key in KEYS_DOWN and self._active_widget < 3:
self._active_widget += 1
elif key in KEYS_UP and self._active_widget > 0:
self._active_widget -= 1
self._render()
def _finish(self) -> None:
self._text = self._text.strip()
if self._text:
self.on_confirm(self._text)
else:
self.on_cancel()
def _render(self) -> None:
if not self._win or not self._text_win:
return
self._win.erase()
self._win.addstr(0, 0, "Choose character name:")
with focus_standout(self._active_widget == 1, self._win):
self._win.addstr(6, 0, "[F5 ] Generate random name")
with focus_standout(self._active_widget == 2, self._win):
self._win.addstr(7, 0, "[F10 ] Continue")
with focus_standout(self._active_widget == 3, self._win):
self._win.addstr(8, 0, "[Esc ] Cancel")
self._win.refresh()
self._text_win.erase()
with focus_standout(self._active_widget == 0, self._text_win):
self._text_win.box()
self._text_win.addnstr(
1, 1, self._text, self._text_win.getmaxyx()[1] - 2
)
self._text_win.refresh()
@@ -0,0 +1,39 @@
import curses
import functools
from collections.abc import Sequence
from pqcli.config import Race
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.views.menu_view import MenuView
class ChooseCharacterRaceView(MenuView[Race]):
def __init__(
self,
screen: curses.window,
races: Sequence[Race],
race: Race | None = None,
) -> None:
super().__init__(
screen,
"Choose character race",
races.index(race) if race is not None else 0,
)
for y, race in enumerate(races, 1):
key: str | None = str(y % 10) if y <= 10 else None
self._choices.append(
Choice(
keys=[ord(key)] if key is not None else [],
desc=f"[{key or '-'}] {race.name}",
callback=functools.partial(self.on_confirm, race),
)
)
self._choices.append(
Choice(
keys=list(KEYS_CANCEL),
desc="[Q] Cancel",
callback=self.on_cancel,
)
)
@@ -0,0 +1,112 @@
import curses
import curses.ascii
from pqcli.config import PRIME_STATS
from pqcli.mechanic import Stats, StatsBuilder
from pqcli.random import RngState
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import KEYS_CANCEL, KEYS_CYCLE, KEYS_DOWN, KEYS_UP
from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets.focusable import focus_standout
class ChooseCharacterStatsView(BaseView):
def __init__(self, screen: curses.window) -> None:
super().__init__(screen)
self.on_cancel = EventHandler[[]]()
self.on_confirm = EventHandler[[Stats, RngState]]()
self._active_widget = 0
self._win: curses.window | None = None
self._stats_win: curses.window | None = None
self._stats_builder = StatsBuilder()
self._stats = self._stats_builder.roll()
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
h2 = len(PRIME_STATS) + 2
h = 7 + h2
w = 30
y = (scr_height - h - 2) // 2
x = (scr_width - w - 2) // 2
self.screen.erase()
self.screen.noutrefresh()
self._win = curses.newwin(h, w, y, x)
self._stats_win = curses.newwin(h2, w, y + 2, x - 1)
self._render()
def stop(self) -> None:
del self._win
del self._stats_win
self._win = None
self._stats_win = None
def keypress(self, key: int) -> None:
if key in KEYS_CANCEL:
self.on_cancel()
elif key == curses.ascii.NL:
if self._active_widget == 0:
self._stats = self._stats_builder.roll()
elif self._active_widget == 1:
self._stats = self._stats_builder.unroll()
elif self._active_widget == 2:
self.on_confirm(self._stats, self._stats_builder.seed)
elif self._active_widget == 3:
self.on_cancel()
else:
assert False
elif key == curses.KEY_F5:
self._stats = self._stats_builder.roll()
elif key == curses.KEY_F6:
self._stats = self._stats_builder.unroll()
elif key == curses.KEY_F10:
self.on_confirm(self._stats, self._stats_builder.seed)
elif key in KEYS_CYCLE:
self._active_widget = (self._active_widget + 1) % 4
elif key in KEYS_DOWN and self._active_widget < 3:
self._active_widget += 1
elif key in KEYS_UP and self._active_widget > 0:
self._active_widget -= 1
self._render()
def _render(self) -> None:
if not self._win or not self._stats_win:
return
y, x = self._win.getbegyx()
stats_h = self._stats_win.getmaxyx()[0]
self._win.erase()
self._win.addstr(0, 0, "Roll character stats:")
with focus_standout(self._active_widget == 0, self._win):
self._win.addstr(3 + stats_h + 0, 0, "[F5 ] Roll")
with focus_standout(self._active_widget == 1, self._win):
self._win.addstr(3 + stats_h + 1, 0, "[F6 ] Unroll")
with focus_standout(self._active_widget == 2, self._win):
self._win.addstr(3 + stats_h + 2, 0, "[F10 ] Continue")
with focus_standout(self._active_widget == 3, self._win):
self._win.addstr(3 + stats_h + 3, 0, "[Esc ] Cancel")
self._win.noutrefresh()
self._stats_win.erase()
for y, stat in enumerate(PRIME_STATS):
self._stats_win.addstr(
y + 1, 1, f"{stat.value}: {self._stats[stat]}"
)
self._stats_win.box()
self._stats_win.noutrefresh()
curses.doupdate()
+279
View File
@@ -0,0 +1,279 @@
import argparse
import curses
import curses.ascii
import datetime
from typing import Protocol, runtime_checkable
from pqcli.game_data import GameData
from pqcli.mechanic import Player, SignalMixin, Simulation
from pqcli.roster import Roster
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import (
KEYS_CANCEL,
KEYS_CYCLE,
KEYS_DOWN,
KEYS_LEFT,
KEYS_RIGHT,
KEYS_UP,
)
from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets import Focusable
from .character_sheet_window import CharacterSheetWindow
from .equipment_window import EquipmentWindow
from .inventory_window import InventoryWindow
from .plot_window import PlotWindow
from .quest_book_window import QuestBookWindow
from .spell_book_window import SpellBookWindow
from .task_progress_window import TaskProgressWindow
class GameWindow(Protocol):
def stop(self) -> None: ...
def sync(self) -> None: ...
@runtime_checkable
class PageScrollable(Protocol):
def scroll_page_up(self) -> None: ...
def scroll_page_down(self) -> None: ...
class GameView(BaseView):
def __init__(
self,
screen: curses.window,
roster: Roster,
player: Player,
args: argparse.Namespace,
data: GameData,
) -> None:
super().__init__(screen)
self.on_exit = EventHandler[[]]()
self._roster = roster
self._player = player
self._args = args
self._simulation = Simulation(player, data)
self._last_tick = datetime.datetime.now()
def keypress(self, key: int) -> None:
focused = self.focused
if key in KEYS_CANCEL:
self.on_exit()
elif key in {curses.KEY_PPAGE}:
if isinstance(focused, PageScrollable):
focused.scroll_page_up()
elif key in {curses.KEY_NPAGE}:
if isinstance(focused, PageScrollable):
focused.scroll_page_down()
elif key in KEYS_CYCLE:
if focused == self._char_sheet_win:
self.focus(self._spell_book_win)
elif focused == self._spell_book_win:
self.focus(self._equipment_win)
elif focused == self._equipment_win:
self.focus(self._inventory_win)
elif focused == self._inventory_win:
self.focus(self._plot_win)
elif focused == self._plot_win:
self.focus(self._quest_book_win)
elif focused == self._quest_book_win:
self.focus(self._char_sheet_win)
elif key in KEYS_DOWN:
if focused == self._char_sheet_win:
self.focus(self._spell_book_win)
elif focused == self._equipment_win:
self.focus(self._inventory_win)
elif focused == self._plot_win:
self.focus(self._quest_book_win)
elif key in KEYS_UP:
if focused == self._spell_book_win:
self.focus(self._char_sheet_win)
elif focused == self._inventory_win:
self.focus(self._equipment_win)
elif focused == self._quest_book_win:
self.focus(self._plot_win)
elif key in KEYS_LEFT:
if focused == self._equipment_win:
self.focus(self._char_sheet_win)
elif focused == self._inventory_win:
self.focus(self._spell_book_win)
elif focused == self._plot_win:
self.focus(self._equipment_win)
elif focused == self._quest_book_win:
self.focus(self._inventory_win)
elif key in KEYS_RIGHT:
if focused == self._char_sheet_win:
self.focus(self._equipment_win)
elif focused == self._spell_book_win:
self.focus(self._inventory_win)
elif focused == self._equipment_win:
self.focus(self._plot_win)
elif focused == self._inventory_win:
self.focus(self._quest_book_win)
elif self._args.cheats and key == ord("t"):
self._simulation.tick()
elif self._args.cheats and key == ord("T"):
for _ in range(100):
self._simulation.tick()
elif self._args.cheats and key == curses.ascii.DC4: # ^t
with SignalMixin.muted():
for _ in range(10000):
self._simulation.tick()
for child in self._children:
child.sync()
else:
super().keypress(key)
curses.doupdate()
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
col1_width = int(scr_width * 0.3)
col3_width = col1_width
col2_width = scr_width - col1_width - col3_width
self.screen.erase()
self.screen.noutrefresh()
self._big_win = curses.newwin(scr_height, scr_width)
self._col1_win = self._big_win.derwin(scr_height - 2, col1_width, 0, 0)
self._col2_win = self._big_win.derwin(
self._col1_win.getmaxyx()[0],
col2_width,
0,
self._col1_win.getmaxyx()[1],
)
self._col3_win = self._big_win.derwin(
self._col1_win.getmaxyx()[0],
col3_width,
0,
self._col1_win.getmaxyx()[1] + self._col2_win.getmaxyx()[1],
)
self._char_sheet_win = CharacterSheetWindow(
player=self._player,
parent=self._col1_win,
h=min(17, self._col1_win.getmaxyx()[0]),
w=self._col1_win.getmaxyx()[1],
y=0,
x=0,
)
self._spell_book_win = SpellBookWindow(
player=self._player,
parent=self._col1_win,
h=self._col1_win.getmaxyx()[0]
- self._char_sheet_win.getmaxyx()[0],
w=self._col1_win.getmaxyx()[1],
y=self._char_sheet_win.getmaxyx()[0],
x=0,
)
self._equipment_win = EquipmentWindow(
player=self._player,
parent=self._col2_win,
h=min(15, self._col2_win.getmaxyx()[0]),
w=self._col2_win.getmaxyx()[1],
y=0,
x=0,
)
self._inventory_win = InventoryWindow(
player=self._player,
parent=self._col2_win,
h=max(
1,
self._col2_win.getmaxyx()[0]
- self._equipment_win.getmaxyx()[0],
),
w=self._col2_win.getmaxyx()[1],
y=self._equipment_win.getmaxyx()[0],
x=0,
)
self._plot_win = PlotWindow(
player=self._player,
parent=self._col3_win,
h=self._equipment_win.getmaxyx()[0],
w=self._col3_win.getmaxyx()[1],
y=0,
x=0,
)
self._quest_book_win = QuestBookWindow(
player=self._player,
parent=self._col3_win,
h=max(
1, self._col3_win.getmaxyx()[0] - self._plot_win.getmaxyx()[0]
),
w=self._col3_win.getmaxyx()[1],
y=self._plot_win.getmaxyx()[0],
x=0,
)
self._task_win = TaskProgressWindow(
player=self._player,
parent=self._big_win,
h=2,
w=scr_width,
y=scr_height - 2,
x=0,
)
focusable_children = (
self._char_sheet_win,
self._spell_book_win,
self._equipment_win,
self._inventory_win,
self._plot_win,
self._quest_book_win,
)
self._focusable_children: list[Focusable] = list(focusable_children)
self._children: list[GameWindow] = [
*focusable_children,
self._task_win,
]
def stop(self) -> None:
for child in self._children:
child.stop()
del self._col1_win
del self._col2_win
del self._col3_win
del self._big_win
def idle(self) -> None:
elapsed = datetime.datetime.now() - self._last_tick
self._simulation.tick(elapsed.total_seconds() * 1000)
self._last_tick = datetime.datetime.now()
if self._args.use_saves:
self._roster.save_periodically()
curses.doupdate()
@property
def focused(self) -> Focusable:
for widget in self._focusable_children:
if widget.focused:
return widget
raise AssertionError
def focus(self, widget: Focusable) -> None:
self.focused.focused = False
widget.focused = True
@@ -0,0 +1,71 @@
import curses
from pqcli.mechanic import Player, StatType
from .progress_bar_window import DataTableProgressBarWindow
class CharacterSheetWindow(DataTableProgressBarWindow):
def __init__(
self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None:
super().__init__(
parent,
h,
w,
y,
x,
" Character Sheet ",
align_right=False,
show_time=True,
)
self._on_focus_change += self._render
self._focused = True
self._player = player
self._player.connect("level_up", self._sync_traits)
self._player.stats.connect("change", self._sync_traits)
self._player.exp_bar.connect("change", self._sync_exp)
self.sync()
def stop(self) -> None:
super().stop()
self._player.disconnect("level_up", self._sync_traits)
self._player.stats.disconnect("change", self._sync_traits)
self._player.exp_bar.disconnect("change", self._sync_exp)
def sync(self) -> None:
self._sync_traits()
self._sync_exp()
def _sync_traits(self) -> None:
if not self._win:
return
self._data_table.clear()
self._data_table.add("Name", self._player.name)
self._data_table.add("Race", self._player.race.name)
self._data_table.add("Class", self._player.class_.name)
self._data_table.add("Level", str(self._player.level))
self._data_table.add(" " * 15, "")
for stat in StatType:
self._data_table.add(stat.value, str(self._player.stats[stat]))
self._render_data_table()
def _sync_exp(self) -> None:
self._cur_pos = self._player.exp_bar.position
self._max_pos = self._player.exp_bar.max_
self._progress_title = (
f"Experience ({self._max_pos - self._cur_pos:.0f} XP to go)"
)
self._render_progress_bar()
@@ -0,0 +1,65 @@
import curses
from pqcli.mechanic import EquipmentType, Player
from pqcli.ui.curses.widgets import DataTable, Focusable, WindowWrapper
class EquipmentWindow(Focusable, WindowWrapper):
def __init__(
self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None:
super().__init__(parent, h, w, y, x)
self._on_focus_change += self._render
self._data_table = DataTable(
self._win, h - 2, w - 2, 1, 1, align_right=False
)
self._player = player
self._player.equipment.connect("change", self._sync_equipment_change)
self.sync()
def stop(self) -> None:
super().stop()
self._data_table.stop()
self._player.equipment.disconnect(
"change", self._sync_equipment_change
)
def sync(self) -> None:
self._data_table.clear()
for equipment_type in EquipmentType:
self._data_table.add(
equipment_type.value.ljust(15),
self._player.equipment[equipment_type] or "",
)
self._data_table.select(None)
self._render()
def _sync_equipment_change(
self, equipment_type: EquipmentType, item_name: str | None
) -> None:
self._data_table.set(equipment_type.value, item_name or "")
self._data_table.select(equipment_type.value)
self._render()
def _render(self) -> None:
if not self._win:
return
with self.focus_standout(self._win):
self._win.box()
text = " Equipment "
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1]))
self._win.noutrefresh()
self._data_table.render()
@@ -0,0 +1,111 @@
import curses
from pqcli.mechanic import InventoryItem, Player
from .progress_bar_window import DataTableProgressBarWindow
class InventoryWindow(DataTableProgressBarWindow):
def __init__(
self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None:
super().__init__(
parent,
h,
w,
y,
x,
" Inventory ",
align_right=True,
show_time=False,
)
self._on_focus_change += self._render
self._player = player
self._player.connect("new_task", self._on_new_task)
self._player.inventory.connect("gold_change", self._sync_gold)
self._player.inventory.connect("item_add", self._sync_item_add)
self._player.inventory.connect("item_del", self._sync_item_del)
self._player.inventory.connect("item_change", self._sync_item_change)
self._player.inventory.encum_bar.connect(
"change", self._sync_encumbrance
)
self.sync()
def stop(self) -> None:
super().stop()
self._player.disconnect("new_task", self._on_new_task)
self._player.inventory.disconnect("gold_change", self._sync_gold)
self._player.inventory.disconnect("item_add", self._sync_item_add)
self._player.inventory.disconnect("item_del", self._sync_item_del)
self._player.inventory.disconnect(
"item_change", self._sync_item_change
)
self._player.inventory.encum_bar.disconnect(
"change", self._sync_encumbrance
)
def scroll_page_up(self) -> None:
self._data_table.scroll_page_up()
self._render()
def scroll_page_down(self) -> None:
self._data_table.scroll_page_down()
self._render()
def sync(self) -> None:
self._sync_gold()
self._sync_items()
self._sync_encumbrance()
def _sync_encumbrance(self) -> None:
self._cur_pos = self._player.inventory.encum_bar.position
self._max_pos = self._player.inventory.encum_bar.max_
self._progress_title = (
f"Encumbrance ({self._cur_pos:.0f}/{self._max_pos} cubits)"
)
self._render_progress_bar()
def _sync_gold(self) -> None:
self._data_table.set("Gold", str(self._player.inventory.gold))
self._data_table.select("Gold")
self._render_data_table()
def _sync_items(self) -> None:
self._data_table.clear()
self._sync_gold()
for item in self._player.inventory:
self._data_table.add(item.name, str(item.quantity))
self._data_table.scroll_to_item(0)
self._data_table.select(None)
self._render_data_table()
def _sync_item_add(self, item: InventoryItem) -> None:
self._data_table.add(item.name, str(item.quantity))
self._data_table.select(item.name)
self._render_data_table()
def _sync_item_del(self, item: InventoryItem) -> None:
self._data_table.delete(item.name)
self._data_table.select(None)
self._render_data_table()
def _sync_item_change(self, item: InventoryItem) -> None:
self._data_table.set(item.name, str(item.quantity))
self._data_table.select(item.name)
self._render_data_table()
def _on_new_task(self) -> None:
description = (
self._player.task.description if self._player.task else "?"
)
if description.lower().startswith("sell"):
self._data_table.scroll_to_item(0)
@@ -0,0 +1,74 @@
import curses
from pqcli.lingo import act_name
from pqcli.mechanic import Player
from .progress_bar_window import ListBoxProgressBarWindow
class PlotWindow(ListBoxProgressBarWindow):
cutoff = 100
def __init__(
self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None:
super().__init__(
parent, h, w, y, x, " Plot Development ", show_time=True
)
self._on_focus_change += self._render
self._player = player
self._player.quest_book.connect("start_act", self._sync_act_add)
self._player.quest_book.plot_bar.connect("change", self._sync_position)
self.sync()
def stop(self) -> None:
super().stop()
self._list_box.stop()
self._player.quest_book.disconnect("start_act", self._sync_act_add)
self._player.quest_book.plot_bar.disconnect(
"change", self._sync_position
)
def scroll_page_up(self) -> None:
self._list_box.scroll_page_up()
self._render()
def scroll_page_down(self) -> None:
self._list_box.scroll_page_down()
self._render()
def sync(self) -> None:
self._sync_acts()
self._sync_position()
def _sync_acts(self) -> None:
self._list_box.clear()
for act_number in range(
max(0, self._player.quest_book.act - self.cutoff),
self._player.quest_book.act + 1,
):
self._sync_act_add(act_number)
self._render_list_box()
def _sync_act_add(self, act_number: int) -> None:
self._list_box.delete(0, max(0, len(self._list_box) - self.cutoff))
prev = self._list_box.get(-1)
if prev is not None:
self._list_box.set(-1, "[X] " + prev[4:])
self._list_box.add("[ ] " + act_name(act_number))
self._list_box.select(-1)
self._render_list_box()
def _sync_position(self) -> None:
self._cur_pos = self._player.quest_book.plot_bar.position
self._max_pos = self._player.quest_book.plot_bar.max_
self._render_progress_bar()
@@ -0,0 +1,157 @@
import curses
from pqcli.ui.curses.widgets import (
DataTable,
Focusable,
ListBox,
ProgressBar,
WindowWrapper,
)
class ProgressBarWindow(Focusable, WindowWrapper):
def __init__(
self,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
title: str,
show_time: bool,
) -> None:
super().__init__(parent, h, w, y, x)
self._title = title
self._cur_pos = 0.0
self._max_pos = 1.0
self._progress_title = ""
try:
self._progress_bar_win = (
self._win.derwin(3, w, h - 3, 0) if self._win else None
)
except curses.error:
self._progress_bar_win = None
self._progress_bar = ProgressBar(
self._progress_bar_win, 1, w - 2, 1, 1, show_time=show_time
)
def stop(self) -> None:
super().stop()
self._progress_bar.stop()
del self._progress_bar_win
self._progress_bar_win = None
def _render_progress_bar(self) -> None:
if not self._progress_bar_win:
return
self._progress_bar_win.erase()
with self.focus_standout(self._progress_bar_win):
self._progress_bar_win.border(
curses.ACS_VLINE,
curses.ACS_VLINE,
curses.ACS_HLINE,
curses.ACS_HLINE,
curses.ACS_LTEE,
curses.ACS_RTEE,
curses.ACS_LLCORNER,
curses.ACS_LRCORNER,
)
text = self._progress_title
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
# Determine the number of characters to write
nwrite = min(len(text), self._progress_bar_win.getmaxyx()[1])
# The amount MUST be greater than zero to avoid linux errors
if nwrite > 0:
self._progress_bar_win.addnstr(
0,
x,
text,
nwrite,
)
self._progress_bar.set_position(self._cur_pos, self._max_pos)
self._progress_bar_win.noutrefresh()
class DataTableProgressBarWindow(ProgressBarWindow):
def __init__(
self,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
title: str,
align_right: bool,
show_time: bool,
) -> None:
super().__init__(parent, h, w, y, x, title, show_time)
self._data_table = DataTable(
self._win, h - 4, w - 2, 1, 1, align_right
)
def stop(self) -> None:
super().stop()
self._data_table.stop()
def _render_data_table(self) -> None:
if not self._win:
return
with self.focus_standout(self._win):
self._win.box()
x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2)
self._win.addnstr(
0, x, self._title, min(len(self._title), self.getmaxyx()[1])
)
self._win.noutrefresh()
self._data_table.render()
def _render(self) -> None:
self._render_data_table()
self._render_progress_bar()
class ListBoxProgressBarWindow(ProgressBarWindow):
def __init__(
self,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
title: str,
show_time: bool,
) -> None:
super().__init__(parent, h, w, y, x, title, show_time)
self._list_box = ListBox(self._win, h - 4, w - 2, 1, 1)
def stop(self) -> None:
super().stop()
self._list_box.stop()
def _render_list_box(self) -> None:
if not self._win:
return
with self.focus_standout(self._win):
self._win.box()
x = max(0, (self.getmaxyx()[1] - len(self._title)) // 2)
self._win.addnstr(
0, x, self._title, min(len(self._title), self.getmaxyx()[1])
)
self._win.noutrefresh()
self._list_box.render()
def _render(self) -> None:
self._render_list_box()
self._render_progress_bar()
@@ -0,0 +1,71 @@
import curses
from pqcli.mechanic import Player
from .progress_bar_window import ListBoxProgressBarWindow
class QuestBookWindow(ListBoxProgressBarWindow):
cutoff = 100
def __init__(
self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None:
super().__init__(parent, h, w, y, x, " Quests ", show_time=True)
self._on_focus_change += self._render
self._player = player
self._player.quest_book.connect("start_quest", self._sync_quest_add)
self._player.quest_book.quest_bar.connect(
"change", self._sync_position
)
self.sync()
def stop(self) -> None:
super().stop()
self._list_box.stop()
self._player.quest_book.disconnect("start_quest", self._sync_quest_add)
self._player.quest_book.quest_bar.disconnect(
"change", self._sync_position
)
def scroll_page_up(self) -> None:
self._list_box.scroll_page_up()
self._render()
def scroll_page_down(self) -> None:
self._list_box.scroll_page_down()
self._render()
def sync(self) -> None:
self._sync_quests()
self._sync_position()
def _sync_quests(self) -> None:
self._list_box.clear()
for quest_name in self._player.quest_book.quests[-self.cutoff :]:
self._sync_quest_add(quest_name)
self._list_box.select(-1)
self._render_list_box()
def _sync_quest_add(self, quest_name: str) -> None:
self._list_box.delete(0, max(0, len(self._list_box) - self.cutoff))
prev = self._list_box.get(-1)
if prev is not None:
self._list_box.set(-1, "[X] " + prev[4:])
self._list_box.add("[ ] " + quest_name)
self._list_box.select(-1)
self._render_list_box()
def _sync_position(self) -> None:
self._cur_pos = self._player.quest_book.quest_bar.position
self._max_pos = self._player.quest_book.quest_bar.max_
self._render_progress_bar()
@@ -0,0 +1,74 @@
import curses
from pqcli.lingo import to_roman
from pqcli.mechanic import Player, Spell
from pqcli.ui.curses.widgets import DataTable, Focusable, WindowWrapper
class SpellBookWindow(Focusable, WindowWrapper):
def __init__(
self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None:
super().__init__(parent, h, w, y, x)
self._on_focus_change += self._render
self._data_table = DataTable(
self._win, h - 2, w - 2, 1, 1, align_right=True
)
self._player = player
self._player.spell_book.connect("add", self._sync_spell_add)
self._player.spell_book.connect("change", self._sync_spell_change)
self.sync()
def stop(self) -> None:
super().stop()
self._data_table.stop()
self._player.spell_book.disconnect("add", self._sync_spell_add)
self._player.spell_book.disconnect("change", self._sync_spell_change)
def scroll_page_up(self) -> None:
self._data_table.scroll_page_up()
self._render()
def scroll_page_down(self) -> None:
self._data_table.scroll_page_down()
self._render()
def sync(self) -> None:
self._data_table.clear()
for spell in self._player.spell_book:
self._data_table.add(spell.name, to_roman(spell.level))
self._data_table.select(None)
self._render()
def _sync_spell_add(self, spell: Spell) -> None:
self._data_table.add(spell.name, to_roman(spell.level))
self._data_table.select(spell.name)
self._render()
def _sync_spell_change(self, spell: Spell) -> None:
self._data_table.set(spell.name, to_roman(spell.level))
self._data_table.select(spell.name)
self._render()
def _render(self) -> None:
if not self._win:
return
with self.focus_standout(self._win):
self._win.box()
text = " Spell Book "
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1]))
self._win.noutrefresh()
self._data_table.render()
@@ -0,0 +1,56 @@
import curses
from pqcli.mechanic import Player
from pqcli.ui.curses.widgets import (
Focusable,
Label,
ProgressBar,
WindowWrapper,
)
class TaskProgressWindow(Focusable, WindowWrapper):
def __init__(
self,
player: Player,
parent: curses.window,
h: int,
w: int,
y: int,
x: int,
) -> None:
super().__init__(parent, h, w, y, x)
self._desc_win = Label(self._win, 1, w, 0, 0)
self._progress_bar = ProgressBar(
self._win, 1, w, 1, 0, show_time=False
)
self._player = player
self._player.connect("new_task", self._sync_task_name)
self._player.task_bar.connect("change", self._sync_position)
self.sync()
def stop(self) -> None:
super().stop()
self._desc_win.stop()
self._progress_bar.stop()
self._player.disconnect("new_task", self._sync_task_name)
self._player.task_bar.disconnect("change", self._sync_position)
def sync(self) -> None:
self._sync_task_name()
self._sync_position()
def _sync_task_name(self) -> None:
self._desc_win.set_text(
f"{self._player.task.description}..." if self._player.task else "?"
)
def _sync_position(self) -> None:
self._progress_bar.set_position(
self._player.task_bar.position, self._player.task_bar.max_
)
+44
View File
@@ -0,0 +1,44 @@
import curses
from typing import Generic, TypeVar
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import Choice
from pqcli.ui.curses.views.base_view import BaseView
from pqcli.ui.curses.widgets import Menu
ItemT = TypeVar("ItemT")
class MenuView(BaseView, Generic[ItemT]):
def __init__(
self, screen: curses.window, title: str, active_choice: int = 0
) -> None:
super().__init__(screen)
self.on_cancel = EventHandler[[]]()
self.on_confirm = EventHandler[[ItemT]]()
self._title = f"{title}:"
self._choices: list[Choice] = []
self._active_choice = active_choice
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
self.screen.erase()
self.screen.noutrefresh()
self.main_menu = Menu(
header=self._title,
choices=self._choices,
active_choice=self._active_choice,
scr_height=scr_height,
scr_width=scr_width,
)
self.main_menu.render()
def stop(self) -> None:
self.main_menu.stop()
def keypress(self, key: int) -> None:
self.main_menu.keypress(key)
self.main_menu.render()
+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()
+110
View File
@@ -0,0 +1,110 @@
import curses
from pqcli.ui.curses.colors import COLOR_LOGO, COLOR_LOGO_ALT, has_colors
from pqcli.ui.curses.event_handler import EventHandler
from pqcli.ui.curses.util import KEYS_CANCEL, Choice
from pqcli.ui.curses.widgets import Menu
from .base_view import BaseView
LOGO = """
█▀▀▄ ▄▀▀█
▀▄ ▀▄ ▄▀ ▄▀
▀▄ ▀▄ ▄▀ ▄▀
▀▄ ▄▀ ▄▀
▄▀ ▄▀▄
▄▄▄▄▀ ▄▀▄ ▀▄▄▄▄
▀▄ █▄▄▀ ▀▄▄█ ▄▀
▄▀█▄ █ █ ▄█▀▄
▄▄▀▄▀ ▀▀ ▀▀ ▀▄▀▄▄
█▄█ █▄█
█▀▀▄ ▄▀▀▄ █
█▄▄▀ █▀▀ ▄▀▀▄ ▄▀▀▄ █▀▀ ▄▀▀▄ ▄▀▀▀ ▄▀▀▀ █ █ █ █ ▄▀▀▄ ▄▀▀▀ ▀█▀
█ █ █ █ ▀▄▄█ █ █▀▀ ▀▀▄ ▀▀▄ █ ▌█ █ █ █▀▀ ▀▀▄ █
▀ ▀ ▀▀ ▄▄▀ ▀ ▀▀ ▀▀▀ ▀▀▀ ▀▀▌ ▀▀ ▀▀ ▀▀▀ ▀
""".strip("\n")
class MainMenu(Menu):
def __init__(
self, choices: list[Choice], scr_height: int, scr_width: int
) -> None:
super().__init__(
header=LOGO,
choices=choices,
active_choice=0,
scr_height=scr_height,
scr_width=scr_width,
)
pad = self._pad
assert pad is not None
if has_colors():
for y in range(len(self._header_lines)):
pad.chgat(y, 0, curses.color_pair(COLOR_LOGO))
pad.chgat(3, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
pad.chgat(4, 29, 5, curses.color_pair(COLOR_LOGO_ALT))
pad.chgat(5, 32, 4, curses.color_pair(COLOR_LOGO_ALT))
pad.chgat(5, 27, 4, curses.color_pair(COLOR_LOGO_ALT))
pad.chgat(6, 34, 2, curses.color_pair(COLOR_LOGO_ALT))
pad.chgat(6, 27, 2, curses.color_pair(COLOR_LOGO_ALT))
class RosterView(BaseView):
def __init__(self, screen: curses.window) -> None:
super().__init__(screen)
self.on_create = EventHandler[[]]()
self.on_play = EventHandler[[]]()
self.on_delete = EventHandler[[]]()
self.on_import = EventHandler[[]]()
self.on_export = EventHandler[[]]()
self.on_quit = EventHandler[[]]()
self.choices: list[Choice] = [
Choice(
keys=list(map(ord, "cC")),
desc="[C] Create new character",
callback=self.on_create,
),
Choice(
keys=list(map(ord, "pP")),
desc="[P] Play",
callback=self.on_play,
),
Choice(
keys=list(map(ord, "dD")),
desc="[D] Delete character",
callback=self.on_delete,
),
Choice(
keys=list(map(ord, "iI")),
desc="[I] Import character (.pqw)",
callback=self.on_import,
),
Choice(
keys=list(map(ord, "eE")),
desc="[E] Export character (.pqw)",
callback=self.on_export,
),
Choice(
keys=list(KEYS_CANCEL), desc="[Q] Quit", callback=self.on_quit
),
]
def start(self) -> None:
scr_height, scr_width = self.screen.getmaxyx()
self.screen.erase()
self.screen.noutrefresh()
self.main_menu = MainMenu(
choices=self.choices, scr_height=scr_height, scr_width=scr_width
)
self.main_menu.render()
def stop(self) -> None:
self.main_menu.stop()
def keypress(self, key: int) -> None:
self.main_menu.keypress(key)
self.main_menu.render()
+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()
+20
View File
@@ -0,0 +1,20 @@
from .base import Widget, WindowWrapper
from .data_table import DataTable
from .focusable import Focusable
from .label import Label
from .list_box import ListBox
from .menu import Menu
from .progress_bar import ProgressBar
from .scrollable import Scrollable
__all__ = [
"Widget",
"WindowWrapper",
"DataTable",
"Focusable",
"Label",
"ListBox",
"Menu",
"ProgressBar",
"Scrollable",
]
+40
View File
@@ -0,0 +1,40 @@
import curses
class Widget:
def __del__(self) -> None:
self.stop()
def stop(self) -> None:
pass
class WindowWrapper(Widget):
def __init__(
self, parent: curses.window | None, h: int, w: int, y: int, x: int
) -> None:
self._parent = parent
self._win: curses.window | None
if not parent:
self._win = None
else:
try:
self._win = parent.derwin(h, w, y, x)
self._win.noutrefresh()
except curses.error:
self._win = None
def stop(self) -> None:
del self._win
self._win = None
def getbegyx(self) -> tuple[int, int]:
if not self._win:
return (0, 0)
return self._win.getbegyx()
def getmaxyx(self) -> tuple[int, int]:
if not self._win:
return (0, 0)
return self._win.getmaxyx()
+80
View File
@@ -0,0 +1,80 @@
import curses
from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors
from .scrollable import Scrollable
class DataTable(Scrollable[tuple[str, str]]):
def __init__(
self,
parent: curses.window | None,
h: int,
w: int,
y: int,
x: int,
align_right: bool = False,
) -> None:
super().__init__(parent, h, w, y, x)
self._align_right = align_right
self._selected: int | None = None
def add(self, text: str, value: str) -> None:
self._items.append((text, value))
self.scroll_to_item(-1)
def set(self, text: str, value: str) -> None:
idx = self.get_idx(text)
if idx is None:
self.add(text, value)
else:
self._items[idx] = (self._items[idx][0], value)
self.scroll_to_item(idx)
def delete(self, text: str) -> None:
idx = self.get_idx(text)
if idx is not None:
del self._items[idx]
self.scroll_to_item(idx)
def select(self, text: str | None) -> None:
self._selected = None if text is None else self.get_idx(text)
def get_idx(self, text: str) -> int | None:
for idx, row in enumerate(self._items):
if row[0].strip() == text.strip():
return idx
return None
def _render_impl(self, h: int, w: int) -> None:
assert self._pad
if self._align_right:
for y, row in enumerate(self._items):
if y == self._selected and has_colors():
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
self._pad.move(y, 0)
self._pad.addnstr(row[0], min(len(row[0]), w))
col2_x = max(0, w - len(row[1]))
if col2_x < w:
self._pad.move(y, col2_x)
self._pad.addstr(row[1])
if y == self._selected and has_colors():
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
else:
col2_x = self._get_col_width(0)
for y, row in enumerate(self._items):
if y == self._selected and has_colors():
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
self._pad.addnstr(y, 0, row[0], min(len(row[0]), w))
# Determine the number of characters to write
nwrite = min(len(row[1]), w - col2_x)
# Ensure we have enough columns, and we are writing more
# than zero
if col2_x < w and nwrite > 0:
self._pad.addnstr(y, col2_x, row[1], nwrite)
if y == self._selected and has_colors():
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
def _get_col_width(self, x: int) -> int:
return max([len(row[0]) for row in self._items] + [0])
+47
View File
@@ -0,0 +1,47 @@
import contextlib
import curses
from collections.abc import Generator
from typing import Any
from pqcli.ui.curses.colors import COLOR_FOCUSED, has_colors
from pqcli.ui.curses.event_handler import EventHandler
@contextlib.contextmanager
def focus_standout(
focused: bool, win: curses.window
) -> Generator[None, None, None]:
if focused:
if has_colors():
win.attron(curses.color_pair(COLOR_FOCUSED))
else:
win.standout()
yield
if focused:
if has_colors():
win.attroff(curses.color_pair(COLOR_FOCUSED))
else:
win.standend()
class Focusable:
def __init__(self, *args: Any, **kwargs: Any) -> None:
self._focused = False
self._on_focus_change = EventHandler[[]]()
super().__init__(*args, **kwargs)
@property
def focused(self) -> bool:
return self._focused
@focused.setter
def focused(self, focused: bool) -> None:
self._focused = focused
self._on_focus_change()
@contextlib.contextmanager
def focus_standout(
self, win: curses.window
) -> Generator[None, None, None]:
with focus_standout(self._focused, win):
yield
+10
View File
@@ -0,0 +1,10 @@
from .base import WindowWrapper
class Label(WindowWrapper):
def set_text(self, text: str) -> None:
if not self._win:
return
self._win.erase()
self._win.addnstr(text, min(len(text), self.getmaxyx()[1] - 1))
self._win.noutrefresh()
+49
View File
@@ -0,0 +1,49 @@
import curses
from pqcli.ui.curses.colors import COLOR_HIGHLIGHT, has_colors
from .scrollable import Scrollable
class ListBox(Scrollable[str]):
def __init__(
self, parent: curses.window | None, h: int, w: int, y: int, x: int
) -> None:
super().__init__(parent, h, w, y, x)
self._selected: int | None = None
def add(self, text: str) -> None:
self._items.append(text)
self.scroll_to_item(-1)
def set(self, idx: int, value: str) -> None:
if idx < 0:
idx %= len(self._items)
self._items[idx] = value
def get(self, idx: int) -> str | None:
if not self._items:
return None
if idx < 0:
idx %= len(self._items)
try:
return self._items[idx]
except IndexError:
return None
def delete(self, idx: int, count: int = 1) -> None:
del self._items[idx : idx + count]
def select(self, idx: int | None) -> None:
if idx is not None and idx < 0 and self._items:
idx %= len(self._items)
self._selected = idx
def _render_impl(self, h: int, w: int) -> None:
assert self._pad
for y, item in enumerate(self._items):
if y == self._selected and has_colors():
self._pad.attron(curses.color_pair(COLOR_HIGHLIGHT))
self._pad.addnstr(y, 0, item, min(len(item), w))
if y == self._selected and has_colors():
self._pad.attroff(curses.color_pair(COLOR_HIGHLIGHT))
+95
View File
@@ -0,0 +1,95 @@
import curses
import curses.ascii
from pqcli.ui.curses.util import KEYS_CYCLE, KEYS_DOWN, KEYS_UP, Choice
from pqcli.ui.curses.widgets.focusable import focus_standout
from .base import Widget
class Menu(Widget):
def __init__(
self,
header: str,
choices: list[Choice],
active_choice: int,
scr_height: int,
scr_width: int,
) -> None:
self._header_lines = header.split("\n")
self._choices = choices
self._scr_width = scr_width
self._scr_height = scr_height
all_lines = self._header_lines + sum(
[choice.desc.splitlines() for choice in choices], []
)
w = max(map(len, all_lines)) + 1
h = len(all_lines) + 1
pad = curses.newpad(h, w)
self._pad: curses.window | None = pad
for y, line in enumerate(self._header_lines):
pad.move(y, 0)
pad.addstr(line)
self._active_choice = active_choice
def stop(self) -> None:
del self._pad
self._pad = None
def getmaxyx(self) -> tuple[int, int]:
if not self._pad:
return (0, 0)
return self._pad.getmaxyx()
def next(self) -> None:
self._active_choice = min(
len(self._choices) - 1, self._active_choice + 1
)
def prev(self) -> None:
self._active_choice = max(0, self._active_choice - 1)
def cycle(self) -> None:
self._active_choice = (self._active_choice + 1) % len(self._choices)
def keypress(self, key: int) -> None:
for choice in self._choices:
if key in choice.keys:
choice.callback()
return
if key == curses.ascii.NL:
self._choices[self._active_choice].callback()
elif key in KEYS_DOWN:
self.next()
elif key in KEYS_UP:
self.prev()
elif key in KEYS_CYCLE:
self.cycle()
def render(self) -> None:
if not self._pad:
return
y = len(self._header_lines) + 1
for i, choice in enumerate(self._choices):
self._pad.move(y, 0)
with focus_standout(i == self._active_choice, self._pad):
lines = choice.desc.splitlines()
max_len = max(map(len, lines))
lines = [line.ljust(max_len) for line in lines]
self._pad.addstr("\n".join(lines))
y += len(choice.desc.splitlines())
self._pad.refresh(
0,
0,
(self._scr_height - self._pad.getmaxyx()[0]) // 2,
(self._scr_width - self._pad.getmaxyx()[1]) // 2,
self._scr_height - 1,
self._scr_width - 1,
)
+70
View File
@@ -0,0 +1,70 @@
import curses
import datetime
from pqcli.lingo import format_timespan
from pqcli.ui.curses.colors import COLOR_PROGRESSBAR, has_colors
from pqcli.ui.curses.widgets.base import WindowWrapper
class ProgressBar(WindowWrapper):
def __init__(
self,
parent: curses.window | None,
h: int,
w: int,
y: int,
x: int,
show_time: bool,
) -> None:
super().__init__(parent, h, w, y, x)
self._cur_pos = 0.0
self._max_pos = 1.0
self._show_time = show_time
self._last_tick: tuple[datetime.datetime, float] | None = None
@property
def time_left(self) -> datetime.timedelta | None:
if self._last_tick is None:
return None
time_then, pos_then = self._last_tick
time_now, pos_now = datetime.datetime.now(), self._cur_pos
div = (time_now - time_then).total_seconds()
if div == 0:
return None
speed = (pos_now - pos_then) / div
if not speed:
return None
return datetime.timedelta(seconds=(self._max_pos - pos_now) / speed)
def set_position(self, cur_pos: float, max_pos: float) -> None:
if not self._win:
return
self._win.erase()
if self._last_tick is None or cur_pos == 0 or max_pos != self._max_pos:
self._last_tick = (datetime.datetime.now(), cur_pos)
self._cur_pos = cur_pos
self._max_pos = max_pos
text = f"{cur_pos / max_pos:.02%}"
if self.time_left and self._show_time:
text += f" ({format_timespan(self.time_left)})"
x = max(0, (self.getmaxyx()[1] - len(text)) // 2)
self._win.addnstr(0, x, text, min(len(text), self.getmaxyx()[1] - 1))
x = int(cur_pos * self.getmaxyx()[1] // max_pos)
if x > 0:
self._win.chgat(
0,
0,
(
curses.color_pair(COLOR_PROGRESSBAR)
if has_colors()
else curses.A_REVERSE
),
)
if x < self.getmaxyx()[1]:
self._win.chgat(0, x, curses.A_NORMAL)
self._win.noutrefresh()
+120
View File
@@ -0,0 +1,120 @@
import curses
from typing import Generic, TypeVar
from pqcli.ui.curses.colors import (
COLOR_SCROLLBAR_THUMB,
COLOR_SCROLLBAR_TRACK,
has_colors,
)
from .base import WindowWrapper
ItemT = TypeVar("ItemT")
class Scrollable(WindowWrapper, Generic[ItemT]):
def __init__(
self, parent: curses.window | None, h: int, w: int, y: int, x: int
) -> None:
super().__init__(parent, h, w, y, x)
self._scroll_pos = 0
self._pad: curses.window | None = curses.newpad(1, 1)
self._items: list[ItemT] = []
def clear(self) -> None:
self._items = []
self.scroll_to_item(0)
def scroll_page_up(self) -> None:
self.scroll_to_item(max(0, self._scroll_pos - self.getmaxyx()[0]))
def scroll_page_down(self) -> None:
self.scroll_to_item(
min(
len(self._items) - 1,
self._scroll_pos + self.getmaxyx()[0] * 2 - 1,
)
)
def scroll_to_item(self, y: int) -> None:
if not self._items or not self._win:
return
if y < 0:
y %= len(self._items)
h = self.getmaxyx()[0]
delta = 1 if self._scroll_pos < y else -1
while not self._scroll_pos <= y < self._scroll_pos + h:
self._scroll_pos += delta
def stop(self) -> None:
super().stop()
del self._pad
self._pad = None
def __len__(self) -> int:
return len(self._items)
def render(self) -> None:
if not self._pad or not self._win:
return
if self._items:
self._pad.resize(
max(1, len(self._items)), max(1, self.getmaxyx()[1] + 1)
)
else:
self._pad.resize(1, 1)
self._win.erase()
self._win.noutrefresh()
self._pad.erase()
h, w = self.getmaxyx()
y, x = self.getbegyx()
try:
if self._scroll_pos > 0 or self._scroll_pos + h < len(self._items):
y1 = self._scroll_pos
y2 = self._scroll_pos + h
thumb_y1 = int(y1 * h // len(self._items))
thumb_y2 = int(y2 * h // len(self._items))
attr_thumb = (
curses.color_pair(COLOR_SCROLLBAR_THUMB)
if has_colors()
else curses.A_REVERSE
)
attr_track = (
curses.color_pair(COLOR_SCROLLBAR_TRACK)
if has_colors()
else curses.A_NORMAL
)
for win_y in range(h):
self._win.chgat(
win_y,
w - 1,
(
attr_thumb
if thumb_y1 <= win_y <= thumb_y2
else attr_track
),
)
self._win.noutrefresh()
self._render_impl(h, w - 1)
self._pad.noutrefresh(
self._scroll_pos, 0, y, x, y + h - 1, x + w - 2
)
else:
self._render_impl(h, w)
self._pad.noutrefresh(
self._scroll_pos, 0, y, x, y + h - 1, x + w - 1
)
except curses.error:
pass
def _render_impl(self, h: int, w: int) -> None:
raise NotImplementedError("not implemented")
-31
View File
@@ -1,31 +0,0 @@
import typing as T
import urwid
_MAGIC = 999 # where to display the cursor (high value = don't display it)
class CustomButton(urwid.Button):
def __init__(
self,
label: str,
*args: T.Any,
hint: T.Optional[str] = None,
**kwargs: T.Any,
) -> None:
super().__init__("", *args, **kwargs)
self._w = urwid.AttrMap(
urwid.Columns(
[
("pack", urwid.Text("< ")),
urwid.SelectableIcon([label], _MAGIC),
(
"pack",
urwid.Text(f"[{hint}]" if hint else "", align="right"),
),
("pack", urwid.Text(" >")),
]
),
"button",
"button-focus",
)
-18
View File
@@ -1,18 +0,0 @@
import typing as T
import urwid
class CustomLineBox(urwid.AttrMap):
def __init__(
self, widget: urwid.Widget, *args: T.Any, **kwargs: T.Any
) -> None:
super().__init__(
urwid.LineBox(
urwid.AttrMap(widget, "linebox-content", "linebox-content"),
*args,
**kwargs,
),
"linebox",
"linebox-focus",
)
-14
View File
@@ -1,14 +0,0 @@
import typing as T
import urwid
class CustomListWalker(urwid.SimpleFocusListWalker):
def append(self, item: T.Any) -> None:
super().append(item)
self.set_focus(len(self) - 1)
class CustomListBox(urwid.ListBox):
def __init__(self, widgets: T.List[urwid.Widget]) -> None:
super().__init__(CustomListWalker(widgets))
-68
View File
@@ -1,68 +0,0 @@
import typing as T
import urwid
# workaround for https://github.com/urwid/urwid/issues/317
class CustomProgressBar(urwid.Widget):
_sizing = frozenset(["flow"])
text_align = "center"
eighths = u" ▏▎▍▌▋▊▉"
def __init__(self) -> None:
self.position = 0.0
self.max_ = 1.0
self.normal = "progressbar-normal"
self.complete = "progressbar-done"
self.smooth = "progressbar-smooth"
def set_completion(self, value: float) -> None:
self.position = value
self._invalidate()
def set_max(self, value: T.Union[int, float]) -> None:
self.max_ = value
self._invalidate()
def rows(self, size: T.Tuple[int, int], focus: bool = False) -> int:
return 1
def get_text(self) -> str:
percent = min(100, max(0, int(self.position * 100 / self.max_)))
return str(percent) + " %"
def render(self, size: T.Tuple[int, int], focus: bool = False) -> T.Any:
(maxcol,) = size
txt = urwid.Text(self.get_text(), self.text_align, "clip")
c = txt.render((maxcol,))
t = c._text[0]
cf = self.position * maxcol / self.max_
ccol_dirty = int(cf)
ccol = len(
c._text[0][:ccol_dirty].decode("utf-8", "ignore").encode("utf-8")
)
cs = 0
cs = int((cf - ccol) * 8)
if ccol < 0 or (ccol == 0 and cs == 0):
c._attr = [[(self.normal, maxcol)]]
elif ccol >= maxcol:
c._attr = [[(self.complete, maxcol)]]
elif cs and c._text[0][ccol] == 32:
t = c._text[0]
cenc = self.eighths[cs].encode("utf-8")
c._text[0] = t[:ccol] + cenc + t[ccol + 1 :]
a = []
if ccol > 0:
a.append((self.complete, ccol))
a.append((self.smooth, len(cenc)))
if maxcol - ccol - 1 > 0:
a.append((self.normal, maxcol - ccol - 1))
c._attr = [a]
c._cs = [[(None, len(c._text[0]))]]
else:
c._attr = [[(self.complete, ccol), (self.normal, maxcol - ccol)]]
return c
-32
View File
@@ -1,32 +0,0 @@
import urwid
class DoubleLineBox(urwid.AttrMap):
def __init__(
self,
top_widget: urwid.Widget,
bottom_widget: urwid.Widget,
top_title: str = "",
bottom_title: str = "",
) -> None:
self.top_line_box = urwid.LineBox(
urwid.AttrMap(top_widget, "linebox-content", "linebox-content"),
title=top_title,
bline="",
)
self.bottom_line_box = urwid.LineBox(
urwid.AttrMap(bottom_widget, "linebox-content", "linebox-content"),
title=bottom_title,
tlcorner="├",
trcorner="┤",
)
super().__init__(
urwid.Pile([self.top_line_box, ("pack", self.bottom_line_box)]),
"linebox",
"linebox-focus",
)
def set_bottom_title(self, text: str) -> None:
self.bottom_line_box.set_title(text)
-463
View File
@@ -1,463 +0,0 @@
import argparse
import contextlib
import datetime
import typing as T
import urwid
from pqcli.config import EquipmentType, StatType
from pqcli.lingo import act_name, to_roman
from pqcli.mechanic import (
InventoryItem,
Player,
SignalMixin,
Simulation,
Spell,
)
from pqcli.ui.custom_line_box import CustomLineBox
from pqcli.ui.custom_list_box import CustomListBox
from pqcli.ui.custom_progress_bar import CustomProgressBar
from pqcli.ui.double_line_box import DoubleLineBox
from pqcli.ui.read_only_check_box import ReadOnlyCheckBox
class CharacterSheetView(DoubleLineBox):
def __init__(self, player: Player) -> None:
self.player = player
self.level_text = urwid.Text("")
self.stat_texts = {stat: urwid.Text("") for stat in list(StatType)}
self.exp_bar = CustomProgressBar()
self.list_box = CustomListBox(
[
urwid.Columns([urwid.Text("Name"), urwid.Text(player.name)]),
urwid.Columns(
[urwid.Text("Race"), urwid.Text(player.race.name)]
),
urwid.Columns(
[urwid.Text("Class"), urwid.Text(player.class_.name)]
),
urwid.Columns([urwid.Text("Level"), self.level_text]),
urwid.Divider(),
]
+ [
urwid.Columns([urwid.Text(stat.value), self.stat_texts[stat]])
for stat in StatType
]
)
self.player.connect("level_up", self.sync_level)
self.player.stats.connect("change", self.sync_stats)
self.player.exp_bar.connect("change", self.sync_exp)
super().__init__(
top_widget=self.list_box,
bottom_widget=self.exp_bar,
top_title="Character Sheet",
bottom_title="Experience",
)
self.sync()
def sync(self) -> None:
self.sync_level()
self.sync_stats()
self.sync_exp()
def sync_level(self) -> None:
self.level_text.set_text(str(self.player.level))
def sync_stats(self) -> None:
for stat in StatType:
self.stat_texts[stat].set_text(str(self.player.stats[stat]))
def sync_exp(self) -> None:
cur = self.player.exp_bar.position
max_ = self.player.exp_bar.max_
self.exp_bar.set_completion(cur)
self.exp_bar.set_max(max_)
self.set_bottom_title(f"Experience ({max_-cur:.0f} XP to go)")
class SpellBookView(CustomLineBox):
def __init__(self, player: Player) -> None:
self.player = player
self.list_box = CustomListBox([])
self.player.spell_book.connect("add", self.sync_spell_add)
self.player.spell_book.connect("change", self.sync_spell_change)
super().__init__(self.list_box, title="Spell Book")
self.sync()
def sync(self) -> None:
del self.list_box.body[:]
for spell in self.player.spell_book:
self.sync_spell_add(spell)
def sync_spell_add(self, spell: Spell) -> None:
self.list_box.body.append(
urwid.Columns(
[
("weight", 3, urwid.Text(spell.name)),
urwid.Text(to_roman(spell.level)),
]
)
)
def sync_spell_change(self, spell: Spell) -> None:
for widget in self.list_box.body:
if widget.contents[0][0].text == spell.name:
widget.contents[1][0].set_text(to_roman(spell.level))
class EquipmentView(CustomLineBox):
def __init__(self, player: Player) -> None:
self.player = player
self.value_texts = {
equipment_type: urwid.Text("") for equipment_type in EquipmentType
}
self.list_box = CustomListBox(
[
urwid.Columns(
[
urwid.Text(equipment_type.value),
("weight", 3, self.value_texts[equipment_type]),
]
)
for equipment_type in EquipmentType
]
)
self.player.equipment.connect("change", self.sync_equipment_change)
super().__init__(self.list_box, title="Equipment")
self.sync()
def sync(self) -> None:
for equipment_type in EquipmentType:
self.sync_equipment_change(
equipment_type, self.player.equipment[equipment_type]
)
def sync_equipment_change(
self, equipment_type: EquipmentType, item_name: T.Optional[str]
) -> None:
self.value_texts[equipment_type].set_text(item_name or "")
class InventoryView(DoubleLineBox):
def __init__(self, player: Player) -> None:
self.player = player
self.gold_text = urwid.Text("")
self.list_box = CustomListBox(
[
urwid.Columns(
[("weight", 3, urwid.Text("Gold")), self.gold_text]
)
]
)
self.encumbrance_bar = CustomProgressBar()
self.player.connect("new_task", self.on_new_task)
self.player.inventory.connect("gold_change", self.sync_gold)
self.player.inventory.connect("item_add", self.sync_item_add)
self.player.inventory.connect("item_del", self.sync_item_del)
self.player.inventory.connect("item_change", self.sync_item_change)
self.player.inventory.encum_bar.connect(
"change", self.sync_encumbrance_position
)
super().__init__(
top_widget=self.list_box,
top_title="Inventory",
bottom_widget=self.encumbrance_bar,
bottom_title="Encumbrance",
)
self.sync()
def sync(self) -> None:
self.sync_gold()
self.sync_items()
self.sync_encumbrance_position()
def sync_gold(self) -> None:
self.gold_text.set_text(str(self.player.inventory.gold))
def sync_items(self) -> None:
del self.list_box.body[1:]
for item in self.player.inventory:
self.sync_item_add(item)
def sync_encumbrance_position(self) -> None:
cur = self.player.inventory.encum_bar.position
max_ = self.player.inventory.encum_bar.max_
self.encumbrance_bar.set_completion(cur)
self.encumbrance_bar.set_max(max_)
self.set_bottom_title(f"Encumbrance ({cur:.0f}/{max_} cubits)")
def sync_item_add(self, item: InventoryItem) -> None:
self.list_box.body.append(
urwid.Columns(
[
("weight", 3, urwid.Text(item.name)),
urwid.Text(str(item.quantity)),
]
)
)
def sync_item_del(self, item: InventoryItem) -> None:
idx = self.get_item_idx_by_name(item.name)
if idx is not None:
del self.list_box.body[idx]
def sync_item_change(self, item: InventoryItem) -> None:
idx = self.get_item_idx_by_name(item.name)
if idx is not None:
self.list_box.body[idx].contents[1][0].set_text(str(item.quantity))
def get_item_idx_by_name(self, name: str) -> T.Optional[int]:
for i, column_widget in enumerate(self.list_box.body):
if column_widget.contents[0][0].text == name:
return i
return None
def on_new_task(self) -> None:
description = self.player.task.description if self.player.task else "?"
if description.lower().startswith("sell"):
self.list_box.set_focus(0)
class PlotView(DoubleLineBox):
cutoff = 100
def __init__(self, player: Player) -> None:
self.player = player
self.list_box = CustomListBox([])
self.plot_bar = CustomProgressBar()
self.player.quest_book.connect("complete_act", self.sync_act_add)
self.player.quest_book.plot_bar.connect("change", self.sync_position)
super().__init__(
top_widget=self.list_box,
top_title="Plot Development",
bottom_widget=self.plot_bar,
)
self.sync()
def sync(self) -> None:
self.sync_acts()
self.sync_position()
def sync_acts(self) -> None:
del self.list_box.body[:]
for act_number in range(
max(0, self.player.quest_book.act - self.cutoff),
self.player.quest_book.act,
):
self.list_box.body.append(
ReadOnlyCheckBox(act_name(act_number), state=True)
)
self.list_box.body.append(
ReadOnlyCheckBox(act_name(self.player.quest_book.act), state=False)
)
def sync_act_add(self) -> None:
del self.list_box.body[: -self.cutoff]
if self.list_box.body:
self.list_box.body[-1].set_state(True)
self.list_box.body.append(
ReadOnlyCheckBox(act_name(self.player.quest_book.act), state=False)
)
def sync_position(self) -> None:
self.plot_bar.set_completion(self.player.quest_book.plot_bar.position)
self.plot_bar.set_max(self.player.quest_book.plot_bar.max_)
class QuestBookView(DoubleLineBox):
cutoff = 100
def __init__(self, player: Player) -> None:
self.player = player
self.list_box = CustomListBox([])
self.quest_bar = CustomProgressBar()
self.player.quest_book.connect("complete_quest", self.sync_quest_add)
self.player.quest_book.quest_bar.connect("change", self.sync_position)
super().__init__(
top_widget=self.list_box,
top_title="Quests",
bottom_widget=self.quest_bar,
)
self.sync()
def sync(self) -> None:
self.sync_quests()
self.sync_position()
def sync_quests(self) -> None:
del self.list_box.body[:]
for quest_name in self.player.quest_book.quests[-self.cutoff :]:
self.list_box.body.append(ReadOnlyCheckBox(quest_name, state=True))
if self.list_box.body:
self.list_box.body[-1].set_state(False)
def sync_quest_add(self) -> None:
del self.list_box.body[: -self.cutoff]
if self.list_box.body:
self.list_box.body[-1].set_state(True)
self.list_box.body.append(
ReadOnlyCheckBox(self.player.quest_book.quests[-1], state=False)
)
def sync_position(self) -> None:
self.quest_bar.set_completion(
self.player.quest_book.quest_bar.position
)
self.quest_bar.set_max(self.player.quest_book.quest_bar.max_)
class TaskView(urwid.Pile):
def __init__(self, player: Player) -> None:
self.player = player
self.current_task_text = urwid.Text("...")
self.current_task_bar = CustomProgressBar()
self.player.connect("new_task", self.sync_task_name)
self.player.task_bar.connect("change", self.sync_position)
super().__init__([self.current_task_text, self.current_task_bar])
self.sync()
def sync(self) -> None:
self.sync_task_name()
self.sync_position()
def sync_task_name(self) -> None:
self.current_task_text.set_text(
f"{self.player.task.description}..." if self.player.task else "?"
)
def sync_position(self) -> None:
self.current_task_bar.set_completion(self.player.task_bar.position)
self.current_task_bar.set_max(self.player.task_bar.max_)
def pack(self, size: T.Tuple[int, int]) -> T.Tuple[int, int]:
return (size[0], 2)
class GameView(urwid.Pile):
signals = ["cancel"]
def __init__(
self, loop: urwid.MainLoop, player: Player, args: argparse.Namespace
) -> None:
self.loop = loop
self.player = player
self.args = args
self.simulation = Simulation(player)
self.last_tick = datetime.datetime.now()
self.character_sheet_view = CharacterSheetView(player)
self.spell_book_view = SpellBookView(player)
self.equipment_view = EquipmentView(player)
self.inventory_view = InventoryView(player)
self.plot_view = PlotView(player)
self.quest_book_view = QuestBookView(player)
self.task_view = TaskView(player)
super().__init__(
[
urwid.Columns(
[
(
"weight",
1,
urwid.Pile(
[
(19, self.character_sheet_view),
self.spell_book_view,
]
),
),
(
"weight",
2,
urwid.Pile(
[
(15, self.equipment_view),
self.inventory_view,
]
),
),
(
"weight",
2,
urwid.Pile(
[(15, self.plot_view), self.quest_book_view]
),
),
]
),
("pack", self.task_view),
]
)
def cancel(self) -> None:
self._emit("cancel")
def tick(self) -> None:
now = datetime.datetime.now()
elapsed = (now - self.last_tick).total_seconds()
self.simulation.tick(elapsed * 1000)
self.last_tick = datetime.datetime.now()
def unhandled_input(self, key: str) -> bool:
if key == "esc":
self.cancel()
return True
if self.args.cheats:
if key == "e":
self.simulation.player.exp_bar.increment(1)
return True
fast_forward = {"t": 1, "T": 100, "ctrl t": 10000}
if key in fast_forward:
iterations = fast_forward[key]
ctx_mgr = (
self.suppress_updates()
if iterations >= 1000
else contextlib.suppress()
)
with ctx_mgr:
for _ in range(iterations):
self.simulation.tick()
return True
return False
@contextlib.contextmanager
def suppress_updates(self) -> T.Generator:
old = SignalMixin.emit
SignalMixin.emit = lambda *_: None
yield
self.character_sheet_view.sync()
self.spell_book_view.sync()
self.equipment_view.sync()
self.inventory_view.sync()
self.plot_view.sync()
self.quest_book_view.sync()
self.task_view.sync()
SignalMixin.emit = old
-221
View File
@@ -1,221 +0,0 @@
import random
import typing as T
import urwid
import urwid_readline
from pqcli.config import CLASSES, PRIME_STATS, RACES
from pqcli.mechanic import StatsBuilder, create_player, generate_name
from pqcli.ui.custom_button import CustomButton
from pqcli.ui.custom_line_box import CustomLineBox
class StatsBox(CustomLineBox):
def __init__(self) -> None:
self.stats_builder = StatsBuilder()
self.stats = self.stats_builder.roll()
self.stat_labels = {stat: urwid.Text("0") for stat in PRIME_STATS}
self.total_label = urwid.Text("0")
self.update_values()
roll_button = CustomButton(
"Roll", hint="F5", on_press=self.on_roll_press
)
unroll_button = CustomButton(
"Unroll", hint="F6", on_press=self.on_unroll_press
)
value_texts = list(self.stat_labels.values())
label_texts = [urwid.Text(f"{stat.value}: ") for stat in PRIME_STATS]
super().__init__(
urwid.ListBox(
[
urwid.Columns(
[urwid.Pile(label_texts), urwid.Pile(value_texts)]
),
urwid.Divider(),
urwid.Columns([urwid.Text("Total: "), self.total_label]),
urwid.Divider(),
roll_button,
unroll_button,
]
),
title="Stats",
)
def roll(self) -> None:
self.stats = self.stats_builder.roll()
self.update_values()
def unroll(self) -> None:
try:
self.stats = self.stats_builder.unroll()
except IndexError:
return
self.update_values()
def on_roll_press(self, _user_data: T.Any) -> None:
self.roll()
def on_unroll_press(self, _user_data: T.Any) -> None:
self.unroll()
def update_values(self) -> None:
for stat, edit in self.stat_labels.items():
edit.set_text(str(self.stats[stat]))
self.total_label.set_text(
str(sum(self.stats[stat] for stat in self.stat_labels.keys()))
)
class RaceBox(CustomLineBox):
def __init__(self) -> None:
self.race = random.choice(RACES)
radio_buttons: T.List[urwid.RadioButton] = []
for race in RACES:
urwid.RadioButton(
group=radio_buttons,
label=race.name,
state=race.name == self.race.name,
on_state_change=self.on_state_change,
user_data=race,
)
super().__init__(urwid.ListBox(radio_buttons), title="Race")
def on_state_change(
self, _widget: urwid.RadioButton, new_state: bool, user_data: T.Any
) -> None:
if new_state:
self.race = user_data
class ClassBox(CustomLineBox):
def __init__(self) -> None:
self.class_ = random.choice(CLASSES)
radio_buttons: T.List[urwid.RadioButton] = []
for class_ in CLASSES:
urwid.RadioButton(
group=radio_buttons,
label=class_.name,
state=class_.name == self.class_.name,
on_state_change=self.on_state_change,
user_data=class_,
)
super().__init__(urwid.ListBox(radio_buttons), title="Class")
def on_state_change(
self, _widget: urwid.RadioButton, new_state: bool, user_data: T.Any
) -> None:
if new_state:
self.class_ = user_data
class NewGameView(urwid.Pile):
signals = ["confirm", "cancel"]
def __init__(self) -> None:
self.race_box = RaceBox()
self.class_box = ClassBox()
self.stats_box = StatsBox()
self.char_name_edit = urwid_readline.ReadlineEdit(
"Name: ", generate_name()
)
generate_char_name_btn = CustomButton(
"Generate random name",
hint="F7",
on_press=self.on_generate_char_name_press,
)
buttons_box = urwid.Filler(
urwid.Padding(
urwid.Pile(
[
CustomButton(
"Sold!", hint="F10", on_press=self.on_confirm_press
),
CustomButton(
"Cancel", hint="Esc", on_press=self.on_cancel_press
),
]
),
width=20,
align="center",
)
)
super().__init__(
[
(1, urwid.Filler(self.char_name_edit)),
(
1,
urwid.Filler(
urwid.Padding(
generate_char_name_btn,
left=len(self.char_name_edit.caption),
width=25,
)
),
),
urwid.Columns(
[
("weight", 2, self.race_box),
("weight", 2, self.class_box),
urwid.Pile([(13, self.stats_box), buttons_box]),
]
),
]
)
def generate_random_char_name(self) -> None:
self.char_name_edit.edit_text = generate_name()
self.char_name_edit.edit_pos = len(self.char_name_edit.edit_text)
def confirm(self) -> None:
player = create_player(
name=self.char_name_edit.edit_text,
race=self.race_box.race,
class_=self.class_box.class_,
stats=self.stats_box.stats,
)
self._emit("confirm", player)
def cancel(self) -> None:
self._emit("cancel")
def on_generate_char_name_press(self, _user_data: T.Any) -> None:
self.generate_random_char_name()
def on_confirm_press(self, _user_data: T.Any) -> None:
self.confirm()
def on_cancel_press(self, _user_data: T.Any) -> None:
self.cancel()
def unhandled_input(self, key: str) -> bool:
if key == "esc":
self.cancel()
return True
if key == "f7":
self.generate_random_char_name()
return True
if key == "f5":
self.stats_box.roll()
return True
if key == "f6":
self.stats_box.unroll()
return True
if key == "f10":
self.confirm()
return True
return False
-16
View File
@@ -1,16 +0,0 @@
import urwid
class ReadOnlyCheckBox(urwid.Text):
def __init__(self, text: str, state: bool = False) -> None:
self.icons = {
key: widget.text for key, widget in urwid.CheckBox.states.items()
}
self._caption = text
super().__init__("")
self.set_state(state)
def set_state(self, state: bool) -> None:
self.set_text(self.icons[state] + " " + self._caption)
-116
View File
@@ -1,116 +0,0 @@
import typing as T
import urwid
from pqcli.lingo import act_name, to_roman
from pqcli.roster import Roster
from pqcli.ui.custom_button import CustomButton
class RosterView(urwid.Filler):
signals = ["load_game", "new_game", "delete_game", "exit"]
def __init__(self, roster: Roster) -> None:
self.roster = roster
logo = urwid.BigText("ProgressQuest", urwid.HalfBlock5x4Font())
buttons = []
for player_idx, player in enumerate(self.roster.players):
best_equip = player.equipment.best
best_spell = player.spell_book.best
if best_spell:
best_spell_name = (
best_spell.name + " " + to_roman(best_spell.level)
)
else:
best_spell_name = "-"
best_stat_name = (
f"{player.stats.best.value} {player.stats[player.stats.best]}"
)
label = (
f"{player.name} the {player.race.name} "
f"({act_name(player.quest_book.act)})\n"
f"Level {player.level} {player.class_.name}\n"
f"{best_equip} / {best_spell_name} / {best_stat_name}"
)
buttons.append(
urwid.Columns(
[
CustomButton(
label=label,
on_press=self.on_resume_game_press,
user_data=player_idx,
),
(
10,
CustomButton(
label="Delete",
on_press=self.on_delete_game_press,
user_data=player_idx,
),
),
]
)
)
buttons.append(urwid.Divider())
buttons.append(
CustomButton(
label="Create a new character",
hint="F1",
on_press=self.on_new_game_press,
)
)
buttons.append(
CustomButton(label="Exit", hint="Esc", on_press=self.on_exit_press)
)
super().__init__(
urwid.Padding(
urwid.Pile(
[urwid.Padding(logo, width="clip"), urwid.Divider()]
+ buttons
+ [
urwid.Divider(),
urwid.Text("Use arrow keys to move around."),
]
),
align="center",
width=logo.pack()[0],
)
)
def on_resume_game_press(
self, _widget: urwid.Widget, user_data: T.Any
) -> None:
player_idx = T.cast(int, user_data)
self._emit("load_game", player_idx)
def on_delete_game_press(
self, _widget: urwid.Widget, user_data: T.Any
) -> None:
player_idx = T.cast(int, user_data)
self._emit("delete_game", player_idx)
def on_new_game_press(self, _user_data: T.Any) -> None:
self._emit("new_game")
def on_exit_press(self, _user_data: T.Any) -> None:
self._emit("exit")
def unhandled_input(self, key: str) -> bool:
if key == "esc":
self._emit("exit")
return True
if key == "f1":
self._emit("new_game")
return True
return False
+56
View File
@@ -0,0 +1,56 @@
[project]
name = "pqcli"
version = "1.1.0"
description = "Progress Quest: the CLI edition"
authors = [{ name = "Laura Kurczewska", email = "dash@wind.garden" }]
license = "MIT"
readme = "README.md"
requires-python = ">=3.10,<4.0"
classifiers = [
"Environment :: Console",
"Development Status :: 4 - Beta",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.10",
"Topic :: Games/Entertainment :: Role-Playing",
"Topic :: Games/Entertainment :: Simulation",
]
dependencies = [
"xdg-base-dirs>=6.0.0,<7.0.0",
"urwid>=2.1.2,<3.0.0",
"urwid-readline>=0.13,<1.0.0",
]
[project.optional-dependencies]
windows = ["windows-curses>=2.3.0,<3.0.0"]
[project.urls]
Repository = "https://github.com/rr-/pq-cli.git"
[project.scripts]
pqcli = "pqcli.__main__:main"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[dependency-groups]
dev = ["prek>=0.2", "pytest>=8.0", "ty>=0.0.89"]
[tool.ty.src]
# Only the package and its tests; agent worktrees under .claude/ would
# otherwise be checked as duplicate copies of the project.
include = ["pqcli", "tests"]
[tool.ruff]
line-length = 79
[tool.ruff.lint]
select = ["E", "F", "W", "I", "UP"]
[tool.ruff.lint.isort]
known-first-party = ["pqcli"]
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

-27
View File
@@ -1,27 +0,0 @@
from setuptools import find_packages, setup
setup(
author="rr-",
author_email="rr-@sakuya.pl",
name="pqcli",
long_description="ProgressQuest: the CLI edition",
version="0.2",
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
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more