Imported from drgitt/assassin-asylum (
AGENTS.md). Install upstream withnpx skills add drgitt/assassin-asylum. Copyright stays with the author.
Repository Guidelines
Project Structure & Module Organization
This is a self-contained Python text adventure. game.py contains the terminal player and shared game state; play_gui.py provides the Tkinter splash screen and scene player. Shared plot loading, path resolution and plot discovery live in tools/plotlib.py; the shared palette, dithering and PNG writer live in tools/pixelart.py.
Each story resides in plot/<plot-name>/. Its plot-tree.json is the authoritative decision tree. Generated scene-description files belong in descriptions/, scene stills in images/, and default-scene.png is the fallback artwork. Repository-level art that belongs to no single plot — splash.png, icon.png — lives in assets/. seed-images/ is visual-era reference only, not game content.
Players who do not use a terminal start the game by double-clicking Play Assassin Asylum.bat (Windows) or play.sh (macOS, Linux), and write new stories with New Assassin Asylum Story.bat / make-story.sh; tools/install_launcher.py puts shortcuts to all of them on the desktop. Keep every launcher working when changing how either program starts.
New stories are generated by tools/new_plot.py (command line) and create_plot_gui.py (windowed), over seven helpers: tools/llm.py is the model catalogue and the two text clients (Anthropic Messages and OpenAI chat, chosen by model id), tools/keyprompt.py asks for an API key without echoing it, tools/story_prompts.py holds the prompts, tools/world.py reads the hand-edited ones out of world/*.md, tools/roles.py reads roles/*.md, tools/council.py runs those roles in order, and tools/plotgen.py turns a story sketch into a folder the engine can load.
The council is data, not code. roles/*.md are the prompts: frontmatter says when a role runs, what its notes are filed under, which earlier notes it is shown and whether it may send the story back; the body is the brief. council.convene() runs them in order, hands each role only the notes it declares in consumes, and lets the veto: true roles reject the finished story once. The user is expected to rewrite these files continually, so nothing in Python may assume a particular role exists — the only invariant roles.load_roles() enforces is that exactly one role has produces: story. A file with no frontmatter (the README) is not a role. --solo bypasses the council entirely, and every repair pass after the first draft is a single call, because what is wrong by then is bookkeeping.
The premise is data too. world/premise.md and world/house-style.md are prose with no schema, read by tools/world.py and pasted at the top of every prompt in the program. story_prompts.premise_brief() prefers the file; with no file it describes the premise from the shipped plot (the-quiet-ward) so the generator and the game still agree; with no readable tree either it uses FALLBACK_PREMISE. Missing, empty and whitespace-only all mean "use the default", because a truncated save must not silently strip the setting out of every prompt. Both are read once at import, so an edit takes effect on the next run rather than mid-council. world.summary() names which is in effect and both front ends print it, and --show-world prints the text itself — an edit that was never read is otherwise indistinguishable from one that did nothing. Tests must not assert on the wording of world/*.md: it exists to be rewritten.
Every generated story is an Assassin Asylum story. PREMISE is embedded in every system prompt, the council's included — there is a test that fails if any of them loses it. A theme supplied by the player is an additional requirement layered on that premise, and is checked against it first by new_plot.check_theme; an off-premise theme is refused with a reason and a suggestion, and the player gets another go (new_plot.settle_theme in the terminal, PlotMakerGUI.on_theme_checked in the window). A check that errors is never treated as a refusal. The division of labour matters: the model is asked for story material only, and every structural rule — kebab ids under five words, unique stems, declared paths, reachability, endings that terminate — is imposed afterwards by plotgen.build_tree and plotgen.repair_tree, which are pure and unit tested.
Build, Test, and Development Commands
No package installation or build step is required for the standard library-based game.
python3 game.py # play in the terminal
python3 play_gui.py # splash screen, then the Tkinter player
python3 play_gui.py --plot NAME # skip the splash screen
python3 tools/validate_plot.py # validate the default plot
python3 tools/validate_plot.py --plot NAME
python3 tools/sync_descriptions.py --check # report description drift
python3 tools/make_splash_image.py # redraw assets/splash.png and icon.png
python3 tools/install_launcher.py --dry-run # show the desktop shortcuts it would add
python3 tools/new_plot.py --list-models # the model short list, checked against the API
python3 tools/new_plot.py --list-roles # the council, read from roles/
python3 tools/new_plot.py --show-world # the premise and house style in effect
python3 tools/new_plot.py --theme "..." # write a new story (costs money)
python3 tools/new_plot.py --solo # one call instead of the council
python3 create_plot_gui.py # the same thing, windowed
Long jobs report progress through tools/progress.py, which is pure arithmetic over (done, total, elapsed) so the terminal bar and the window's bar render from the same numbers and can both be tested without a clock. generate_scene_images.main takes a progress(done, total, elapsed) callback; the window passes one that posts to its queue from the worker thread. council.convene takes a progress(done, total, elapsed, label) callback with one extra argument, the name of the agent about to work — the label always names who is thinking now, never who just finished.
Anything that calls a model costs the user money, and the council multiplies it: one story is roughly ten requests, not one. Never run new_plot.py, create_plot_gui.py or generate_scene_images.py against the live API to "check it works" — the test suite mocks every request, and --list-models, --list-roles, --show-world and --dry-run are free. Keys come from the environment or .env.local, per provider: ANTHROPIC_API_KEY for Claude models, OPENAI_API_KEY for gpt models and for every image. llm.key_for(model) picks the right one; one provider's key is never sent to the other. When neither source has one, keyprompt.ensure_key(model) says where to make a key and reads it with getpass; on a stream that cannot hide typing it refuses to ask rather than echoing the key, and points at .env.local instead. Never print a key, log it, or put it in a command line. A key goes in the authorisation header and nowhere else — never in a prompt, which llm.assert_key_not_in_prompt() enforces inside chat_json — and provider error bodies go through llm.redact() before they are raised. Show a key as llm.fingerprint() when one has to be identified; reveal it in full only when the user explicitly asks.
The Anthropic client is raw urllib, not the official SDK, and that is deliberate: this repository has no dependency file and no install step, which is what makes the double-click launchers work for people who do not have a terminal. Adding pip install anthropic would break that for exactly the users it was built for. The payload carries no temperature, top_p or top_k (Opus 5 answers 400 to them) and no output_config (its JSON mode wants a schema, and every role answers with a different shape); stop_reason is checked for refusal and max_tokens before the content is read.
Run python3 tools/sync_descriptions.py --plot NAME after changing a tree description. Use python3 tools/export_image_prompts.py --missing-only to list stills that need artwork. Pillow is optional for JPEG images and smoother GUI scaling.
Lint with ruff, configured in ruff.toml (100 columns):
python3 -m venv .venv && .venv/bin/python -m pip install ruff # once
.venv/bin/ruff check .
Coding Style & Naming Conventions
Use Python 3, four-space indentation, type annotations where they improve clarity, and standard-library-first dependencies. Keep modules small and use pathlib.Path for repository paths. Follow existing snake_case for functions and variables, PascalCase for classes, and concise module docstrings.
Plot IDs, choice IDs, item keys, and scene asset stems use lowercase kebab-case. Scene IDs must be unique and fewer than five words; corresponding files use the same stem, such as descriptions/loose-tile-under-cot.md.
Testing Guidelines
Unit tests live in tests/, use unittest, and mock every network call — they never reach the OpenAI API. Every module in the repository has a matching tests/test_<module>.py.
python3 -m unittest discover -s tests # whole suite
python3 -m unittest discover -s tests -v # per-test names
python3 -m unittest tests.test_game.GatingTests # one class
python3 -m unittest tests.test_generate_scene_images.GenerateSceneImagesTests.test_read_env_key_prefers_environment_then_env_file
Name new test modules tests/test_<module>.py; tools are imported by adding tools/ to sys.path, as every existing test does. Keep the suite silent: capture the output a tool prints with redirect_stdout/redirect_stderr and assert on it rather than letting it reach the terminal.
Tests that build Tk widgets need a display and are skipped without one, so a headless run is expected to report skips rather than failures. tests/test_play_gui.py maps its window instead of withdrawing it, because a withdrawn toplevel never runs its geometry managers and every canvas inside it then reports 1x1.
Scene stills are fitted into the canvas, so how large they appear is decided by the window layout rather than by the files: see SCENE_BOX and preferred_window_size in play_gui.py. Keep the reserved box a whole fraction of a shipped still (768x512 is half of 1536x1024) so the stdlib scaler can hit it exactly, and never let it exceed a still's own resolution.
Two invariants worth preserving when touching the drawing code: the generated PNGs are byte-for-byte reproducible, so a refactor can be verified by regenerating and comparing; and a pending Tk after callback must be cancelled before its widget is destroyed, or Tk prints an error to stderr that looks, to a player, like a crash.
Treat tools/validate_plot.py as the required integrity test before submitting plot or asset changes. It checks graph reachability, choice targets, endings, path declarations, IDs, generated descriptions, and fallback images. Manually exercise changed branches with python3 game.py --plot NAME.
Commit & Pull Request Guidelines
The history currently uses short imperative subjects (for example, Initial commit: The Quiet Ward). Write focused, present-tense subjects and keep unrelated code, story, and generated-art changes separate when practical.
Pull requests should explain the player-visible change, name affected plot IDs or tools, and confirm validation. Include screenshots for GUI or visual-asset changes; link the relevant issue when one exists. Do not edit generated description markdown directly—update plot-tree.json and regenerate it.