107 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
109 changed files with 12810 additions and 2890 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"]
+149 -8
View File
@@ -10,27 +10,168 @@ Relive the great adventure… this time in the terminal realm!
## Features
- Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat`
- Clean terminal interface
- Smooth progress bars using UTF-8 block characters
- Scrollable list boxes
- Faithful port of the game logic
- Saves (with backups) to `$XDG_CONFIG_HOME/pqcli/save.dat`
- Terminal interface that comes in 2 flavors:
- Rich and colorful (`--curses`, default)
- Minimal, suitable for raw grind (`--basic`)
- Ideal to run on your server
## How it looks like
![Screenshot](screen.png)
Curses interface:
![Screenshot](screen-curses-logo.png)
![Screenshot](screen-curses.png)
Basic interface:
![Screenshot](screen-basic.png)
## 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.
In case if you want to use the git version, the process is just a little bit
more complex:
In case if you want to use the git version, the process is just a bit more complex:
```console
$ git clone https://github.com/rr-/pq-cli.git
$ cd pq-cli
$ pip install --user .
```
## Docker / Docker Compose
The repository includes a `Dockerfile` and `docker-compose.yml` that run the
game with `--basic` and keep save data in a persistent named volume.
There is no registry pipeline required for this setup: build locally, then run
with Compose.
```console
# Build image locally from the Dockerfile
docker compose build
# First run (interactive): create your character in the shared save volume
docker compose run --rm pqcli-init
# Later runs on a server (detached, default save slot 1):
docker compose up -d pqcli
# List existing saves in the same volume:
docker compose run --rm pqcli-init pqcli --basic --list-saves
# Change detached slot if needed (example: slot 2):
PQCLI_SAVE_SLOT=2 docker compose up -d pqcli
# Follow logs / stop detached container:
docker compose logs -f pqcli
docker compose stop pqcli
```
On first run, if no save data exists in the mounted volume, `pqcli` will
automatically launch an interactive character-creation bootstrap in CLI mode
before starting the normal interface.
For server usage, run first-time setup without a forced slot (`pqcli-init`),
then run the long-lived detached service (`pqcli`) that loads slot `1` by
default.
## 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
+183 -13
View File
@@ -1,25 +1,53 @@
import argparse
import sys
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.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:
parser = argparse.ArgumentParser(prog="pqcli")
parser.add_argument(
"--no-config",
dest="use_config",
action="store_false",
help=(
"Don't load or save configuration files from $XDG_CONFIG_HOME "
"(player data will still be saved)"
),
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",
@@ -27,15 +55,157 @@ def parse_args() -> argparse.Namespace:
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()
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:
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:
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:
if args.use_saves:
roster.save()
+6 -680
View File
@@ -1,5 +1,4 @@
import enum
import typing as T
from dataclasses import dataclass
@@ -39,56 +38,9 @@ PRIME_STATS = [
ALL_STATS = PRIME_STATS + [StatType.hp_max, StatType.mp_max]
SPELLS = [
"Slime Finger",
"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",
]
# The word lists live in pqcli/data/*.json (see pqcli.game_data).
# Saves pickle Monster, Race and Class instances by import path, so these
# classes must stay in this module.
@dataclass
@@ -97,652 +49,26 @@ class Modifier:
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
class EquipmentPreset:
name: str
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
class Monster:
name: str
level: int
item: T.Optional[str]
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"),
]
item: str | None
@dataclass
class Race:
name: str
attr: T.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]),
]
attr: list[StatType]
@dataclass
class Class:
name: str
attr: T.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",
]
attr: list[StatType]
+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
def format_float(num: float) -> str:
ret = f"{num:.01f}"
if ret.endswith("0"):
ret = ret[:-2]
return ret
def format_timespan(timespan: datetime.timedelta) -> str:
num = timespan.total_seconds()
if num < 60.0:
return f"~{int(num)}s"
num /= 60
if num < 60.0:
return f"~{int(num)}m"
num /= 60
if num < 24.0:
return f"~{format_float(num)}h"
num /= 24
return f"~{format_float(num)}d"
def generate_name() -> str:
parts = [
"br|cr|dr|fr|gr|j|kr|l|m|n|pr||||r|sh|tr|v|wh|x|y|z".split("|"),
@@ -33,6 +54,11 @@ def to_roman(num: int) -> str:
ret = "-"
num = -num
while _rome(10000, "T"):
pass
_rome(9000, "MT")
_rome(5000, "A")
_rome(4000, "MA")
while _rome(1000, "M"):
pass
_rome(900, "CM")
@@ -53,6 +79,43 @@ def to_roman(num: int) -> str:
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:
if act == 0:
return "Prologue"
@@ -75,7 +138,7 @@ def plural(subject: str) -> str:
def indefinite(subject: str, qty: int) -> str:
if qty == 1:
if subject.startswith(tuple("AEIOU?aeiou?")):
if subject.startswith(tuple("AEIOUÜaeiouü")):
return "an " + subject
return "a " + subject
return str(qty) + " " + plural(subject)
@@ -87,7 +150,7 @@ def definite(subject: str, qty: int) -> str:
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)
if m < 1 or m > len(a):
return subject
@@ -122,3 +185,8 @@ def special(m: int, subject: str) -> str:
return prefix(
["Battle-", "cursed ", "Were-", "undead ", "demon "], m, subject, ""
)
def terminate_message(player_name: str) -> str:
adjective = random.choice(["faithful", "noble", "loyal", "brave"])
return f"Terminate {adjective} {player_name}?"
+273 -138
View File
@@ -1,40 +1,85 @@
import contextlib
import datetime
import functools
import itertools
import logging
import typing as T
import math
from collections import defaultdict
from collections.abc import Callable, Generator, Iterator, Sequence
from dataclasses import dataclass
import urwid
from typing import Any
from pqcli import random
from pqcli.config import *
from pqcli.lingo import *
from pqcli.config 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__)
_SIGNALS: dict[tuple["SignalMixin", str], list[Callable[..., object]]] = (
defaultdict(list)
)
class SignalMixin:
def __init_subclass__(cls: T.Any, **kwargs: T.Any) -> None:
urwid.signals.register_signal(cls, cls.signals)
super().__init_subclass__(**kwargs)
_muted = 0
def emit(self, signal_name: str, *user_data: T.Any) -> None:
urwid.signals.emit_signal(self, signal_name, *user_data)
@staticmethod
@contextlib.contextmanager
def muted() -> Generator[None, None, None]:
"""Suppress all signal emission for the duration of the block."""
SignalMixin._muted += 1
try:
yield
finally:
SignalMixin._muted -= 1
def connect(self, signal_name: str, callback: T.Callable) -> None:
urwid.signals.connect_signal(self, signal_name, callback)
def emit(self, signal_name: str, *user_data: object) -> None:
if SignalMixin._muted:
return
for callback in _SIGNALS[self, signal_name]:
callback(*user_data)
def __getstate__(self) -> T.Any:
return {
key: value
for key, value in self.__dict__.items()
if "signal" not in key
}
def connect(
self, signal_name: str, callback: Callable[..., object]
) -> None:
_SIGNALS[self, signal_name].append(callback)
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:
# seconds
return 20 * level * 60
# seconds: 20 minutes for level 1, exponential increase after that.
# 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):
@@ -46,6 +91,9 @@ class Bar(SignalMixin):
self.emit("change")
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._position = position
self.emit("change")
@@ -66,17 +114,20 @@ class Bar(SignalMixin):
return self._position >= self.max_
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")
class Stats(SignalMixin):
signals = ["change"]
def __init__(self, values: T.Dict[StatType, int]) -> None:
def __init__(self, values: dict[StatType, int]) -> None:
self._values = values
def __iter__(self) -> T.Iterator[T.Tuple[StatType, int]]:
def __iter__(self) -> Iterator[tuple[StatType, int]]:
return iter(self._values.items())
def __getitem__(self, stat: StatType) -> int:
@@ -102,22 +153,22 @@ class QuestBook(SignalMixin):
signals = ["start_act", "start_quest"]
def __init__(self) -> None:
self._quests: T.List[str] = []
self._quests: list[str] = []
self._act = 0
self.plot_bar = Bar(max_=1)
self.quest_bar = Bar(max_=1)
self.monster: T.Optional[Monster] = None
self.monster: Monster | None = None
@property
def act(self) -> int:
return self._act
@property
def quests(self) -> T.List[str]:
def quests(self) -> list[str]:
return self._quests
@property
def current_quest(self) -> T.Optional[str]:
def current_quest(self) -> str | None:
if self._quests:
return self._quests[-1]
return None
@@ -128,7 +179,7 @@ class QuestBook(SignalMixin):
def add_quest(self, name: str) -> None:
logger.info("Commencing quest: %s", name)
self._quests = self._quests[-100:]
self._quests = self._quests[-99:]
self._quests.append(name)
@@ -143,7 +194,7 @@ class Inventory(SignalMixin):
def __init__(self, capacity: int = 0) -> None:
self._gold = 0
self._items: T.List[InventoryItem] = []
self._items: list[InventoryItem] = []
self.encum_bar = Bar(max_=capacity)
@property
@@ -153,7 +204,7 @@ class Inventory(SignalMixin):
def __len__(self) -> int:
return len(self._items)
def __iter__(self) -> T.Iterator[InventoryItem]:
def __iter__(self) -> Iterator[InventoryItem]:
return iter(self._items)
def __getitem__(self, idx: int) -> InventoryItem:
@@ -170,7 +221,7 @@ class Inventory(SignalMixin):
def pop(self, index: int) -> None:
item = self._items[index]
logger.info("Lost %s (qty=%d)", item.name, item.quantity)
logger.info("Lost %s", indefinite(item.name, item.quantity))
self._items.pop(index)
self.sync_encumbrance()
self.emit("item_del", item)
@@ -199,16 +250,16 @@ class Equipment(SignalMixin):
signals = ["change"]
def __init__(self) -> None:
self._items: T.Dict[EquipmentType, str] = {
self._items: dict[EquipmentType, str] = {
EquipmentType.weapon: "Sharp Rock",
EquipmentType.hauberk: "-3 Burlap",
}
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())
def __getitem__(self, equipment_type: EquipmentType) -> T.Optional[str]:
def __getitem__(self, equipment_type: EquipmentType) -> str | None:
return self._items.get(equipment_type, None)
def put(self, equipment_type: EquipmentType, item_name: str) -> None:
@@ -232,11 +283,17 @@ class SpellBook(SignalMixin):
signals = ["add", "change"]
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)
def __bool__(self) -> bool:
return bool(self._spells)
def __len__(self) -> int:
return len(self._spells)
def add(self, spell_name: str, level: int) -> None:
for spell in self._spells:
if spell.name == spell_name:
@@ -251,7 +308,7 @@ class SpellBook(SignalMixin):
self.emit("add", spell)
@property
def best(self) -> T.Optional[Spell]:
def best(self) -> Spell | None:
if not self._spells:
return None
return max(self._spells, key=lambda spell: spell.level)
@@ -265,7 +322,7 @@ class BaseTask:
@dataclass
class KillTask(BaseTask):
monster: T.Optional[Monster] = None
monster: Monster | None = None
class BuyTask(BaseTask):
@@ -292,6 +349,10 @@ class PlotTask(RegularTask):
pass
class LoadingTask(RegularTask):
pass
class Player(SignalMixin):
signals = ["new_task", "level_up"]
@@ -302,12 +363,14 @@ class Player(SignalMixin):
race: Race,
class_: Class,
stats: Stats,
rng: Alea | None = None,
) -> None:
self.name: str = name
self.birthday: datetime.datetime = birthday
self.race: Race = race
self.class_: Class = class_
self.stats: Stats = stats
self.rng: Alea = rng or Alea()
self.elapsed = 0.0
self.exp_bar = Bar(max_=level_up_time(1))
@@ -319,12 +382,14 @@ class Player(SignalMixin):
self.spell_book = SpellBook()
self.task_bar = Bar(max_=1)
self.task: T.Optional[BaseTask] = None
self.queue: T.List[BaseTask] = []
self.task: BaseTask | None = None
self.queue: list[BaseTask] = []
def __setstate__(self, obj) -> None:
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:
self.task = task
@@ -335,7 +400,7 @@ class Player(SignalMixin):
def equip_price(self) -> int:
return 5 * (self.level**2) + 10 * self.level + 20
def level_up(self) -> None:
def level_up(self, data: GameData) -> None:
self.level += 1
logger.info("Leveled up to level %d!", self.level)
self.stats.increment(
@@ -348,23 +413,23 @@ class Player(SignalMixin):
)
self.win_stat()
self.win_stat()
self.win_spell()
self.win_spell(data)
self.exp_bar.reset(level_up_time(self.level))
self.emit("level_up")
def win_stat(self) -> bool:
chosen_stat: T.Optional[StatType] = None
chosen_stat: StatType | None = None
if random.odds(1, 2):
chosen_stat = random.choice(list(StatType))
else:
# favor the best stat so it will tend to clump
t = sum(value ** 2 for _stat, value in self.stats)
t = sum(self.stats[stat] ** 2 for stat in PRIME_STATS)
t = random.below(t)
chosen_stat = None
for stat, value in self.stats:
for stat in PRIME_STATS:
chosen_stat = stat
t -= value ** 2
t -= self.stats[stat] ** 2
if t < 0:
break
@@ -374,31 +439,34 @@ class Player(SignalMixin):
self.inventory.set_capacity(10 + self.stats[StatType.strength])
return True
def win_spell(self) -> None:
def win_spell(self, data: GameData) -> None:
spells = data.spells
self.spell_book.add(
SPELLS[
spells[
random.below_low(
min(self.stats[StatType.wisdom] + self.level, len(SPELLS))
min(self.stats[StatType.wisdom] + self.level, len(spells))
)
],
1,
)
def win_equipment(self) -> None:
def win_equipment(self, data: GameData) -> None:
choice = random.choice(list(EquipmentType))
stuff: T.List[EquipmentPreset]
better: T.List[Modifier]
worse: T.List[Modifier]
stuff: Sequence[EquipmentPreset]
better: Sequence[Modifier]
worse: Sequence[Modifier]
if choice == EquipmentType.weapon:
stuff = WEAPONS
better = OFFENSE_ATTRIB
worse = OFFENSE_BAD
stuff = data.weapons
better = data.offense_attrib
worse = data.offense_bad
else:
stuff = SHIELDS if choice == EquipmentType.shield else ARMORS
better = DEFENSE_ATTRIB
worse = DEFENSE_BAD
stuff = (
data.shields if choice == EquipmentType.shield else data.armors
)
better = data.defense_attrib
worse = data.defense_bad
equipment = pick_equipment(stuff, self.level)
name = equipment.name
@@ -426,20 +494,36 @@ class Player(SignalMixin):
logger.info("Gained %s %s", name, choice.value)
self.equipment.put(choice, name)
def win_item(self) -> None:
self.inventory.add(special_item(), 1)
def win_item(self, data: GameData) -> None:
# 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:
def __init__(self, player: Player) -> None:
def __init__(self, player: Player, data: GameData) -> None:
self.player = player
self.data = data
self.last_tick = datetime.datetime.now()
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:
self.player.set_task(RegularTask("Loading", 2000))
self.player.set_task(LoadingTask("Loading", 2000))
self.player.queue += [
RegularTask(
"Experiencing an enigmatic and foreboding night vision",
@@ -462,7 +546,7 @@ class Simulation:
),
PlotTask(f"Loading {act_name(1)}", 2000),
]
self.player.quest_book.plot_bar.reset(28)
self.player.quest_book.plot_bar.reset(26)
return
if not self.player.task_bar.done:
@@ -473,7 +557,7 @@ class Simulation:
gain = self.player.task and isinstance(self.player.task, KillTask)
if gain:
if self.player.exp_bar.done:
self.player.level_up()
self.player.level_up(self.data)
else:
self.player.exp_bar.increment(self.player.task_bar.max_ / 1000)
@@ -497,6 +581,13 @@ class Simulation:
self.player.quest_book.plot_bar.increment(
self.player.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()
@@ -508,7 +599,7 @@ class Simulation:
or self.player.task.monster.item is None
):
# npc
self.player.win_item()
self.player.win_item(self.data)
elif self.player.task.monster.item:
self.player.inventory.add(
(
@@ -522,9 +613,9 @@ class Simulation:
elif isinstance(self.player.task, BuyTask):
# buy some equipment
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):
item = self.player.inventory[0]
amount = item.quantity * self.player.level
@@ -544,17 +635,22 @@ class Simulation:
)
break
elif isinstance(self.player.task, PlotTask):
self.complete_act()
old = self.player.task
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:
self.player.set_task(
HeadingToMarketTask("Heading to market to sell loot", 4000)
)
elif not isinstance(old, (KillTask, HeadingToKillingFieldsTask)):
elif not isinstance(old, KillTask | HeadingToKillingFieldsTask):
if self.player.inventory.gold > self.player.equip_price():
self.player.set_task(
BuyTask(
@@ -570,7 +666,9 @@ class Simulation:
else:
self.player.set_task(
monster_task(
self.player.level, self.player.quest_book.monster
self.data,
self.player.level,
self.player.quest_book.monster,
)
)
@@ -580,21 +678,21 @@ class Simulation:
60 * 60 * (1 + 5 * self.player.quest_book.act)
)
if self.player.quest_book.act > 1:
self.player.win_item()
self.player.win_equipment()
self.player.win_item(self.data)
self.player.win_equipment(self.data)
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:
logger.info(
"Quest completed: %s", self.player.quest_book.current_quest
)
random.choice(
[
self.player.win_spell,
self.player.win_equipment,
functools.partial(self.player.win_spell, self.data),
functools.partial(self.player.win_equipment, self.data),
self.player.win_stat,
self.player.win_item,
functools.partial(self.player.win_item, self.data),
]
)()
@@ -602,20 +700,21 @@ class Simulation:
caption = ""
choice = random.below(5)
if choice == 0:
self.player.quest_book.monster = unnamed_monster(
self.player.level, iterations=3
)
caption = "Exterminate " + definite(
self.player.quest_book.monster.name, 2
monster = unnamed_monster(
self.data, self.player.level, iterations=3
)
self.player.quest_book.monster = monster
caption = "Exterminate " + definite(monster.name, 2)
elif choice == 1:
caption = "Seek " + definite(interesting_item(), 1)
caption = "Seek " + definite(interesting_item(self.data), 1)
elif choice == 2:
caption = "Deliver this " + boring_item()
caption = "Deliver this " + boring_item(self.data)
elif choice == 3:
caption = "Fetch me " + indefinite(boring_item(), 1)
caption = "Fetch me " + indefinite(boring_item(self.data), 1)
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)
else:
raise AssertionError
@@ -658,7 +757,7 @@ class Simulation:
)
)
nemesis = named_monster(self.player.level + 3)
nemesis = named_monster(self.data, self.player.level + 3)
enqueue(
RegularTask(
@@ -696,7 +795,7 @@ class Simulation:
enqueue(
RegularTask(
f"Victory! {nemesis} is slain! "
"Exhausted, you lose conciousness",
"Exhausted, you lose consciousness",
3000,
)
)
@@ -707,24 +806,25 @@ class Simulation:
)
elif choice == 2:
nemesis = impressive_guy()
nemesis = impressive_guy(self.data)
enqueue(
RegularTask(
"Oh sweet relief! "
f"You've reached the protection of the good {nemesis}",
f"You've reached the kind protection of {nemesis}",
2000,
)
)
enqueue(
RegularTask(
"There is rejoicing, "
f"and an unnerving encouter with {nemesis} in private",
f"and an unnerving encounter with {nemesis} in private",
3000,
)
)
enqueue(
RegularTask(
f"You forget your {boring_item()} and go back to get it",
f"You forget your {boring_item(self.data)} "
"and go back to get it",
2000,
)
)
@@ -738,7 +838,7 @@ class Simulation:
)
enqueue(
RegularTask(
"Who can possibly be trusted with this news!? ... "
"Who can possibly be trusted with this news!? -- "
"Oh yes, of course",
3000,
)
@@ -754,57 +854,54 @@ class Simulation:
)
def special_item() -> str:
return interesting_item() + " of " + T.cast(str, random.choice(ITEM_OFS))
def special_item(data: GameData) -> str:
return interesting_item(data) + " of " + random.choice(data.item_ofs)
def interesting_item() -> str:
return (
T.cast(str, random.choice(ITEM_ATTRIB))
+ " "
+ T.cast(str, random.choice(SPECIALS))
)
def interesting_item(data: GameData) -> str:
return random.choice(data.item_attrib) + " " + random.choice(data.specials)
def boring_item() -> str:
return T.cast(str, random.choice(BORING_ITEMS))
def boring_item(data: GameData) -> str:
return random.choice(data.boring_items)
def impressive_guy() -> str:
return T.cast(str, random.choice(IMPRESSIVE_TITLES)) + (
" of the " + T.cast(Race, random.choice(RACES)).name
if random.below(2)
else " of " + generate_name()
)
def impressive_guy(data: GameData) -> str:
if random.below(2):
title = random.choice(data.impressive_titles)
race = random.choice(data.races)
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:
result = T.cast(Monster, random.choice(MONSTERS))
def unnamed_monster(data: GameData, level: int, iterations: int) -> Monster:
result = random.choice(data.monsters)
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):
result = alternative
return result
def named_monster(level: int) -> str:
monster = unnamed_monster(level, iterations=4)
def named_monster(data: GameData, level: int) -> str:
monster = unnamed_monster(data, level, iterations=4)
return generate_name() + " the " + monster.name
def pick_equipment(
source: T.List[EquipmentPreset], goal: int
source: Sequence[EquipmentPreset], goal: int
) -> EquipmentPreset:
result = T.cast(EquipmentPreset, random.choice(source))
result = random.choice(source)
for _ in range(5):
alternative = T.cast(EquipmentPreset, random.choice(source))
alternative = random.choice(source)
if abs(goal - alternative.quality) < abs(goal - result.quality):
result = alternative
return result
def monster_task(
player_level: int, quest_monster: T.Optional[Monster]
data: GameData, player_level: int, quest_monster: Monster | None
) -> KillTask:
level = player_level
for _ in range(level):
@@ -814,15 +911,17 @@ def monster_task(
level = 1
is_definite = False
monster: T.Optional[Monster] = None
monster: Monster | None = None
if random.odds(1, 25):
# use an NPC every once in a while
race = random.choice(RACES)
race = random.choice(data.races)
if random.odds(1, 2):
result = "passing " + race.name + " " + random.choice(CLASSES).name
result = (
"passing " + race.name + " " + random.choice(data.classes).name
)
else:
result = (
random.choice_low(TITLES)
random.choice_low(data.titles)
+ " "
+ generate_name()
+ " the "
@@ -836,8 +935,9 @@ def monster_task(
result = monster.name
lev = monster.level
else:
# pick the monster out of so many random ones closest to the level we want
monster = unnamed_monster(level, iterations=5)
# pick the monster out of so many random ones closest to the level
# we want
monster = unnamed_monster(data, level, iterations=5)
result = monster.name
lev = monster.level
@@ -876,15 +976,41 @@ def monster_task(
result = indefinite(result, qty)
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:
def __init__(self) -> None:
self.history: T.List[Stats] = []
def __init__(self, rng: Alea | None = None) -> None:
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:
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)
for stat in PRIME_STATS
}
@@ -895,20 +1021,29 @@ class StatsBuilder:
random.below(8) + values[StatType.intelligence] // 6
)
stats = Stats(values)
self.history.append(stats)
self.history.append(_Roll(stats, seed, self._rng.state()))
return stats
def unroll(self) -> Stats:
if len(self.history) > 1:
self.history.pop()
return self.history[-1]
self._rng = Alea.from_state(self.history[-1].after)
return self.history[-1].stats
def create_player(
name: str, race: Race, class_: Class, stats: Stats
name: str,
race: Race,
class_: Class,
stats: Stats,
seed: RngState | None = None,
) -> Player:
now = datetime.datetime.now()
random.seed(now)
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
+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 typing as T
import contextlib
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:
random.seed(source)
def _to_uint32(value: float) -> float:
# 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))]
def choice_low(source: T.Sequence[T.Any]) -> T.Any:
def choice_low(source: Sequence[T]) -> T:
return source[below_low(len(source))]
def below(num: int) -> int:
return random.randint(0, num - 1)
return _current.uint32() % num
def below_low(num: int) -> int:
+13 -3
View File
@@ -1,17 +1,19 @@
import logging
import pickle
import typing as T
from datetime import datetime
from pathlib import Path
from pqcli.mechanic import Player
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.players = players
self._last_save = datetime.now()
@staticmethod
def load(path: T.Union[str, Path]) -> "Roster":
def load(path: str | Path) -> "Roster":
real_path = Path(path)
if real_path.exists():
return Roster(
@@ -20,10 +22,18 @@ class Roster:
return Roster(real_path, players=[])
def save(self) -> None:
self._last_save = datetime.now()
old_path = self.path.with_name(self.path.name + ".old")
tmp_path = self.path.with_name(self.path.name + ".new")
tmp_path.parent.mkdir(parents=True, exist_ok=True)
tmp_path.write_bytes(pickle.dumps(self.players))
if self.path.exists():
if old_path.exists():
old_path.unlink()
self.path.rename(old_path)
tmp_path.rename(self.path)
def save_periodically(self) -> None:
if (datetime.now() - self._last_save).total_seconds() >= 300:
logging.info("Saving...")
self.save()
-115
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 = 1_664_525
SEED_B = 2141
SEED_C = 1_013_904_223
def custom_random(num: int) -> int:
global SEED_A, SEED_B, SEED_C
SEED_B = (SEED_A * SEED_B + SEED_C) % 0x100000000
return SEED_B % num
random.below = custom_random
def character_sheet(player: Player) -> None:
print(f"{player.name} the {player.race.name}")
print(f"Level {player.level} {player.class_.name}")
print(f"Plot stage: {act_name(player.quest_book.act)}")
print(f"Quest: {player.quest_book.current_quest}")
print()
print("Stats:")
for stat, value in player.stats:
print(f" {stat.value}: {value}")
print()
print(f"Equipment (best: {player.equipment.best}):")
for equipment, item_name in player.equipment:
print(f" {equipment.value}: {item_name}")
print()
print("Spell Book:")
for spell in player.spell_book:
print(f" {spell.name}: {spell.level}")
print()
print("Inventory:")
print(f" Gold: {player.inventory.gold}")
for item in player.inventory:
print(f" {item.name}: {item.quantity}")
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser()
parser.add_argument("-r", "--roster", type=Path)
parser.add_argument("-l", "--max-level", type=int, default=5)
return parser.parse_args()
def random_player() -> Player:
return create_player(
stats=StatsBuilder().roll(),
name=generate_name(),
race=random.choice(RACES),
class_=random.choice(CLASSES),
)
def main() -> None:
args = parse_args()
if args.roster:
roster = Roster.load(args.roster)
if not roster.players:
roster.players.append(random_player())
roster.save()
player = roster.players[0]
else:
roster = None
player = random_player()
start = datetime.datetime.now()
elapsed = 0
last_level = 0
simulation = Simulation(player)
try:
while True:
if player.level != last_level:
last_level = player.level
character_sheet(player)
if player.level >= args.max_level:
break
simulation.tick()
elapsed += 100
finally:
print("real time: ", (datetime.datetime.now() - start))
print("virtual time: ", datetime.timedelta(milliseconds=elapsed))
if roster:
roster.save()
if __name__ == "__main__":
main()
-167
View File
@@ -1,167 +0,0 @@
import argparse
import json
import typing as T
from pathlib import Path
import urwid
import xdg
from pqcli import random
from pqcli.mechanic import Player
from pqcli.roster import Roster
from pqcli.ui.confirm_dialog import ConfirmDialog
from pqcli.ui.game_view import GameView
from pqcli.ui.new_game_view import NewGameView
from pqcli.ui.roster_view import RosterView
TICK_FREQ = 0.1
SAVE_FREQ = 60.0
PALETTE: T.List[T.Tuple[str, ...]] = [
("button", "", ""),
("button-focus", "light red", "black"),
("linebox", "", ""),
("linebox-focus", "light red", "black"),
("linebox-content", "", ""),
("linebox-content-focus", "", ""),
("progressbar-normal", "", "", "standout"),
("progressbar-done", "black", "light red"),
("progressbar-smooth", "light red", ""),
("scrollbar", "light gray", "black"),
("scrollbar-focus", "light red", "black"),
("scrollable", "", ""),
("scrollable-focus", "", ""),
]
PALETTE_PATH = Path(xdg.XDG_CONFIG_HOME) / "pqcli" / "palette.json"
def load_palette() -> bool:
global PALETTE
if PALETTE_PATH.exists():
PALETTE = json.loads(PALETTE_PATH.read_text())
return True
return False
def save_palette() -> None:
global PALETTE
PALETTE_PATH.parent.mkdir(exist_ok=True, parents=True)
PALETTE_PATH.write_text(json.dumps(PALETTE, indent=4))
class ConfirmExitDialog(ConfirmDialog):
def __init__(self, old_view: urwid.Widget) -> None:
super().__init__("Really quit?", old_view)
def bind_commands() -> None:
for key, direction in {
"k": "up",
"j": "down",
"h": "left",
"l": "right",
}.items():
for key_variant in {key, key.upper()}:
urwid.command_map[key_variant] = f"cursor {direction}"
class Ui:
def __init__(self, roster: Roster, args: argparse.Namespace) -> None:
bind_commands()
if args.use_config:
if not load_palette():
save_palette() # create a file for the user to edit
self.roster = roster
self.args = args
self.loop = urwid.MainLoop(
None, PALETTE, unhandled_input=self.unhandled_input
)
self.old_views: T.List[urwid.Widget] = []
self.switch_to_roster_view()
def run(self) -> None:
self.loop.set_alarm_in(TICK_FREQ, self.on_tick)
self.loop.set_alarm_in(SAVE_FREQ, self.on_save)
self.loop.run()
def unhandled_input(self, key: str) -> bool:
callback = getattr(self.loop.widget, "unhandled_input", None)
if callback and callback(key):
return True
if key == "ctrl q":
self.switch_to_exit_view()
return True
return False
def switch_to_roster_view(self) -> None:
self.loop.widget = RosterView(self.roster)
self._connect("load_game", self.switch_to_game_view)
self._connect("new_game", self.switch_to_new_game_view)
self._connect("delete_game", self.switch_to_delete_player_view)
self._connect("exit", self.switch_to_exit_view)
def switch_to_exit_view(self) -> None:
if isinstance(self.loop.widget, ConfirmExitDialog):
return
self.old_views.append(self.loop.widget)
self.loop.widget = ConfirmExitDialog(self.old_views[-1])
self._connect("confirm", self.exit)
self._connect("cancel", self.cancel_dialog)
def switch_to_new_game_view(self) -> None:
self.loop.widget = NewGameView()
self._connect("confirm", self.create_player)
self._connect("cancel", self.switch_to_roster_view)
def create_player(self, player: Player) -> None:
self.roster.players.append(player)
self.switch_to_roster_view()
def delete_player(self, player_idx: int) -> None:
del self.roster.players[player_idx]
self.switch_to_roster_view()
def switch_to_game_view(self, player_idx: int) -> None:
player = self.roster.players[player_idx]
self.loop.widget = GameView(self.loop, player, self.args)
self._connect("cancel", self.switch_to_roster_view)
def switch_to_delete_player_view(self, player_idx: int) -> None:
adjective = random.choice(["faithful", "noble", "loyal", "brave"])
player_name = self.roster.players[player_idx].name
self.old_views.append(self.loop.widget)
self.loop.widget = ConfirmDialog(
f"Terminate {adjective} {player_name}?", self.old_views[-1]
)
self._connect("confirm", lambda: self.delete_player(player_idx))
self._connect("cancel", self.cancel_dialog)
def exit(self) -> None:
raise urwid.ExitMainLoop()
def cancel_dialog(self) -> None:
assert len(self.old_views)
self.loop.widget = self.old_views.pop()
def _connect(self, signal_name: str, callback: T.Callable) -> None:
urwid.signals.connect_signal(
self.loop.widget,
signal_name,
lambda _sender, *args: callback(*args),
)
def on_tick(self, _loop: urwid.MainLoop, _user_data: T.Any) -> None:
if hasattr(self.loop.widget, "tick"):
self.loop.widget.tick()
self.loop.set_alarm_in(TICK_FREQ, self.on_tick)
def on_save(self, _loop: urwid.MainLoop, _user_data: T.Any) -> None:
if self.args.use_saves:
self.roster.save()
self.loop.set_alarm_in(SAVE_FREQ, self.on_save)
+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]
-47
View File
@@ -1,47 +0,0 @@
import typing as T
import urwid
from pqcli.ui.custom_button import CustomButton
from pqcli.ui.custom_line_box import CustomLineBox
from pqcli.ui.layout import NPile
class ConfirmDialog(urwid.Overlay):
signals = ["confirm", "cancel"]
def __init__(self, label: str, parent: urwid.Widget) -> None:
question = urwid.Text(("bold", label), "center")
yes_btn = CustomButton(
"Yes", hint="Y", on_press=lambda _user_data: self.confirm()
)
no_btn = CustomButton(
"No", hint="N", on_press=lambda _user_data: self.cancel()
)
line_box = CustomLineBox(
urwid.Filler(NPile([question, no_btn, yes_btn], outermost=True))
)
super().__init__(
line_box, parent, "center", len(label) + 6, "middle", 5
)
def keypress(self, size: T.Any, key: str) -> T.Optional[str]:
if key in {"y", "Y"}:
self.confirm()
return None
if key in {"n", "N"}:
self.cancel()
return None
return T.cast(T.Optional[str], super().keypress(size, key))
def confirm(self) -> None:
self._emit("confirm")
def cancel(self) -> None:
self._emit("cancel")
def tick(self) -> None:
if hasattr(self.bottom_w, "tick"):
self.bottom_w.tick()
+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(
[
(urwid.PACK, urwid.Text("< ")),
urwid.SelectableIcon([label], _MAGIC),
(
urwid.PACK,
urwid.Text(f"[{hint}]" if hint else "", align="right"),
),
(urwid.PACK, urwid.Text(" >")),
]
),
"button",
"button-focus",
)
-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",
)
-119
View File
@@ -1,119 +0,0 @@
import datetime
import typing as T
import urwid
def format_float(num: float) -> str:
ret = f"{num:.01f}"
if ret.endswith("0"):
ret = ret[:-2]
return ret
def format_timespan(timespan: datetime.timedelta) -> str:
num = timespan.total_seconds()
if num < 60.0:
return f"~{int(num)}s"
num /= 60
if num < 60.0:
return f"~{int(num)}m"
num /= 60
if num < 24.0:
return f"~{format_float(num)}h"
num /= 24
return f"~{format_float(num)}d"
class CustomProgressBar(urwid.Widget):
_sizing = frozenset(["flow"])
text_align = "center"
eighths = " ▏▎▍▌▋▊▉"
def __init__(self, show_time: bool) -> None:
self.show_time = show_time
self.position = 0.0
self.max_ = 1.0
self.normal = "progressbar-normal"
self.complete = "progressbar-done"
self.smooth = "progressbar-smooth"
self.last_tick: T.Optional[datetime.datetime, float] = None
@property
def time_left(self) -> T.Optional[datetime.timedelta]:
if self.last_tick is None:
return None
time_now = datetime.datetime.now()
time_then = self.last_tick[0]
pos_now = self.position
pos_then = self.last_tick[1]
speed = (pos_now - pos_then) / (time_now - time_then).total_seconds()
if not speed:
return None
return datetime.timedelta(seconds=(self.max_ - pos_now) / speed)
def set_completion(self, value: float) -> None:
if self.last_tick is None or value == 0:
self.last_tick = (datetime.datetime.now(), value)
self.position = value
self._invalidate()
def set_max(self, value: T.Union[int, float]) -> None:
if self.max_ == value:
return
self.last_tick = None
self.max_ = value
self._invalidate()
def reset(self, cur: float, max_: T.Union[int, float]) -> None:
if self.last_tick is None or cur == 0 or max_ != self.max_:
self.last_tick = (datetime.datetime.now(), cur)
self.position = cur
self.max_ = max_
self._invalidate()
def rows(self, size: T.Tuple[int, int], focus: bool = False) -> int:
return 1
def get_text(self) -> str:
percent = min(100.0, max(0.0, self.position * 100.0 / self.max_))
ret = f"{percent:.02f}%"
if self.time_left and self.show_time:
ret += f" ({format_timespan(self.time_left)})"
return ret
# workaround for https://github.com/urwid/urwid/issues/317
def render(self, size: T.Tuple[int, int], focus: bool = False) -> T.Any:
(maxcol,) = size
txt = urwid.Text(self.get_text(), self.text_align, "clip")
c = txt.render((maxcol,))
t = c._text[0]
cf = self.position * maxcol / self.max_
ccol_dirty = int(cf)
ccol = len(
c._text[0][:ccol_dirty].decode("utf-8", "ignore").encode("utf-8")
)
cs = 0
cs = int((cf - ccol) * 8)
if ccol < 0 or (ccol == 0 and cs == 0):
c._attr = [[(self.normal, maxcol)]]
elif ccol >= maxcol:
c._attr = [[(self.complete, maxcol)]]
elif cs and c._text[0][ccol] == 32:
t = c._text[0]
cenc = self.eighths[cs].encode("utf-8")
c._text[0] = t[:ccol] + cenc + t[ccol + 1 :]
a = []
if ccol > 0:
a.append((self.complete, ccol))
a.append((self.smooth, len(cenc)))
if maxcol - ccol - 1 > 0:
a.append((self.normal, maxcol - ccol - 1))
c._attr = [a]
c._cs = [[(None, len(c._text[0]))]]
else:
c._attr = [[(self.complete, ccol), (self.normal, maxcol - ccol)]]
return c
-64
View File
@@ -1,64 +0,0 @@
import typing as T
import urwid
from pqcli.ui.layout import NColumns, NPile
class DataTable(NPile):
def __init__(self, columns: T.Any) -> None:
self.column_count = len(columns)
self._columns = columns
self._rows: T.List[T.List[urwid.Widget]] = []
super().__init__([])
@property
def row_count(self) -> int:
return len(self._rows)
@property
def data_rows(self) -> T.Iterable[T.List[urwid.Widget]]:
# "rows" was already used by urwid
return self._rows
def add_row(self, *widgets: T.List[urwid.Widget]) -> None:
if len(widgets) != self.column_count:
raise VauleError(f"Expected {self.column_count} widgets")
self.contents.append(
(
NColumns(
list((*x, y) for x, y in zip(self._columns, widgets))
),
(urwid.PACK, 1),
)
)
self.resize_columns()
self._rows.append(widgets)
def delete_row(self, idx: int) -> None:
del self.contents[idx]
del self._rows[idx]
self.resize_columns()
def delete_rows(self, start_idx: int, end_idx: int) -> None:
del self.contents[start_idx:end_idx]
del self._rows[start_idx:end_idx]
self.resize_columns()
def resize_columns(self) -> None:
if not self._rows:
return
for i, sizing in enumerate(self._columns):
if isinstance(sizing, tuple) and sizing[0] == urwid.PACK:
width = max(
row_widgets[i].pack()[0] for row_widgets in self._rows
)
for item in self.contents:
columns = item[0]
columns.contents[i] = (
columns.contents[i][0],
(urwid.GIVEN, width, None),
)
-34
View File
@@ -1,34 +0,0 @@
import urwid
from pqcli.ui.layout import NPile
class DoubleLineBox(urwid.AttrMap):
def __init__(
self,
top_widget: urwid.Widget,
bottom_widget: urwid.Widget,
top_title: str = "",
bottom_title: str = "",
) -> None:
self.top_line_box = urwid.LineBox(
urwid.AttrMap(top_widget, "linebox-content", "linebox-content"),
title=top_title,
bline="",
)
self.bottom_line_box = urwid.LineBox(
urwid.AttrMap(bottom_widget, "linebox-content", "linebox-content"),
title=bottom_title,
tlcorner="├",
trcorner="┤",
)
super().__init__(
NPile([self.top_line_box, (urwid.PACK, self.bottom_line_box)]),
"linebox",
"linebox-focus",
)
def set_bottom_title(self, text: str) -> None:
self.bottom_line_box.set_title(text)
-446
View File
@@ -1,446 +0,0 @@
import argparse
import contextlib
import datetime
import typing as T
import urwid
from pqcli.config import EquipmentType, StatType
from pqcli.lingo import act_name, to_roman
from pqcli.mechanic import (
InventoryItem,
Player,
SignalMixin,
Simulation,
Spell,
)
from pqcli.ui.custom_line_box import CustomLineBox
from pqcli.ui.custom_progress_bar import CustomProgressBar
from pqcli.ui.data_table import DataTable
from pqcli.ui.double_line_box import DoubleLineBox
from pqcli.ui.layout import NColumns, NPile
from pqcli.ui.read_only_check_box import ReadOnlyCheckBox
from pqcli.ui.scrollable import Scrollable, ScrollBar
class CharacterSheetView(DoubleLineBox):
def __init__(self, player: Player) -> None:
self.player = player
self.level_text = urwid.Text("")
self.stat_texts = {stat: urwid.Text("") for stat in list(StatType)}
self.exp_bar = CustomProgressBar(show_time=True)
data_table = DataTable(columns=[(urwid.WEIGHT, 1), (urwid.WEIGHT, 2)])
data_table.add_row(urwid.Text("Name"), urwid.Text(player.name))
data_table.add_row(urwid.Text("Race"), urwid.Text(player.race.name))
data_table.add_row(urwid.Text("Class"), urwid.Text(player.class_.name))
data_table.add_row(urwid.Text("Level"), self.level_text)
data_table.add_row(urwid.Divider(), urwid.Divider())
for stat in StatType:
data_table.add_row(urwid.Text(stat.value), self.stat_texts[stat])
self.player.connect("level_up", self.sync_level)
self.player.stats.connect("change", self.sync_stats)
self.player.exp_bar.connect("change", self.sync_exp)
super().__init__(
top_widget=ScrollBar(Scrollable(data_table)),
bottom_widget=self.exp_bar,
top_title="Character Sheet",
bottom_title="Experience",
)
self.sync()
def sync(self) -> None:
self.sync_level()
self.sync_stats()
self.sync_exp()
def sync_level(self) -> None:
self.level_text.set_text(str(self.player.level))
def sync_stats(self) -> None:
for stat in StatType:
self.stat_texts[stat].set_text(str(self.player.stats[stat]))
def sync_exp(self) -> None:
cur = self.player.exp_bar.position
max_ = self.player.exp_bar.max_
self.exp_bar.reset(cur, max_)
self.set_bottom_title(f"Experience ({max_-cur:.0f} XP to go)")
class SpellBookView(CustomLineBox):
def __init__(self, player: Player) -> None:
self.player = player
self.data_table = DataTable(columns=[(urwid.WEIGHT, 3), (urwid.PACK,)])
self.scrollable = Scrollable(self.data_table)
self.player.spell_book.connect("add", self.sync_spell_add)
self.player.spell_book.connect("change", self.sync_spell_change)
super().__init__(ScrollBar(self.scrollable), title="Spell Book")
self.sync()
def sync(self) -> None:
self.data_table.delete_rows(0, len(self.data_table.data_rows))
for spell in self.player.spell_book:
self.sync_spell_add(spell)
def sync_spell_add(self, spell: Spell) -> None:
self.data_table.add_row(
urwid.Text(spell.name), urwid.Text(to_roman(spell.level))
)
self.scrollable.set_scrollpos(len(self.data_table.data_rows) - 1)
def sync_spell_change(self, spell: Spell) -> None:
for row_widgets in self.data_table.data_rows:
if row_widgets[0].text == spell.name:
row_widgets[1].set_text(to_roman(spell.level))
self.data_table.resize_columns()
class EquipmentView(CustomLineBox):
def __init__(self, player: Player) -> None:
self.player = player
self.value_texts = {
equipment_type: urwid.Text("") for equipment_type in EquipmentType
}
data_table = DataTable(columns=[(urwid.WEIGHT, 1), (urwid.WEIGHT, 3)])
for equipment_type in EquipmentType:
data_table.add_row(
urwid.Text(equipment_type.value),
self.value_texts[equipment_type],
)
self.player.equipment.connect("change", self.sync_equipment_change)
super().__init__(ScrollBar(Scrollable(data_table)), title="Equipment")
self.sync()
def sync(self) -> None:
for equipment_type in EquipmentType:
self.sync_equipment_change(
equipment_type, self.player.equipment[equipment_type]
)
def sync_equipment_change(
self, equipment_type: EquipmentType, item_name: T.Optional[str]
) -> None:
self.value_texts[equipment_type].set_text(item_name or "")
class InventoryView(DoubleLineBox):
def __init__(self, player: Player) -> None:
self.player = player
self.gold_text = urwid.Text("", align=urwid.RIGHT)
self.data_table = DataTable(columns=[(urwid.WEIGHT, 3), (urwid.PACK,)])
self.data_table.add_row(urwid.Text("Gold"), self.gold_text)
self.scrollable = Scrollable(self.data_table)
self.encumbrance_bar = CustomProgressBar(show_time=False)
self.player.connect("new_task", self.on_new_task)
self.player.inventory.connect("gold_change", self.sync_gold)
self.player.inventory.connect("item_add", self.sync_item_add)
self.player.inventory.connect("item_del", self.sync_item_del)
self.player.inventory.connect("item_change", self.sync_item_change)
self.player.inventory.encum_bar.connect(
"change", self.sync_encumbrance_position
)
super().__init__(
top_widget=ScrollBar(self.scrollable),
top_title="Inventory",
bottom_widget=self.encumbrance_bar,
bottom_title="Encumbrance",
)
self.sync()
def sync(self) -> None:
self.sync_gold()
self.sync_items()
self.sync_encumbrance_position()
def sync_gold(self) -> None:
self.gold_text.set_text(str(self.player.inventory.gold))
def sync_items(self) -> None:
self.data_table.delete_rows(1, len(self.data_table.data_rows) - 1)
for item in self.player.inventory:
self.sync_item_add(item)
def sync_encumbrance_position(self) -> None:
cur = self.player.inventory.encum_bar.position
max_ = self.player.inventory.encum_bar.max_
self.encumbrance_bar.reset(cur, max_)
self.set_bottom_title(f"Encumbrance ({cur:.0f}/{max_} cubits)")
def sync_item_add(self, item: InventoryItem) -> None:
self.data_table.add_row(
urwid.Text(item.name),
urwid.Text(str(item.quantity), align=urwid.RIGHT),
)
self.scrollable.set_scrollpos(self.scrollable.rows_max() - 1)
def sync_item_del(self, item: InventoryItem) -> None:
idx = self.get_item_idx_by_name(item.name)
if idx is not None:
self.data_table.delete_row(idx)
def sync_item_change(self, item: InventoryItem) -> None:
idx = self.get_item_idx_by_name(item.name)
if idx is not None:
self.data_table.data_rows[idx][1].set_text(str(item.quantity))
def get_item_idx_by_name(self, name: str) -> T.Optional[int]:
for i, row_widgets in enumerate(self.data_table.data_rows):
if row_widgets[0].text == name:
return i
return None
def on_new_task(self) -> None:
description = self.player.task.description if self.player.task else "?"
if description.lower().startswith("sell"):
self.scrollable.set_scrollpos(0)
class PlotView(DoubleLineBox):
cutoff = 100
def __init__(self, player: Player) -> None:
self.player = player
self.data_table = DataTable(columns=[(urwid.WEIGHT, 1)])
self.scrollable = Scrollable(self.data_table)
self.plot_bar = CustomProgressBar(show_time=True)
self.player.quest_book.connect("start_act", self.sync_act_add)
self.player.quest_book.plot_bar.connect("change", self.sync_position)
super().__init__(
top_widget=ScrollBar(self.scrollable),
top_title="Plot Development",
bottom_widget=self.plot_bar,
)
self.sync()
def sync(self) -> None:
self.sync_acts()
self.sync_position()
def sync_acts(self) -> None:
self.data_table.delete_rows(0, len(self.data_table.data_rows))
for act_number in range(
max(0, self.player.quest_book.act - self.cutoff),
self.player.quest_book.act + 1,
):
self.sync_act_add(act_number)
def sync_act_add(self, act_number: int) -> None:
self.data_table.delete_rows(
0, max(0, self.data_table.row_count - self.cutoff)
)
if self.data_table.data_rows:
self.data_table.data_rows[-1][0].set_state(True)
self.data_table.add_row(
ReadOnlyCheckBox(act_name(act_number), state=False)
)
self.scrollable.set_scrollpos(self.scrollable.rows_max() - 1)
def sync_position(self) -> None:
self.plot_bar.reset(
self.player.quest_book.plot_bar.position,
self.player.quest_book.plot_bar.max_,
)
class QuestBookView(DoubleLineBox):
cutoff = 100
def __init__(self, player: Player) -> None:
self.player = player
self.data_table = DataTable(columns=[(urwid.WEIGHT, 1)])
self.quest_bar = CustomProgressBar(show_time=True)
self.scrollable = Scrollable(self.data_table)
self.player.quest_book.connect("start_quest", self.sync_quest_add)
self.player.quest_book.quest_bar.connect("change", self.sync_position)
super().__init__(
top_widget=ScrollBar(self.scrollable),
top_title="Quests",
bottom_widget=self.quest_bar,
)
self.sync()
def sync(self) -> None:
self.sync_quests()
self.sync_position()
def sync_quests(self) -> None:
for quest_name in self.player.quest_book.quests[-self.cutoff :]:
self.sync_quest_add(quest_name)
def sync_quest_add(self, quest_name: str) -> None:
self.data_table.delete_rows(
0, max(0, self.data_table.row_count - self.cutoff)
)
if self.data_table.data_rows:
self.data_table.data_rows[-1][0].set_state(True)
self.data_table.add_row(ReadOnlyCheckBox(quest_name, state=False))
self.scrollable.set_scrollpos(self.scrollable.rows_max() - 1)
def sync_position(self) -> None:
self.quest_bar.reset(
self.player.quest_book.quest_bar.position,
self.player.quest_book.quest_bar.max_,
)
class TaskView(NPile):
def __init__(self, player: Player) -> None:
self.player = player
self.current_task_text = urwid.Text("...")
self.current_task_bar = CustomProgressBar(show_time=False)
self.player.connect("new_task", self.sync_task_name)
self.player.task_bar.connect("change", self.sync_position)
super().__init__([self.current_task_text, self.current_task_bar])
self.sync()
def sync(self) -> None:
self.sync_task_name()
self.sync_position()
def sync_task_name(self) -> None:
self.current_task_text.set_text(
f"{self.player.task.description}..." if self.player.task else "?"
)
def sync_position(self) -> None:
self.current_task_bar.reset(
self.player.task_bar.position, self.player.task_bar.max_
)
def pack(self, size: T.Tuple[int, int]) -> T.Tuple[int, int]:
return (size[0], 2)
class GameView(NPile):
signals = ["cancel"]
def __init__(
self, loop: urwid.MainLoop, player: Player, args: argparse.Namespace
) -> None:
self.loop = loop
self.player = player
self.args = args
self.simulation = Simulation(player)
self.last_tick = datetime.datetime.now()
self.character_sheet_view = CharacterSheetView(player)
self.spell_book_view = SpellBookView(player)
self.equipment_view = EquipmentView(player)
self.inventory_view = InventoryView(player)
self.plot_view = PlotView(player)
self.quest_book_view = QuestBookView(player)
self.task_view = TaskView(player)
self.columns = NColumns(
[
(
urwid.WEIGHT,
4,
NPile(
[(19, self.character_sheet_view), self.spell_book_view]
),
),
(
urwid.WEIGHT,
5,
NPile([(15, self.equipment_view), self.inventory_view]),
),
(
urwid.WEIGHT,
4,
NPile([(15, self.plot_view), self.quest_book_view]),
),
]
)
super().__init__(
[self.columns, (urwid.PACK, self.task_view)], outermost=True
)
def cancel(self) -> None:
self._emit("cancel")
def tick(self) -> None:
now = datetime.datetime.now()
elapsed = (now - self.last_tick).total_seconds()
self.simulation.tick(elapsed * 1000)
self.last_tick = datetime.datetime.now()
def keypress(self, size: T.Tuple[int, int], key: str) -> bool:
if key == "esc":
self.cancel()
return None
if self._command_map[key] == urwid.CURSOR_RIGHT:
old_focus_position = self.columns.focus.focus_position
self.columns.focus_position = min(
self.columns.focus_position + 1, len(self.columns.contents) - 1
)
self.columns.focus.focus_position = old_focus_position
return None
if self._command_map[key] == urwid.CURSOR_LEFT:
old_focus_position = self.columns.focus.focus_position
self.columns.focus_position = max(
self.columns.focus_position - 1, 0
)
self.columns.focus.focus_position = old_focus_position
return None
if self.args.cheats:
if key == "e":
self.simulation.player.exp_bar.increment(1)
return None
fast_forward = {"t": 1, "T": 100, "ctrl t": 10000}
if key in fast_forward:
iterations = fast_forward[key]
ctx_mgr = (
self.suppress_updates()
if iterations >= 1000
else contextlib.suppress()
)
with ctx_mgr:
for _ in range(iterations):
self.simulation.tick()
return None
return super().keypress(size, key)
@contextlib.contextmanager
def suppress_updates(self) -> T.Generator:
old = SignalMixin.emit
SignalMixin.emit = lambda *_: None
yield
self.character_sheet_view.sync()
self.spell_book_view.sync()
self.equipment_view.sync()
self.inventory_view.sync()
self.plot_view.sync()
self.quest_book_view.sync()
self.task_view.sync()
SignalMixin.emit = old
-119
View File
@@ -1,119 +0,0 @@
# Copyright (c) 2013-2016 Christian Geier et al.
#
# Permission is hereby granted, free of charge, to any person obtaining
# a copy of this software and associated documentation files (the
# "Software"), to deal in the Software without restriction, including
# without limitation the rights to use, copy, modify, merge, publish,
# distribute, sublicense, and/or sell copies of the Software, and to
# permit persons to whom the Software is furnished to do so, subject to
# the following conditions:
#
# The above copyright notice and this permission notice shall be
# included in all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
import typing as T
import urwid
class SupportsNext:
"""
Classes inheriting from SupportsNext must implement the following methods:
_select_first_selectable
_select_last_selectable
"""
def __init__(self, *args, **kwargs):
self.outermost = kwargs.get("outermost", False)
if "outermost" in kwargs:
kwargs.pop("outermost")
super().__init__(*args, **kwargs)
class NextMixin(SupportsNext):
"""Implements SupportsNext for urwid.Pile and urwid.Columns"""
def _select_first_selectable(self):
"""
select our first selectable item
(recursively if that item SupportsNext)
"""
i = self._first_selectable()
self.set_focus(i)
if isinstance(self.contents[i][0], SupportsNext):
self.contents[i][0]._select_first_selectable()
def _select_last_selectable(self):
"""
select our last selectable item
(recursively if that item SupportsNext)
"""
i = self._last_selectable()
self.set_focus(i)
if isinstance(self._contents[i][0], SupportsNext):
self.contents[i][0]._select_last_selectable()
def _first_selectable(self):
"""return sequence number of self.contents last selectable item"""
for j in range(0, len(self._contents)):
if self._contents[j][0].selectable():
return j
return False
def _last_selectable(self):
"""return sequence number of self._contents last selectable item"""
for j in range(len(self._contents) - 1, -1, -1):
if self._contents[j][0].selectable():
return j
return False
def keypress(self, size, key) -> T.Optional[str]:
key = super().keypress(size, key)
if key == "tab":
if (
self.outermost
and self.focus_position == self._last_selectable()
):
self._select_first_selectable()
return None
for i in range(self.focus_position + 1, len(self._contents)):
if self._contents[i][0].selectable():
self.set_focus(i)
if isinstance(self._contents[i][0], SupportsNext):
self._contents[i][0]._select_first_selectable()
return None
elif key == "shift tab":
if (
self.outermost
and self.focus_position == self._first_selectable()
):
self._select_last_selectable()
return None
for i in range(self.focus_position - 1, 0 - 1, -1):
if self._contents[i][0].selectable():
self.set_focus(i)
if isinstance(self._contents[i][0], SupportsNext):
self._contents[i][0]._select_last_selectable()
return None
return key
class NPile(NextMixin, urwid.Pile):
pass
class NColumns(NextMixin, urwid.Columns):
pass
-223
View File
@@ -1,223 +0,0 @@
import random
import typing as T
import urwid
import urwid_readline
from pqcli.config import CLASSES, PRIME_STATS, RACES
from pqcli.mechanic import StatsBuilder, create_player, generate_name
from pqcli.ui.custom_button import CustomButton
from pqcli.ui.custom_line_box import CustomLineBox
from pqcli.ui.layout import NColumns, NPile
class StatsBox(CustomLineBox):
def __init__(self) -> None:
self.stats_builder = StatsBuilder()
self.stats = self.stats_builder.roll()
self.stat_labels = {stat: urwid.Text("0") for stat in PRIME_STATS}
self.total_label = urwid.Text("0")
self.update_values()
roll_button = CustomButton(
"Roll", hint="F5", on_press=self.on_roll_press
)
unroll_button = CustomButton(
"Unroll", hint="F6", on_press=self.on_unroll_press
)
value_texts = list(self.stat_labels.values())
label_texts = [urwid.Text(f"{stat.value}: ") for stat in PRIME_STATS]
super().__init__(
urwid.Filler(
NPile(
[
NColumns([NPile(label_texts), NPile(value_texts)]),
urwid.Divider(),
NColumns([urwid.Text("Total: "), self.total_label]),
urwid.Divider(),
roll_button,
unroll_button,
]
)
),
title="Stats",
)
def roll(self) -> None:
self.stats = self.stats_builder.roll()
self.update_values()
def unroll(self) -> None:
try:
self.stats = self.stats_builder.unroll()
except IndexError:
return
self.update_values()
def on_roll_press(self, _user_data: T.Any) -> None:
self.roll()
def on_unroll_press(self, _user_data: T.Any) -> None:
self.unroll()
def update_values(self) -> None:
for stat, edit in self.stat_labels.items():
edit.set_text(str(self.stats[stat]))
self.total_label.set_text(
str(sum(self.stats[stat] for stat in self.stat_labels.keys()))
)
class RaceBox(CustomLineBox):
def __init__(self) -> None:
self.race = random.choice(RACES)
radio_buttons: T.List[urwid.RadioButton] = []
for race in RACES:
urwid.RadioButton(
group=radio_buttons,
label=race.name,
state=race.name == self.race.name,
on_state_change=self.on_state_change,
user_data=race,
)
super().__init__(urwid.ListBox(radio_buttons), title="Race")
def on_state_change(
self, _widget: urwid.RadioButton, new_state: bool, user_data: T.Any
) -> None:
if new_state:
self.race = user_data
class ClassBox(CustomLineBox):
def __init__(self) -> None:
self.class_ = random.choice(CLASSES)
radio_buttons: T.List[urwid.RadioButton] = []
for class_ in CLASSES:
urwid.RadioButton(
group=radio_buttons,
label=class_.name,
state=class_.name == self.class_.name,
on_state_change=self.on_state_change,
user_data=class_,
)
super().__init__(urwid.ListBox(radio_buttons), title="Class")
def on_state_change(
self, _widget: urwid.RadioButton, new_state: bool, user_data: T.Any
) -> None:
if new_state:
self.class_ = user_data
class NewGameView(NPile):
signals = ["confirm", "cancel"]
def __init__(self) -> None:
self.race_box = RaceBox()
self.class_box = ClassBox()
self.stats_box = StatsBox()
self.char_name_edit = urwid_readline.ReadlineEdit(
"Name: ", generate_name()
)
generate_char_name_btn = CustomButton(
"Generate random name",
hint="F7",
on_press=self.on_generate_char_name_press,
)
buttons_box = urwid.Filler(
urwid.Padding(
NPile(
[
CustomButton(
"Sold!", hint="F10", on_press=self.on_confirm_press
),
CustomButton(
"Cancel", hint="Esc", on_press=self.on_cancel_press
),
]
),
width=20,
align="center",
)
)
super().__init__(
[
(1, urwid.Filler(self.char_name_edit)),
(
1,
urwid.Filler(
urwid.Padding(
generate_char_name_btn,
left=len(self.char_name_edit.caption),
width=25,
)
),
),
NColumns(
[
(urwid.WEIGHT, 2, self.race_box),
(urwid.WEIGHT, 2, self.class_box),
NPile([(13, self.stats_box), buttons_box]),
]
),
],
outermost=True,
)
def generate_random_char_name(self) -> None:
self.char_name_edit.edit_text = generate_name()
self.char_name_edit.edit_pos = len(self.char_name_edit.edit_text)
def confirm(self) -> None:
player = create_player(
name=self.char_name_edit.edit_text,
race=self.race_box.race,
class_=self.class_box.class_,
stats=self.stats_box.stats,
)
self._emit("confirm", player)
def cancel(self) -> None:
self._emit("cancel")
def on_generate_char_name_press(self, _user_data: T.Any) -> None:
self.generate_random_char_name()
def on_confirm_press(self, _user_data: T.Any) -> None:
self.confirm()
def on_cancel_press(self, _user_data: T.Any) -> None:
self.cancel()
def unhandled_input(self, key: str) -> bool:
if key == "esc":
self.cancel()
return True
if key == "f7":
self.generate_random_char_name()
return True
if key == "f5":
self.stats_box.roll()
return True
if key == "f6":
self.stats_box.unroll()
return True
if key == "f10":
self.confirm()
return True
return False
-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)
-115
View File
@@ -1,115 +0,0 @@
import typing as T
import urwid
from pqcli.lingo import act_name, to_roman
from pqcli.roster import Roster
from pqcli.ui.custom_button import CustomButton
from pqcli.ui.layout import NColumns, NPile
class RosterView(urwid.Filler):
signals = ["load_game", "new_game", "delete_game", "exit"]
def __init__(self, roster: Roster) -> None:
self.roster = roster
logo = urwid.BigText("Progress Quest", urwid.HalfBlock5x4Font())
buttons = []
for player_idx, player in enumerate(self.roster.players):
best_equip = player.equipment.best
best_spell = player.spell_book.best
if best_spell:
best_spell_name = (
best_spell.name + " " + to_roman(best_spell.level)
)
else:
best_spell_name = "-"
best_stat_name = (
f"{player.stats.best_prime.value} "
f"{player.stats[player.stats.best_prime]}"
)
label = (
f"{player.name} the {player.race.name} "
f"({act_name(player.quest_book.act)})\n"
f"Level {player.level} {player.class_.name}\n"
f"{best_equip} / {best_spell_name} / {best_stat_name}"
)
buttons.append(
NColumns(
[
CustomButton(
label=label,
on_press=self.on_resume_game_press,
user_data=player_idx,
),
(
10,
CustomButton(
label="Delete",
on_press=self.on_delete_game_press,
user_data=player_idx,
),
),
]
)
)
buttons.append(urwid.Divider())
buttons.append(
CustomButton(
label="Create a new character",
hint="F1",
on_press=self.on_new_game_press,
)
)
buttons.append(
CustomButton(label="Exit", hint="Esc", on_press=self.on_exit_press)
)
super().__init__(
urwid.Padding(
NPile(
[urwid.Padding(logo, width="clip"), urwid.Divider()]
+ buttons,
outermost=True,
),
align="center",
width=logo.pack()[0],
)
)
def on_resume_game_press(
self, _widget: urwid.Widget, user_data: T.Any
) -> None:
player_idx = T.cast(int, user_data)
self._emit("load_game", player_idx)
def on_delete_game_press(
self, _widget: urwid.Widget, user_data: T.Any
) -> None:
player_idx = T.cast(int, user_data)
self._emit("delete_game", player_idx)
def on_new_game_press(self, _user_data: T.Any) -> None:
self._emit("new_game")
def on_exit_press(self, _user_data: T.Any) -> None:
self._emit("exit")
def unhandled_input(self, key: str) -> bool:
if key == "esc":
self._emit("exit")
return True
if key == "f1":
self._emit("new_game")
return True
return False
-498
View File
@@ -1,498 +0,0 @@
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details
# http://www.gnu.org/licenses/gpl-3.0.txt
# Taken from stig (stig.tui.scroll).
# Changes compared to upstream:
# - reformatted the code
# - added AttrMap wrapping
# - added padding support
# - removed up/down arrow navigation (for widget navigation)
import typing as T
import urwid
from urwid.widget import BOX, FIXED, FLOW
# Scroll actions
SCROLL_LINE_UP = "line up"
SCROLL_LINE_DOWN = "line down"
SCROLL_PAGE_UP = "page up"
SCROLL_PAGE_DOWN = "page down"
SCROLL_TO_TOP = "to top"
SCROLL_TO_END = "to end"
# Scrollbar positions
SCROLLBAR_LEFT = "left"
SCROLLBAR_RIGHT = "right"
class EmptyCanvas(urwid.Canvas):
def __init__(self, cols: int, rows: int) -> None:
super().__init__()
self._text, cs = urwid.apply_target_encoding(" ")
self._cs = cs[0][0]
self.size = cols, rows
self.cursor = None
def cols(self) -> int:
return self.size[0]
def rows(self) -> int:
return self.size[1]
def content(
self,
trim_left: int = 0,
trim_top: int = 0,
cols: T.Optional[int] = None,
rows: T.Optional[int] = None,
attr: T.Optional[str] = None,
) -> T.Generator:
if cols is None:
cols = self.size[0]
if rows is None:
rows = self.size[1]
line = [(None, self._cs, self._text * cols)]
for i in range(rows):
yield line
def content_delta(self, other: T.Any) -> T.Any:
if other is self:
return [self.cols()] * self.rows()
return self.content()
class _Scrollable(urwid.WidgetDecoration):
def __init__(self, widget):
"""Box widget that makes a fixed or flow widget vertically scrollable
TODO: Focusable widgets are handled, including switching focus, but
possibly not intuitively, depending on the arrangement of widgets.
When switching focus to a widget that is ouside of the visible part of
the original widget, the canvas scrolls up/down to the focused widget.
It would be better to scroll until the next focusable widget is in
sight first. But for that to work we must somehow obtain a list of
focusable rows in the original canvas.
"""
if not any(s in widget.sizing() for s in (FIXED, FLOW)):
raise ValueError("Not a fixed or flow widget: %r" % widget)
self._trim_top = 0
self._scroll_action = None
self._forward_keypress = None
self._old_cursor_coords = None
self._rows_max_cached = 0
super().__init__(widget)
def sizing(self):
return frozenset([BOX])
def selectable(self):
return True
def render(self, size, focus=False):
maxcol, maxrow = size
# Render complete original widget
ow = self._original_widget
ow_size = self._get_original_widget_size(size)
canv_full = ow.render(ow_size, focus)
# Make full canvas editable
canv = urwid.CompositeCanvas(canv_full)
canv_cols, canv_rows = canv.cols(), canv.rows()
if canv_cols <= maxcol:
pad_width = maxcol - canv_cols
if pad_width > 0:
# Canvas is narrower than available horizontal space
canv.pad_trim_left_right(0, pad_width)
if canv_rows <= maxrow:
fill_height = maxrow - canv_rows
if fill_height > 0:
# Canvas is lower than available vertical space
canv.pad_trim_top_bottom(0, fill_height)
if canv_cols <= maxcol and canv_rows <= maxrow:
# Canvas is small enough to fit without trimming
return canv
self._adjust_trim_top(canv, size)
# Trim canvas if necessary
trim_top = self._trim_top
trim_end = canv_rows - maxrow - trim_top
trim_right = canv_cols - maxcol
if trim_top > 0:
canv.trim(trim_top)
if trim_end > 0:
canv.trim_end(trim_end)
if trim_right > 0:
canv.pad_trim_left_right(0, -trim_right)
# Disable cursor display if cursor is outside of visible canvas parts
if canv.cursor is not None:
curscol, cursrow = canv.cursor
if cursrow >= maxrow or cursrow < 0:
canv.cursor = None
# Figure out whether we should forward keypresses to original widget
if canv.cursor is not None:
# Trimmed canvas contains the cursor, e.g. in an Edit widget
self._forward_keypress = True
else:
if canv_full.cursor is not None:
# Full canvas contains the cursor, but scrolled out of view
self._forward_keypress = False
else:
# Original widget does not have a cursor, but may be selectable
# FIXME: Using ow.selectable() is bad because the original
# widget may be selectable because it's a container widget with
# a key-grabbing widget that is scrolled out of view.
# ow.selectable() returns True anyway because it doesn't know
# how we trimmed our canvas.
#
# To fix this, we need to resolve ow.focus and somehow
# ask canv whether it contains bits of the focused widget.
# I can't see a way to do that.
if ow.selectable():
self._forward_keypress = True
else:
self._forward_keypress = False
return canv
def keypress(self, size, key):
# Maybe offer key to original widget
if self._forward_keypress:
ow = self._original_widget
ow_size = self._get_original_widget_size(size)
# Remember previous cursor position if possible
if hasattr(ow, "get_cursor_coords"):
self._old_cursor_coords = ow.get_cursor_coords(ow_size)
key = ow.keypress(ow_size, key)
if key is None:
return None
# Handle up/down, page up/down, etc
command_map = self._command_map
if command_map[key] == urwid.CURSOR_PAGE_UP:
self._scroll_action = SCROLL_PAGE_UP
elif command_map[key] == urwid.CURSOR_PAGE_DOWN:
self._scroll_action = SCROLL_PAGE_DOWN
elif command_map[key] == urwid.CURSOR_MAX_LEFT: # 'home'
self._scroll_action = SCROLL_TO_TOP
elif command_map[key] == urwid.CURSOR_MAX_RIGHT: # 'end'
self._scroll_action = SCROLL_TO_END
else:
return key
self._invalidate()
def mouse_event(self, size, event, button, col, row, focus):
ow = self._original_widget
if hasattr(ow, "mouse_event"):
ow_size = self._get_original_widget_size(size)
row += self._trim_top
return ow.mouse_event(ow_size, event, button, col, row, focus)
else:
return False
def _adjust_trim_top(self, canv, size):
"""Adjust self._trim_top according to self._scroll_action"""
action = self._scroll_action
self._scroll_action = None
maxcol, maxrow = size
trim_top = self._trim_top
canv_rows = canv.rows()
if trim_top < 0:
# Negative trim_top values use bottom of canvas as reference
trim_top = canv_rows - maxrow + trim_top + 1
if canv_rows <= maxrow:
self._trim_top = 0 # Reset scroll position
return
def ensure_bounds(new_trim_top):
return max(0, min(canv_rows - maxrow, new_trim_top))
if action == SCROLL_LINE_UP:
self._trim_top = ensure_bounds(trim_top - 1)
elif action == SCROLL_LINE_DOWN:
self._trim_top = ensure_bounds(trim_top + 1)
elif action == SCROLL_PAGE_UP:
self._trim_top = ensure_bounds(trim_top - maxrow + 1)
elif action == SCROLL_PAGE_DOWN:
self._trim_top = ensure_bounds(trim_top + maxrow - 1)
elif action == SCROLL_TO_TOP:
self._trim_top = 0
elif action == SCROLL_TO_END:
self._trim_top = canv_rows - maxrow
else:
self._trim_top = ensure_bounds(trim_top)
# If the cursor was moved by the most recent keypress, adjust trim_top
# so that the new cursor position is within the displayed canvas part.
# But don't do this if the cursor is at the top/bottom edge so we can still scroll out
if (
self._old_cursor_coords is not None
and self._old_cursor_coords != canv.cursor
):
self._old_cursor_coords = None
curscol, cursrow = canv.cursor
if cursrow < self._trim_top:
self._trim_top = cursrow
elif cursrow >= self._trim_top + maxrow:
self._trim_top = max(0, cursrow - maxrow + 1)
def _get_original_widget_size(self, size):
ow = self._original_widget
sizing = ow.sizing()
if FIXED in sizing:
return ()
elif FLOW in sizing:
return (size[0],)
def get_scrollpos(self, size=None, focus=False):
"""Current scrolling position
Lower limit is 0, upper limit is the maximum number of rows with the
given maxcol minus maxrow.
NOTE: The returned value may be too low or too high if the position has
changed but the widget wasn't rendered yet.
"""
return self._trim_top
def set_scrollpos(self, position):
"""Set scrolling position
If `position` is positive it is interpreted as lines from the top.
If `position` is negative it is interpreted as lines from the bottom.
Values that are too high or too low values are automatically adjusted
during rendering.
"""
self._trim_top = int(position)
self._invalidate()
def rows_max(self, size=None, focus=False):
"""Return the number of rows for `size`
If `size` is not given, the currently rendered number of rows is returned.
"""
if size is not None:
ow = self._original_widget
ow_size = self._get_original_widget_size(size)
sizing = ow.sizing()
if FIXED in sizing:
self._rows_max_cached = ow.pack(ow_size, focus)[1]
elif FLOW in sizing:
self._rows_max_cached = ow.rows(ow_size, focus)
else:
raise RuntimeError(
"Not a flow/box widget: %r" % self._original_widget
)
return self._rows_max_cached
class _ScrollBar(urwid.WidgetDecoration):
def __init__(
self,
widget,
thumb_char="\N{FULL BLOCK}",
trough_char=" ",
side=SCROLLBAR_RIGHT,
width=1,
padding=0,
):
"""Box widget that adds a scrollbar to `widget`
`widget` must be a box widget with the following methods:
- `get_scrollpos` takes the arguments `size` and `focus` and returns
the index of the first visible row.
- `set_scrollpos` (optional; needed for mouse click support) takes the
index of the first visible row.
- `rows_max` takes `size` and `focus` and returns the total number of
rows `widget` can render.
`thumb_char` is the character used for the scrollbar handle.
`trough_char` is used for the space above and below the handle.
`side` must be 'left' or 'right'.
`width` specifies the number of columns the scrollbar uses.
`padding` specifies the number of columns the scrollbar is separated
from the content by.
"""
if BOX not in widget.sizing():
raise ValueError("Not a box widget: %r" % widget)
super().__init__(widget)
self._thumb_char = thumb_char
self._trough_char = trough_char
self._scrollbar_side = side
self._scrollbar_width = max(1, width)
self._padding_width = padding
self._original_widget_size = (0, 0)
def sizing(self):
return frozenset((BOX,))
def selectable(self):
return True
def render(self, size, focus=False):
maxcol, maxrow = size
sb_width = self._scrollbar_width
pad_width = self._padding_width
self._original_widget_size = ow_size = (
maxcol - sb_width - pad_width,
maxrow,
)
ow = self._original_widget
ow_base = self.scrolling_base_widget
ow_rows_max = ow_base.rows_max(size, focus)
if ow_rows_max <= maxrow:
# Canvas fits without scrolling - no scrollbar needed
self._original_widget_size = size
return ow.render(size, focus)
ow_rows_max = ow_base.rows_max(ow_size, focus)
ow_canv = ow.render(ow_size, focus)
pos = ow_base.get_scrollpos(ow_size, focus)
posmax = ow_rows_max - maxrow
# Thumb shrinks/grows according to the ratio of
# <number of visible lines> / <number of total lines>
thumb_weight = min(1, maxrow / max(1, ow_rows_max))
thumb_height = max(1, round(thumb_weight * maxrow))
# Thumb may only touch top/bottom if the first/last row is visible
top_weight = float(pos) / max(1, posmax)
top_height = int((maxrow - thumb_height) * top_weight)
if top_height == 0 and top_weight > 0:
top_height = 1
# Bottom part is remaining space
bottom_height = maxrow - thumb_height - top_height
assert thumb_height + top_height + bottom_height == maxrow
# Create scrollbar canvas
top = urwid.SolidCanvas(self._trough_char, sb_width, 1)
thumb = urwid.SolidCanvas(self._thumb_char, sb_width, 1)
bottom = urwid.SolidCanvas(self._trough_char, sb_width, 1)
sb_canv = urwid.CanvasCombine(
[(top, None, False)] * top_height
+ [(thumb, None, False)] * thumb_height
+ [(bottom, None, False)] * bottom_height
)
# Create padding canvas
pad_canv = EmptyCanvas(pad_width, maxrow)
combinelist = [
(ow_canv, None, True, ow_size[0]),
(pad_canv, None, False, pad_width),
(sb_canv, None, False, sb_width),
]
if self._scrollbar_side != SCROLLBAR_LEFT:
return urwid.CanvasJoin(combinelist)
else:
return urwid.CanvasJoin(reversed(combinelist))
@property
def scrollbar_width(self):
"""Columns the scrollbar uses"""
return max(1, self._scrollbar_width)
@scrollbar_width.setter
def scrollbar_width(self, width):
self._scrollbar_width = max(1, int(width))
self._invalidate()
@property
def scrollbar_side(self):
"""Where to display the scrollbar; must be 'left' or 'right'"""
return self._scrollbar_side
@scrollbar_side.setter
def scrollbar_side(self, side):
if side not in (SCROLLBAR_LEFT, SCROLLBAR_RIGHT):
raise ValueError(
'scrollbar_side must be "left" or "right", not %r' % side
)
self._scrollbar_side = side
self._invalidate()
@property
def scrolling_base_widget(self):
"""Nearest `original_widget` that is compatible with the scrolling API"""
def orig_iter(w):
while hasattr(w, "original_widget"):
w = w.original_widget
yield w
yield w
def is_scrolling_widget(w):
return hasattr(w, "get_scrollpos") and hasattr(w, "rows_max")
for w in orig_iter(self):
if is_scrolling_widget(w):
return w
raise ValueError("Not compatible to be wrapped by ScrollBar: %r" % w)
def keypress(self, size, key):
return self._original_widget.keypress(self._original_widget_size, key)
def mouse_event(self, size, event, button, col, row, focus):
ow = self._original_widget
ow_size = self._original_widget_size
handled = False
if hasattr(ow, "mouse_event"):
handled = ow.mouse_event(ow_size, event, button, col, row, focus)
if not handled and hasattr(ow, "set_scrollpos"):
if button == 4: # scroll wheel up
pos = ow.get_scrollpos(ow_size)
ow.set_scrollpos(pos - 1)
return True
elif button == 5: # scroll wheel down
pos = ow.get_scrollpos(ow_size)
ow.set_scrollpos(pos + 1)
return True
return False
class Scrollable(urwid.AttrWrap):
def __init__(self, *args, **kwargs):
super().__init__(
_Scrollable(*args, **kwargs), "scrollable", "scrollable-focus"
)
class ScrollBar(urwid.AttrWrap):
def __init__(self, *args, **kwargs):
super().__init__(
_ScrollBar(*args, **kwargs), "scrollbar", "scrollbar-focus"
)
+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

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

-26
View File
@@ -1,26 +0,0 @@
from setuptools import find_packages, setup
setup(
author="rr-",
author_email="rr-@sakuya.pl",
name="pqcli",
long_description="Progress Quest: the CLI edition",
version="0.3",
url="https://github.com/rr-/pq-cli.git",
packages=find_packages(),
entry_points={"console_scripts": ["pqcli = pqcli.__main__:main"]},
package_dir={"pqcli": "pqcli"},
install_requires=["xdg", "urwid", "urwid_readline"],
classifiers=[
"Environment :: Console",
"Development Status :: 4 - Beta",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: MIT License",
"Natural Language :: English",
"Operating System :: POSIX :: Linux",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.7",
"Topic :: Games/Entertainment :: Role-Playing",
"Topic :: Games/Entertainment :: Simulation",
],
)
+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
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more