Commit Graph
28 Commits
Author SHA1 Message Date
Dash 9689372e25 tests: run in parallel with pytest-xdist
pytest now runs with -n auto; pass -n0 to run tests one by one. The
pty harness starts pqcli through subprocess instead of pty.fork(),
which is unsafe in the threaded xdist workers, and the child claims
the pty as its controlling terminal so ^C still raises SIGINT.
2026-10-17 19:53:53 +02:00
Dash d1d7c2d9df tests: play shorter games when comparing
The upstream games ran 10000 tasks and the .pqw comparisons 3000 or
2000 more. Differences so far showed within the first few tasks, so
3000, 1000 and 500 keep the tests sharp at a fraction of the time.
Two .pqw save points that tested nothing new are dropped.
2026-10-17 19:53:51 +02:00
Dash c169102c32 pqcli: reject --load-save 0 and negative numbers
`if args.load_save:` treated 0 as unset, so the menu opened instead,
and a negative number indexed from the end of the roster, so -1
played the last character. Both now list the characters and exit
like any other number out of range.
2026-10-17 19:51:19 +02:00
Dash 60d2c54a88 tests: drive the curses UI in a pseudo-terminal
tests/pty_app.py starts pqcli with pty.fork at a fixed size, with
XDG_CONFIG_HOME in a temporary directory, and feeds its output to a
pyte screen. Tests send keys and poll the screen until it matches,
counting a match once the output pauses or the match has held for a
moment, so they never read a half-drawn frame and need no fixed
sleeps.

The tests cover the roster menu, creating a character (names, races,
classes, rolling and unrolling stats, and every way back), the game
view's panes, focus moves and page scrolling, deleting, importing and
exporting .pqw files, resizing the terminal, the cheat keys, Ctrl+C
and --load-save. They are skipped on Windows.

pyte does not draw the DEC line drawing characters, so the app runs
with NCURSES_NO_UTF8_ACS=1 to draw its boxes with Unicode lines.
2026-10-17 19:46:06 +02:00
Dash f8c370cc2e tests: cover the basic UI
Drive the line-based UI in-process: input() answers come from a list
and the output is captured. The tests walk the main menu through
creating, playing, querying, deleting, importing and exporting
characters, plus bad menu input and the Ctrl+C handler.

The play loop runs on a fake clock that advances a minute per call and
stops when time.sleep has been called a set number of times, so it
levels up and saves on schedule without waiting.

The test helpers move to tests/helpers.py, which now also holds the
input() feeder.
2026-10-17 19:46:05 +02:00
Dash 083dbe01c2 tests: cover the roster and the command line
The roster tests pin down the .old backup rotation and the five minute
periodic save, with a fake clock in place of datetime.

The command line tests call main() in-process with the save and data
paths in a temporary directory, so coverage counts them. They cover
listing, loading, importing and exporting saves, dumping and loading
word lists, and the first-run character creation. The curses UI is
swapped for a stub there so a stray code path fails instead of taking
over the terminal.

--load-save 0 falls through to the menu and --load-save -1 plays the
last character instead of being rejected; both are marked xfail.

A shared fixture restores pytest's SIGINT handler after the UIs
install their own.
2026-10-17 19:46:05 +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 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 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 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