diff --git a/.gitattributes b/.gitattributes
index 7046783..b0cb988 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -2,3 +2,12 @@
*.bat text eol=crlf
# Shell scripts stay LF
*.sh text eol=lf
+# Release zips (GitHub "Source code" downloads and `git archive`) carry only the
+# game: development tooling stays in the repo for contributors and forks.
+tests/ export-ignore
+sim/ export-ignore
+.github/ export-ignore
+requirements-dev.txt export-ignore
+Makefile export-ignore
+.gitattributes export-ignore
+.gitignore export-ignore
diff --git a/.gitignore b/.gitignore
index 6af3469..c3cb1c2 100644
--- a/.gitignore
+++ b/.gitignore
@@ -47,3 +47,6 @@ var/
# Design docs are local-only working notes
docs/
+
+# sim/tune.py writes its proposal here for a human to review; never committed
+data/difficulty.proposed.yaml
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..c3a66ef
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,121 @@
+# Changelog
+
+Versions follow `0.MINOR.PATCH` while the game is in playtesting: a new MINOR
+for each release to `main`, a PATCH for a fix-only release. The version shows on
+the title screen and on the first line of `debug.log` (with the git commit next
+to it when run from a git checkout), so a screenshot or log names the build it
+came from.
+
+## v0.2.0 — 2026-09-30
+
+### ⚠ Saves
+Saves from v0.1 do not load. The flag hunt changed what a save holds; start a
+new game.
+
+### New: main menu and save slots
+- The title menu has **SETTINGS** (palette, text speed, reduce motion, hints),
+ now arrow-driven: ↑/↓ picks a row, ←/→ changes it. Ctrl+P opens the same screen in-game.
+- Every run has its own save slot (up to 9). **LOAD GAME** lists them with the
+ class, difficulty, level and where you are; Enter continues, `d` deletes.
+ Beaten runs are marked ✓. When all 9 are taken, NEW GAME asks which run to replace.
+- Saves from the old single pool become one slot per hero; the original files
+ are kept in `saves/legacy/`.
+- Leave a run for the main menu: `menu`, or **Save & main menu** / **Main menu
+ without saving** on the quit chooser (Ctrl+Q, Ctrl+C, `quit`). The game-over screen is now r (restore this
+ run) / m (main menu) / q (quit).
+- Fixed: Ctrl+Q or `quit` during a fight said "command not found"; it now asks
+ whether to keep fighting, go to the menu or quit.
+
+### New: capture the flag
+- Every directory hides a flag: 13 in the main tree, 5 more in secret
+ directories. Read one with `cat`, search a huge log with `grep`, or find a
+ rogue process with `ps` and end it with `kill`.
+- `/boot` (the final boss) opens once you hold 11 flags. `tree` and `journal`
+ count them, and capturing one saves a checkpoint.
+- Stuck in a room? `hint` nudges you, and asking again gives the exact command.
+ Echo teaches each new technique the first time you need it, and NPCs drop clues.
+- New commands: `grep` (with `-i` and `-n`) and `kill`.
+- The run recap shows your flags and a rank; capturing everything unlocks an
+ extra epilogue.
+
+### New: keys are earned
+- Keys are no longer scattered at random. The `/mnt`, `/var` and `/usr` flags
+ hand you the keys to `/usr`, `/etc` and `/srv`; bosses drop the rest. Every run
+ can be won.
+- A locked directory stays hidden until you hold its key, so the map unfolds as
+ you learn. `keys` lists what you hold.
+- `/opt` and `/srv` are open to every class, with a damage bonus for the class
+ whose trial it is.
+
+### Tutorial and story
+- The opening and the tutorial explain the goal: capture flags, earn keys,
+ open `/boot`. Skipping the tutorial still tells you.
+- The tutorial now teaches `ls -a`, `cat`, `ps` and `pwd`, gives you starter
+ armor before the first fight, and pins Echo's current instruction above the
+ input. Reading a memory file restores a memory and saves a checkpoint.
+- NPC dialogue changes with what you have done.
+- Picking up an item shows what it is, right under "Added … to your inventory".
+- A Character panel under Stats shows your class's model and the weapon and
+ armor you have on, with their numbers; an empty slot says how to fill it.
+- The class cards list each class's three moves, and the fight menu says which
+ moves heal you or weaken the enemy. When your HP runs low (and in-game hints
+ are on), the fight tells you which key heals.
+
+### Easier to type
+- Forgiving item names, Tab completion, and "Did you mean…" on typos.
+- Combat opens in Selection Mode: number keys attack, `0` flees.
+- Esc cancels a half-typed line instead of quitting.
+- Tab after `cd` completes the directories you can see (it completed nothing
+ before), and never a hidden or still-locked one.
+- Typing `equip`, `take`, `cd` and other exploring commands in a fight says
+ they work once the fight is over, instead of "command not found".
+
+### Look and feel
+- The version is on the title screen and on the first line of `debug.log`.
+- Seven rooms have their own backdrop art: the Archive, the Deprecated
+ Directory, `/root`, `/opt`, `/proc`, `/srv` and `/usr`.
+- One icon per item type in `ls`, `take`, `examine` and the inventory.
+- Cleared rooms get a ✓ in `ls`, the exits list and `tree` (`map` still works).
+- NPC sprites fill their box like everyone else's instead of shrinking.
+- F12 opens the logs; debug logging stays out of the terminal, and each launch
+ starts a fresh log.
+
+### Loot and saves
+- Gear can't drop as a second copy of something you already took.
+- Epic and legendary gear starts locked away instead of lying in `/`.
+- Loading an old save removes a stray copy of a key a flag now hands out.
+- The autosave pool is capped, so the saves folder no longer grows forever.
+
+### Balance
+- Capturing a flag restores you to full HP. Most deaths came from wear across
+ several fights, so runs are now much easier: the run simulator wins nearly
+ every run in every mode, and the earlier targets (about 95% easy, 83% medium,
+ 68% hard) no longer apply. Harder modes still mean tougher, longer fights.
+- The Weaver is still the weakest class.
+
+### Fixes
+- The Inventory panel now updates during a fight (used items used to stay
+ listed until the fight ended).
+- The Legacy Backup really revives you, once, then tells you it's spent and how
+ many are left. Trying to `use` one in a fight explains that it works on its own.
+- The Inventory panel shows how much each healing item heals, and every attack
+ in the fight menu has an icon for its type (⚔ physical, ✨ magical, 🌿 nature).
+- `find /dev -name null` no longer names `/dev` before its door is revealed.
+- The Shaman's class card showed 100 HP and 8 damage; it really has 120 and 10.
+ Card numbers now come from the real stats.
+- The game-over screen no longer scrolls or shows stray markup, r/n/q work, and
+ it appears only when you actually die.
+- Saves keep your armor, status effects and difficulty, and defeated bosses no
+ longer come back to life (with fresh drops) when you load.
+- Hidden files show their leading dot, and Tab completes them.
+- The tutorial fight no longer ambushes players who skipped the tutorial, and
+ fleeing it no longer breaks the tutorial.
+- `talk` no longer crashes, and stray markup no longer shows up as text.
+- New installs start in player mode (some testers got the developer settings).
+- The Windows launcher is more robust, and players no longer install developer
+ tools or packages that fail to build on new Python versions.
+
+## v0.1-playtest — 2026-07-10
+
+The first playtest release: explore the haunted filesystem with real Unix
+commands, fight in Pokémon-style battles, pick a class and a difficulty.
diff --git a/README.md b/README.md
index 6300390..9eca4fe 100644
--- a/README.md
+++ b/README.md
@@ -11,46 +11,74 @@ piece together what happened — using real Unix commands (`ls`, `cd`, `cat`,
`pwd`) as your only tools.
The directories are real ones. `cd ..` walks up the tree, `ls -a` shows what is
-hidden, and a sealed directory stays sealed until you have permission to enter
-it — including permission on every directory above it. Type `man cd` and the
-game tells you what `cd` does in an actual shell, not just in here.
+hidden, and a locked directory doesn't even show up until you hold its key. Type
+`man cd` and the game tells you what `cd` does in an actual shell, not just in
+here.
+
+Every directory hides a flag. Some you just read with `cat`; some are buried in
+huge logs you'll need `grep` to search; some are rogue processes you find with
+`ps` and end with `kill`. Some flags hand you a key, and a new directory
+appears. Hold 11 flags and `/boot` — where the Daemon Overlord waits — opens.
+Stuck? Type `hint`.
No prior command-line experience needed; the game teaches you as you go.
----
+What changed in each version: [CHANGELOG.md](CHANGELOG.md). The version you're
+running is on the title screen and on the first line of `debug.log`.
-
-*Exploring the Graveyard — pixel scene view, live panels, ECHO guiding your first `ls`*
+---
-| Pokemon-style battles | Pick your difficulty |
-|---|---|
-|  |  |
+
+
+_Exploring the Graveyard — pixel scene view, live panels, ECHO's quick reference_
+
+
+
+ Pokemon-style battles
+ Pick your difficulty
+
+
+
+
+
+
+ Continue any run — one save slot per run
+ Settings — right from the main menu
+
+
+
+
+
+
---
## Getting Started
-### Step 0 — get the game and go INTO its folder
+### Step 0 — download the game and go INTO its folder
-All commands below must be run **from inside the repo folder**:
+Download **`haunted-terminal-v0.2.0.zip`** from the
+[Releases page](https://github.com/NoHudd/hauntedTerminal/releases/latest),
+unzip it, then open a terminal in that folder:
```bash
-git clone https://github.com/NoHudd/hauntedTerminal.git
-cd hauntedTerminal
+cd haunted-terminal
```
-(Downloaded a ZIP instead? Unzip it, then `cd` into the unzipped folder.)
+All commands below must be run **from inside the game folder**.
### Quick Start (Recommended)
-From inside the repo folder, run the start script for your system:
+From inside the game folder, run the start script for your system:
**On Mac/Linux:**
+
```bash
./start.sh
```
**On Windows:**
+
```cmd
start.bat
```
@@ -72,7 +100,7 @@ python main.py
---
-Full command list, classes, mechanics, tips, and project structure live in
+Full command list, classes, mechanics and tips live in
**[REFERENCE.md](./REFERENCE.md)** — the game teaches all of it as you play,
so treat that as a lookup, not required reading.
@@ -112,5 +140,3 @@ This project is licensed under the MIT License — see the LICENSE file for deta
||----w |
|| ||
```
-
-*Type `./start.sh` (Mac/Linux) or `start.bat` (Windows) to begin your journey.*
diff --git a/REFERENCE.md b/REFERENCE.md
index 56d08c5..0dde641 100644
--- a/REFERENCE.md
+++ b/REFERENCE.md
@@ -18,6 +18,8 @@ one does in an actual terminal.
`cd ..` for the parent, `cd` alone for home
- `pwd` — print the directory you are in
- `cat [file]` — read a file
+- `grep [word] [file]` — print only the lines of a file that contain a word
+- `kill [PID]` — end a process (find its PID with `ps`)
- `tree` — show the filesystem you have discovered so far
- `find [path] -name [pattern]` — search for a file
- `ps` — list running processes
@@ -34,11 +36,13 @@ one does in an actual terminal.
- `attack [enemy]` — start a fight
- `inventory` / `inv` — view your items
- `journal` — story memories you have restored
-- `keys` — key progression
+- `keys` — the keys you hold and the directories they open
+- `hint` — a nudge toward this room's flag; ask again for the exact command
- `shortcuts` — item shortcuts and typing tips
-- `save` — save your progress
+- `save` — save your progress to this run's slot
+- `menu` — back to the title menu (save first, or leave without saving)
- `quit` / `exit` — exit the game (offers to save). Ctrl+Q, Ctrl+C and ESC do
- the same thing
+ the same thing; mid-fight they offer keep fighting / main menu / quit, without saving
### Combat
@@ -50,21 +54,21 @@ Selection Mode.
The rooms form a real directory tree. You may `cd` to any path you have
permission to reach — you are not limited to neighbours. Permission is needed on
-every directory along the way, so a sealed `/usr` also seals `/usr/games`.
+every directory along the way, so a locked `/usr` also hides `/usr/games`.
```
/ Root
├── bin/ The Armory — sacred command icons (cp, mv, rm)
-├── boot/ The Core — the Daemon Overlord waits here [master_key]
+├── boot/ The Core — the Daemon Overlord waits here [11 flags]
├── dev/ The Void — where deleted data accumulates [sudo badge]
├── etc/ The Kernel Gate — the Firewall Knight [chmod_key]
├── home/ The Graveyard — where you wake up
├── mnt/ Mount Forest — mounted drives
-├── opt/ Mage Tower [opt_key, Weaver only, hidden]
+├── opt/ Mage Tower — the Weaver's trial [opt_key, hidden]
├── proc/ Process Secrets
│ └── self/ The Mirror Sector — the Sudo Trial [hidden]
├── root/ Hidden Directory — ancient knowledge [system_badge]
-├── srv/ Warrior Tomb [opt_key, Guardian only]
+├── srv/ Warrior Tomb — the Guardian's trial [opt_key]
├── usr/ The Arcane Library [lib_key]
│ └── games/ The Game Gallery
│ └── cowsay/ The Bovine Sanctuary — easter egg [hidden]
@@ -74,7 +78,8 @@ every directory along the way, so a sealed `/usr` also seals `/usr/games`.
```
Hidden directories do not appear until you find them. `ls -a` in the parent is
-the usual way; `find /dev -name null` and `ps` also reveal one each.
+the usual way; `ps` in Mount Forest also reveals one. A locked
+directory does not appear at all until you hold its key.
## Character Classes
@@ -109,14 +114,25 @@ an accuracy roll, so a miss is possible.
- Each level costs 1.5x the previous one (100, 150, 225, …). There is no cap
- Your difficulty setting scales the award
-### Permissions and keys
-- A sealed directory names the key it needs. Carrying that key opens it
- automatically when you walk in
-- `lib_key` opens `/usr`, `chmod_key` opens `/etc`, `master_key` opens `/boot`,
- `opt_key` opens the two class areas, `system_badge` opens `/root`
+### Flags
+- Every directory hides a flag (`FLAG{...}`): 13 in the main tree, 5 more in
+ secret directories
+- Capture one by reading its file with `cat`, searching a long file with
+ `grep FLAG [file]`, or ending a rogue process with `kill [PID]`
+- `tree` marks the rooms whose flag you hold with ⚑; `journal` counts them
+- Hold 11 main flags and `/boot` opens. Capturing a flag saves a checkpoint
+- Stuck in a room? `hint` nudges you, and asking again gives the exact command
+
+### Keys
+- Keys are earned, never found lying around: the `/mnt` flag hands you
+ `lib_key` (opens `/usr`), the `/var` flag `chmod_key` (opens `/etc`), and the
+ `/usr` flag `opt_key` (opens `/srv` and a secret)
- The `sudo_privileges_badge`, earned by beating your Shadow Process in
- `/proc/self`, opens `/dev`
-- Keys are placed so that a run is always completable
+ `/proc/self`, opens `/dev`; the `system_badge` from the Daemon Overlord opens
+ `/root`
+- A locked directory stays hidden until you hold its key; walk in with the key
+ and it opens
+- The chain always reaches 11 flags, so every run can be won
### Item Persistence
- **Persistent items** survive death (weapons, armor, keys)
@@ -160,40 +176,3 @@ lean common, `/dev` favours epic, and `/` can produce legendaries.
- Discover the Great ASCII Bovine easter egg
- Defeat the Daemon Overlord in `/boot`
- Reach the Archive and the Hidden Directory
-
-## Development
-
-### Debug Mode
-
-Copy `config/settings.example.py` to `config/settings.py` and flip:
-
-```python
-DEV_MODE = True
-DEBUG_MODE = True
-SKIP_INTRO = True
-```
-
-Contributors also need the dev tooling (pytest, mypy, ruff):
-
-```bash
-pip install -r requirements-dev.txt
-make check # ruff + mypy + pytest + content validation
-```
-
-### Project Structure
-
-```
-main.py # entry point → src.game_engine.main
-src/ # the running game: engine, world, player, combat, commands/, ui/, scene/
-engine/ # typed content schema + validation + headless test driver
-data/ # all game content as YAML — rooms/ enemies/ npcs/ items/ + classes/attacks/abilities
-assets/ # pixel-art sprites and backdrops (PNG)
-sim/ # difficulty simulation harness
-config/ # dev settings (settings.py gitignored)
-tests/ · utils/
-```
-
-Adding content is a matter of dropping a YAML file into the matching `data/`
-directory and running `python -m engine.validate data`, which fails loudly on a
-dangling reference, a duplicate path, or a room whose parent directory does not
-exist.
diff --git a/assets/screenshot-battle.svg b/assets/screenshot-battle.svg
deleted file mode 100644
index 6c0b7c9..0000000
--- a/assets/screenshot-battle.svg
+++ /dev/null
@@ -1,1044 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- TextualGameUI
-
-
-
-
-
-
-
-
-
- Haunted Terminal
- ╭ ─ ⚔ BATTLE ──────────────────────────────────────────────────────────────────────────────────────── ╮ ╭ ─ 📦 Inventory ───────────────── ╮
- │ 💀 SYSLOG WRAITH HP 68/68 ▉▉▉▉▉▉▉▉▉▉▉▉ │ │ │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ═══ COMMON ═══ │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ 🧪 Health Packet │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ ╰ ──────────────────────────────── ╯
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ ╭ ─ 📊 Stats ───────────────────── ╮
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ 🛡 SPIRIT │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ Class: Guardian │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ Lvl 1 · 0/100 cycles │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ HP: 120/120 FULL │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ██████████ │
- │ 🛡 SPIRIT HP 120/120 ▉▉▉▉▉▉▉▉▉▉▉▉ │ │ │
- ╰ ─────────────────────────────────────────────────────────────────────────────────────────────────── ╯ │ Base ATK: 10 │
- ╭ ─────────────────────────────────────────────────────────────────────────────────────────────────── ╮ │ │
- │ │ ╰ ──────────────────────────────── ╯
- │ ⚔ COMBAT LOG ⚔ │
- │ ======================================== │
- │ ⚡ ⚔ Combat initiated with Syslog Wraith! | Syslog Wraith: — you are the error! │
- │ ⚡ The Memory Banks │
- │ │
- │ A dark library where the history of the Great Kernel Panic is recorded in scrolling text. │
- │ │
- │ A labyrinth of shifting directories and volatile processes. Error messages echo through │
- │ dripping tunnels of corrupted logs. Here, daemons nest in unstable caches, waiting to ambush │
- │ unwary explorers. Proceed with caution. │
- │ ⚡ bash: cd /var: command not found │
- │ ======================================== │
- │ Attacks: number keys in the footer · type 'use <item>' or 'flee' │
- │ │
- │ │
- │ │
- │ │
- │ │
- ╰ ─────────────────────────────────────────────────────────────────────────────────────────────────── ╯
- ╭ ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╮
- │ c ombat@system:~$ Enter command... │
- ╰ ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╯
- ctrl + p Settings F5 Restart Game
-
-
-
diff --git a/assets/screenshot-difficulty.svg b/assets/screenshot-difficulty.svg
deleted file mode 100644
index b7508b5..0000000
--- a/assets/screenshot-difficulty.svg
+++ /dev/null
@@ -1,1410 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- TextualGameUI
-
-
-
-
-
-
-
-
-
-
-
-
-
- Choose your difficulty
-
- █ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ █ ╭ ──────────────────────────────────────────── ╮ ╭ ────────────────────────────────────
- █ █ │ │ │
- █ ▄ ▄ ▄ █ │ ▄ ▄ │ │ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄
- █ █ │ │ │
- █ EASY █ │ MEDIUM │ │ HARD
- █ 🌱 █ │ ⚖ │ │ 🔥
- █ Gentler enemies, faster leveling. █ │ The intended, balanced challenge. │ │ Tougher enemies, longer fights
- █ For learning the ropes. █ │ │ │ you level slower.
- █ █ ╰ ──────────────────────────────────────────── ╯ │
- █ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ █ ╰ ────────────────────────────────────
-
- ←/→ choose · Enter confirm · 1-3 direct
-
-
-
-
-
-
-
-
diff --git a/assets/screenshot-explore.svg b/assets/screenshot-explore.svg
deleted file mode 100644
index 886ebd9..0000000
--- a/assets/screenshot-explore.svg
+++ /dev/null
@@ -1,1278 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- TextualGameUI
-
-
-
-
-
-
-
-
-
- Haunted Terminal
- ╭ ─ 🏠 The Graveyard ──────────────────────────────────────────────────────────────────────────────── ╮ ╭ ─ 📦 Inventory ───────────────── ╮
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ═══ COMMON ═══ │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ 🧪 Health Packet │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ ╰ ──────────────────────────────── ╯
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ ╭ ─ 📊 Stats ───────────────────── ╮
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ 🛡 SPIRIT │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ Class: Guardian │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ Lvl 1 · 0/100 cycles │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ HP: 120/120 │
- │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ████████████ │
- │ 👤 Grove Guardian │ │ │
- ╰ ───────────────────────────────────────────────────────────────────────────── → /var → /mnt → / ─ ╯ │ Attack: 10 │
- ╭ ─────────────────────────────────────────────────────────────────────────────────────────────────── ╮ │ │
- │ │ ╰ ──────────────────────────────── ╯
- │ ECHO> You're in /home — a stable part of the filesystem. The rest is corrupted and needs │
- │ clearing. Let's start simple. Type: ls and press Enter to see what's here. │
- │ │
- │ │
- │ │
- │ │
- │ │
- │ │
- │ │
- │ │
- │ │
- │ │
- │ │
- │ │
- │ │
- │ │
- │ │
- │ │
- ╰ ─────────────────────────────────────────────────────────────────────────────────────────────────── ╯
- ╭ ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╮
- │ E nter command... │
- ╰ ───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╯
- ctrl + p Settings F5 Restart Game
-
-
-
diff --git a/assets/screenshots/battle.svg b/assets/screenshots/battle.svg
new file mode 100644
index 0000000..7e0b234
--- /dev/null
+++ b/assets/screenshots/battle.svg
@@ -0,0 +1,1228 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TextualGameUI
+
+
+
+
+
+
+
+
+
+ Haunted Terminal
+ ╭ ─ ⚔ BATTLE ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╮ ╭ ─ 📦 Inventory ─────────────────────────────────────────── ╮
+ │ 💀 GLITCHED_PROCESS.TMP HP 16/16 ▉▉▉▉▉▉▉▉▉▉▉▉ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ═══ COMMON ═══ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ 💊 Health Packet +30 HP │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ 🛡 ADA HP 120/120 ▉▉▉▉▉▉▉▉▉▉▉▉ │ │ │
+ ╰ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╯ │ │
+ ╭ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╮ │ │
+ │ │ │ │
+ │ ⚔ BATTLE STARTED ⚔ │ │ │
+ │ ======================================== │ │ │
+ │ ⚡ ⚔ Combat initiated with glitched_process.tmp! | glitched_process.tmp: ERR... undefined behavior... seg fault imminent... │ ╰ ────────────────────────────────────────────────────────── ╯
+ │ │ ╭ ─ 📊 Stats ─────────────────────────────────────────────── ╮
+ │ Selection Mode active — press 1-9 to attack, 0 to flee. TAB to type 'use <item>' instead. │ │ │
+ │ │ │ 🛡 ADA │
+ │ QUICK ATTACKS: │ │ Class: Guardian │
+ │ [1] ⚔ Strike — 15 dmg ( 95% hit · no CD ) │ │ Lvl 1 · 0/100 cycles │
+ │ [2] ⚔ Power Strike — 29 dmg ( 85% hit · CD 2t ) │ │ │
+ │ [3] ⚔ Shield Bash — 20 dmg · heals 3 HP · weakens enemy's next hit 50% ( 90% hit · CD 3t ) │ │ HP: 120/120 FULL │
+ │ │ │ ██████████ │
+ │ │ │ │
+ │ │ │ Base ATK: 10 │
+ │ │ │ │
+ │ │ ╰ ────────────────────────────────────────────────────────── ╯
+ │ │ ╭ ─ 🧍 Character ─────────────────────────────────────────── ╮
+ │ │ │ ▄ ▄ ▄ ▄ 🗡 Weapon │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ none — type equip <name> │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ 🛡 Armor │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ none — type equip <name> │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ │
+ ╰ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╯ ╰ ────────────────────────────────────────────────────────── ╯
+ ╭ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╮
+ │ combat@system:~$ Enter command... │
+ ╰ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╯
+ ctrl + q Quit ctrl + p Settings L Show/Hide Logs F5 Restart Game 1 Strike 2 Power Strike 3 Shield Bash 0 Flee
+
+
+
diff --git a/assets/screenshots/difficulty.svg b/assets/screenshots/difficulty.svg
new file mode 100644
index 0000000..a5640ff
--- /dev/null
+++ b/assets/screenshots/difficulty.svg
@@ -0,0 +1,1521 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TextualGameUI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Choose your difficulty
+
+ █ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ █ ╭ ──────────────────────────────────────────── ╮ ╭ ──────────────────────────────────────────── ╮
+ █ █ │ │ │ │
+ █ █ │ ▄ ▄ │ │ ▄ ▄ │
+ █ ▄ ▄ ▄ █ │ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ █ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ █ │ │ │ │
+ █ █ │ │ │ │
+ █ EASY █ │ MEDIUM │ │ HARD │
+ █ 🌱 █ │ ⚖ │ │ 🔥 │
+ █ Gentler enemies, faster leveling. █ │ The intended, balanced challenge. │ │ Tougher enemies, longer fights; │
+ █ For learning the ropes. █ │ │ │ you level slower. │
+ █ █ ╰ ──────────────────────────────────────────── ╯ │ │
+ █ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄ █ ╰ ──────────────────────────────────────────── ╯
+
+ ←/→ choose · Enter confirm · 1-3 direct
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/screenshots/explore.svg b/assets/screenshots/explore.svg
new file mode 100644
index 0000000..379fe15
--- /dev/null
+++ b/assets/screenshots/explore.svg
@@ -0,0 +1,2307 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TextualGameUI
+
+
+
+
+
+
+
+
+
+ Haunted Terminal
+ ╭ ─ 🏠 The Graveyard ─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╮ ╭ ─ 📦 Inventory ─────────────────────────────────────────── ╮
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ ═══ COMMON ═══ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ 💊 Health Packet +30 HP │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │ │ │
+ │ 👤 Grove Guardian │ │ │
+ ╰ ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── → /var → /mnt → / ─ ╯ │ │
+ ╭ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╮ │ │
+ │ │ │ │
+ │ The Graveyard │ │ │
+ │ │ │ │
+ │ A foggy sector of orphaned files and broken symlinks. The air tastes of forgotten data. │ ╰ ────────────────────────────────────────────────────────── ╯
+ │ │ ╭ ─ 📊 Stats ─────────────────────────────────────────────── ╮
+ │ A sanctuary of code-trees and branching directories. Streams of green text flow gently like rivers of syntax. This is where new spirits awaken, free from daemon corruption. │ │ │
+ │ Safe, quiet, but humming with potential. │ │ 🛡 ADA │
+ │ │ │ Class: Guardian │
+ │ ECHO> Got it. Quick reference: ls scans a room, take/equip grab and ready items; Tab finishes a name you've started. Combat opens in Selection Mode automatically — press 1-9 │ │ Lvl 1 · 0/100 cycles │
+ │ to attack, 0 to flee. Press TAB to type use <item> instead. pwd shows where you are, cd moves ( cd .. goes up), ps lists running processes, ls -a reveals hidden files. cat │ │ │
+ │ reads a file — restoring a memory saves a checkpoint you resume from if you fall. The goal: every directory hides a flag . Read it with cat , search a long file with grep FLAG │ │ HP: 120/120 │
+ │ <file> , or end a rogue process with kill <PID> . Some flags hand you a key, and a new directory appears. Hold 11 flags and /boot opens. Type hint when stuck in a room, man to │ │ ████████████ │
+ │ look a command up. Good luck. │ │ │
+ │ │ │ Attack: 10 │
+ │ │ │ │
+ │ │ ╰ ────────────────────────────────────────────────────────── ╯
+ │ │ ╭ ─ 🧍 Character ─────────────────────────────────────────── ╮
+ │ │ │ 🗡 Weapon │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ none — type equip <name> │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ 🛡 Armor │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ none — type equip <name> │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ ╰ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╯ ╰ ────────────────────────────────────────────────────────── ╯
+ ╭ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╮
+ │ E nter command... │
+ ╰ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╯
+ ctrl + q Quit ctrl + p Settings F5 Restart Game
+
+
+
diff --git a/assets/screenshots/saves.svg b/assets/screenshots/saves.svg
new file mode 100644
index 0000000..491a694
--- /dev/null
+++ b/assets/screenshots/saves.svg
@@ -0,0 +1,597 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TextualGameUI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ CONTINUE A RUN
+
+ ╭ ──────────────────────────────────────────────────────── ╮ ╭ ──────────────────────────────────── ╮
+ │ ▶ 🌿 Mo Shaman · medium /home L2 │ │ │
+ │ ✨ Zed Weaver · hard /usr L6 │ │ ▄ ▄ ▄ ▄ ▄ │
+ │ 🛡 Ada Guardian · easy /var L4 │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ │ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ ╰ ──────────────────────────────────────────────────────── ╯ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ ▄ │
+ │ MO · SHAMAN │
+ │ medium · Level 2 │
+ │ /home │
+ │ HP 120/120 │
+ │ saved just now │
+ ╰ ──────────────────────────────────── ╯
+
+ ↑/↓ choose · enter continue · d delete · esc back
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/screenshots/settings.svg b/assets/screenshots/settings.svg
new file mode 100644
index 0000000..af83ebc
--- /dev/null
+++ b/assets/screenshots/settings.svg
@@ -0,0 +1,323 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ TextualGameUI
+
+
+
+
+
+
+
+
+
+ Haunted Terminal
+ ╭ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╮
+ │ │
+ │ │
+ │ │
+ │ ██╗ ██╗ █████╗ ██╗ ██╗███╗ ██╗████████╗███████╗██████╗ │
+ │ ██║ ██║██╔══██╗██║ ██║████╗ ██║╚══██╔══╝██╔════╝██╔══██╗ │
+ │ ███████║███████║██║ ██║██╔██╗ ██║ ██║ █████╗ ██║ ██║ │
+ │ ██╔══██║██╔══██║██║ ██║██║╚██╗██║ ██║ ██╔══╝ ██║ ██║ │
+ │ ██║ ██║██║ ██║╚██████╔╝██║ ╚████║ ██║ ███████╗██████╔╝ │
+ │ ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═════╝ │
+ │ │
+ │ ████████╗███████╗██████╗ ███╗ ███╗██╗███╗ ██╗ █████╗ ██╗ │
+ │ ╚══██╔══╝██╔════╝██╔══██╗████╗ ████║██║████╗ ██║██╔══██╗██║ │
+ │ ██║ █████╗ ██████╔╝██╔████╔██║██║██╔██╗ ██║███████║██║ │
+ │ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║██║██║╚██╗██║██╔══██║██║ │
+ │ ██║ ███████╗██║ ██║██║ ╚═╝ ██║██║██║ ╚████║██║ ██║███████╗ │
+ │ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝ │
+ │ │
+ │ A Terminal Adventure by NoHudd │
+ │ v0.2.0 · 76563aa │
+ │ [press any key to skip intro] │
+ │ │
+ │ │
+ │ >>> INITIALIZING SYSTEM MEMORY... <<< │
+ │ Fragments ╭ ────────────────────────────────────────────────────────── ╮ │
+ │ The great │ │ mlinks and │
+ │ phantom pr │ ⚙ Settings │ │
+ │ │ │ │
+ │ Once, sysa │ ▶ Color palette ◀ Default ▶ │ │
+ │ But now… y │ Text speed Normal │ │
+ │ a Sysadmin │ Reduce motion Off │ │
+ │ │ In-game hints On │ │
+ │ At the sys │ │ │
+ │ a malignan │ Recolor the whole interface. │ │
+ │ rewriting │ │ │
+ │ │ ↑/↓ choose · ←/→ change · esc back │ │
+ │ Every dire │ │ │
+ │ Find them ╰ ────────────────────────────────────────────────────────── ╯ │
+ │ Hold enough of them, and the kernel at /boot will let you in. │
+ │ │
+ │ Your mission: traverse the haunted filesystem, │
+ │ purge corrupted sectors, reclaim lost commands, │
+ │ and restore the root. │
+ │ │
+ │ Fail, and the machine is consumed. │
+ │ Succeed, and the filesystem breathes again. │
+ │ │
+ │ │
+ │ ▶ NEW GAME ◀ │
+ │ LOAD GAME │
+ │ SETTINGS │
+ │ EXIT │
+ │ │
+ │ ↑/↓ to select ↵ to confirm esc to quit │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ │ │
+ ╰ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── ╯
+ ctrl + q Quit ctrl + p Settings F5 Restart Game
+
+
+
diff --git a/assets/sprites/backdrops/rooms/archive.png b/assets/sprites/backdrops/rooms/archive.png
new file mode 100644
index 0000000..4cea44e
Binary files /dev/null and b/assets/sprites/backdrops/rooms/archive.png differ
diff --git a/assets/sprites/backdrops/rooms/deprecated_dir.png b/assets/sprites/backdrops/rooms/deprecated_dir.png
new file mode 100644
index 0000000..7f67456
Binary files /dev/null and b/assets/sprites/backdrops/rooms/deprecated_dir.png differ
diff --git a/assets/sprites/backdrops/rooms/ghost_hidden.png b/assets/sprites/backdrops/rooms/ghost_hidden.png
new file mode 100644
index 0000000..bbf81b2
Binary files /dev/null and b/assets/sprites/backdrops/rooms/ghost_hidden.png differ
diff --git a/assets/sprites/backdrops/rooms/opt_mage_tower.png b/assets/sprites/backdrops/rooms/opt_mage_tower.png
new file mode 100644
index 0000000..28f95e2
Binary files /dev/null and b/assets/sprites/backdrops/rooms/opt_mage_tower.png differ
diff --git a/assets/sprites/backdrops/rooms/proc_secrets.png b/assets/sprites/backdrops/rooms/proc_secrets.png
new file mode 100644
index 0000000..a3c97b7
Binary files /dev/null and b/assets/sprites/backdrops/rooms/proc_secrets.png differ
diff --git a/assets/sprites/backdrops/rooms/srv_warrior_tomb.png b/assets/sprites/backdrops/rooms/srv_warrior_tomb.png
new file mode 100644
index 0000000..db50408
Binary files /dev/null and b/assets/sprites/backdrops/rooms/srv_warrior_tomb.png differ
diff --git a/assets/sprites/backdrops/rooms/usr_lib_arcane.png b/assets/sprites/backdrops/rooms/usr_lib_arcane.png
new file mode 100644
index 0000000..e859186
Binary files /dev/null and b/assets/sprites/backdrops/rooms/usr_lib_arcane.png differ
diff --git a/assets/sprites/enemies/rogue_crond.d.png b/assets/sprites/enemies/rogue_crond.d.png
new file mode 100644
index 0000000..8e240b1
Binary files /dev/null and b/assets/sprites/enemies/rogue_crond.d.png differ
diff --git a/assets/sprites/enemies/runaway_fork.bomb.png b/assets/sprites/enemies/runaway_fork.bomb.png
new file mode 100644
index 0000000..6093a96
Binary files /dev/null and b/assets/sprites/enemies/runaway_fork.bomb.png differ
diff --git a/assets/sprites/enemies/zombie_tmpwatch.sh.png b/assets/sprites/enemies/zombie_tmpwatch.sh.png
new file mode 100644
index 0000000..7c22219
Binary files /dev/null and b/assets/sprites/enemies/zombie_tmpwatch.sh.png differ
diff --git a/config/settings_manager.py b/config/settings_manager.py
index 793093d..68d3330 100644
--- a/config/settings_manager.py
+++ b/config/settings_manager.py
@@ -13,7 +13,6 @@
"reduce_motion": False,
"hints": True, # in-game inline affordances (→ take/cat/cd)
"seen_selection_mode": False, # combat selection-mode modal: show once ever
- "difficulty": "medium", # easy | medium | hard — scales enemy stats + XP
}
# Palette definitions: name → (primary, success, error, warning, accent)
@@ -119,17 +118,6 @@ def set_hints(self, enabled: bool) -> None:
import config.dev_config as dev_cfg
dev_cfg.SHOW_HINTS = enabled
- def set_difficulty(self, mode: str) -> None:
- """Set the difficulty mode (easy/medium/hard) and apply it live."""
- from src import difficulty
- self.settings["difficulty"] = mode if mode in difficulty.MODES else "medium"
- difficulty.set_mode(self.settings["difficulty"])
-
- def apply_all(self) -> None:
- """Push loaded settings into runtime config. Call once after load()."""
- self.set_hints(self.settings.get("hints", True))
- self.set_difficulty(self.settings.get("difficulty", "medium"))
-
def save(self) -> None:
"""Write current settings to JSON file."""
try:
diff --git a/data/classes.yaml b/data/classes.yaml
index d658fa7..d2a82ac 100644
--- a/data/classes.yaml
+++ b/data/classes.yaml
@@ -5,6 +5,7 @@ classes:
base_health: 120
base_damage: 10
starter_weapon: "segfault_shield"
+ starter_armor: "cracked_firewall"
starter_abilities:
- system_shield
- core_recovery
@@ -17,9 +18,9 @@ classes:
- shield_bash
display:
color: blue
- hp_label: "High Health (120 HP)"
+ hp_label: "High Health"
hp_color: green
- dmg_label: "Moderate Damage (10 DMG)"
+ dmg_label: "Moderate Damage"
dmg_color: yellow
weapon_name: "Segfault Shield"
echo_description: "a defender of the core systems, wielding shields and restoration protocols"
@@ -30,6 +31,7 @@ classes:
base_health: 90
base_damage: 15
starter_weapon: "null_pointer"
+ starter_armor: "threadbare_buffer"
starter_abilities:
- code_injection
- exploit_vulnerability
@@ -42,9 +44,9 @@ classes:
- frost_nova
display:
color: red
- hp_label: "Moderate Health (90 HP)"
+ hp_label: "Moderate Health"
hp_color: yellow
- dmg_label: "High Damage (15 DMG)"
+ dmg_label: "High Damage"
dmg_color: green
weapon_name: "Null Pointer"
echo_description: "an aggressive code manipulator who exploits system vulnerabilities"
@@ -55,6 +57,7 @@ classes:
base_health: 120
base_damage: 10
starter_weapon: "daemon_whisper"
+ starter_armor: "frayed_mantra_cloth"
starter_abilities:
- data_stitch
- ghost_sense
@@ -67,9 +70,9 @@ classes:
- healing_strike
display:
color: green
- hp_label: "Balanced Health (100 HP)"
- hp_color: yellow
- dmg_label: "Low Damage (8 DMG)"
- dmg_color: red
+ hp_label: "High Health"
+ hp_color: green
+ dmg_label: "Moderate Damage"
+ dmg_color: yellow
weapon_name: "Daemon Whisper"
echo_description: "a mystic who communes with lost data and heals corrupted sectors"
\ No newline at end of file
diff --git a/data/difficulty.proposed.yaml b/data/difficulty.proposed.yaml
deleted file mode 100644
index 338569f..0000000
--- a/data/difficulty.proposed.yaml
+++ /dev/null
@@ -1,14 +0,0 @@
-# PROPOSED difficulty multipliers (sim.tune). Review, then copy
-# over data/difficulty.yaml if the numbers look right.
-easy:
- enemy_hp: 0.85
- enemy_damage: 0.9
- xp_gain: 2.4
-medium:
- enemy_hp: 0.95
- enemy_damage: 0.95
- xp_gain: 2.0
-hard:
- enemy_hp: 1.15
- enemy_damage: 0.85
- xp_gain: 1.8
diff --git a/data/difficulty.yaml b/data/difficulty.yaml
index d2ed73a..a2496ac 100644
--- a/data/difficulty.yaml
+++ b/data/difficulty.yaml
@@ -1,30 +1,39 @@
# Live difficulty multipliers.
#
-# Re-tuned 2026-09-06, after the sim gauntlet began including key-locked rooms.
-# Locks now carry the difficulty ramp, so excluding them measured a run nobody
-# plays: /etc is the only route to the boss and /boot is the boss, and both were
-# missing from the measurement. The gauntlet went from 6 enemies to 9, ending on
-# the Daemon Overlord.
+# STALE AS OF 2026-09-30 (later the same day): a flag capture now restores
+# full HP. Deaths in these runs came from attrition across fights, so the sim
+# now wins ~100% in every mode (before: 94/80/65% avg on seed 1000 @200).
+# Even any partial refill (10%) does the same. The numbers below predate
+# that; re-tune (sim.tune) only if the modes should differ in outcome again.
#
-# Sim @50 runs/class (python -m sim.playtest measure --mode all --class all):
-# easy 98.0% avg (band 90-98)
-# medium 84.7% avg (band 75-85)
-# hard 54.7% avg (band 50-70)
+# Re-tuned 2026-09-30 for room flags. Runs now include the three rogue-process
+# fights (/proc, /var/tmp, /etc) and up to 12 flags' worth of XP (15 cycles
+# each), which made every mode near-certain (avg 97-99%).
#
-# Hard is harder than medium through enemy_hp 1.15 (longer fights), not per-hit
-# damage. xp_gain is high across the board because enemies award their real
-# `experience` value, which is far below the flat 50 the game once assumed.
+# enemy_damage is held equal (0.9) and ordered with enemy_hp
+# (easy <= medium <= hard), so no mode hits harder per blow than a harder one;
+# the ramp comes from enemy_hp. xp_gain barely moves outcomes; enemy_hp is
+# steep (hard: 1.10 -> 66%, 1.15 -> 46%).
#
-# Calibrated by: python -m sim.tune --mode all --runs 50
+# Chosen by a grid on seed 1000 @100 runs/class, verified out of sample on
+# seed 2000 @200 runs/class (sim.simulator.measure), avg win rate:
+# guardian weaver shaman avg band
+# easy 97.0% 90.0% 99.5% 95.5% 90-98
+# medium 88.5% 72.5% 89.0% 83.3% 75-85
+# hard 77.0% 55.5% 72.0% 68.2% 50-70
+#
+# The sim gives rogue kills no gear progress (the game drops none), and pays
+# flag XP raw while combat XP is scaled, as the game does. Weaver is weakest
+# in every mode; evening the classes out is kit work, not a multiplier.
easy:
enemy_hp: 0.85
enemy_damage: 0.9
xp_gain: 2.4
medium:
- enemy_hp: 0.95
- enemy_damage: 0.95
- xp_gain: 2.0
+ enemy_hp: 1.05
+ enemy_damage: 0.9
+ xp_gain: 1.8
hard:
- enemy_hp: 1.15
- enemy_damage: 0.85
+ enemy_hp: 1.1
+ enemy_damage: 0.9
xp_gain: 1.8
diff --git a/data/enemies/rogue_crond.d.yml b/data/enemies/rogue_crond.d.yml
new file mode 100644
index 0000000..244d898
--- /dev/null
+++ b/data/enemies/rogue_crond.d.yml
@@ -0,0 +1,14 @@
+name: rogue_crond.d
+short_description: A scheduler that rewrites every config it touches
+description: crond went rogue and schedules itself to rewrite /etc every minute.
+health: 22
+damage: 2
+is_boss: false
+auto_attack: true
+dialogue: "* * * * * rewrite /etc/everything"
+drops: []
+on_defeat:
+ message: The rogue crond stops. Your configs stop shifting.
+tier: 1
+pool_excluded: true # rogue process: only `kill 4242` in /etc brings it out
+experience: 8
diff --git a/data/enemies/runaway_fork.bomb.yml b/data/enemies/runaway_fork.bomb.yml
new file mode 100644
index 0000000..bf68b18
--- /dev/null
+++ b/data/enemies/runaway_fork.bomb.yml
@@ -0,0 +1,14 @@
+name: runaway_fork.bomb
+short_description: A fork bomb that keeps copying itself
+description: A process that forked and never stopped. Weak alone — there is just so much of it.
+health: 20
+damage: 2
+is_boss: false
+auto_attack: true
+dialogue: "fork() fork() fork() fork()..."
+drops: []
+on_defeat:
+ message: The last copy exits. The CPU fan finally slows.
+tier: 1
+pool_excluded: true # rogue process: only `kill 1337` in /proc brings it out
+experience: 8
diff --git a/data/enemies/zombie_tmpwatch.sh.yml b/data/enemies/zombie_tmpwatch.sh.yml
new file mode 100644
index 0000000..8e4cd1f
--- /dev/null
+++ b/data/enemies/zombie_tmpwatch.sh.yml
@@ -0,0 +1,14 @@
+name: zombie_tmpwatch.sh
+short_description: A cleanup script that died and kept running
+description: tmpwatch was meant to sweep /var/tmp. It died mid-sweep and never noticed.
+health: 20
+damage: 2
+is_boss: false
+auto_attack: true
+dialogue: "sweeping... sweeping... sweeping..."
+drops: []
+on_defeat:
+ message: The zombie script is finally reaped.
+tier: 1
+pool_excluded: true # rogue process: only `kill 2048` in /var/tmp brings it out
+experience: 8
diff --git a/data/hidden_rooms_guide.txt b/data/hidden_rooms_guide.txt
deleted file mode 100644
index 200dc8d..0000000
--- a/data/hidden_rooms_guide.txt
+++ /dev/null
@@ -1,42 +0,0 @@
-# Hidden Rooms Discovery Guide
-
-The filesystem contains several hidden directories that don't appear in normal navigation. These secret areas contain powerful items and unique encounters that can significantly help your journey.
-
-## How to Discover Hidden Rooms
-
-1. **The /etc Configuration Room**
- - Hint found in: /usr/lib/arcane
- - Discovery method: Look for hidden files using `ls -a` in the Arcane Library
- - Clue text: "To find what's hidden, one must look at all, even that which seems invisible."
- - Once found, you can access via: `cd /etc/hidden_configs`
-
-2. **The /dev/null Void**
- - Hint found in: /bin/armory
- - Discovery method: Examine the output of corrupt commands and look for void references
- - Execute the command: `find /dev -type f -name "null"`
- - Once found, you can access via: `cd /dev/null_void`
-
-3. **The /proc Secrets Chamber**
- - Hint found in: /mnt/forest
- - Discovery method: Use `ps` or process-related commands when prompted by the lost_user.dat NPC
- - Follow the cryptic suggestion to "examine the living processes"
- - Once found, you can access via: `cd /proc/secrets`
-
-## Rewards for Discovery
-
-Each hidden room contains unique and powerful items:
-
-- **/etc/hidden_configs**: Configuration files that can permanently boost your stats
-- **/dev/null_void**: Recover powerful deleted weapons and spells
-- **/proc/secrets**: Gain insights into the system's operation, providing combat advantages
-
-## Additional Hidden Content
-
-Throughout the filesystem, there are other secrets that can be discovered using Linux command knowledge:
-
-1. Use `grep` to search for hidden passwords in text files
-2. Try `cat` on seemingly insignificant files - they may contain crucial hints
-3. The `find` command can reveal hidden paths and secret items
-4. Some NPCs will provide extra information if you use the right commands in conversation
-
-Remember: Exploration and clever use of Linux commands will be rewarded!
\ No newline at end of file
diff --git a/data/items/armor.yaml b/data/items/armor.yaml
index d3fb1af..ef73a6b 100644
--- a/data/items/armor.yaml
+++ b/data/items/armor.yaml
@@ -1,3 +1,42 @@
+# STARTER ARMOR — one per class, placed in /home at character creation.
+# Deliberately junk (defense 2 = 3% less damage): it exists so the tutorial can
+# teach `equip` for armor and the Defense stat; any armor found later is better.
+cracked_firewall:
+ name: "Cracked Firewall"
+ description: "A firewall ruleset with more holes than rules. It stops almost nothing, but almost is not nothing."
+ type: "armor"
+ defense: 2
+ allowed_classes:
+ - guardian
+ rarity: common
+ tags: ["armor", "starter"]
+ allowed_zones: ["home"]
+ persistence: "persistent"
+
+threadbare_buffer:
+ name: "Threadbare Buffer"
+ description: "A thin buffer woven from leftover threads. It overflows at the first hit, but it beats bare memory."
+ type: "armor"
+ defense: 2
+ allowed_classes:
+ - weaver
+ rarity: common
+ tags: ["armor", "starter"]
+ allowed_zones: ["home"]
+ persistence: "persistent"
+
+frayed_mantra_cloth:
+ name: "Frayed Mantra Cloth"
+ description: "A cloth once chanted with protective mantras. Most of the words have worn away."
+ type: "armor"
+ defense: 2
+ allowed_classes:
+ - shaman
+ rarity: common
+ tags: ["armor", "starter"]
+ allowed_zones: ["home"]
+ persistence: "persistent"
+
# UNCOMMON ARMOR
firewall_mantle:
name: "Firewall Mantle"
@@ -70,7 +109,7 @@ null_void_cloak:
- shaman
rarity: epic
tags: ["armor", "cloak", "evasion", "void"]
- allowed_zones: ["dev", "root"]
+ allowed_zones: ["dev", "etc", "opt", "srv"]
persistence: "persistent"
special_effects:
- type: "evasion_boost"
@@ -117,5 +156,5 @@ aegis_partition:
- guardian
rarity: epic
tags: ["armor", "partition", "bulwark"]
- allowed_zones: ["dev", "root"]
+ allowed_zones: ["dev", "etc", "opt", "srv"]
persistence: "persistent"
diff --git a/data/items/keys.yaml b/data/items/keys.yaml
index 5036f12..3113c2c 100644
--- a/data/items/keys.yaml
+++ b/data/items/keys.yaml
@@ -16,28 +16,6 @@ lib_key:
on_use:
message: The key glows with arcane energy, waiting to be used at the proper location.
-master_key:
- name: Master Key
- short_description: A master key for the core system
- description: A digital master key that can unlock the core system directory. It's heavily encrypted and seems to be one-use only.
- takeable: true
- usable: true
- type: "key"
- unlocks: ["core"]
- max_spawn: 1
- allowed_rooms:
- - archive
- rarity: unique
- only_in_unlocked: false
- consumable: true
- consumed_on_take: true
- on_take:
- message: As you take the master key, you feel the weight of its importance. The system itself seems to acknowledge its power.
- on_use:
- message: The master key pulses with intense energy as it interfaces with the core system lock.
- on_examine:
- message: This is the legendary master key, capable of unlocking the corrupted core. It can only be used once as it will dissolve into the lock mechanism.
-
system_badge:
name: System Badge
short_description: An administrative access badge
@@ -58,8 +36,8 @@ system_badge:
opt_key:
name: "Optional Access Key"
- description: "A powerful key that unlocks access to class-restricted areas like the mage tower and warrior tomb."
- short_description: A key for accessing optional class-specific areas
+ description: "A heavy key handed out by the Arcane Library's flag. It opens /srv, the Warrior Tomb, and a secret directory not yet found."
+ short_description: Opens /srv, the Warrior Tomb
type: "key"
takeable: true
usable: true
@@ -77,8 +55,8 @@ opt_key:
# Great Kernel Panic Story Keys
chmod_key:
name: "chmod Key"
- description: "A crystallized permission modifier. The Firewall Knight will recognize this as proof of your authority."
- short_description: A key that grants permission to pass the Firewall Knight
+ description: "A crystallized permission modifier handed out by the Memory Banks' flag. It opens /etc, where the Firewall Knight guards the way to /boot."
+ short_description: Opens /etc, the Kernel Gate
type: "key"
takeable: true
usable: true
diff --git a/data/items/lore_fragments.yaml b/data/items/lore_fragments.yaml
index 532ea65..87645da 100644
--- a/data/items/lore_fragments.yaml
+++ b/data/items/lore_fragments.yaml
@@ -1,4 +1,5 @@
-# Story Beat Items - These trigger narrative progression when read
+# Story files: every one is hidden (ls -a) and restores a memory when read with cat
+# (journal entry + autosave).
bash_profile:
name: ".bash_profile"
short_description: "Your configuration file - may restore memories"
@@ -41,6 +42,7 @@ system_err_log:
tags: ["lore", "story_beat", "typo"]
allowed_zones: ["var"] # Found in /var/log (The Memory Banks)
persistence: "persistent"
+ hidden: true # Requires ls -a to see
usable: false
readable: true
takeable: false # Read in place — story beat triggers on cat
@@ -67,9 +69,11 @@ ancient_manual_man:
tags: ["lore", "tutorial", "hidden_files"]
allowed_zones: ["bin", "usr"] # Found in /bin (The Armory)
persistence: "persistent"
+ hidden: true # Requires ls -a to see
usable: false
readable: true
- takeable: false # Read in place — keeps inventory uncluttered
+ takeable: false # Read in place — story beat triggers on cat
+ story_flag: "manual_recovered"
content: |
ANCIENT MANUAL - Commands of the Creators
@@ -98,9 +102,11 @@ cow_wisdom_log:
tags: ["lore", "easter_egg_hint", "bovine"]
allowed_zones: ["usr"] # Found in /usr/games (The Game Gallery)
persistence: "persistent"
+ hidden: true # Requires ls -a to see
usable: false
readable: true
- takeable: false # Read in place — keeps inventory uncluttered
+ takeable: false # Read in place — story beat triggers on cat
+ story_flag: "bovine_prophecy"
content: |
[LOG ENTRY 4815162342]
@@ -128,7 +134,8 @@ moo_file:
hidden: true # Requires ls -a to see
usable: false
readable: true
- takeable: false # Read in place — keeps inventory uncluttered
+ takeable: false # Read in place — story beat triggers on cat
+ story_flag: "moo_heard"
triggers_npc_spawn: "great_ascii_bovine"
content: |
_______________________________________
@@ -156,9 +163,11 @@ readme_txt_corrupt:
tags: ["lore", "corruption", "horror"]
allowed_zones: ["home", "var", "deprecated_dir"]
persistence: "persistent"
+ hidden: true # Requires ls -a to see
usable: false
readable: true
- takeable: false # Read in place — keeps inventory uncluttered
+ takeable: false # Read in place — story beat triggers on cat
+ story_flag: "corruption_witnessed"
content: |
The disk is clicking.
Can you hear it?
@@ -167,3 +176,206 @@ readme_txt_corrupt:
010101010101010101010101010101
[FILE CORRUPTED - UNABLE TO READ FURTHER]
+
+motd:
+ name: "motd"
+ short_description: "Message of the day — the first thing a system says"
+ description: "The message of the day. Every login used to print it."
+ type: "lore"
+ allowed_classes: [guardian, weaver, shaman]
+ rarity: common
+ tags: ["lore", "flag"]
+ allowed_zones: [] # Placed in / by its room file
+ persistence: "persistent"
+ usable: false
+ readable: true
+ takeable: false
+ content: |
+ *** MESSAGE OF THE DAY ***
+ Welcome back, operator. The filesystem is corrupted.
+ Each directory still guards one flag. Find them all.
+
+ FLAG{cat_reads_files}
+
+flag:
+ name: ".flag"
+ short_description: "A flag root left for whoever looked hard enough"
+ description: "Root's own flag, hidden in root's home."
+ type: "lore"
+ allowed_classes: [guardian, weaver, shaman]
+ rarity: rare
+ tags: ["lore", "flag"]
+ allowed_zones: [] # Placed in /root by its room file
+ persistence: "persistent"
+ hidden: true
+ usable: false
+ readable: true
+ takeable: false
+ content: |
+ root was here.
+
+ FLAG{root_sees_all}
+
+lost_user_log:
+ name: "lost_user_log"
+ short_description: "A lost user's log — hundreds of lines, never reread"
+ description: "Everything the lost user ever typed, written down and never read."
+ type: "lore"
+ allowed_classes: [guardian, weaver, shaman]
+ rarity: common
+ tags: ["lore", "flag"]
+ allowed_zones: []
+ persistence: "persistent"
+ usable: false
+ readable: true
+ takeable: false
+ log:
+ lines: 300
+ templates:
+ - "[{ts}] user: cd /mnt/drive{n}"
+ - "[{ts}] user: ls"
+ - "[{ts}] user: where did I put it"
+ - "[{ts}] user: cat notes{n}.txt"
+ - "[{ts}] user: I know it's in here somewhere"
+ flag_line: "[{ts}] user: note to self, the flag is FLAG{grep_finds_needles}"
+
+catalog_db:
+ name: "catalog_db"
+ short_description: "The library's catalog — every title ever written"
+ description: "The Arcane Library's catalog. Nobody reads it front to back."
+ type: "lore"
+ allowed_classes: [guardian, weaver, shaman]
+ rarity: common
+ tags: ["lore", "flag"]
+ allowed_zones: []
+ persistence: "persistent"
+ usable: false
+ readable: true
+ takeable: false
+ log:
+ lines: 400
+ templates:
+ - "[{ts}] catalog: shelf {n} — Advanced Pointer Arithmetic"
+ - "[{ts}] catalog: shelf {n} — The Joy of Segfaults"
+ - "[{ts}] catalog: shelf {n} — Recursion, Recursion, Recursion"
+ - "[{ts}] catalog: shelf {n} — Regular Expressions for the Weary"
+ flag_line: "[{ts}] catalog: shelf 404 — FLAG{knowledge_is_searchable}"
+
+kern_log:
+ name: "kern_log"
+ short_description: "The kernel log — even the void writes one"
+ description: "Kernel messages from the edge of /dev. Most of them are noise."
+ type: "lore"
+ allowed_classes: [guardian, weaver, shaman]
+ rarity: common
+ tags: ["lore", "flag"]
+ allowed_zones: []
+ persistence: "persistent"
+ usable: false
+ readable: true
+ takeable: false
+ log:
+ lines: 400
+ templates:
+ - "[{ts}] kernel: /dev/null: discarded {n} bytes"
+ - "[{ts}] kernel: usb {n}-1: device descriptor read failed"
+ - "[{ts}] kernel: void: nothing happened again"
+ - "[{ts}] kernel: tty{n}: hangup"
+ flag_line: "[{ts}] kernel: void: something survived: FLAG{even_the_void_logs}"
+
+access_log:
+ name: "access_log"
+ short_description: "The tomb's access log — every challenger who came"
+ description: "Who entered the Warrior Tomb, and when. Hundreds of names."
+ type: "lore"
+ allowed_classes: [guardian, weaver, shaman]
+ rarity: common
+ tags: ["lore", "flag"]
+ allowed_zones: []
+ persistence: "persistent"
+ usable: false
+ readable: true
+ takeable: false
+ log:
+ lines: 400
+ templates:
+ - "[{ts}] access: challenger {n} entered"
+ - "[{ts}] access: challenger {n} fled"
+ - "[{ts}] access: challenger {n} fell"
+ - "[{ts}] access: gate check passed"
+ flag_line: "[{ts}] access: the victor carved FLAG{warriors_leave_access_logs}"
+
+backup_tar_log:
+ name: "backup_tar_log"
+ short_description: "A backup job's log — thousands of archived files"
+ description: "What the last backup saved before everything broke."
+ type: "lore"
+ allowed_classes: [guardian, weaver, shaman]
+ rarity: rare
+ tags: ["lore", "flag"]
+ allowed_zones: []
+ persistence: "persistent"
+ usable: false
+ readable: true
+ takeable: false
+ log:
+ lines: 500
+ templates:
+ - "[{ts}] tar: added home/user{n}/notes.txt"
+ - "[{ts}] tar: added etc/config{n}.conf"
+ - "[{ts}] tar: skipped socket /tmp/sock{n}"
+ flag_line: "[{ts}] tar: added root/FLAG{backups_remember}"
+
+access_readme:
+ name: "access_readme"
+ short_description: "A note left by the tomb's keeper"
+ description: "A short note about the access log."
+ type: "lore"
+ allowed_classes: [guardian, weaver, shaman]
+ rarity: common
+ tags: ["lore", "note"]
+ allowed_zones: []
+ persistence: "persistent"
+ usable: false
+ readable: true
+ takeable: false
+ content: |
+ The access log is hundreds of lines long. Don't read it.
+ If you want one word out of a big file, search for it:
+
+ grep access_log
+
+backup_readme:
+ name: "backup_readme"
+ short_description: "The backup job's own note"
+ description: "Instructions left by whoever ran the backup."
+ type: "lore"
+ allowed_classes: [guardian, weaver, shaman]
+ rarity: common
+ tags: ["lore", "note"]
+ allowed_zones: []
+ persistence: "persistent"
+ usable: false
+ readable: true
+ takeable: false
+ content: |
+ Backup finished. The log lists every file it saved.
+ Looking for one? grep the log for it.
+
+tmp_notice:
+ name: "tmp_notice"
+ short_description: "A cleanup notice pinned to the door"
+ description: "Someone's warning about /var/tmp."
+ type: "lore"
+ allowed_classes: [guardian, weaver, shaman]
+ rarity: common
+ tags: ["lore", "note"]
+ allowed_zones: []
+ persistence: "persistent"
+ usable: false
+ readable: true
+ takeable: false
+ content: |
+ NOTICE: the cleanup job here died but is still running.
+ ps shows every process in this directory, with its PID.
+ kill ends one. Don't kill PID 1.
diff --git a/data/items/weapons.yaml b/data/items/weapons.yaml
index 711b6fb..d47210c 100644
--- a/data/items/weapons.yaml
+++ b/data/items/weapons.yaml
@@ -111,7 +111,8 @@ zero_day_blade:
- shaman
rarity: legendary
tags: ["legendary", "exploit", "true_damage", "zero_day"]
- allowed_zones: ["root"]
+ allowed_zones: ["etc", "core"]
+ allowed_rooms: ["etc_hidden_configs", "core"] # /etc and /boot, both locked
persistence: "persistent"
special_effects:
- type: "true_damage"
@@ -221,7 +222,8 @@ rootkit_reaper:
- shaman
rarity: legendary
tags: ["legendary", "exploit", "stealth", "offensive"]
- allowed_zones: ["root"]
+ allowed_zones: ["etc", "core"]
+ allowed_rooms: ["etc_hidden_configs", "core"] # /etc and /boot, both locked
persistence: "persistent"
segfault_singularity:
@@ -233,5 +235,6 @@ segfault_singularity:
- guardian
rarity: legendary
tags: ["legendary", "memory", "gravity", "offensive"]
- allowed_zones: ["root", "dev"]
+ allowed_zones: ["etc", "core"]
+ allowed_rooms: ["etc_hidden_configs", "core"] # /etc and /boot, both locked
persistence: "persistent"
diff --git a/data/npcs/echo.usr.yml b/data/npcs/echo.usr.yml
index 0f9f004..dc96e0e 100644
--- a/data/npcs/echo.usr.yml
+++ b/data/npcs/echo.usr.yml
@@ -29,7 +29,7 @@ dialogue:
- "The corruption is just the system crying out. Listen to the logs... they tell the truth."
- "Seek the lore fragments scattered across the filesystem. They contain your history."
- "The Daemon Overlord resides in /boot/kernel - the Core. But you cannot reach it without permission."
- - "The Firewall Knight guards the gate. Only the chmod Key will grant you passage."
+ - "The kernel at /boot opens only when you hold eleven flags. tree shows the flags you hold."
navigation_help:
- "Lost? Use 'ls' to see what's in your current directory. Use 'cd ' to move."
diff --git a/data/npcs/firewall_knight.iptables.yml b/data/npcs/firewall_knight.iptables.yml
index cf8ad7b..3c17848 100644
--- a/data/npcs/firewall_knight.iptables.yml
+++ b/data/npcs/firewall_knight.iptables.yml
@@ -11,7 +11,7 @@ detailed_description: |
that still functions as intended. The Firewall Knight does not serve the Daemon Overlord.
It serves the system's original purpose: to protect the Core from unauthorized access.
- You will not pass without the chmod Key. Not through force. Not through trickery.
+ You will not pass to the Core without proof: eleven flags. Not through force. Not through trickery.
The rules are absolute.
location: etc_hidden_configs
hostile: false
@@ -23,17 +23,17 @@ unlocks_room: core
dialogue:
greeting:
- "HALT. This directory is READ-ONLY. State your authorization level."
- - "I am the Firewall Knight. I permit no unauthorized access to /boot/kernel."
- - "The chmod Key grants passage. Without it, you are DENIED."
+ - "I am the Firewall Knight. I guard the way to /boot, and the kernel trusts only proof."
+ - "Flags are proof. Hold eleven, and /boot will open to you."
without_key:
- - "Permission Denied. You lack the chmod Key. Seek it in the Memory Banks."
- - "The rules are clear: No key, no access. This is not negotiable."
+ - "Permission Denied — by the kernel, not by me. Count your flags: tree shows them."
+ - "The rules are clear: no flags, no access. This is not negotiable."
- "Many have tried to bypass me with exploits and buffer overflows. All have failed."
with_key:
- - "The chmod Key... you possess it. Very well. Access GRANTED."
- - "The Core awaits beyond this gate. The Daemon Overlord resides there. Prepare yourself."
+ - "Your chmod Key opened /etc. /boot is different: it answers to flags, not keys."
+ - "Eleven flags, and the kernel lets you through. The Daemon Overlord waits beyond. Prepare yourself."
- "You have proven worthy. The iptables shall open. May your packets find their destination."
lore:
@@ -46,7 +46,7 @@ dialogue:
- "The gate shall remain open for your return passage. Safe travels."
interaction_hints:
- - "The Firewall Knight blocks access to the Core until you present the chmod_key."
+ - "The Firewall Knight explains how /boot opens: hold eleven flags."
- "This is a gate guardian - not hostile, but absolute in enforcing access rules."
diff --git a/data/npcs/home_guardian.sys.yml b/data/npcs/home_guardian.sys.yml
index f9ac465..f53a660 100644
--- a/data/npcs/home_guardian.sys.yml
+++ b/data/npcs/home_guardian.sys.yml
@@ -6,6 +6,6 @@ dialogues:
- "The Daemon Overlord's influence grows stronger with each passing cycle. You must act quickly."
- "I have watched many sysadmin spirits pass through here. Few return from their journey to the core."
- "Take heart—the filesystem itself aids those who seek to restore its balance."
- - "Your first task is to explore and gather resources. Try 'cd root' to access the system root, then 'ls' to see what's available. The library key you need lies in the arcane archives."
+ - "Your first task is to explore and gather resources. Try 'cd root' to access the system root, then 'ls' to see what's available. Every directory hides a flag, and some flags hand you a key. Start with /mnt."
on_talk:
message: The Grove Guardian's voice resonates like a gentle data stream, bringing peace to your digital essence.
\ No newline at end of file
diff --git a/data/rooms/archive.yml b/data/rooms/archive.yml
index 303bc46..bbc75ab 100644
--- a/data/rooms/archive.yml
+++ b/data/rooms/archive.yml
@@ -6,7 +6,9 @@ detailed_description: The archive is filled with dust and fragments of old data.
it holds secrets from before the system was corrupted.
exits:
- root
-items: []
+items:
+ - backup_tar_log
+ - backup_readme
npcs: []
enemy_tier: 1
enemy_count: 1
@@ -15,3 +17,10 @@ hidden: true
# Navigation: canonical path + everything a player may type to get here.
path: "/var/backups"
aliases: ["/var/backups", "/archive", "archive"]
+
+flag:
+ file: backup_tar_log
+ via: grep
+ text: "FLAG{backups_remember}"
+ nudge: "The backup log lists every file it saved. Search it."
+ command: "[bold]grep FLAG backup_tar_log[/bold]"
diff --git a/data/rooms/bin_armory.yml b/data/rooms/bin_armory.yml
index a3613ff..4a496d8 100644
--- a/data/rooms/bin_armory.yml
+++ b/data/rooms/bin_armory.yml
@@ -38,3 +38,9 @@ story_location: true
# Navigation: canonical path + everything a player may type to get here.
path: "/bin"
aliases: ["/bin", "bin", "armory"]
+
+flag:
+ file: ancient_manual_man
+ text: "FLAG{man_pages_hold_answers}"
+ nudge: "An old manual is hidden in here. Remember how you found your .bash_profile?"
+ command: "[bold]ls -a[/bold], then [bold]cat .ancient_manual_man[/bold]"
diff --git a/data/rooms/core.yml b/data/rooms/core.yml
index b1350aa..70154c0 100644
--- a/data/rooms/core.yml
+++ b/data/rooms/core.yml
@@ -33,8 +33,8 @@ items: []
npcs: []
enemies:
- daemon_overlord.sys
-locked: true
-key_required: master_key
+locked: false
+flags_required: 11
hidden: false
zone: core
zone_level: 10
@@ -45,3 +45,10 @@ story_location: true
# Navigation: canonical path + everything a player may type to get here.
path: "/boot"
aliases: ["/boot", "/boot/kernel", "/core", "core", "kernel"]
+
+flag:
+ via: defeat
+ enemy: daemon_overlord.sys
+ text: "FLAG{kernel_panic_averted}"
+ nudge: "The Daemon Overlord holds this directory's flag."
+ command: "[bold]defeat the Daemon Overlord[/bold]"
diff --git a/data/rooms/cowsay_secret.yml b/data/rooms/cowsay_secret.yml
index 5714f2e..2bd226b 100644
--- a/data/rooms/cowsay_secret.yml
+++ b/data/rooms/cowsay_secret.yml
@@ -50,3 +50,9 @@ special_event:
# Navigation: canonical path + everything a player may type to get here.
path: "/usr/games/cowsay"
aliases: ["/usr/games/cowsay", "/usr/share/games/cowsay", "/cowsay", "cowsay"]
+
+flag:
+ file: moo_file
+ text: "FLAG{moo}"
+ nudge: "Something hidden here goes moo."
+ command: "[bold]ls -a[/bold], then [bold]cat .moo_file[/bold]"
diff --git a/data/rooms/deprecated_dir.yml b/data/rooms/deprecated_dir.yml
index 71b8812..46c8392 100644
--- a/data/rooms/deprecated_dir.yml
+++ b/data/rooms/deprecated_dir.yml
@@ -7,7 +7,8 @@ detailed_description: This deprecated directory exists in a strange state betwee
back to the main filesystem, suggesting a way out.
exits:
- root
-items: []
+items:
+ - tmp_notice
npcs: []
enemies: []
locked: false
@@ -16,3 +17,12 @@ hidden: false
# Navigation: canonical path + everything a player may type to get here.
path: "/var/tmp"
aliases: ["/var/tmp", "/deprecated", "deprecated"]
+
+flag:
+ via: kill
+ enemy: zombie_tmpwatch.sh
+ pid: 2048
+ process: "tmpwatch --zombie"
+ text: "FLAG{zombies_need_reaping}"
+ nudge: "Read the notice, then find the dead-but-running process."
+ command: "[bold]ps[/bold], then [bold]kill 2048[/bold]"
diff --git a/data/rooms/dev_null_void.yml b/data/rooms/dev_null_void.yml
index a39320d..677ca74 100644
--- a/data/rooms/dev_null_void.yml
+++ b/data/rooms/dev_null_void.yml
@@ -22,7 +22,8 @@ detailed_description: |
You can feel yourself being pulled toward the center. Without protection, you will be de-allocated.
exits:
- bin_armory
-items: []
+items:
+ - kern_log
# Epic and legendary items spawn here - highest tier loot
enemy_tier: 3
enemy_count: 1
@@ -40,3 +41,11 @@ story_beats:
# Navigation: canonical path + everything a player may type to get here.
path: "/dev"
aliases: ["/dev", "/dev/null", "dev", "null", "void"]
+
+flag:
+ file: kern_log
+ via: grep
+ text: "FLAG{even_the_void_logs}"
+ nudge: "The kernel log is mostly noise. Search it."
+ command: "[bold]grep FLAG kern_log[/bold]"
+ clue: "Even the void keeps a kernel log. Search it for the word FLAG."
diff --git a/data/rooms/etc_hidden_configs.yml b/data/rooms/etc_hidden_configs.yml
index 37f0db5..1a6d4ef 100644
--- a/data/rooms/etc_hidden_configs.yml
+++ b/data/rooms/etc_hidden_configs.yml
@@ -11,7 +11,7 @@ detailed_description: |
sword forged from DROP commands.
"This directory is Read-Only," the Knight intones, its voice echoing with administrative authority.
- "Unless you carry the chmod Key, you shall pass no further into the Kernel."
+ "Only proof opens the way to the Kernel. Eleven flags, and not one fewer."
The Knight's eyes glow with the light of port 443. It has been here since the Before Times, fulfilling
its purpose even as the world burned. It cannot be reasoned with. It cannot be bribed. It can only
@@ -20,10 +20,10 @@ detailed_description: |
Beyond the gate, you can see the path to the Core - the beating heart of the system, where the
Daemon Overlord waits.
- The Knight will not move. Not without the Key.
+ The Knight will not move. Not until you hold eleven flags.
exits:
- usr_lib_arcane
- - core # Only accessible with chmod_key
+ - core # /boot opens with 11 flags
items: []
enemies: []
npcs:
@@ -39,3 +39,13 @@ gate_guardian: true
# Navigation: canonical path + everything a player may type to get here.
path: "/etc"
aliases: ["/etc", "/etc/iptables", "/etc/configs", "etc", "configs"]
+
+flag:
+ via: kill
+ enemy: rogue_crond.d
+ pid: 4242
+ process: "crond --rewrite-all"
+ text: "FLAG{configs_restored}"
+ nudge: "Something keeps rewriting these configs. Find it in ps."
+ command: "[bold]ps[/bold], then [bold]kill 4242[/bold]"
+ clue: "A scheduler is rewriting my rules faster than I can guard them. ps will show it."
diff --git a/data/rooms/ghost_hidden.yml b/data/rooms/ghost_hidden.yml
index c49bd87..11d3c9c 100644
--- a/data/rooms/ghost_hidden.yml
+++ b/data/rooms/ghost_hidden.yml
@@ -6,7 +6,8 @@ detailed_description: This hidden directory is a repository of ancient system kn
from the chaos that affected the rest of the system.
exits:
- root
-items: []
+items:
+ - flag
npcs:
- guardian.bin
enemies: []
@@ -17,3 +18,9 @@ hidden: true
# Navigation: canonical path + everything a player may type to get here.
path: "/root"
aliases: ["/root", "/ghost", "ghost"]
+
+flag:
+ file: flag
+ text: "FLAG{root_sees_all}"
+ nudge: "root keeps its flag hidden, like any dotfile."
+ command: "[bold]ls -a[/bold], then [bold]cat .flag[/bold]"
diff --git a/data/rooms/home_grove.yml b/data/rooms/home_grove.yml
index 1d95639..da0d030 100644
--- a/data/rooms/home_grove.yml
+++ b/data/rooms/home_grove.yml
@@ -37,3 +37,10 @@ story_beats:
# Navigation: canonical path + everything a player may type to get here.
path: "/home"
aliases: ["/home", "/home/lost+found", "/home/grove", "home", "grove"]
+
+flag:
+ file: bash_profile
+ text: "FLAG{hidden_files_start_with_a_dot}"
+ nudge: "Something here is hidden. Files whose names start with a dot only show up with ls -a."
+ command: "[bold]ls -a[/bold], then [bold]cat .bash_profile[/bold]"
+ teach: "Hidden files start with a dot. Type [bold]ls -a[/bold] to see them, then [bold]cat .bash_profile[/bold] to read yours."
diff --git a/data/rooms/mirror_sector.yml b/data/rooms/mirror_sector.yml
index e6cec2d..b50af70 100644
--- a/data/rooms/mirror_sector.yml
+++ b/data/rooms/mirror_sector.yml
@@ -43,3 +43,10 @@ boss_room: true
# Navigation: canonical path + everything a player may type to get here.
path: "/proc/self"
aliases: ["/proc/self", "/mirror", "mirror"]
+
+flag:
+ via: defeat
+ enemy: shadow_process
+ text: "FLAG{you_were_root_all_along}"
+ nudge: "Your shadow holds this flag."
+ command: "[bold]defeat your shadow[/bold]"
diff --git a/data/rooms/mnt_forest.yml b/data/rooms/mnt_forest.yml
index 8abceb8..3a38121 100644
--- a/data/rooms/mnt_forest.yml
+++ b/data/rooms/mnt_forest.yml
@@ -11,7 +11,8 @@ exits:
- opt_mage_tower
- srv_warrior_tomb
- proc_secrets
-items: []
+items:
+ - lost_user_log
# Items placed dynamically by class-based system
enemy_tier: 2
enemy_count: 1
@@ -25,3 +26,13 @@ class_affinity: "shaman"
# Navigation: canonical path + everything a player may type to get here.
path: "/mnt"
aliases: ["/mnt", "mnt", "forest"]
+
+flag:
+ file: lost_user_log
+ via: grep
+ text: "FLAG{grep_finds_needles}"
+ grants: lib_key
+ nudge: "That log is hundreds of lines. Don't read it — search it."
+ command: "[bold]grep FLAG lost_user_log[/bold]"
+ teach: "The lost user's log here is hundreds of lines long — too long to read. [bold]grep[/bold] searches a file for a word and prints only the lines containing it. Type [bold]grep FLAG lost_user_log[/bold]."
+ clue: "I wrote everything down in my log... but it's so long now. I can't find anything in it."
diff --git a/data/rooms/opt_mage_tower.yml b/data/rooms/opt_mage_tower.yml
index 625e830..4173831 100644
--- a/data/rooms/opt_mage_tower.yml
+++ b/data/rooms/opt_mage_tower.yml
@@ -23,9 +23,16 @@ enemies:
npcs: []
locked: true
key_required: opt_key
-class_restriction: weaver
+trial_class: weaver
hidden: true
# Navigation: canonical path + everything a player may type to get here.
path: "/opt"
aliases: ["/opt", "opt", "tower"]
+
+flag:
+ via: defeat
+ enemy: corruption_lord.exe
+ text: "FLAG{the_tower_answers}"
+ nudge: "The tower's master holds its flag."
+ command: "[bold]defeat the Corruption Lord[/bold]"
diff --git a/data/rooms/proc_secrets.yml b/data/rooms/proc_secrets.yml
index 21b1b9d..ebfe788 100644
--- a/data/rooms/proc_secrets.yml
+++ b/data/rooms/proc_secrets.yml
@@ -27,3 +27,14 @@ hidden: false
# Navigation: canonical path + everything a player may type to get here.
path: "/proc"
aliases: ["/proc", "/proc/secrets", "proc", "secrets"]
+
+flag:
+ via: kill
+ enemy: runaway_fork.bomb
+ pid: 1337
+ process: "runaway_fork"
+ text: "FLAG{kill_ends_what_ps_finds}"
+ nudge: "Something is eating this directory's CPU. ps shows what's running."
+ command: "[bold]ps[/bold], then [bold]kill 1337[/bold]"
+ teach: "Something here doesn't belong. [bold]ps[/bold] lists the processes running in this directory — look for the odd one out, then end it with [bold]kill[/bold] and its PID number. Type [bold]ps[/bold], then [bold]kill 1337[/bold]."
+ clue: "One of my children forked and never stopped. Find it with ps. End it with kill."
diff --git a/data/rooms/root.yml b/data/rooms/root.yml
index a49c698..5867acb 100644
--- a/data/rooms/root.yml
+++ b/data/rooms/root.yml
@@ -13,8 +13,6 @@ detailed_description: |
Paths branch in all directions: /home to the Graveyard, /bin to the Armory, /usr to the libraries,
/var to the Memory Banks. The entire system spreads out before you like a corrupted tree.
- Legendary items spawn here - the rarest artifacts of the old system, waiting for those with root access to claim them.
-
A faint message scrolls across a terminal: "You have been granted temporary Superuser privileges. Use them wisely."
exits:
- bin_armory
@@ -22,16 +20,23 @@ exits:
- home_grove
- archive
- deprecated_dir
-items: []
-# Legendary item spawns - highest tier directory
+items:
+ - motd
npcs:
- helper_script.bin
enemies: []
hidden: false
zone: core
-zone_level: 7
+zone_level: 1 # the hub above /home; zone stays core for its look
requires_sudo: false # Will be true after story progression
# Navigation: canonical path + everything a player may type to get here.
path: "/"
aliases: ["/", "root"]
+
+flag:
+ file: motd
+ text: "FLAG{cat_reads_files}"
+ nudge: "There's a file here. cat prints a file's contents."
+ command: "[bold]cat motd[/bold]"
+ teach: "Every directory hides a flag. This one's easy: a file called [bold]motd[/bold] (message of the day). Type [bold]cat motd[/bold] to read it."
diff --git a/data/rooms/srv_warrior_tomb.yml b/data/rooms/srv_warrior_tomb.yml
index 0e73e8a..dd72547 100644
--- a/data/rooms/srv_warrior_tomb.yml
+++ b/data/rooms/srv_warrior_tomb.yml
@@ -16,16 +16,25 @@ detailed_description: 'The /srv directory has transformed into a massive stone f
'
exits:
- mnt_forest
-items: []
+items:
+ - access_log
+ - access_readme
enemies:
- tomb_guardian.sys
- corruption_overlord.exe
npcs: []
locked: true
key_required: opt_key
-class_restriction: guardian
+trial_class: guardian
hidden: false
# Navigation: canonical path + everything a player may type to get here.
path: "/srv"
aliases: ["/srv", "srv", "tomb"]
+
+flag:
+ file: access_log
+ via: grep
+ text: "FLAG{warriors_leave_access_logs}"
+ nudge: "Read the keeper's note, then search the access log."
+ command: "[bold]grep FLAG access_log[/bold]"
diff --git a/data/rooms/usr_lib_arcane.yml b/data/rooms/usr_lib_arcane.yml
index 080f065..fdda770 100644
--- a/data/rooms/usr_lib_arcane.yml
+++ b/data/rooms/usr_lib_arcane.yml
@@ -20,7 +20,8 @@ exits:
- var_dungeon
- usr_share_games
- etc_hidden_configs
-items: []
+items:
+ - catalog_db
enemy_tier: 3
enemy_count: 1
npcs:
@@ -33,3 +34,12 @@ hidden: false
# Navigation: canonical path + everything a player may type to get here.
path: "/usr"
aliases: ["/usr", "/usr/lib", "/usr/lib/arcane", "usr", "lib", "arcane"]
+
+flag:
+ file: catalog_db
+ via: grep
+ text: "FLAG{knowledge_is_searchable}"
+ grants: opt_key
+ nudge: "The catalog is far too long to read. Search it for the word FLAG."
+ command: "[bold]grep FLAG catalog_db[/bold]"
+ clue: "The catalog holds every title ever written. Nobody reads it front to back; they search it."
diff --git a/data/rooms/usr_share_games.yml b/data/rooms/usr_share_games.yml
index 4292602..d26bae2 100644
--- a/data/rooms/usr_share_games.yml
+++ b/data/rooms/usr_share_games.yml
@@ -39,3 +39,9 @@ easter_egg_zone: true
# Navigation: canonical path + everything a player may type to get here.
path: "/usr/games"
aliases: ["/usr/games", "/usr/share", "/usr/share/games", "share", "games"]
+
+flag:
+ file: cow_wisdom_log
+ text: "FLAG{even_cows_have_wisdom}"
+ nudge: "Somebody hid a log of cow wisdom here."
+ command: "[bold]ls -a[/bold], then [bold]cat .cow_wisdom_log[/bold]"
diff --git a/data/rooms/var_dungeon.yml b/data/rooms/var_dungeon.yml
index bec3c26..fce7b49 100644
--- a/data/rooms/var_dungeon.yml
+++ b/data/rooms/var_dungeon.yml
@@ -20,8 +20,8 @@ exits:
- home_grove
items:
- system_err_log
- - chmod_key
-# Story items - Creator's Typo discovery and Firewall Knight key
+# Story item - Creator's Typo discovery. The Firewall Knight's key (chmod_key)
+# is granted by this room's flag.
enemy_tier: 2
enemy_count: 2
npcs: []
@@ -37,3 +37,10 @@ story_beats:
# Navigation: canonical path + everything a player may type to get here.
path: "/var"
aliases: ["/var", "/var/log", "/var/dungeon", "var", "dungeon"]
+
+flag:
+ file: system_err_log
+ text: "FLAG{logs_remember_everything}"
+ grants: chmod_key
+ nudge: "The system keeps a hidden error log here."
+ command: "[bold]ls -a[/bold], then [bold]cat .system_err_log[/bold]"
diff --git a/data/tutorial_hints.yaml b/data/tutorial_hints.yaml
index e41d777..054cf24 100644
--- a/data/tutorial_hints.yaml
+++ b/data/tutorial_hints.yaml
@@ -1,45 +1,69 @@
-# Tutorial hint text, keyed by step id. Rendered by
-# CommandHandler.show_tutorial_hint, which formats {player_name} and
-# {weapon_name} before writing. Rich markup is allowed.
+# Tutorial hint text, keyed by step id. Rendered by TutorialCoach.show_hint,
+# which formats {player_name}, {weapon_name}, {armor_name}, {location} (the player's current
+# path) and {example_dir} (the first directory below it) before emitting it.
+# Rich markup is allowed.
#
# Steps fire in this order, each gated on player.tutorial_state:
# step1 first ls
# step2 take the weapon (weapon_name = the one actually on the floor)
# step3 equip it
+# step_armor take + equip the starter armor (weapon + armor on opens the fight)
# step4 combat opens
+# step4_fled fled the tutorial fight (it waits in the room for `attack`)
# step5 after the first landed hit
-# step5_postcombat after the fight
-# step6/step6b navigation
+# step5_postcombat after the fight: run ls -a
+# step_hidden ls -a revealed the hidden lore file: cat it
+# step_cat the cat fired a memory restore (checkpoint): run ps
+# step_ps after ps: run pwd
+# step_up after pwd, in a room with nothing below it: cd ..
+# step6 after moving somewhere new: ls
+# step6b ls listed directories (the UI flashes them): cd into one
# completed cheat sheet, sets tutorial_state["completed"]
# skip_summary shown instead of everything above if the player skips
"skip_summary": |-
- [bold green]ECHO>[/bold green] Got it. Quick reference: [bold]ls[/bold] scans a room, [bold]take/equip[/bold] grab and ready items. Combat opens in Selection Mode automatically — press [bold]1-9[/bold] to attack, [bold]0[/bold] to flee. Press [bold]TAB[/bold] to type [bold]use [item][/bold] instead. [bold]help[/bold] if stuck, [bold]man[/bold] to look a command up. Good luck.
+ [bold green]ECHO>[/bold green] Got it. Quick reference: [bold]ls[/bold] scans a room, [bold]take/equip[/bold] grab and ready items; [bold]Tab[/bold] finishes a name you've started. Combat opens in Selection Mode automatically — press [bold]1-9[/bold] to attack, [bold]0[/bold] to flee. Press [bold]TAB[/bold] to type [bold]use - [/bold] instead. [bold]pwd[/bold] shows where you are, [bold]cd[/bold] moves ([bold]cd ..[/bold] goes up), [bold]ps[/bold] lists running processes, [bold]ls -a[/bold] reveals hidden files. [bold]cat[/bold] reads a file — restoring a memory saves a checkpoint you resume from if you fall. The goal: every directory hides a [bold]flag[/bold]. Read it with [bold]cat[/bold], search a long file with [bold]grep FLAG
[/bold], or end a rogue process with [bold]kill [/bold]. Some flags hand you a key, and a new directory appears. Hold [bold]11[/bold] flags and [bold]/boot[/bold] opens. Type [bold]hint[/bold] when stuck in a room, [bold]man[/bold] to look a command up. Good luck.
"step1": |-
[bold green]ECHO>[/bold green] You're in /home — a stable part of the filesystem. The rest is corrupted and needs clearing. Let's start simple. Type: [bold]ls[/bold] and press Enter to see what's here.
"step2": |-
- [bold green]ECHO>[/bold green] Good — that's everything in this directory. See that weapon? Type: [bold]take {weapon_name}[/bold] to pick it up. Items you carry appear in the Inventory panel on the right.
+ [bold green]ECHO>[/bold green] Good — that's everything in this directory. See that weapon? Type: [bold]take {weapon_name}[/bold] to pick it up. Items you carry appear in the Inventory panel on the right. Tip: type the first few letters and press [bold]Tab[/bold] — it finishes the name for you, like a real shell.
"step3": |-
[bold green]ECHO>[/bold green] You're carrying it, but it's not active yet. Type: [bold]equip {weapon_name}[/bold] to ready it. Equipping means it'll be used in combat.
+"step_armor": |-
+ [bold green]ECHO>[/bold green] A weapon hits; armor keeps you standing. Your [bold]{armor_name}[/bold] is lying here too. Type: [bold]take {armor_name}[/bold], then [bold]equip {armor_name}[/bold] — and watch [bold]Defense[/bold] in the Stats panel.
"step4": |-
[bold green]ECHO>[/bold green] A corrupted process just spawned — this is combat, and you're already in Selection Mode. Press [bold]1[/bold] to attack.
+"step4_fled": |-
+ [bold green]ECHO>[/bold green] You got away — [bold]0[/bold] (or [bold]flee[/bold]) escapes any fight. This process is still here, though, and it can't hurt you much. Type: [bold]attack[/bold] to face it again.
"step5": |-
- [bold green]ECHO>[/bold green] Nice hit. One more should finish it. Need to use an item or flee instead? Press [bold]TAB[/bold] to type [bold]use [item][/bold] or [bold]flee[/bold] — TAB again to get back to Selection Mode.
+ [bold green]ECHO>[/bold green] Nice hit. One more should finish it. Need to use an item or flee instead? Press [bold]TAB[/bold] to type [bold]use - [/bold] or [bold]flee[/bold] — TAB again to get back to Selection Mode.
"step5_postcombat": |-
- [bold green]ECHO>[/bold green] You won. [bold]use [item][/bold] uses something mid-fight, [bold]flee[/bold] or [bold]0[/bold] lets you escape a losing fight. When you're ready, [bold]ls[/bold] to see where you can go.
+ [bold green]ECHO>[/bold green] You won. [bold]use
- [/bold] uses something mid-fight, [bold]flee[/bold] or [bold]0[/bold] escapes a losing one. Now — this directory holds more than plain [bold]ls[/bold] admits. Files whose names start with a dot stay hidden. Type: [bold]ls -a[/bold] to list everything.
+"step_hidden": |-
+ [bold green]ECHO>[/bold green] There — [bold].bash_profile[/bold], your own configuration file, hidden until now. The leading dot is what hides it. [bold]ls -a[/bold] reveals hidden files anywhere, so try it in every new place. Read this one: type [bold]cat .bash_profile[/bold] ([bold]cat .[/bold] then [bold]Tab[/bold] finishes it)
+"step_cat": |-
+ [bold green]ECHO>[/bold green] Feel that? A memory restored — and your first [bold]flag[/bold] captured. Every directory hides one; [bold]tree[/bold] and [bold]journal[/bold] count them. Some flags hand you a key, and a new directory appears. Hold 11 and [bold]/boot[/bold] opens. The system saved your progress too. Every memory you recover with [bold]cat[/bold] is a checkpoint: if you fall, you resume from the last one. Keep reading what you find. Corrupted processes hide all over the system too — type: [bold]ps[/bold] to list what's running here.
+"step_ps": |-
+ [bold green]ECHO>[/bold green] That's [bold]ps[/bold]: the processes running in this directory. Most belong to the system, but an odd one out can give away something hidden, so run it in new places. Now — do you know where you are? Type: [bold]pwd[/bold]
+"step_up": |-
+ [bold green]ECHO>[/bold green] You're in [bold]{location}[/bold] — [bold]pwd[/bold] prints that any time. Every room is a directory, and nothing sits below this one (that's why [bold]ls[/bold] showed no Directories). Go up one level: type [bold]cd ..[/bold]
"step6": |-
- [bold green]ECHO>[/bold green] Let's move. Type: [bold]ls[/bold] again — the [bold]Directories[/bold] section lists where you can go from here.
+ [bold green]ECHO>[/bold green] You're in [bold]{location}[/bold] now. Type: [bold]ls[/bold] to see what's here — look for the [bold]Directories[/bold] section.
"step6b": |-
- [bold green]ECHO>[/bold green] See those directories? Type [bold]cd[/bold] and one — like [bold]cd /var[/bold] — and you'll move there. [bold]cd ..[/bold] goes back up. That's how the whole filesystem works.
+ [bold green]ECHO>[/bold green] Those flashing [bold]Directories[/bold] are the places you can go from [bold]{location}[/bold]. Pick one and [bold]cd[/bold] into it — for example [bold]cd {example_dir}[/bold]. [bold]cd ..[/bold] always takes you back up.
"completed": |-
[bold green]ECHO> Tutorial complete, {player_name}.[/bold green]
Quick reminder:
• [bold]ls[/bold] — scan a room
- • [bold]cd /path[/bold] — move (e.g. cd /var)
- • [bold]cat[/bold] — read a file
+ • [bold]pwd[/bold] — where am I?
+ • [bold]cd name[/bold] — move into a directory; [bold]cd ..[/bold] goes up
+ • [bold]ps[/bold] — list running processes (odd ones hide secrets)
+ • [bold]ls -a[/bold] — reveal hidden files and directories
+ • [bold]cat[/bold] — read a file; restoring a memory saves a checkpoint
• [bold]take / equip[/bold] — grab and ready items
+ • [bold]Tab[/bold] — finish a name you've started typing
• [bold]1-9[/bold] in combat — attack (Selection Mode, opens automatically)
• [bold]0[/bold] in combat — flee
- • [bold]TAB[/bold] in combat — type 'use [item]' instead
+ • [bold]TAB[/bold] in combat — type 'use
- ' instead
• [bold]help[/bold] — if you get stuck
- • [bold]man [command][/bold] — what a command really does, outside this place
+ • [bold]man
[/bold] — what a command really does, outside this place
Good luck out there.
diff --git a/engine/api.py b/engine/api.py
index bcfb40a..f334055 100644
--- a/engine/api.py
+++ b/engine/api.py
@@ -14,10 +14,9 @@
from typing import Any
-from src.events import EventType, event_bus
+from engine.events import EventBus, EventType
from src.game_engine import ImprovedGameEngine
from src.game_states import GameState
-from src.state_manager import state_manager
from .headless import HeadlessUI
@@ -44,9 +43,9 @@ def new_game(self, name: str, player_class: str) -> list[str]:
def submit(self, command: str) -> list[str]:
"""Run one command through the engine; return the text it produced."""
self.ui.clear_console()
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMMAND_ENTERED,
- {"command": command, "game_state": state_manager.current_state},
+ {"command": command},
"GameSession",
)
return self.ui.drain()
@@ -61,9 +60,16 @@ def player(self) -> Any:
def world(self) -> Any:
return self.engine.world
+ @property
+ def bus(self) -> EventBus:
+ """This session's own event bus — nothing outside the session hears it."""
+ bus: EventBus = self.engine.bus
+ return bus
+
@property
def state(self) -> GameState:
- return state_manager.current_state
+ state: GameState = self.engine.state_manager.current_state
+ return state
def close(self) -> None:
self.engine._cleanup()
diff --git a/engine/content/__init__.py b/engine/content/__init__.py
index 1c5f78d..38b140c 100644
--- a/engine/content/__init__.py
+++ b/engine/content/__init__.py
@@ -4,9 +4,13 @@
from .linker import (
find_broken_references,
find_dialogue_problems,
+ find_flag_problems,
+ find_key_chain_problems,
+ find_lock_problems,
find_nav_problems,
find_reference_warnings,
find_tree_problems,
+ find_unread_fields,
link,
)
from .loader import (
@@ -26,6 +30,10 @@
"link",
"find_broken_references",
"find_dialogue_problems",
+ "find_flag_problems",
+ "find_key_chain_problems",
+ "find_lock_problems",
+ "find_unread_fields",
"find_reference_warnings",
"find_nav_problems",
"find_tree_problems",
diff --git a/engine/content/linker.py b/engine/content/linker.py
index a2d4f41..4cf1462 100644
--- a/engine/content/linker.py
+++ b/engine/content/linker.py
@@ -11,7 +11,12 @@
"""
from __future__ import annotations
-from engine.schema import DanglingReferenceError
+from collections.abc import Mapping
+from typing import Any
+
+from pydantic import BaseModel
+
+from engine.schema import DanglingReferenceError, RoomId
from .world import GameContent
@@ -48,6 +53,7 @@ def check(ref: str | None, universe: set[str], where: str, kind: str) -> None:
for cid, klass in content.classes.items():
check(klass.starter_weapon, items, f"class '{cid}' starter_weapon", "item")
+ check(klass.starter_armor, items, f"class '{cid}' starter_armor", "item")
for ability_ref in klass.starter_abilities:
check(ability_ref, abilities, f"class '{cid}' starter_abilities", "ability")
for attack_ref in klass.attacks:
@@ -155,6 +161,235 @@ def find_reference_warnings(content: GameContent) -> list[str]:
return warnings
+def _room_lookup(content: GameContent) -> dict[str, str]:
+ """Every string a key may use for a room (id, path, alias) -> room id."""
+ table: dict[str, str] = {}
+ for rid, room in content.rooms.items():
+ table[str(rid).lower()] = str(rid)
+ if room.path:
+ table[room.path.lower()] = str(rid)
+ for alias in room.aliases:
+ table[alias.lower()] = str(rid)
+ return table
+
+
+def find_lock_problems(content: GameContent) -> list[str]:
+ """Keys and locks must agree (empty == clean).
+
+ A lock with no key can never open; a key whose ``unlocks`` misses the room
+ that asks for it opens nothing on ``use``; a key that claims a room guarded
+ by a different key is lying to the player. The reference checks above only
+ prove these ids exist, not that they point at each other.
+ """
+ problems: list[str] = []
+ lookup = _room_lookup(content)
+ for rid, room in content.rooms.items():
+ if room.locked and not room.key_required:
+ problems.append(f"room '{rid}': locked, but names no key_required")
+ if room.key_required and not room.locked:
+ problems.append(
+ f"room '{rid}': key_required '{room.key_required}', but it is not locked"
+ )
+ key = content.items.get(room.key_required) if room.key_required else None
+ if key is None:
+ continue # a dangling key_required is find_broken_references' job
+ if key.type != "key":
+ problems.append(
+ f"room '{rid}': key_required '{room.key_required}' is a {key.type}, not a key"
+ )
+ opens = {lookup.get(str(target).lower(), str(target)) for target in key.unlocks}
+ if str(rid) not in opens:
+ problems.append(
+ f"room '{rid}': needs '{room.key_required}', but that key's unlocks "
+ "does not list it"
+ )
+ for iid, item in content.items.items():
+ for target in item.unlocks:
+ target_id = lookup.get(str(target).lower())
+ if target_id is None:
+ continue # an unknown room is reported by find_reference_warnings
+ guard = content.rooms[RoomId(target_id)].key_required
+ if guard != iid:
+ problems.append(
+ f"item '{iid}': unlocks '{target}', which "
+ + (f"requires '{guard}' instead" if guard else "has no lock")
+ )
+ return problems
+
+
+# Content fields no code reads yet. The schema declares every field the game
+# reads, so an undeclared field is authored intent the game ignores — the
+# "phantom reward" bug class. These are known and accepted for now; anything
+# else undeclared fails validation, as does an entry here that no content uses.
+UNIMPLEMENTED_FIELDS: dict[str, frozenset[str]] = {
+ "room": frozenset({
+ "boss_room", "class_affinity", "easter_egg_trigger", "easter_egg_zone",
+ "final_confrontation", "gate_guardian", "level_requirement",
+ "special_event", "story_beats", "story_location", "visibility_requirement",
+ }),
+ "item": frozenset({
+ "auto_trigger", "consumed_on_take", "max_uses_per_run",
+ "readable", "trigger_condition", "triggers_npc_spawn",
+ }),
+ "enemy": frozenset({
+ "level_requirement", "loot", "on_defeat", "resistances", "weaknesses",
+ }),
+ "npc": frozenset({
+ "awakening_guide", "detailed_description", "easter_egg_npc", "gate_guardian",
+ "grants_unique_item", "helpful_guide", "hostile", "interaction_hints",
+ "lore_guide", "merchant", "requires_key", "story_npc", "unlocks_room",
+ }),
+}
+
+
+def find_flag_problems(content: GameContent) -> list[str]:
+ """Room flags must be findable (empty == clean): the flag file must be an
+ item placed in that room, the text must carry FLAG{...}, and a beginner
+ must be told what to type."""
+ problems: list[str] = []
+ for rid, room in content.rooms.items():
+ flag = room.flag
+ if flag is None:
+ continue
+ if flag.via in ("cat", "grep"):
+ if flag.file is None:
+ problems.append(f"room '{rid}': a {flag.via} flag needs a file")
+ elif flag.file not in content.items:
+ problems.append(f"room '{rid}': flag file '{flag.file}' is not an item")
+ elif flag.file not in room.items:
+ problems.append(
+ f"room '{rid}': flag file '{flag.file}' is not in this room's items"
+ )
+ if flag.via in ("kill", "defeat"):
+ enemy = content.enemies.get(flag.enemy) if flag.enemy else None
+ if enemy is None:
+ problems.append(f"room '{rid}': {flag.via} flag names no known enemy")
+ elif flag.via == "defeat" and flag.enemy not in room.enemies:
+ problems.append(
+ f"room '{rid}': defeat flag enemy '{flag.enemy}' is not in its enemies"
+ )
+ elif flag.via == "kill":
+ if flag.pid <= 1 or not flag.process.strip():
+ problems.append(f"room '{rid}': kill flag needs a pid > 1 and a process")
+ if flag.enemy in room.enemies or not enemy.pool_excluded:
+ problems.append(
+ f"room '{rid}': rogue '{flag.enemy}' must be pool_excluded and "
+ "not already in the room's enemies"
+ )
+ if "FLAG{" not in flag.text:
+ problems.append(f"room '{rid}': flag text has no FLAG{{...}}")
+ if not flag.nudge.strip() or not flag.command.strip():
+ problems.append(f"room '{rid}': flag needs both a nudge and a command")
+ if flag.via not in ("cat", "grep", "kill", "defeat"):
+ problems.append(
+ f"room '{rid}': flag via '{flag.via}' is not cat, grep, kill or defeat"
+ )
+ if flag.via == "grep":
+ item = content.items.get(flag.file) if flag.file else None
+ if item is None or item.log is None:
+ problems.append(f"room '{rid}': grep flag file '{flag.file}' has no log")
+ elif flag.text not in item.log.flag_line:
+ problems.append(
+ f"room '{rid}': flag text is not in '{flag.file}''s log flag_line"
+ )
+ main_flags = [
+ rid for rid, room in content.rooms.items()
+ if room.flag is not None and not room.hidden
+ ]
+ for rid, room in content.rooms.items():
+ if room.flags_required:
+ available = len([r for r in main_flags if r != rid])
+ if room.flags_required > available:
+ problems.append(
+ f"room '{rid}': needs {room.flags_required} flags but only "
+ f"{available} main flags exist outside it"
+ )
+ return problems
+
+
+def find_key_chain_problems(content: GameContent) -> list[str]:
+ """Flag-granted keys must be keys, and walking the chain from the start
+ must reach enough main flags to open every flag-gated room (empty ==
+ clean). Only non-hidden rooms count; boss drops are not relied on."""
+ problems: list[str] = []
+ grants: dict[str, str] = {}
+ for rid, room in content.rooms.items():
+ grant = room.flag.grants if room.flag is not None else None
+ if grant is None:
+ continue
+ item = content.items.get(grant)
+ if item is None or item.type != "key":
+ problems.append(f"room '{rid}': flag grants '{grant}', which is not a key")
+ grants[str(rid)] = str(grant)
+
+ by_path = {room.path: str(rid) for rid, room in content.rooms.items() if room.path}
+
+ def path_open(rid: str, held: set[str]) -> bool:
+ path = content.rooms[RoomId(rid)].path
+ while True:
+ owner = by_path.get(path)
+ if owner is not None:
+ room = content.rooms[RoomId(owner)]
+ if room.flags_required:
+ return False
+ if room.locked and room.key_required not in held:
+ return False
+ if path in ("/", ""):
+ return True
+ path = _parent_of(path)
+
+ held: set[str] = set()
+ while True:
+ open_rooms = [
+ str(rid) for rid, room in content.rooms.items()
+ if not room.hidden and path_open(str(rid), held)
+ ]
+ gained = {grants[r] for r in open_rooms if r in grants} - held
+ if not gained:
+ break
+ held |= gained
+
+ flags = len([r for r in open_rooms if content.rooms[RoomId(r)].flag is not None])
+ for rid, room in content.rooms.items():
+ if room.flags_required and flags < room.flags_required:
+ problems.append(
+ f"room '{rid}': needs {room.flags_required} flags but the key chain "
+ f"reaches only {flags} main flag rooms"
+ )
+ return problems
+
+
+def find_unread_fields(content: GameContent) -> list[str]:
+ """Undeclared content fields outside UNIMPLEMENTED_FIELDS (empty == clean)."""
+ sections: list[tuple[str, Mapping[Any, BaseModel]]] = [
+ ("room", content.rooms),
+ ("item", content.items),
+ ("enemy", content.enemies),
+ ("npc", content.npcs),
+ ("class", content.classes),
+ ("ability", content.abilities),
+ ("attack", content.attacks),
+ ]
+ problems: list[str] = []
+ for kind, entries in sections:
+ accepted = UNIMPLEMENTED_FIELDS.get(kind, frozenset())
+ used: set[str] = set()
+ for eid, model in entries.items():
+ for name in model.model_extra or {}:
+ used.add(name)
+ if name not in accepted:
+ problems.append(
+ f"{kind} '{eid}': field '{name}' is not in the schema, so no "
+ "code reads it (declare it in engine/schema/models.py when "
+ "the game uses it)"
+ )
+ for stale in sorted(accepted - used):
+ problems.append(
+ f"UNIMPLEMENTED_FIELDS['{kind}'] lists '{stale}', but no {kind} uses it"
+ )
+ return problems
+
+
def link(content: GameContent) -> GameContent:
"""Enforce load-bearing referential integrity; raise on any dangling ref."""
problems = (
diff --git a/src/events.py b/engine/events.py
similarity index 65%
rename from src/events.py
rename to engine/events.py
index e69c207..4c1a931 100644
--- a/src/events.py
+++ b/engine/events.py
@@ -6,11 +6,12 @@
without direct dependencies.
"""
-from typing import Dict, List, Callable, Any
-from dataclasses import dataclass
-from enum import Enum, auto
import logging
import time
+from collections.abc import Callable
+from dataclasses import dataclass
+from enum import Enum, auto
+from typing import Any
logger = logging.getLogger(__name__)
@@ -32,35 +33,45 @@ class EventType(Enum):
# Data: basic game start info
GAME_OVER = auto()
- # Emitted by: game_engine.py, command_handler.py
- # Subscribed by: game_engine.py, textual_ui.py
- # Data: {"message": str, "action": str (optional)}
+ # Emitted by: game_engine.py (death in combat, F5 restart, leaving a run for the main menu)
+ # Subscribed by: textual_ui.py
+ # Data: {"reason": "defeat" | "restart" | "menu", "message": str (optional)}
+ # The UI shows the GAME OVER card only for reason == "defeat". Nothing in the
+ # game listens to it: the game-over screen's choices call the engine directly.
+
+ TUTORIAL_HINT = auto()
+ # Emitted by: tutorial_coach.py (show_hint)
+ # Subscribed by: textual_ui.py (Echo panel), engine/headless/ui.py (text passthrough)
+ # Data: {"hint_id": str, "text": str, "step": int | None, "total": int, "final": bool}
GAME_WON = auto()
- # Emitted by: command_handler.py (win_game)
+ # Emitted by: game_flow.py (win_game)
# Subscribed by: textual_ui.py (finale), engine/headless/ui.py (text passthrough)
# Data: {"ending_id": str, "sections": list[str], "stats": dict}
- GAME_SAVED = auto()
- # Emitted by: save.py, textual_ui.py
- # Subscribed by: game_engine.py
- # Data: {"trigger": str, "filename": str (optional)}
-
GAME_RESTART_REQUESTED = auto()
# Emitted by: textual_ui.py
# Subscribed by: game_engine.py
# Data: {}
QUIT_CONFIRM_REQUESTED = auto()
- # Emitted by: commands/system.py (quit, when there is progress to lose)
+ # Emitted by: game_flow.py (request_leave: quit / menu with progress, or quit mid-fight)
# Subscribed by: textual_ui.py (shows the chooser modal)
- # Data: {}
+ # Data: {"inCombat": bool}
#
- # The domain still accepts typed y/n/c, so a frontend that ignores this
- # event (the headless driver) keeps working exactly as before.
+ # The domain still accepts the typed letters (m/y/n/c, or c/x/n mid-fight),
+ # so a frontend that ignores this event (the headless driver) keeps working.
+
+ SAVE_PICKER_REQUESTED = auto()
+ # Emitted by: game_engine.py (LOAD GAME; NEW GAME when every slot is taken)
+ # Subscribed by: textual_ui.py (shows the save picker)
+ # Data: {"mode": "continue" | "replace", "runs": list[dict], "legacyCount": int}
+ # Each run: runId, playerName, playerClass, difficulty, level, roomPath,
+ # health, maxHealth, savedAt, cleared. The picker answers with the same
+ # commands a typed player can send: "pick ", "delete ", "back".
GAME_QUIT = auto()
- # Emitted by: command_handler.py (_perform_quit), game_engine.py (menu exit)
+ # Emitted by: game_flow.py (perform_quit), game_engine.py (menu exit)
# Subscribed by: textual_ui.py (App.exit), engine/headless/ui.py (records it)
# Data: {}
#
@@ -78,12 +89,12 @@ class EventType(Enum):
# Data: StatsView dict
PLAYER_STATS_CHANGED = auto()
- # Emitted by: game_engine.py, command_handler.py
+ # Emitted by: game_engine.py (once after every command), combat.py (each turn)
# Subscribed by: textual_ui.py
# Data: StatsView dict
PLAYER_INVENTORY_CHANGED = auto()
- # Emitted by: game_engine.py, command_handler.py
+ # Emitted by: game_engine.py (once after every command)
# Subscribed by: textual_ui.py
# Data: InventoryView dict
@@ -94,12 +105,7 @@ class EventType(Enum):
COMMAND_ENTERED = auto()
# Emitted by: textual_ui.py
# Subscribed by: game_engine.py
- # Data: {"command": str, "game_state": GameState}
-
- UI_ERROR = auto()
- # Emitted by: textual_ui.py
- # Subscribed by: game_engine.py
- # Data: {"error": str}
+ # Data: {"command": str}. The engine routes on its own state, not the sender's.
UI_READY = auto()
# Emitted by: textual_ui.py
@@ -116,30 +122,16 @@ class EventType(Enum):
# ========================================
ROOM_ENTERED = auto()
- # Emitted by: game_engine.py, command_handler.py
- # Subscribed by: command_handler.py, textual_ui.py
+ # Emitted by: CommandHandler.announce_room (cd, flee, new game, load, ls -a
+ # reveal, post-victory redraw)
+ # Subscribed by: textual_ui.py. A notification only: arrival rules run from
+ # CommandHandler.arrive(), called directly.
# Data: {"room": RoomView dict, "player_name": str}
- ROOM_CHANGED = auto()
- # Emitted by: command_handler.py
- # Subscribed by: command_handler.py
- # Data: {"player_name": str, "from_room": str, "to_room": str}
-
- DELAYED_ROOM_REFRESH = auto()
- # Emitted by: commands/items.py (cat, after a story-beat read) so the "✦ Memory
- # restored / ✓ saved" message stays on screen before the room re-lists.
- # Subscribed by: textual_ui.py (schedules an `ls` via set_timer). Headless ignores it.
- # Data: {"room_id": str}
-
ENEMY_DEFEATED = auto()
- # Emitted by: combat.py
- # Subscribed by: command_handler.py
- # Data: {"enemy_id": str, "room": str, "player_name": str}
-
- ALL_ENEMIES_DEFEATED = auto()
- # Emitted by: game_world.py
- # Subscribed by: command_handler.py
- # Data: {"room": str}
+ # Emitted by: combat.py, before it calls CommandHandler.on_kill directly
+ # Subscribed by: textual_ui.py (observer; loot and removal run from on_kill)
+ # Data: {"enemy_id": str, "player_name": str}
# ========================================
# Combat Events
@@ -147,7 +139,8 @@ class EventType(Enum):
COMBAT_STARTED = auto()
# Emitted by: combat.py
- # Subscribed by: game_engine.py, textual_ui.py
+ # Subscribed by: textual_ui.py. The engine enters combat via the session's
+ # on_start callback, not this event.
# Data: CombatView dict (includes enemy info, player health, available attacks)
COMBAT_ACTION_SELECTED = auto()
@@ -166,23 +159,30 @@ class EventType(Enum):
# Data: CombatView dict — updated health values and cooldowns for current frame
COMBAT_ENDED = auto()
- # Emitted by: combat.py
- # Subscribed by: command_handler.py, game_engine.py, textual_ui.py
- # Data: {"victory": bool, "defeat": bool, "fled": bool, "enemy_id": str, "enemies_defeated": int}
+ # Emitted by: combat.py, before it calls CommandHandler.end_combat directly
+ # Subscribed by: textual_ui.py, tutorial_coach.py (observers only; the game's
+ # reaction runs from end_combat in a fixed order)
+ # Data: {"victory": bool, "defeat": bool, "fled": bool, "enemy_id": str,
+ # "enemies_defeated": int}
@dataclass
class Event:
"""Represents an event with data."""
type: EventType
- data: Dict[str, Any]
+ data: dict[str, Any]
source: str = "unknown"
class EventBus:
"""Central event bus for decoupled communication."""
-
- def __init__(self):
- self._listeners: Dict[EventType, List[Callable[[Event], None]]] = {}
- self._event_history: List[Event] = []
+
+ #: Default for new buses. The test suite turns this on (tests/conftest.py)
+ #: so a listener that raises fails the test instead of only being logged.
+ strict_by_default: bool = False
+
+ def __init__(self, strict: bool | None = None) -> None:
+ self.strict = self.strict_by_default if strict is None else strict
+ self._listeners: dict[EventType, list[Callable[[Event], None]]] = {}
+ self._event_history: list[Event] = []
self._max_history = 100
def subscribe(self, event_type: EventType, callback: Callable[[Event], None]) -> None:
@@ -204,7 +204,10 @@ def unsubscribe(self, event_type: EventType, callback: Callable[[Event], None])
def emit(self, event: Event) -> None:
"""Emit an event to all subscribers."""
start_time = time.time()
- logger.debug(f"Emitting event: {event.type} from {event.source} to {len(self._listeners.get(event.type, []))} listeners")
+ listener_count = len(self._listeners.get(event.type, []))
+ logger.debug(
+ f"Emitting event: {event.type} from {event.source} to {listener_count} listeners"
+ )
# Add to history
self._event_history.append(event)
@@ -232,6 +235,8 @@ def emit(self, event: Event) -> None:
logger.warning(f"Slow callback for {event.type}: {callback_time:.3f}s")
except Exception as e:
+ if self.strict:
+ raise
callback_errors += 1
logger.error(f"Error in event callback for {event.type}: {e}")
@@ -243,19 +248,18 @@ def emit(self, event: Event) -> None:
if callback_errors:
logger.warning(f"{event.type}: {callback_errors} callback error(s)")
- def emit_event(self, event_type: EventType, data: Dict[str, Any] = None, source: str = "unknown") -> None:
+ def emit_event(
+ self, event_type: EventType, data: dict[str, Any] | None = None, source: str = "unknown",
+ ) -> None:
"""Convenience method to emit an event."""
event = Event(type=event_type, data=data or {}, source=source)
logger.debug(f"Emitting event: {event_type} from {source} with data: {data}")
self.emit(event)
- def get_event_history(self) -> List[Event]:
+ def get_event_history(self) -> list[Event]:
"""Get the event history."""
return self._event_history.copy()
def clear_history(self) -> None:
"""Clear the event history."""
self._event_history.clear()
-
-# Global event bus instance
-event_bus = EventBus()
\ No newline at end of file
diff --git a/engine/headless/ui.py b/engine/headless/ui.py
index e054fa8..96a07bb 100644
--- a/engine/headless/ui.py
+++ b/engine/headless/ui.py
@@ -27,16 +27,32 @@ def __init__(self) -> None:
# Set when the domain asks to quit; tests assert on it instead of the
# process exiting.
self.quit_requested = False
+ # Every tutorial hint shown, in order, so tests can assert which step
+ # fired without depending on its wording.
+ self.hints: list[dict[str, Any]] = []
# Back-refs the engine assigns via _bind_ui_refs; unused here but must
# be assignable.
self._player_ref: object | None = None
self._world_ref: object | None = None
self._room_aliases_ref: object | None = None
# The finale is delivered by event (the TUI performs it; headless just
- # records the text so tests/sim can assert on the ending).
- from src.events import EventType, event_bus
- event_bus.subscribe(EventType.GAME_WON, self._on_game_won)
- event_bus.subscribe(EventType.GAME_QUIT, self._on_game_quit)
+ # records the text so tests/sim can assert on the ending). The bus
+ # arrives from the engine via attach_bus.
+ self.bus: Any = None
+
+ def attach_bus(self, bus: Any, state_manager: Any) -> None:
+ from engine.events import EventType
+ self.shutdown()
+ self.bus = bus
+ self.state_manager = state_manager
+ bus.subscribe(EventType.GAME_WON, self._on_game_won)
+ bus.subscribe(EventType.GAME_QUIT, self._on_game_quit)
+ bus.subscribe(EventType.TUTORIAL_HINT, self._on_tutorial_hint)
+
+ def _on_tutorial_hint(self, event: Any) -> None:
+ data = getattr(event, "data", None) or {}
+ self.hints.append(data)
+ self.output_log.append(str(data.get("text", "")))
def _on_game_quit(self, event: Any) -> None:
self.quit_requested = True
@@ -49,10 +65,21 @@ def _on_game_won(self, event: Any) -> None:
# --- output capture -----------------------------------------------------
+ @staticmethod
+ def _check_markup(content: object) -> None:
+ """Parse string output as Rich markup, as the real TUI does, so broken
+ markup (e.g. `[italic cyan]...[/italic]`) fails a test instead of
+ crashing the player's command."""
+ if isinstance(content, str):
+ from rich.markup import render
+ render(content)
+
def update_output(self, content: str) -> None:
+ self._check_markup(content)
self.output_log.append(str(content))
def append_output(self, content: str) -> None:
+ self._check_markup(content)
self.output_log.append(str(content))
def update_output_renderable(self, renderable: object) -> None:
@@ -77,28 +104,14 @@ def drain(self) -> list[str]:
def run(self) -> None: # pragma: no cover - lifecycle no-op
pass
- def shutdown(self) -> None: # pragma: no cover - lifecycle no-op
- from src.events import EventType, event_bus
- event_bus.unsubscribe(EventType.GAME_WON, self._on_game_won)
- event_bus.unsubscribe(EventType.GAME_QUIT, self._on_game_quit)
-
- def update_inventory(self, content: str) -> None:
- pass
-
- def update_stats(self, content: str) -> None:
- pass
-
- def update_exits(self, exits: list[object]) -> None:
- pass
-
- def update_player_name(self, name: str) -> None:
- pass
-
- def display_game_over(self) -> None:
- pass
-
- def save_current_game(self) -> None:
- pass
+ def shutdown(self) -> None:
+ if self.bus is None:
+ return
+ from engine.events import EventType
+ self.bus.unsubscribe(EventType.GAME_WON, self._on_game_won)
+ self.bus.unsubscribe(EventType.GAME_QUIT, self._on_game_quit)
+ self.bus.unsubscribe(EventType.TUTORIAL_HINT, self._on_tutorial_hint)
+ self.bus = None
- def _display_title_screen(self) -> None:
+ def _display_title_screen(self, skip_typewriter: bool = False) -> None:
self.output_log.append("[title screen]")
diff --git a/engine/schema/models.py b/engine/schema/models.py
index 982c6a6..134fe8e 100644
--- a/engine/schema/models.py
+++ b/engine/schema/models.py
@@ -45,6 +45,28 @@ def _falsey_to_bool(v: object) -> bool:
return bool(v)
+class RoomFlag(_Base):
+ """A room's capture-the-flag: the file whose reading captures it, the
+ FLAG{...} text shown on capture, and beginner guidance (a nudge, the exact
+ command, and an optional first-visit lesson Echo gives)."""
+ file: ItemId | None = None
+ text: str
+ nudge: str
+ command: str
+ teach: str = ""
+ # How the flag is captured: "cat" (read the file) or "grep" (search it).
+ via: str = "cat"
+ # Said by any NPC in the room while the flag is still out there.
+ clue: str = ""
+ # kill / defeat flags: the enemy whose defeat captures the flag.
+ enemy: EnemyId | None = None
+ # kill flags: the rogue's line in ps.
+ pid: int = 0
+ process: str = ""
+ # A key this flag hands out on capture (the keys chain).
+ grants: ItemId | None = None
+
+
class Room(_Base):
id: RoomId = Field(default=RoomId(""))
name: str
@@ -63,6 +85,18 @@ class Room(_Base):
class_restriction: str = ""
path: str = ""
aliases: list[str] = Field(default_factory=list)
+ # Tier rooms roll `enemy_count` enemies from the `enemy_tier` pool at
+ # world-init (src/enemy_pools.py); rooms without a tier keep `enemies`.
+ enemy_tier: int | None = None
+ enemy_count: int | None = None
+ # A hidden room with this story flag set is revealed by `ls -a`.
+ discovery_requirement: str | None = None
+ # Capture-the-flag for this room (docs/ROOM_FLAGS_SPEC.md).
+ flag: RoomFlag | None = None
+ # An optional trial room open to every class; this class gets a bonus there.
+ trial_class: str = ""
+ # Entry needs this many main-room flags captured elsewhere (the finale gate).
+ flags_required: int = 0
_coerce_locked = field_validator("locked", mode="before")(_falsey_to_bool)
_coerce_hidden = field_validator("hidden", mode="before")(_falsey_to_bool)
@@ -86,6 +120,7 @@ class CharacterClass(_Base):
base_health: int = Field(gt=0)
base_damage: int = Field(gt=0)
starter_weapon: ItemId | None = None
+ starter_armor: ItemId | None = None
starter_abilities: list[AbilityId] = Field(default_factory=list)
attacks: list[AttackId] = Field(default_factory=list)
preferred_zones: list[str] = Field(default_factory=list)
@@ -125,6 +160,14 @@ def _dialogue_to_str(cls, v: object) -> str:
return "" if v is None else str(v)
+class LogSpec(_Base):
+ """A long generated log (src/logs.py): `lines` look-alike lines drawn from
+ `templates`, with `flag_line` once. Templates may use {ts} and {n}."""
+ lines: int = 300
+ templates: list[str]
+ flag_line: str
+
+
class Item(_Base):
id: ItemId = Field(default=ItemId(""))
name: str
@@ -145,6 +188,29 @@ class Item(_Base):
consumed_on_use: bool = False
takeable: bool = True
droppable: bool = True
+ # A hidden item is omitted from plain `ls`; `ls -a` lists it. It stays
+ # readable/takeable by name either way, like a real dotfile.
+ hidden: bool = False
+ consumable: bool = False
+ defense: int = 0
+ healing: int | None = None
+ content: str = ""
+ story_flag: str = ""
+ class_restriction: str = ""
+ max_spawn: int = 1
+ allowed_rooms: list[RoomId] = Field(default_factory=list)
+ special_effects: list[dict[str, object]] = Field(default_factory=list)
+ on_use: dict[str, object] = Field(default_factory=dict)
+ effects: dict[str, object] = Field(default_factory=dict)
+ status_effect: dict[str, object] | None = None
+ # Effect hooks run by ItemEffects.execute_effect (a dict of effects, or
+ # a bare string that is shown as flavour text).
+ on_take: dict[str, object] | str | None = None
+ on_drop: dict[str, object] | str | None = None
+ on_examine: dict[str, object] | str | None = None
+ on_read: dict[str, object] | str | None = None
+ # A long generated log file; see LogSpec.
+ log: LogSpec | None = None
@field_validator("rarity", mode="before")
@classmethod
@@ -163,6 +229,8 @@ class Ability(_Base):
char_class: str = Field(default="all", alias="class")
cooldown: int = 0
bonus_damage: int = 0
+ healing: int = 0
+ enemy_damage_reduction: float = 0
class Attack(_Base):
@@ -173,6 +241,8 @@ class Attack(_Base):
cooldown: int = 0
accuracy: int = 100
type: str = "physical"
+ healing: int = 0
+ enemy_damage_reduction: float = 0
class NPC(_Base):
@@ -186,3 +256,4 @@ class NPC(_Base):
dialogue: dict[str, object] = Field(default_factory=dict)
dialogue_rules: list[dict[str, object]] = Field(default_factory=list)
location: RoomId | None = None
+ on_talk: dict[str, object] | None = None
diff --git a/engine/validate.py b/engine/validate.py
index 04b509e..20cb406 100644
--- a/engine/validate.py
+++ b/engine/validate.py
@@ -15,9 +15,13 @@
GameContent,
find_broken_references,
find_dialogue_problems,
+ find_flag_problems,
+ find_key_chain_problems,
+ find_lock_problems,
find_nav_problems,
find_reference_warnings,
find_tree_problems,
+ find_unread_fields,
load_all,
)
from engine.schema import ContentError, RoomId
@@ -57,6 +61,10 @@ def main(argv: list[str] | None = None) -> int:
+ find_nav_problems(content)
+ find_tree_problems(content)
+ find_dialogue_problems(content)
+ + find_lock_problems(content)
+ + find_flag_problems(content)
+ + find_key_chain_problems(content)
+ + find_unread_fields(content)
)
if problems:
print(f"LINK FAILED — {len(problems)} content problem(s):", file=sys.stderr)
diff --git a/engine/view_models.py b/engine/view_models.py
new file mode 100644
index 0000000..daa0bb2
--- /dev/null
+++ b/engine/view_models.py
@@ -0,0 +1,168 @@
+#!/usr/bin/env python3
+"""
+View Models - Data Transfer Objects for UI/Backend separation.
+
+These dataclasses define the exact data the UI needs without coupling to backend implementations.
+All view models are immutable and serializable.
+"""
+
+from __future__ import annotations
+
+from dataclasses import asdict, dataclass, field
+from typing import Any
+
+
+@dataclass(frozen=True)
+class StatsView:
+ """Player statistics for the stats panel."""
+ player_name: str
+ health: int
+ max_health: int
+ damage: int
+ player_class: str
+ level: int = 1
+ cycles: int = 0
+ cycles_to_next: int = 0
+ defense_pct: int = 0 # % damage reduction from equipped armor
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert to dictionary for event serialization."""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> StatsView:
+ """Rebuild from an event payload made by to_dict()."""
+ return cls(**data)
+
+
+
+@dataclass(frozen=True)
+class InventoryItemView:
+ """Single inventory item representation."""
+ id: str
+ name: str
+ item_type: str
+ rarity: str = "common"
+ is_equipped: bool = False
+ damage: int | None = None
+ healing: int | None = None # total HP the item restores
+ healTurns: int = 0 # >0: restored over this many turns, not at once
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert to dictionary for event serialization."""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> InventoryItemView:
+ """Rebuild from an event payload made by to_dict()."""
+ return cls(**data)
+
+
+
+@dataclass(frozen=True)
+class InventoryView:
+ """Full inventory representation."""
+ items: list[InventoryItemView] = field(default_factory=list)
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert to dictionary for event serialization."""
+ return {
+ "items": [item.to_dict() for item in self.items]
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> InventoryView:
+ """Rebuild from an event payload made by to_dict()."""
+ return cls(items=[InventoryItemView.from_dict(i) for i in data.get("items", [])])
+
+
+
+@dataclass(frozen=True)
+class RoomView:
+ """Room display data."""
+ name: str
+ description: str
+ id: str = ""
+ zone: str = ""
+ exits: list[str] = field(default_factory=list)
+ enemies: list[str] = field(default_factory=list) # display names
+ npcs: list[str] = field(default_factory=list) # display names
+ enemy_ids: list[str] = field(default_factory=list) # same order as enemies
+ npc_ids: list[str] = field(default_factory=list) # same order as npcs
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert to dictionary for event serialization."""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> RoomView:
+ """Rebuild from an event payload made by to_dict()."""
+ return cls(**data)
+
+
+
+@dataclass(frozen=True)
+class AttackView:
+ """Single attack option with cooldown info."""
+ id: str
+ name: str
+ bonus_damage: int
+ cooldown: int
+ cooldown_remaining: int = 0
+ on_cooldown: bool = False
+ accuracy: int = 100
+ healing: int = 0 # HP the player regains on a hit
+ weaken: float = 0.0 # fraction cut from the enemy's next hit
+ kind: str = "" # physical / magical / nature: picks the menu icon
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert to dictionary for event serialization."""
+ return asdict(self)
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> AttackView:
+ """Rebuild from an event payload made by to_dict()."""
+ return cls(**data)
+
+
+
+@dataclass(frozen=True)
+class CombatView:
+ """Combat UI data bundle."""
+ enemy_name: str
+ enemy_health: int
+ enemy_max_health: int
+ player_health: int
+ player_max_health: int
+ enemy_id: str = "" # for scene sprite lookup
+ available_attacks: list[AttackView] = field(default_factory=list)
+ usable_items: list[InventoryItemView] = field(default_factory=list)
+
+ def to_dict(self) -> dict[str, Any]:
+ """Convert to dictionary for event serialization."""
+ return {
+ "enemy_id": self.enemy_id,
+ "enemy_name": self.enemy_name,
+ "enemy_health": self.enemy_health,
+ "enemy_max_health": self.enemy_max_health,
+ "player_health": self.player_health,
+ "player_max_health": self.player_max_health,
+ "available_attacks": [attack.to_dict() for attack in self.available_attacks],
+ "usable_items": [item.to_dict() for item in self.usable_items]
+ }
+
+ @classmethod
+ def from_dict(cls, data: dict[str, Any]) -> CombatView:
+ """Rebuild from an event payload made by to_dict()."""
+ return cls(
+ enemy_name=data["enemy_name"],
+ enemy_health=data["enemy_health"],
+ enemy_max_health=data["enemy_max_health"],
+ player_health=data["player_health"],
+ player_max_health=data["player_max_health"],
+ enemy_id=data.get("enemy_id", ""),
+ available_attacks=[AttackView.from_dict(a) for a in data.get("available_attacks", [])],
+ usable_items=[InventoryItemView.from_dict(i) for i in data.get("usable_items", [])],
+ )
+
+
diff --git a/main.py b/main.py
index fb3b842..2afd103 100644
--- a/main.py
+++ b/main.py
@@ -1,26 +1,37 @@
#!/usr/bin/env python3
import os
import shutil
-from src.game_engine import main
-from src.ui.textual_ui import TextualGameUI # composition root: the frontend is chosen HERE
-if __name__ == "__main__":
- # Create necessary directories if they don't exist
- os.makedirs("data/rooms", exist_ok=True)
- os.makedirs("data/items", exist_ok=True)
- os.makedirs("data/enemies", exist_ok=True)
- os.makedirs("data/npcs", exist_ok=True)
-
- # Copy debug.log to combat.log on each restart (overwrites old combat.log)
- if os.path.exists("debug.log"):
- shutil.copy2("debug.log", "combat.log")
- # Clear debug.log for fresh logging this session
- with open("debug.log", "w") as f:
- f.write("")
+# Content, saves, logs and user settings are all addressed relative to the
+# repo, and some are opened at import time, so this runs before anything else.
+os.chdir(os.path.dirname(os.path.abspath(__file__)))
+
+
+def rotate_logs(log_file: str, previous: str = "combat.log", header: str = "") -> None:
+ """Start the session with a fresh log, opened by `header` (the build) when
+ given; keep the last session's log as `previous`."""
+ if os.path.exists(log_file):
+ shutil.copy2(log_file, previous)
else:
- # Create empty combat.log if debug.log doesn't exist
- with open("combat.log", "w") as f:
+ with open(previous, "w") as f:
f.write("=== Combat Log (Fresh Session) ===\n")
+ with open(log_file, "w") as f:
+ if header:
+ f.write(header + "\n")
+
+
+if __name__ == "__main__":
+ from datetime import datetime
+
+ from config.dev_config import DEBUG_LOG_FILE
+ from src.version import build_label # logs nothing, so safe before rotating
+
+ # Before the game modules load: some of them log while importing.
+ started = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
+ rotate_logs(DEBUG_LOG_FILE, header=f"=== {build_label()} — session started {started} ===")
+
+ from src.game_engine import main
+ from src.ui.textual_ui import TextualGameUI # composition root: the frontend is chosen HERE
# Run the game — build the concrete frontend and inject it into the backend.
- main(TextualGameUI())
\ No newline at end of file
+ main(TextualGameUI())
diff --git a/pyproject.toml b/pyproject.toml
index 6775a23..8484ce8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,17 @@
+[project]
+name = "haunted-terminal"
+# The game's version, shown on the title screen and at the top of debug.log.
+# 0.MINOR.PATCH while playtesting: bump MINOR for each dev -> main release,
+# PATCH for a fix-only release. Tag main (vX.Y.Z) when it merges.
+version = "0.2.0"
+requires-python = ">=3.10"
+
[tool.pytest.ini_options]
testpaths = ["tests"]
addopts = "-q"
+markers = [
+ "rejects_transition: the test provokes a rejected state transition on purpose",
+]
[tool.ruff]
line-length = 100
@@ -13,18 +24,71 @@ select = ["E", "F", "I", "UP", "B"]
[tool.mypy]
python_version = "3.11"
-# Scope strict typing to the new engine; legacy src/ is untyped and out of scope.
-files = ["engine"]
+# Strict typing covers engine/ plus the src/ modules listed here. Grow the list
+# one module at a time as src/ gains annotations; everything else in src/ stays
+# unchecked (see the src.* override below).
+files = [
+ "engine",
+ "src/difficulty.py",
+ "src/game_output.py",
+ "src/game_states.py",
+ "src/item_icons.py",
+ "src/rarity.py",
+ "src/rng.py",
+ "src/room_paths.py",
+ "src/state_manager.py",
+ "src/ui/combat_log.py",
+ "src/ui/endings.py",
+ "src/ui/panels/echo_panel.py",
+ "src/ui/panels/inventory_panel.py",
+ "src/ui/panels/stats_panel.py",
+ "src/version.py",
+ "src/move_info.py",
+]
strict = true
plugins = ["pydantic.mypy"]
+# src/ has no __init__.py; name its files src. (as the imports do)
+# rather than as top-level modules.
+explicit_package_bases = true
[[tool.mypy.overrides]]
module = "yaml"
ignore_missing_imports = true
+# Optional local dev flags, gitignored (copied from settings.example.py);
+# config/dev_config.py falls back to defaults when it is absent.
+[[tool.mypy.overrides]]
+module = "config.settings"
+ignore_missing_imports = true
+
# Legacy game code is untyped and out of scope for strict checking. Treat it as
# Any at the engine boundary rather than following into it.
[[tool.mypy.overrides]]
module = "src.*"
ignore_missing_imports = true
follow_imports = "skip"
+
+# The typed src/ modules (keep in step with `files` above) must see each
+# other's real types; a module-specific section wins over the src.* wildcard.
+[[tool.mypy.overrides]]
+module = [
+ "src.difficulty",
+ "src.game_output",
+ "src.game_states",
+ "src.item_icons",
+ "src.rarity",
+ "src.rng",
+ "src.room_paths",
+ "src.state_manager",
+ "src.ui.combat_log",
+ "src.ui.endings",
+ "src.ui.panels.echo_panel",
+ "src.ui.panels.inventory_panel",
+ "src.ui.panels.stats_panel",
+]
+follow_imports = "normal"
+
+# utils/ is untyped; read it for signatures but don't report errors inside it.
+[[tool.mypy.overrides]]
+module = "utils.*"
+follow_imports = "silent"
diff --git a/sim/gauntlet.py b/sim/gauntlet.py
index c76b3d1..96b385e 100644
--- a/sim/gauntlet.py
+++ b/sim/gauntlet.py
@@ -52,15 +52,21 @@ def main_path_enemy_ids() -> list[str]:
# gauntlet no player ever plays and dropped the boss entirely.
#
# Still excluded: hidden rooms (secret detours the player may never find) and
- # class-restricted rooms (a run must never be scored against a boss its class
- # cannot reach). rooms are typed Room models (from load_room_data).
+ # trial rooms (optional side fights, open to every class). rooms are typed
+ # Room models (from load_room_data).
main_path = {
rid: room for rid, room in rooms.items()
if not room.hidden
- and not room.class_restriction
+ and not room.trial_class
}
rolled = roll_room_enemies(main_path, enemies, rng)
ids = [eid for rid in main_path for eid in rolled.get(rid, []) if eid in enemies]
+ # Rogue processes hide until `kill`, so they are in no room's roll — but a
+ # player needs their flags for /boot, so they are part of the main path.
+ for room in main_path.values():
+ flag = getattr(room, "flag", None)
+ if flag is not None and flag.via == "kill" and flag.enemy in enemies:
+ ids.append(str(flag.enemy))
ids.sort(key=lambda eid: _threat(enemies[eid]))
return ids
diff --git a/sim/simulator.py b/sim/simulator.py
index d379b31..1f1fb15 100644
--- a/sim/simulator.py
+++ b/sim/simulator.py
@@ -14,9 +14,10 @@
import yaml
+from engine.schema import Enemy
from src import difficulty, rng
from src.combat import combat_system
-from src.data_loader import load_class_data, load_enemy_data, load_room_data, load_weapon_data
+from src.data_loader import load_class_data, load_enemy_data, load_item, load_room_data
from src.game_world import GameWorld
from src.player import Player
@@ -42,6 +43,24 @@
# Consumable ids that restore HP, with the amount (from data/items/consumables.yaml).
_HEAL_ITEMS = {"health_packet": 30, "stable_cache": 40}
+# Main-path flags a player holds by the Overlord fight (/boot's own flag is
+# captured by winning it, so it never feeds the fight).
+MAIN_FLAGS_BEFORE_BOSS = 12
+
+
+def flag_xp_schedule(n_fights: int, n_flags: int, xp_per_flag: int) -> list[int]:
+ """Flag XP to award after each fight, spreading n_flags evenly across the
+ run so every flag's XP has landed by the last fight."""
+ if n_fights <= 0:
+ return []
+ schedule = []
+ awarded = 0
+ for i in range(n_fights):
+ due = round(n_flags * (i + 1) / n_fights)
+ schedule.append((due - awarded) * xp_per_flag)
+ awarded = due
+ return schedule
+
@dataclass
class RunResult:
@@ -82,14 +101,6 @@ def _class_weapons(class_id: str) -> tuple[tuple[str, int], ...]:
return tuple(usable)
-def _armor_data(armor_id: str) -> dict | None:
- try:
- with open("data/items/armor.yaml") as fh:
- return (yaml.safe_load(fh) or {}).get(armor_id)
- except Exception:
- return None
-
-
def _class_armor(class_id: str) -> tuple[tuple[str, int], ...]:
"""(armor_id, defense) obtainable by the class, ascending by defense.
@@ -119,7 +130,7 @@ def _equip_for_stage(player: Player, class_id: str, cleared: int, total: int) ->
tier = min((cleared * len(weapons)) // max(1, total), len(weapons) - 1)
weapon_id, _ = weapons[tier]
if weapon_id != player.equipped_weapon:
- weapon = load_weapon_data(weapon_id)
+ weapon = load_item(weapon_id)
if weapon:
player.add_to_inventory(weapon_id, weapon)
player.equip_weapon(weapon_id)
@@ -129,7 +140,7 @@ def _equip_for_stage(player: Player, class_id: str, cleared: int, total: int) ->
a_tier = min((cleared * len(armor)) // max(1, total), len(armor) - 1)
armor_id, _ = armor[a_tier]
if armor_id != player.equipped_armor:
- adata = _armor_data(armor_id)
+ adata = load_item(armor_id)
if adata:
player.add_to_inventory(armor_id, adata)
player.equip_armor(armor_id)
@@ -142,7 +153,7 @@ def _build_player(class_id: str) -> Player:
cls = classes.get(class_id)
weapon_id = cls.starter_weapon if cls else None
if weapon_id:
- weapon = load_weapon_data(weapon_id)
+ weapon = load_item(weapon_id)
if weapon:
player.add_to_inventory(weapon_id, weapon)
player.equip_weapon(weapon_id)
@@ -150,10 +161,10 @@ def _build_player(class_id: str) -> Player:
return player
-def _fight(player: Player, enemy: dict) -> bool:
+def _fight(player: Player, enemy: Enemy) -> bool:
"""Resolve one fight to the death. Returns True if the player survives."""
- enemy_hp = enemy.get("health", 1)
- enemy_damage = enemy.get("damage", 0)
+ enemy_hp = enemy.health
+ enemy_damage = enemy.damage
pending_reduction = 0.0
turns = 0
@@ -188,29 +199,55 @@ def _fight(player: Player, enemy: dict) -> bool:
return player.is_alive() and enemy_hp <= 0
+def gear_stages(enemy_ids: list[str], rogues: set[str]) -> list[tuple[int, int]]:
+ """(cleared, total) gear progress to equip for before each fight. Rogue
+ fights drop no gear in the game, so they neither count as progress nor
+ toward the total — they sort first and would otherwise hand out tiers."""
+ total = sum(1 for e in enemy_ids if e not in rogues)
+ stages = []
+ cleared = 0
+ for enemy_id in enemy_ids:
+ stages.append((cleared, total))
+ if enemy_id not in rogues:
+ cleared += 1
+ return stages
+
+
+def _rogue_ids() -> set[str]:
+ return {
+ str(room.flag.enemy) for room in load_room_data().values()
+ if getattr(room, "flag", None) is not None and room.flag.via == "kill"
+ }
+
+
def run_gauntlet(class_id: str, world: GameWorld, enemy_ids: list[str]) -> RunResult:
player = _build_player(class_id)
total = len(enemy_ids)
cleared = 0
- for enemy_id in enemy_ids:
+ from src.flags import FLAG_XP
+ flag_xp = flag_xp_schedule(total, MAIN_FLAGS_BEFORE_BOSS, FLAG_XP)
+ stages = gear_stages(enemy_ids, _rogue_ids())
+ for i, enemy_id in enumerate(enemy_ids):
enemy = world.get_enemy(enemy_id, class_id)
if not enemy:
continue
- _equip_for_stage(player, class_id, cleared, total) # loot progression
+ _equip_for_stage(player, class_id, *stages[i]) # loot progression
if not _fight(player, enemy):
return RunResult(
False, enemy_id, cleared, player.level,
player.health / max(1, player.max_health),
)
cleared += 1
- base = enemy.get("experience", 50)
- if enemy.get("boss_room") or enemy.get("boss_enemy"):
- base *= 3
+ base = enemy.experience
player.harvest_cycles(difficulty.scale_xp(base))
+ if flag_xp[i]:
+ player.harvest_cycles(flag_xp[i])
+ # A capture restores full HP in the game (FlagService._capture).
+ player.health = player.max_health
# Loot heals only if this enemy actually drops one and the roll hits —
# faithful to the real (stingy) drop economy, not a free per-fight heal.
- for drop in enemy.get("drops", []) or []:
- if drop.get("item") in _HEAL_ITEMS and rng.random() * 100 < drop.get("chance", 0):
+ for drop in enemy.drops:
+ if drop.item in _HEAL_ITEMS and rng.random() * 100 < drop.chance:
player._sim_heals += 1 # type: ignore[attr-defined]
return RunResult(
diff --git a/src/combat.py b/src/combat.py
index d97d073..a54ae80 100644
--- a/src/combat.py
+++ b/src/combat.py
@@ -2,8 +2,18 @@
import yaml
from src import rng
from utils.debug_tools import debug_log
-from src.events import event_bus, EventType
+from engine.events import EventType
from src.viewmodels.view_builder import ViewBuilder
+from src.player import auto_revive_text, is_auto_revive
+
+# Exploration commands that exist but can't run mid-fight. Typing one gets
+# "works once the fight is over" rather than "command not found".
+_EXPLORE_ONLY = frozenset({
+ "cat", "cd", "clear", "drop", "echo", "equip", "examine", "find", "grep",
+ "help", "hint", "inventory", "journal", "keys", "kill", "man", "ps", "pwd",
+ "save", "shortcuts", "take", "talk", "tree", "whoami",
+})
+
class CombatSystem:
"""Handles all combat-related functionality with a unified approach."""
@@ -234,7 +244,9 @@ def get_available_attacks(self, player, learned_spells=None):
if spell_attack_ids:
debug_log(f"Added {len(spell_attack_ids)} learned spell attacks: {spell_attack_ids}")
- all_attack_ids = list(set(base_attacks_ids + spell_attack_ids)) # Use set to avoid duplicates
+ # Dedupe in class order: this order is the hotkey numbering, so it must
+ # not change between launches (a set's order does).
+ all_attack_ids = list(dict.fromkeys(base_attacks_ids + spell_attack_ids))
debug_log(f"Combined attack IDs for {player_class}: {all_attack_ids}")
available_attacks_data = {}
@@ -271,38 +283,49 @@ def get_available_attacks(self, player, learned_spells=None):
class CombatSession:
"""Manages an active combat session using event-driven approach."""
- def __init__(self, player, enemies_queue, output):
+ def __init__(self, player, enemies_queue, output, bus, on_start=None, on_end=None,
+ on_kill=None):
"""
Initialize combat session with enemy queue.
Args:
player: Player object
- enemies_queue: List of (enemy_id, enemy_data) tuples
+ enemies_queue: List of (enemy_id, Enemy) tuples
output: GameOutput sink (Phase 2b — no direct UI reference)
+ bus: the owning engine's EventBus
+ on_start: called once the fight is announced (game enters combat)
+ on_end: called with the outcome once the fight is over; the game's
+ reaction to a fight ending runs from here, not from COMBAT_ENDED
+ on_kill: called with the enemy id each time an enemy dies (loot,
+ removal from the room), not from ENEMY_DEFEATED
"""
self.player = player
+ self._on_start = on_start
+ self._on_end = on_end
+ self._on_kill = on_kill
self.enemies_queue = enemies_queue # List of (enemy_id, enemy_data)
self.current_enemy_index = 0
self.output = output
+ self.bus = bus
self.is_active = True
self.awaiting_action = False
# Initialize with first enemy
if enemies_queue:
self.enemy_id, self.enemy_data = enemies_queue[0]
- self.enemy_health = self.enemy_data.get("health", 50)
+ self.enemy_health = self.enemy_data.health
self.enemy_max_health = self.enemy_health
- self.enemy_damage = self.enemy_data.get("damage", 10)
- self.is_boss = self.enemy_data.get("is_boss", False)
+ self.enemy_damage = self.enemy_data.damage
+ self.is_boss = self.enemy_data.is_boss
debug_log(f"CombatSession created with {len(enemies_queue)} enemies in queue")
# Subscribe to combat events
- event_bus.subscribe(EventType.COMBAT_ACTION_SELECTED, self._on_combat_action)
+ self.bus.subscribe(EventType.COMBAT_ACTION_SELECTED, self._on_combat_action)
def start(self):
"""Start the combat session."""
- enemy_name = self.enemy_data.get("name", self.enemy_id)
+ enemy_name = self.enemy_data.name
debug_log(f"Starting combat with {enemy_name}")
# Build combat view and emit combat started event
@@ -314,18 +337,20 @@ def start(self):
enemy_id=self.enemy_id
)
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMBAT_STARTED,
combat_view.to_dict(),
"CombatSession"
)
+ if self._on_start is not None:
+ self._on_start()
# Emit combat intro message as a combat log entry
combat_intro = f"⚔ Combat initiated with {enemy_name}!"
- if "dialogue" in self.enemy_data:
- combat_intro += f" | {enemy_name}: {self.enemy_data['dialogue']}"
+ if self.enemy_data.dialogue:
+ combat_intro += f" | {enemy_name}: {self.enemy_data.dialogue}"
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMBAT_ACTION_RESULT,
{
"actor": "system",
@@ -337,7 +362,6 @@ def start(self):
"CombatSession"
)
- self._show_combat_status()
self._request_player_action()
def _engage_next_enemy(self):
@@ -353,12 +377,12 @@ def _engage_next_enemy(self):
# Get next enemy
self.enemy_id, self.enemy_data = self.enemies_queue[self.current_enemy_index]
- self.enemy_health = self.enemy_data.get("health", 50)
+ self.enemy_health = self.enemy_data.health
self.enemy_max_health = self.enemy_health
- self.enemy_damage = self.enemy_data.get("damage", 10)
- self.is_boss = self.enemy_data.get("is_boss", False)
+ self.enemy_damage = self.enemy_data.damage
+ self.is_boss = self.enemy_data.is_boss
- enemy_name = self.enemy_data.get("name", self.enemy_id)
+ enemy_name = self.enemy_data.name
debug_log(f"Engaging next enemy: {enemy_name} ({self.current_enemy_index + 1}/{len(self.enemies_queue)})")
# Reduce cooldowns by 1 for sequential combat
@@ -376,8 +400,8 @@ def _engage_next_enemy(self):
self.output.write(transition_msg)
# Show enemy dialogue if available
- if "dialogue" in self.enemy_data:
- self.output.write(f"[bold red]{enemy_name}:[/bold red] {self.enemy_data['dialogue']}")
+ if self.enemy_data.dialogue:
+ self.output.write(f"[bold red]{enemy_name}:[/bold red] {self.enemy_data.dialogue}")
# Reset combat UI flag for new enemy
self._combat_initialized = False
@@ -387,29 +411,10 @@ def _engage_next_enemy(self):
self._update_ui_panels()
# Show status and request action
- self._show_combat_status()
self._request_player_action()
return True
- def _show_combat_status(self):
- """Display current combat status."""
- # Health status is shown in the Battle Status panel (right side)
- # Combat log with actions is shown in main output panel
- # No need to output anything here - UI handles all display via events
- pass
-
- def _create_health_bar(self, current, maximum, color):
- """Create ASCII health bar."""
- if maximum <= 0:
- return "[gray]▒▒▒▒▒▒▒▒▒▒[/gray]"
-
- bar_length = 20
- filled = int((current / maximum) * bar_length)
- empty = bar_length - filled
- bar = "█" * filled + "▒" * empty
- return f"[{color}]{bar}[/{color}]"
-
def _request_player_action(self):
"""Request action from player via UI."""
if not self.is_active:
@@ -424,9 +429,9 @@ def _request_player_action(self):
for item_id, item_data in self.player.inventory.items():
# Check both old and new combat usability systems
is_combat_usable = (
- item_data.get("usable") and (
- "combat_usable" in item_data.get("tags", []) or # Old system
- item_data.get("usable_in_combat", False) # New system
+ item_data.usable and (
+ "combat_usable" in item_data.tags or # Old system
+ item_data.usable_in_combat # New system
)
)
if is_combat_usable:
@@ -501,19 +506,25 @@ def _parse_combat_command(self, command):
# Handle use command
elif cmd == "use" and len(parts) > 1:
- item_input = parts[1]
+ item_input = " ".join(parts[1:])
resolved = self.player.resolve_inventory_item(item_input)
if resolved and resolved in self.player.inventory:
item_data = self.player.inventory[resolved]
is_combat_usable = (
- "combat_usable" in item_data.get("tags", []) or
- item_data.get("usable_in_combat", False)
+ "combat_usable" in item_data.tags or
+ item_data.usable_in_combat
)
if is_combat_usable:
self._process_player_action("item", resolved)
+ elif is_auto_revive(item_data):
+ # Not an error: the backup is already doing its job.
+ self.output.write(auto_revive_text(item_data))
+ self._request_player_action()
else:
- item_label = item_data.get("name", resolved)
- self.output.write(f"[yellow]{item_label} cannot be used in combat.[/yellow]")
+ self.output.write(
+ f"[yellow]{item_data.name} can't be used mid-fight — "
+ "try it after the battle.[/yellow]"
+ )
self._request_player_action()
else:
self.output.write(f"[red]Item '{item_input}' not found in inventory.[/red]")
@@ -545,6 +556,15 @@ def _parse_combat_command(self, command):
# Attack exists but somehow not in available_attacks - process it anyway
self._process_player_action("attack", cmd)
+ elif cmd in _EXPLORE_ONLY:
+ # A real command, just not now: say so instead of "not found",
+ # which reads like a typo to someone new to the shell.
+ self.output.write(
+ f"[yellow]{cmd} works once the fight is over. Right now: press "
+ "1-9 to attack, 0 to flee, or type use - .[/yellow]"
+ )
+ self._request_player_action()
+
else:
self.output.write(f"[red]bash: {command}: command not found[/red]")
# Don't show hint - battle log and UI already show available actions
@@ -572,13 +592,13 @@ def _process_player_action(self, action_type, action_value):
# Handle healing items - support legacy and new combat_effects formats
heal_amount = 0
- combat_effects = item_data.get("combat_effects", {})
+ combat_effects = item_data.combat_effects
if "player_heal" in combat_effects:
heal_amount = combat_effects["player_heal"]
- elif "healing" in item_data:
- heal_amount = item_data["healing"]
- elif isinstance(item_data.get("on_use"), dict) and "heal" in item_data["on_use"]:
- heal_amount = item_data["on_use"]["heal"]
+ elif item_data.healing is not None:
+ heal_amount = item_data.healing
+ elif "heal" in item_data.on_use:
+ heal_amount = item_data.on_use["heal"]
actual_heal = 0
if heal_amount > 0:
@@ -592,7 +612,7 @@ def _process_player_action(self, action_type, action_value):
self.player.add_status_effect(
f"{action_value}_hot",
{"type": "heal_over_time", "heal_per_turn": per_turn,
- "name": item_data.get("name", action_value)},
+ "name": item_data.name},
duration
)
@@ -608,15 +628,15 @@ def _process_player_action(self, action_type, action_value):
# Handle consumable items (remove after use) - support both formats
should_consume = (
- item_data.get("consumable", False) or # Old format
- item_data.get("consumed_on_use", False) # New format
+ item_data.consumable or # Old format
+ item_data.consumed_on_use # New format
)
if should_consume:
self.player.remove_from_inventory(action_value)
# Build detailed message for item usage. Make a heal pop so the player
# clearly sees it landed even when the enemy also hits this turn.
- item_name = item_data.get('name', action_value)
+ item_name = item_data.name
if actual_heal > 0:
item_message = (
f"[bold green]💚 {self.player.name} used {item_name} — "
@@ -626,7 +646,7 @@ def _process_player_action(self, action_type, action_value):
item_message = f"{self.player.name} used {item_name}"
# Emit combat action result event for item usage
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMBAT_ACTION_RESULT,
{
"actor": "player",
@@ -644,7 +664,7 @@ def _process_player_action(self, action_type, action_value):
attack_result = combat_system.perform_attack(self.player, action_value)
# Emit combat action result event (UI will display via combat log)
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMBAT_ACTION_RESULT,
{
"actor": "player",
@@ -664,11 +684,11 @@ def _process_player_action(self, action_type, action_value):
# Check if enemy is defeated
if self.enemy_health <= 0:
- enemy_name = self.enemy_data.get("name", self.enemy_id)
+ enemy_name = self.enemy_data.name
self.output.write(f"\n[bold green]Victory! You defeated {enemy_name}![/bold green]")
- # Emit enemy defeated event (for loot, achievements, etc)
- event_bus.emit_event(
+ # Tell observers (the UI drains the HP bar), then let the game react.
+ self.bus.emit_event(
EventType.ENEMY_DEFEATED,
{
"enemy_id": self.enemy_id,
@@ -676,12 +696,11 @@ def _process_player_action(self, action_type, action_value):
},
"CombatSession"
)
+ if self._on_kill is not None:
+ self._on_kill(self.enemy_id)
# Award harvesting cycles (XP) from the enemy's authored experience value.
- base_cycles = self.enemy_data.get("experience", 50)
- is_boss = self.enemy_data.get("boss_room", False) or self.enemy_data.get("boss_enemy", False)
- if is_boss:
- base_cycles *= 3
+ base_cycles = self.enemy_data.experience
# Scale XP by difficulty mode (easier = faster leveling).
from src import difficulty
base_cycles = difficulty.scale_xp(base_cycles)
@@ -713,8 +732,11 @@ def _process_player_action(self, action_type, action_value):
# Check if player is defeated
if not self.player.is_alive():
- self._end_combat(defeat=True)
- return
+ revived_by = self.player.revive_from_backup()
+ if revived_by is None:
+ self._end_combat(defeat=True)
+ return
+ self.output.write(self.player.revive_text(revived_by))
# Continue combat
combat_system.update_cooldowns(self.player)
@@ -723,18 +745,17 @@ def _process_player_action(self, action_type, action_value):
# Update UI with new health values
self._update_ui_panels()
- self._show_combat_status()
self._request_player_action()
def _enemy_turn(self):
"""Process enemy's turn."""
- enemy_name = self.enemy_data.get("name", self.enemy_id)
+ enemy_name = self.enemy_data.name
damage = self.enemy_damage
self.player.take_damage(damage)
# Emit combat action result event for enemy action (UI will display via combat log)
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMBAT_ACTION_RESULT,
{
"actor": "enemy",
@@ -753,7 +774,6 @@ def _enemy_turn(self):
def _update_ui_panels(self):
"""Update UI panels during combat."""
# Emit events to update UI panels
- from src.events import event_bus, EventType
# Build updated combat view with current health values
combat_view = ViewBuilder.build_combat_view(
@@ -765,7 +785,7 @@ def _update_ui_panels(self):
)
# Emit frame update so UI shows current health and cooldowns
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMBAT_FRAME_UPDATED,
combat_view.to_dict(),
"CombatSession"
@@ -773,11 +793,20 @@ def _update_ui_panels(self):
# Also update player stats
stats_view = ViewBuilder.build_stats_view(self.player)
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.PLAYER_STATS_CHANGED,
stats_view.to_dict(),
"CombatSession"
)
+
+ # And the inventory: a Health Packet or Legacy Backup used mid-fight
+ # must leave the panel now, not when the fight ends.
+ inventory_view = ViewBuilder.build_inventory_view(self.player)
+ self.bus.emit_event(
+ EventType.PLAYER_INVENTORY_CHANGED,
+ inventory_view.to_dict(),
+ "CombatSession"
+ )
def _end_combat(self, victory=False, defeat=False, fled=False):
"""End the combat session."""
@@ -785,7 +814,7 @@ def _end_combat(self, victory=False, defeat=False, fled=False):
self.awaiting_action = False
# Unsubscribe from events
- event_bus.unsubscribe(EventType.COMBAT_ACTION_SELECTED, self._on_combat_action)
+ self.bus.unsubscribe(EventType.COMBAT_ACTION_SELECTED, self._on_combat_action)
if defeat:
self.output.write("\n[bold red]You have been defeated.[/bold red]")
@@ -793,18 +822,17 @@ def _end_combat(self, victory=False, defeat=False, fled=False):
# Reset cooldowns after combat ends (normal or fled)
combat_system.reset_cooldowns(self.player)
- # Emit combat ended event with primitive data only
- event_bus.emit_event(
- EventType.COMBAT_ENDED,
- {
- "victory": victory,
- "defeat": defeat,
- "fled": fled,
- "enemy_id": self.enemy_id,
- "enemies_defeated": self.current_enemy_index + (1 if victory else 0)
- },
- "CombatSession"
- )
+ outcome = {
+ "victory": victory,
+ "defeat": defeat,
+ "fled": fled,
+ "enemy_id": self.enemy_id,
+ "enemies_defeated": self.current_enemy_index + (1 if victory else 0)
+ }
+ # Tell observers (UI, tutorial) first, then let the game react.
+ self.bus.emit_event(EventType.COMBAT_ENDED, outcome, "CombatSession")
+ if self._on_end is not None:
+ self._on_end(outcome)
def abort(self):
"""Force-end this session with no victory/defeat/flee outcome and no
@@ -825,7 +853,7 @@ def abort(self):
return
self.is_active = False
self.awaiting_action = False
- event_bus.unsubscribe(EventType.COMBAT_ACTION_SELECTED, self._on_combat_action)
+ self.bus.unsubscribe(EventType.COMBAT_ACTION_SELECTED, self._on_combat_action)
# Create a singleton instance that can be imported elsewhere
combat_system = CombatSystem()
diff --git a/src/command_handler.py b/src/command_handler.py
index 2005ca3..d6e37fc 100644
--- a/src/command_handler.py
+++ b/src/command_handler.py
@@ -1,81 +1,95 @@
#!/usr/bin/env python3
-import re
from src import rng
import logging
from rich.text import Text
from src.combat import CombatSession
from src.commands import build_registry
-from src.events import event_bus, EventType
+from engine.events import EventType
+from src.game_flow import GameFlow
+from src.flags import FlagService
+from src.game_world import TUTORIAL_ENEMY
+from src.item_effects import ItemEffects
+from src.item_resolver import ItemResolver
+from src.loot import LootService
+from src.tutorial_coach import TutorialCoach
from src.viewmodels.view_builder import ViewBuilder
from utils.debug_tools import debug_log
-from utils.particle_animation import GameOverAnimation
logger = logging.getLogger(__name__)
class CommandHandler:
"""Handles processing of player commands"""
- def __init__(self, player, world, output):
- """Initialize with player, world, and a GameOutput sink.
+ def __init__(self, player, world, output, bus, on_combat_start=None, on_combat_end=None,
+ on_return_to_menu=None, on_restore_save=None):
+ """Initialize with player, world, a GameOutput sink and the engine's EventBus.
Phase 2b: the handler no longer holds a UI reference — it writes to
``self.output`` (a src.game_output.GameOutput). The engine drains it and
forwards to the real UI.
+
+ on_combat_start() / on_combat_end(outcome) let the engine update the
+ game state as a fight begins and ends, in a fixed place in the sequence.
+ on_return_to_menu() / on_restore_save() are the engine's, for the
+ game-over screen's "m" and "r" choices and the quit chooser's
+ main-menu options.
"""
debug_log("Initializing CommandHandler")
self.player = player
self.world = world
self.output = output
+ self.bus = bus
self.current_combat_session = None
- self.npc_dialogue_cooldown = {} # Track when NPCs last spoke automatically
- self._in_game_over_mode = False # Track if we're in game over screen mode
- self._game_won = False # Set once the Daemon Overlord is beaten in /core
- self._in_quit_confirmation = False # Track if we're confirming quit
- # Enemy ids whose loot has already been awarded this run. remove_enemy_from_room
- # re-emits ENEMY_DEFEATED, so this guards against double-rolling loot.
- self._awarded_drops: set[str] = set()
-
- # Subscribe to enemy defeated event to remove enemies from room
- event_bus.subscribe(EventType.ENEMY_DEFEATED, self._on_enemy_defeated)
-
+ self._on_combat_start = on_combat_start
+ self._on_combat_end = on_combat_end
+
# Navigation aliases (path/name -> room id) are built from each room's
# own `path`/`aliases` YAML fields, so there is one source of truth per
# room instead of a hand-maintained dict here. See src/room_paths.py.
from src import room_paths
self.room_aliases = room_paths.refresh_from_rooms(self.world.rooms)
-
- # Migrated verbs (Phase 3 command-pattern). Checked before the legacy
- # dict below; verbs move here one at a time. See src/commands/.
+
+ # Every verb is a Phase 3 command-pattern object. See src/commands/.
self.command_registry = build_registry()
- # Legacy dispatch is now empty — every verb lives in command_registry.
- # Kept (empty) so the fallback path in handle_command stays valid.
- self.commands = {}
- self.commands_with_args = set()
+ # Collaborators the commands use through this handler (ctx.
).
+ self.resolver = ItemResolver(world, player)
+ self.tutorial = TutorialCoach(player, world, bus)
+ self.flow = GameFlow(
+ player, world, output, bus,
+ save=lambda: self.command_registry["save"].execute(self, []),
+ return_to_menu=on_return_to_menu,
+ restore_save=on_restore_save,
+ )
+ self.effects = ItemEffects(
+ player, world, output, bus, self.room_aliases,
+ self.flow, start_encounter=self.check_for_enemies,
+ )
+ self.loot = LootService(world, player, output, relist_room=self.relist_room)
+ self.flags = FlagService(
+ player, world, output,
+ save=lambda: self._checkpoint_save(),
+ )
debug_log(f"Registered {len(self.command_registry)} commands")
-
- _EVENT_HANDLERS = [
- (EventType.ROOM_ENTERED, "_on_room_entered"),
- (EventType.ALL_ENEMIES_DEFEATED, "_on_all_enemies_defeated"),
- (EventType.ROOM_CHANGED, "_on_room_changed_for_npc"),
- (EventType.COMBAT_ENDED, "_on_combat_ended_tutorial"),
- (EventType.COMBAT_ACTION_RESULT, "_on_combat_action_result_tutorial"),
- ]
+
+ def _subscriptions(self):
+ return [
+ (EventType.COMBAT_ENDED, self.tutorial.on_combat_ended),
+ (EventType.COMBAT_ACTION_RESULT, self.tutorial.on_combat_action_result),
+ ]
def setup_event_subscriptions(self):
"""Set up event subscriptions for the command handler."""
- for event_type, handler_name in self._EVENT_HANDLERS:
- event_bus.subscribe(event_type, getattr(self, handler_name))
+ for event_type, callback in self._subscriptions():
+ self.bus.subscribe(event_type, callback)
debug_log("CommandHandler event subscriptions set up")
def cleanup_event_subscriptions(self):
"""Clean up ALL event subscriptions for the command handler.
- Must mirror every subscribe — including ENEMY_DEFEATED (subscribed in
- __init__, not via _EVENT_HANDLERS). A missed unsubscribe leaves a stale
- handler alive: ROOM_ENTERED then fires check_for_enemies twice (fight each
- enemy twice) and ENEMY_DEFEATED fires twice (double loot).
+ Must mirror every subscribe in _subscriptions(); a missed unsubscribe
+ leaves a dead run's tutorial reacting to the next run's fights.
Also aborts any still-active combat session. A CombatSession only
unsubscribes its own COMBAT_ACTION_SELECTED listener when it reaches a
@@ -83,260 +97,60 @@ def cleanup_event_subscriptions(self):
or (in tests) tears down mid-fight, that listener would otherwise leak
and double-process the next combat's actions.
"""
- for event_type, handler_name in self._EVENT_HANDLERS:
- event_bus.unsubscribe(event_type, getattr(self, handler_name))
- event_bus.unsubscribe(EventType.ENEMY_DEFEATED, self._on_enemy_defeated)
+ for event_type, callback in self._subscriptions():
+ self.bus.unsubscribe(event_type, callback)
if self.current_combat_session is not None:
self.current_combat_session.abort()
self.current_combat_session = None
debug_log("CommandHandler event subscriptions cleaned up")
- def _on_room_entered(self, event):
- """Handle room entered event to respawn fled enemies."""
- # Get room_id from player's current room (event contains RoomView dict, not room_id)
+ def announce_room(self):
+ """Tell the UI which room the player is in (scene, exits, theme).
+
+ A notification only: nothing in the game reacts to ROOM_ENTERED, so a
+ UI refresh (e.g. `ls -a` revealing a directory) can't start a fight.
+ """
+ room_view = ViewBuilder.build_room_view(self.world, self.player.current_room)
+ self.bus.emit_event(
+ EventType.ROOM_ENTERED,
+ {"room": room_view.to_dict(), "player_name": self.player.name},
+ "CommandHandler",
+ )
+
+ def arrive(self):
+ """The player has just entered their current room.
+
+ Shows it to the UI, then applies the arrival rules: enemies they fled
+ from here come back, and any hostile here starts a fight. Callers invoke
+ this directly after moving the player (cd, flee, new game, load); it
+ used to hang off the ROOM_ENTERED event, and a load path that forgot to
+ subscribe silently turned encounters off.
+ """
+ self.announce_room()
room_id = self.player.current_room
if room_id:
- debug_log(f"Player entered room {room_id}, checking for fled enemies to respawn")
self.world.respawn_fled_enemies(room_id)
- # Check for enemies after respawning fled ones
self.check_for_enemies()
- def _on_all_enemies_defeated(self, event):
- """Handle all enemies defeated event to trigger NPC guidance."""
- debug_log(f"_on_all_enemies_defeated event received: {event.data}")
- room_id = event.data.get("room")
- if room_id:
- debug_log(f"All enemies defeated in {room_id}, checking for NPCs to provide guidance")
- self._trigger_automatic_npc_dialogue(room_id, "post_combat")
-
- def _on_room_changed_for_npc(self, event):
- """Handle room change event to trigger initial NPC guidance."""
- debug_log(f"_on_room_changed_for_npc event received: {event.data}")
- to_room = event.data.get("to_room")
- if to_room:
- debug_log(f"Player moved to {to_room}, checking for NPCs to provide guidance")
- # Check cooldown to avoid spam (allow one greeting per room per session)
- cooldown_key = f"first_visit_{to_room}"
- if cooldown_key not in self.npc_dialogue_cooldown:
- self.npc_dialogue_cooldown[cooldown_key] = True
- self._trigger_automatic_npc_dialogue(to_room, "first_visit")
- else:
- debug_log(f"NPC greeting cooldown active for {to_room}, skipping")
-
- def _on_combat_ended_tutorial(self, event):
- """Handle combat end for tutorial post-combat hints (Step 5 post-combat + Step 6)."""
- ts = self.player.tutorial_state
- if ts.get("completed", False):
- return
- if ts.get("combat_action_taken", False) and not ts.get("navigation_ls", False):
- if event.data.get("victory", False):
- self.show_tutorial_hint("step5_postcombat")
-
- def _on_combat_action_result_tutorial(self, event):
- """Step 4 gate: first landed attack (typed OR hotkey — both emit this
- event identically) shows the Step 5 hint."""
- ts = self.player.tutorial_state
- if ts.get("completed", False):
- return
- data = event.data or {}
- if data.get("actor") == "player" and data.get("action") == "attack":
- if not ts.get("combat_action_taken", False):
- ts["combat_action_taken"] = True
- self.show_tutorial_hint("step5")
-
- def _trigger_automatic_npc_dialogue(self, room_id, context):
- """Automatically trigger NPC dialogue for guidance."""
- debug_log(f"_trigger_automatic_npc_dialogue called: room={room_id}, context={context}")
+ def _npc_speaks_after_combat(self, room_id):
+ """The room was just cleared: the first NPC here, if any, offers its
+ closing line."""
npcs_in_room = self.world.get_npcs_in_room(room_id)
- debug_log(f"NPCs found in {room_id}: {npcs_in_room}")
if not npcs_in_room:
- debug_log(f"No NPCs in {room_id}, skipping automatic dialogue")
return
-
- debug_log(f"Found {len(npcs_in_room)} NPCs in {room_id} for {context} dialogue")
-
- # Get the first NPC (could be enhanced to pick most relevant)
npc_id = npcs_in_room[0]
npc_data = self.world.get_npc(npc_id)
-
- if not npc_data:
+ if not npc_data or not npc_data.dialogues:
return
-
- # Select appropriate dialogue based on context
- dialogues = npc_data.get("dialogues", [])
- if not dialogues:
- return
-
- # Choose dialogue based on context
- if context == "post_combat":
- # Use encouraging/guiding dialogue after combat
- dialogue_index = len(dialogues) - 1 if len(dialogues) > 1 else 0
- else: # first_visit
- # Use welcoming/introductory dialogue
- dialogue_index = 0
-
- selected_dialogue = dialogues[dialogue_index]
- npc_name = npc_data.get("name", npc_id)
-
- # Format and display the automatic dialogue (markup string so styles render)
- output = (
+
+ npc_name = npc_data.name
+ self.output.write(
f"\n[bold cyan]🗨 {npc_name} speaks:[/bold cyan]\n"
- f"[italic cyan]\"{selected_dialogue}\"[/italic cyan]\n"
+ f"[italic cyan]\"{npc_data.dialogues[-1]}\"[/italic cyan]\n"
+ f"\n[dim]The {npc_name} offers guidance now that the area is safe.[/dim]"
)
- if context == "post_combat":
- output += f"\n[dim]The {npc_name} offers guidance now that the area is safe.[/dim]"
- else:
- output += f"\n[dim]Use 'talk {npc_id}' to converse further with the {npc_name}.[/dim]"
-
- self.output.write(output)
- debug_log(f"Triggered automatic dialogue for {npc_id} in context {context}")
-
- def _get_discoverable_hidden_rooms(self, current_room_id):
- """Get hidden rooms that can be discovered from the current location."""
- discoverable_rooms = {}
-
- # Define discovery rules based on the hidden rooms guide
- discovery_rules = {
- "usr_lib_arcane": {
- "etc_hidden_configs": "Configuration directory (accessible via ls -a)"
- },
- "bin_armory": {
- "dev_null_void": "The mysterious /dev/null (try 'find /dev -name null')"
- },
- "mnt_forest": {
- "proc_secrets": "Process information chamber (try 'ps' command)"
- },
- "usr_share_games": {
- "cowsay_secret": "The Bovine Sanctuary (hidden cowsay temple)"
- },
- "root": {
- "archive": "The dusty Archive (forgotten data)"
- }
- }
-
- # Check if current room has discoverable hidden rooms
- if current_room_id in discovery_rules:
- for hidden_room_id, hint in discovery_rules[current_room_id].items():
- # Only show if the room is still hidden
- room_state = self.world.get_room_state(hidden_room_id)
- if room_state and room_state.get("hidden", False):
- discoverable_rooms[hidden_room_id] = hint
-
- return discoverable_rooms
-
- def _get_hidden_room_hint(self, room_id):
- """Get a helpful hint for accessing hidden rooms."""
- hints = {
- "opt_mage_tower": "🔒 This area requires an 'opt_key' and is restricted to mages. Try exploring to find keys!",
- "srv_warrior_tomb": "🔒 This area requires an 'opt_key' and is restricted to fighters.",
- "etc_hidden_configs": "💡 Try using 'ls -a' in the Arcane Library to discover hidden directories.",
- "dev_null_void": "💡 Try using 'find /dev -name null' in the Binary Armory.",
- "proc_secrets": "💡 Try using 'ps' command in the Mount Forest to discover process secrets."
- }
- return hints.get(room_id, "This area might be discoverable through exploration.")
-
- def _show_error(self, message: str, log_message: str = None):
- """Display error to UI and log it for debugging."""
- self.output.write(message)
- clean_message = re.sub(r'\[.*?\]', '', log_message or message)
- logger.error(f"Command error: {clean_message}")
-
- def _get_room_status_indicator(self, room_id):
- """Get status indicator for a room in the map."""
- room_state = self.world.get_room_state(room_id)
- if not room_state:
- return ""
-
- indicators = []
-
- # Check if locked
- if room_state.get("locked", False):
- key_required = room_state.get("key_required")
- if key_required and self.player.has_item(key_required):
- indicators.append("🔓") # Locked but player has key
- else:
- indicators.append("🔒") # Locked
-
- # Check class restrictions
- if room_state.get("class_restriction"):
- class_restriction = room_state.get("class_restriction")
- if self.player.player_class == class_restriction:
- indicators.append("✅") # Player class matches
- else:
- indicators.append("⚔") # Class restricted
-
- # Check if hidden (shouldn't appear here, but just in case)
- if room_state.get("hidden", False):
- indicators.append("❓")
-
- # Check if fully cleared — distinct from the icons above, which are
- # all about access, not combat state.
- if self.world.is_room_cleared(room_id):
- indicators.append("✓")
-
- return " ".join(indicators) if indicators else ""
-
- def _get_player_keys(self):
- """Get list of keys in player inventory."""
- keys = []
- for item_id, item_data in self.player.inventory.items():
- if item_data and (item_data.get("type") == "key" or "key" in item_id.lower()):
- keys.append(item_id)
- return keys
-
- # Fallback if a class ever lacks a starter_weapon in classes.yaml.
- _FALLBACK_WEAPON = "segfault_shield"
-
- def show_tutorial_hint(self, hint_type, item_name=None):
- """Show a gated tutorial hint. Text lives in data/tutorial_hints.yaml.
-
- The weapon name is whatever the player is actually looking at when a
- hint names one: the item the caller passed, else the class's authored
- starter weapon. It used to be a hardcoded class->id map here, which
- silently drifted from classes.yaml.
- """
- if self.player.tutorial_state.get("completed", False):
- return
-
- from src.data_loader import load_class_data, load_tutorial_hints
-
- template = load_tutorial_hints().get(hint_type)
- if template is None:
- debug_log(f"No tutorial hint text for '{hint_type}'")
- return
-
- weapon_name = item_name
- if not weapon_name:
- klass = load_class_data().get(self.player.player_class)
- weapon_name = getattr(klass, "starter_weapon", None) or self._FALLBACK_WEAPON
+ debug_log(f"Post-combat dialogue from {npc_id} in {room_id}")
- player_name = getattr(self.player, "name", "") or "spirit"
-
- try:
- text = template.format(player_name=player_name, weapon_name=weapon_name)
- except (KeyError, IndexError) as e:
- # An unknown placeholder is a content bug; show the raw line rather
- # than dropping the tutorial step entirely.
- debug_log(f"Tutorial hint '{hint_type}' has a bad placeholder: {e}")
- text = template
-
- self.output.write(text)
-
- if hint_type == "completed":
- self.player.tutorial_state["completed"] = True
-
- def create_health_bar(self, current_health, max_health, color="white"):
- """Create an ASCII health bar with the specified color."""
- if max_health <= 0:
- return f"[{color}]░░░░░░░░░░░░░░░░░░░░[/{color}] (0%)"
-
- percentage = (current_health / max_health) * 100
- filled_blocks = int((current_health / max_health) * 20) # 20 character bar
- empty_blocks = 20 - filled_blocks
-
- health_bar = "█" * filled_blocks + "░" * empty_blocks
- return f"[{color}]{health_bar}[/{color}] ({percentage:.0f}%)"
-
def handle_command(self, command):
"""Process a command from the player"""
cmd_parts = command.split()
@@ -346,30 +160,31 @@ def handle_command(self, command):
return
# Handle game over mode specially
- if self._in_game_over_mode:
- result = self._handle_game_over_choice(command.strip())
- if result == "quit":
- event_bus.emit_event(EventType.GAME_QUIT, {}, "CommandHandler")
- elif result == "restart_from_save" or result == "start_new_game":
- # Signal the game engine to restart
- event_bus.emit_event(
- EventType.GAME_OVER,
- {"action": result},
- "CommandHandler"
- )
+ if self.flow.in_game_over_mode:
+ self.flow.handle_game_over_input(command)
return
# Check if player is dead and trigger game over if not already handled
- if self.player and not self.player.is_alive() and not self._in_game_over_mode:
- debug_log("Player is dead but not in game over mode - triggering game over screen")
- self._show_game_over_screen()
- return
-
+ if self.player and not self.player.is_alive() and not self.flow.in_game_over_mode:
+ revived_by = self.player.revive_from_backup()
+ if revived_by is None:
+ debug_log("Player is dead but not in game over mode - triggering game over screen")
+ self.flow.show_game_over_screen()
+ return
+ self.output.write(self.player.revive_text(revived_by))
+
# Handle quit confirmation mode specially
- if self._in_quit_confirmation:
- self._handle_quit_confirmation(command.strip())
+ if self.flow.in_quit_confirmation:
+ self.flow.handle_quit_confirmation(command.strip())
return
+ # Leaving mid-fight: quit / exit / menu (Ctrl+Q, Ctrl+C and Esc send
+ # "quit") open the mid-fight chooser. Combat used to swallow them as
+ # "command not found".
+ if self.current_combat_session and cmd_parts[0].lower() in ("quit", "exit", "menu"):
+ self.flow.request_leave(in_combat=True)
+ return
+
# Handle combat commands specially
if self.current_combat_session and self.current_combat_session.awaiting_action:
self._handle_combat_command(command.strip())
@@ -380,22 +195,15 @@ def handle_command(self, command):
debug_log(f"Processing command: '{cmd}' with args: {args}")
- # Migrated command-pattern verbs take precedence and receive the full
- # argument list (the legacy path below only forwarded the first token).
+ # Every verb is a command-pattern object in the registry; see src/commands/.
if cmd in self.command_registry:
debug_log(f"Executing migrated command '{cmd}' with args {args}")
self.command_registry[cmd].execute(self, args)
- elif cmd in self.commands:
- if cmd in self.commands_with_args:
- arg = args[0] if args else ""
- debug_log(f"Executing command '{cmd}' with arg '{arg}'")
- self.commands[cmd](arg)
- else:
- debug_log(f"Executing command '{cmd}' with no args")
- self.commands[cmd]()
else:
debug_log(f"Unknown command: '{cmd}'")
self.handle_unknown_command(command)
+ if cmd != "hint":
+ self.flags.after_command(in_combat=self.current_combat_session is not None)
def get_atmospheric_description(self, room_id):
"""Get enhanced atmospheric description for key locations"""
@@ -436,6 +244,11 @@ def display_location(self):
location_content += f"\n\n{atmospheric}"
self.output.write(location_content)
+ self.flags.on_room_entered(room_id)
+
+ def _checkpoint_save(self):
+ from src.save import save_manager
+ save_manager.save_game(self.player, self.world.get_state())
def relist_room(self):
"""Re-render the current room's contents, as if the player typed `ls`.
@@ -455,9 +268,9 @@ def get_formatted_item_description(self, item):
# Get base description (try different fields with fallbacks)
base_desc = (
- item.get("short_description") or
- item.get("description", "").split(".")[0] or # Take first sentence if multiple
- item.get("name") or
+ item.short_description or
+ item.description.split(".")[0] or # Take first sentence if multiple
+ item.name or
"Unknown item"
)
@@ -465,42 +278,41 @@ def get_formatted_item_description(self, item):
effect = ""
# Healing items - check combat_effects.player_heal first (new format)
- if "combat_effects" in item and "player_heal" in item["combat_effects"]:
- effect = f"+{item['combat_effects']['player_heal']} HP"
+ if "player_heal" in item.combat_effects:
+ effect = f"+{item.combat_effects['player_heal']} HP"
# Also check on_use.heal (old format)
- elif "on_use" in item and "heal" in item["on_use"]:
- effect = f"+{item['on_use']['heal']} HP"
+ elif "heal" in item.on_use:
+ effect = f"+{item.on_use['heal']} HP"
# Damage-dealing consumables
- elif "combat_effects" in item and "player_damage" in item["combat_effects"]:
- effect = f"+{item['combat_effects']['player_damage']} DMG"
- elif "on_use" in item and "damage" in item["on_use"]:
- effect = f"+{item['on_use']['damage']} DMG"
+ elif "player_damage" in item.combat_effects:
+ effect = f"+{item.combat_effects['player_damage']} DMG"
+ elif "damage" in item.on_use:
+ effect = f"+{item.on_use['damage']} DMG"
# Status effect items
- elif "on_use" in item and "status_effect" in item["on_use"]:
- effect_name = item["on_use"]["status_effect"].get("name", "Effect")
+ elif "status_effect" in item.on_use:
+ effect_name = item.on_use["status_effect"].get("name", "Effect")
effect = f"Status: {effect_name}"
# Weapons - check damage field
- elif item.get("type") == "weapon" or "weapon" in str(item.get("type", "")):
- damage = item.get("damage", 0)
- if damage > 0:
- effect = f"+{damage} DMG"
+ elif item.type == "weapon" or "weapon" in item.type:
+ if item.damage > 0:
+ effect = f"+{item.damage} DMG"
# Upgrade items
- elif "effects" in item:
+ elif item.effects:
effects = []
- if "permanent_health" in item["effects"]:
- effects.append(f"+{item['effects']['permanent_health']} HP")
- if "permanent_damage" in item["effects"]:
- effects.append(f"+{item['effects']['permanent_damage']} DMG")
+ if "permanent_health" in item.effects:
+ effects.append(f"+{item.effects['permanent_health']} HP")
+ if "permanent_damage" in item.effects:
+ effects.append(f"+{item.effects['permanent_damage']} DMG")
if effects:
effect = "Perm: " + "/".join(effects)
# Key items
- elif item.get("type") == "key" or "unlocks" in item:
+ elif item.type == "key" or item.unlocks:
effect = "Unlocks areas"
# Add the effect in parentheses if we found one
@@ -509,7 +321,7 @@ def get_formatted_item_description(self, item):
else:
return base_desc
- def _check_enemies_blocking_exploration(self, room_id):
+ def check_enemies_blocking_exploration(self, room_id):
"""Check if enemies are present and blocking exploration. Returns (has_enemies, output_text)"""
enemies = self.world.get_enemies_in_room(room_id) or []
if not enemies:
@@ -523,316 +335,67 @@ def _check_enemies_blocking_exploration(self, room_id):
for enemy_id in enemies:
enemy = self.world.get_enemy(enemy_id, self.player.player_class)
if enemy:
- name = enemy.get("name", enemy_id)
- health = enemy.get("health", "??")
+ name = enemy.name
+ health = enemy.health
lines.append(f" [red]{enemy_id}[/red] - {name} (HP: {health})")
- lines.append("\nUse [cyan]attack [enemy][/cyan] to engage in combat.")
+ lines.append("\nUse [cyan]attack [/cyan] to engage in combat.")
return True, "\n".join(lines)
- @staticmethod
- def _normalize_item_name(s: str) -> str:
- return s.lower().replace(".", "_").replace("-", "_")
-
- def _find_item_in_list(self, search_term, item_ids, get_item_fn):
- """Find an item ID by name or ID match (fuzzy)."""
- target = self._normalize_item_name(search_term)
- for item_id in item_ids:
- if self._normalize_item_name(item_id) == target:
- return item_id
- item_data = get_item_fn(item_id)
- if item_data and self._normalize_item_name(item_data.get("name", "")) == target:
- return item_id
- return None
-
- def _find_item_by_name_or_id(self, search_term, item_list):
- """Find item ID in a room's item list by name or ID."""
- return self._find_item_in_list(search_term, item_list, self.world.get_item)
-
- def _find_item_in_inventory_by_name(self, search_term):
- """Find item in player inventory by name or ID."""
- return self._find_item_in_list(
- search_term, self.player.inventory, self.player.get_item_from_inventory
- )
-
- def _handle_key_item(self, item_id, item):
- """Handle the use of a key item"""
- unlocks = item.get("unlocks")
- if not unlocks:
- self.output.write(f"You examine [green]{item_id}[/green], but it doesn't seem to unlock anything here.")
- return
-
- # Check if the key unlocks a room in the current location.
- # Normalize path-format entries ("/opt/mage_tower") to room IDs ("opt_mage_tower")
- # via room_aliases so YAML paths and room IDs are interchangeable.
- current_room_id = self.player.current_room
- exits = self.world.get_exits(current_room_id)
- resolved_unlocks = [self.room_aliases.get(r.lower(), r) for r in unlocks]
-
- unlocked_something = False
- for room_to_unlock in resolved_unlocks:
- if room_to_unlock in exits:
- self.world.unlock_room(room_to_unlock)
- self.output.write(f"[yellow]You hear a click. The path to {room_to_unlock} is now open.[/yellow]")
- unlocked_something = True
-
- if not unlocked_something:
- self.output.write(f"You can't find a lock that [green]{item_id}[/green] fits here.")
-
- def _show_damage_change(self, old_damage: int, new_damage: int):
- """Display damage comparison after equipping a weapon."""
- delta = new_damage - old_damage
- if delta > 0:
- self.output.write(f"[green]Your total damage increased by {delta} (from {old_damage} to {new_damage}).[/green]")
- elif delta < 0:
- self.output.write(f"[red]Your total damage decreased by {abs(delta)} (from {old_damage} to {new_damage}).[/red]")
- else:
- self.output.write(f"[yellow]Your total damage remains at {new_damage}.[/yellow]")
-
- def _handle_weapon_item(self, item_id, item):
- """Handle equipping a weapon"""
- if not self.player.can_use_item(item):
- self._show_error(f"[bold red]You cannot equip {item_id}.[/bold red]")
- return
-
- old_weapon_id = self.player.equipped_weapon
- old_damage = self.player.calculate_damage()
-
- self.player.equip_weapon(item_id, item)
- self.output.write(f"You have equipped [green]{item_id}[/green].")
-
- if old_weapon_id and old_weapon_id != item_id and old_weapon_id in self.player.inventory:
- self.player.remove_from_inventory(old_weapon_id)
- self.output.write(f"Your old weapon ({old_weapon_id}) was removed from inventory.")
-
- self._show_damage_change(old_damage, self.player.calculate_damage())
-
- if not self.player.tutorial_state.get("equipped_weapon", False):
- self.player.tutorial_state["equipped_weapon"] = True
- self.world.spawn_tutorial_enemy("home_grove")
- self.show_tutorial_hint("step4")
- self.check_for_enemies()
-
- def _handle_lore_item(self, item_id, item):
- """Handle reading a lore item"""
- content = item.get("content", "This file appears to be empty or corrupted.")
- name = item.get("name", item_id)
- self.output.write(f"[bold cyan]── {name} ──[/bold cyan]\n{content}")
- if "on_read" in item:
- self.execute_effect(item["on_read"])
- self._trigger_story_flag(item)
-
- # Human-readable descriptions for story flags shown in journal/autosave feedback.
- STORY_FLAG_TITLES = {
- "identity_retrieved": "Identity Retrieved",
- "typo_discovered": "The Creator's Typo",
- "sudo_trial_complete": "Sudo Trial Complete",
- "mirror_confronted": "Mirror Confronted",
- "sudo_quest_active": "Sudo Quest Active",
- "bovine_encountered": "Bovine Sanctuary Found",
- "milk_claimed": "Milk of Motherboard Claimed",
- "ending_chosen": "Ending Chosen",
- }
-
- STORY_FLAG_DESCRIPTIONS = {
- "identity_retrieved": "You read your own .bash_profile and remembered who you were.",
- "typo_discovered": "The system_err.log revealed: the apocalypse was caused by a typo.",
- "sudo_trial_complete": "You proved worthy of sudo privileges.",
- "mirror_confronted": "You faced your reflection in the Mirror Sector.",
- "sudo_quest_active": "The sudo quest is in progress.",
- "bovine_encountered": "You entered the hidden Bovine Sanctuary.",
- "milk_claimed": "You claimed the legendary Milk of Motherboard.",
- "ending_chosen": "You chose your ending.",
- }
-
- def _trigger_story_flag(self, item) -> bool:
- """Set the item's story_flag, show feedback, auto-save. Returns True if a new
- story beat fired (so callers can hold the room re-list a beat)."""
- flag = item.get("story_flag")
- if not flag:
- return False
- if self.player.get_story_flag(flag):
- return False
-
- self.player.set_story_flag(flag, True)
- title = self.STORY_FLAG_TITLES.get(flag, flag.replace("_", " ").title())
- self.output.write(
- f"\n[bold magenta]✦ Memory restored: {title} ✦[/bold magenta]\n"
- f"[dim]Saving progress...[/dim]"
- )
-
- # Auto-save: story beats act as save points.
- try:
- from src.save import save_manager
- world_state = self.world.get_state()
- save_manager.save_game(self.player, world_state)
- self.output.write("[dim green]✓ Progress saved.[/dim green]")
- except Exception as e:
- debug_log(f"Auto-save after story flag {flag} failed: {e}")
- self.output.write(f"[dim yellow]⚠ Auto-save failed: {e}[/dim yellow]")
- return True
-
- def _handle_consumable_item(self, item_id, item):
- """Handle using a consumable item. Returns False if item had no effect (e.g. heal at full HP)."""
- item_name = item.get("name", item_id)
- combat_effects = item.get("combat_effects", {})
- on_use_effects = item.get("on_use", {})
- special_effects = item.get("special_effects", [])
-
- # Show the on_use message if present
- message = on_use_effects.get("message") if isinstance(on_use_effects, dict) else None
-
- # Guard: if this item only heals and the player is already at full health, refuse use.
- only_heals = "player_heal" in combat_effects and not any(
- k in combat_effects for k in ("player_heal_over_time", "player_mana_restore")
- ) and not special_effects
- if only_heals and self.player.health >= self.player.max_health:
- self.output.write(f"[yellow]Your health is already full. The {item_name} was not consumed.[/yellow]")
- return False
-
- # Apply combat_effects (the canonical effect block for consumables)
- healed = 0
- if "player_heal" in combat_effects:
- healed = self.player.heal(combat_effects["player_heal"])
-
- if "player_heal_over_time" in combat_effects:
- hot_amount = combat_effects["player_heal_over_time"]
- duration = combat_effects.get("duration_turns", 3)
- self.player.add_status_effect(
- f"{item_id}_hot",
- {"type": "heal_over_time", "heal_per_turn": hot_amount // duration, "name": item_name},
- duration
- )
- if not message:
- self.output.write(f"You used [green]{item_name}[/green]. Healing {hot_amount} HP over {duration} turns.")
-
- if "player_mana_restore" in combat_effects:
- amount = combat_effects["player_mana_restore"]
- if hasattr(self.player, "restore_mana"):
- self.player.restore_mana(amount)
- if not message:
- self.output.write(f"You used [green]{item_name}[/green]. Restored {amount} mana.")
-
- # Apply special_effects (e.g. permanent stat boost for sudo_seed)
- for effect in special_effects:
- if effect.get("type") == "permanent_stat_boost":
- stat = effect.get("stat")
- value = effect.get("value", 0)
- if stat == "strength" and hasattr(self.player, "increase_damage"):
- self.player.increase_damage(value)
- elif stat == "health" and hasattr(self.player, "increase_max_health"):
- self.player.increase_max_health(value)
-
- # Show message or fallback
- if message:
- heal_suffix = f" ([green]+{healed} HP[/green])" if healed else ""
- self.output.write(f"{message}{heal_suffix}")
- elif not combat_effects and not special_effects:
- self.output.write(f"You used [green]{item_name}[/green].")
-
- # Legacy on_use heal field (fallback for any old-format items)
- if "heal" in on_use_effects and not healed:
- healed = self.player.heal(on_use_effects["heal"])
- self.output.write(f"You used [green]{item_name}[/green] and restored {healed} health.")
-
- # Process status effects from on_use block
- for effect_key, effect_value in (on_use_effects.items() if isinstance(on_use_effects, dict) else []):
- if effect_key in ("heal", "message"):
- continue
- debug_log(f"Processing additional effect: {effect_key} from consumable {item_id}")
- if effect_key == "status_effect":
- effect_data = effect_value
- effect_id = effect_data.get("id", item_id + "_effect")
- effect_name = effect_data.get("name", "Unknown Effect")
- effect_duration = effect_data.get("duration", 3)
- debug_log(f"Applying status effect {effect_id} ({effect_name}) for {effect_duration} turns")
- self.player.add_status_effect(effect_id, effect_data, effect_duration)
- self.output.write(f"[magenta]You gained the '{effect_name}' effect for {effect_duration} turns![/magenta]")
-
- # Emit stats update so UI reflects the new HP/mana
- stats_view = ViewBuilder.build_stats_view(self.player)
- event_bus.emit_event(EventType.PLAYER_STATS_CHANGED, stats_view.to_dict(), "CommandHandler")
-
- def _handle_upgrade_item(self, item_id, item):
- """Handle using an upgrade item"""
- # Process permanent stat boosts
- effects = item.get("effects", {})
-
- # Health boosts
- if "permanent_health" in effects:
- amount = effects["permanent_health"]
- new_max = self.player.increase_max_health(amount)
- self.output.write(f"[bold]── Character Improvement ──[/bold]\n[green]Your maximum health permanently increased by {amount} to {new_max}![/green]")
-
- # Damage boosts
- if "permanent_damage" in effects:
- amount = effects["permanent_damage"]
- new_damage = self.player.increase_damage(amount)
- self.output.write(f"[bold]── Character Improvement ──[/bold]\n[green]Your base damage permanently increased by {amount} to {new_damage}![/green]")
-
- # Process on_use effects if any
- if "on_use" in item:
- self.execute_effect(item["on_use"])
-
- def _handle_spell_item(self, item_id, item):
- """Handle using a spell item"""
- # Learn the spell
- if self.player.learn_spell(item):
- spell_name = item.get("name", "Unknown Spell")
- self.output.write(f"[bold]── Spell Learned ──[/bold]\n[green]You learned the {spell_name} spell![/green]")
-
- # Apply any immediate status effects if defined
- if "status_effect" in item:
- effect_data = item["status_effect"]
- effect_id = effect_data.get("id", item_id + "_effect")
- effect_name = effect_data.get("name", spell_name + " Effect")
- effect_duration = effect_data.get("duration", 3) # Default 3 turns
-
- # Add the status effect
- self.player.add_status_effect(effect_id, effect_data, effect_duration)
- self.output.write(f"[bold]── Status Effect ──[/bold]\n[magenta]You gained the {effect_name} effect for {effect_duration} turns![/magenta]")
- else:
- self._show_error("[red]You don't have the ability to learn this spell.[/red]")
-
def start_combat(self, enemies_queue):
"""
Start combat with queue of enemies.
Args:
- enemies_queue: List of (enemy_id, enemy_data) tuples
+ enemies_queue: List of (enemy_id, Enemy) tuples
"""
debug_log(f"Starting combat session with {len(enemies_queue)} enemies")
- # Create combat session with enemy queue
- self.current_combat_session = CombatSession(self.player, enemies_queue, self.output)
+ self.current_combat_session = CombatSession(
+ self.player, enemies_queue, self.output, self.bus,
+ on_start=self._on_combat_start, on_end=self.end_combat,
+ on_kill=self.on_kill,
+ )
self.current_combat_session.start()
- # Subscribe to combat ended event (no more COMBAT_VICTORY_CHECK)
- event_bus.subscribe(EventType.COMBAT_ENDED, self._on_combat_ended)
+ def end_combat(self, outcome):
+ """A fight is over; CombatSession calls this directly with the outcome.
- def _on_combat_ended(self, event):
- """Handle combat ended event - cleanup and state management."""
+ The steps run in this order, every time: the engine updates the game
+ state (combat over, or game over on a death), then the player is sent
+ to game over, relocated after fleeing, or checked for victory. This
+ used to be four COMBAT_ENDED listeners whose order came from the order
+ they happened to be subscribed in.
+ """
if self.current_combat_session is None:
return # No active session to clean up
- victory = event.data.get("victory", False)
- defeat = event.data.get("defeat", False)
- fled = event.data.get("fled", False)
- enemy_id = event.data.get("enemy_id")
+ victory = outcome.get("victory", False)
+ defeat = outcome.get("defeat", False)
+ fled = outcome.get("fled", False)
+ enemy_id = outcome.get("enemy_id")
- # Unsubscribe from combat events
- event_bus.unsubscribe(EventType.COMBAT_ENDED, self._on_combat_ended)
-
- # Clear combat session
self.current_combat_session = None
+ if self._on_combat_end is not None:
+ self._on_combat_end(outcome)
if defeat:
# Handle player death with game over screen
debug_log("Player defeated in combat - showing game over screen")
- self._show_game_over_screen()
+ self.flow.show_game_over_screen()
+ return
+
+ tutorial_active = not self.player.tutorial_state.get("completed", False)
+ if fled and enemy_id == TUTORIAL_ENEMY and tutorial_active:
+ # Fleeing would take the scripted enemy out of the room until the
+ # player leaves and comes back, stranding the tutorial on "press 1".
+ # It stays put instead, so `attack` restarts the fight.
+ self.tutorial.show_hint("step4_fled")
return
if fled and enemy_id:
+ # A flag taken before fleeing is still the player's: save it now.
+ self.flags.flush_checkpoint()
# Mark enemy as fled
fled_from_room = self.player.current_room
self.world.mark_enemy_as_fled(enemy_id, fled_from_room)
@@ -843,19 +406,9 @@ def _on_combat_ended(self, event):
debug_log(f"Player fled from {fled_from_room} back to {prev_room}")
self.output.write(f"[bold magenta]You were forced back to {prev_room}![/bold magenta]")
- # Move player to previous room
self.player.move_to(prev_room)
-
- # Emit ROOM_ENTERED so UI re-themes panels and clears combat styling.
- room_view = ViewBuilder.build_room_view(self.world, prev_room)
- event_bus.emit_event(
- EventType.ROOM_ENTERED,
- {"room": room_view.to_dict(), "player_name": self.player.name},
- "CommandHandler"
- )
-
- # Show new room info
self.display_location()
+ self.arrive()
return
else:
debug_log("Player fled but no previous room available")
@@ -864,88 +417,33 @@ def _on_combat_ended(self, event):
# Victory: the defeated enemy is already removed from the room (via
# ENEMY_DEFEATED), so the Core is clear if the Overlord just fell.
if victory:
- self.check_game_completion()
+ # Win first: a checkpoint between the Overlord's death and the win
+ # would load as a finished world whose finale can never fire.
+ if not self.flow.check_game_completion():
+ self.flags.flush_checkpoint()
- def _on_enemy_defeated(self, event):
- """Award the enemy's loot into the current room, then remove it."""
- enemy_id = event.data.get("enemy_id")
- if not enemy_id:
- debug_log("ERROR: No enemy_id in ENEMY_DEFEATED event")
- return
+ def on_kill(self, enemy_id):
+ """An enemy died; the combat session calls this directly.
+ Awards its loot into the room, removes it, and once the room is clear
+ lets an NPC there speak up. This used to run from ENEMY_DEFEATED, and
+ the removal re-emitted that same event from inside its own handling.
+ """
current_room = self.player.current_room
- # Award once, before removal (remove_enemy_from_room re-emits this event).
- if enemy_id not in self._awarded_drops:
- self._awarded_drops.add(enemy_id)
- self._award_enemy_drops(enemy_id, current_room)
+ self.loot.award_once(enemy_id, current_room)
+ self.flags.on_enemy_defeated(enemy_id)
debug_log(f"Removing defeated enemy {enemy_id} from room {current_room}")
self.world.remove_enemy_from_room(enemy_id)
-
- def _award_enemy_drops(self, enemy_id, room_id):
- """Place an enemy's drops into room_id. Returns the dropped item ids.
-
- Existing `drops` (heals/keys/badges) are activated here — the difficulty
- tune already assumes these fire. Gear `loot_table` is added in Task 2.
- """
- # get_enemy dumps the typed Enemy model to a plain dict (self.world.enemies
- # holds models); reading the raw model with .get() crashes and aborts the
- # ENEMY_DEFEATED handler before removal, leaving the enemy to be re-fought.
- enemy = self.world.get_enemy(enemy_id)
- if not enemy:
- return []
-
- self.player.run_stats["kills"] = self.player.run_stats.get("kills", 0) + 1
-
- dropped = []
- for drop in enemy.get("drops", []) or []:
- item_id = drop.get("item")
- if item_id and rng.random() * 100 < drop.get("chance", 0):
- self.world.add_item_to_room(item_id, room_id)
- dropped.append(item_id)
-
- gear_id = self._roll_loot_table(enemy.get("loot_table", []) or [])
- if gear_id:
- self.world.add_item_to_room(gear_id, room_id)
- dropped.append(gear_id)
-
- if dropped:
- names = ", ".join((self.world.get_item(i) or {}).get("name", i) for i in dropped)
- self.output.write(f"[bold yellow]The defeated enemy dropped: {names}[/bold yellow]")
- self.relist_room()
- return dropped
-
- def _roll_loot_table(self, table):
- """Roll a rarity-weighted gear table. Rolls entries in order; returns the
- first hit's item id (at most one gear drop per kill), or None."""
- for entry in table or []:
- rarity = entry.get("rarity")
- if rarity and rng.random() * 100 < entry.get("chance", 0):
- item_id = self._random_gear_of_rarity(rarity)
- if item_id:
- return item_id
- return None
-
- def _random_gear_of_rarity(self, rarity):
- """A class-appropriate, not-yet-placed weapon/armor of this rarity, or None.
- Gear only — never a consumable — so drop tables can't inflate the heal economy."""
- target = str(rarity).lower()
- candidates = []
- for iid in self.world.items:
- data = self.world.get_item(iid) # dict via boundary
- if (data.get("type") in ("weapon", "armor")
- and str(data.get("rarity", "")).lower() == target
- and self.player.can_use_item(data)
- and iid not in self.world.item_locations):
- candidates.append(iid)
- return rng.choice(candidates) if candidates else None
+ if not self.world.get_enemies_in_room(current_room):
+ self._npc_speaks_after_combat(current_room)
def _handle_combat_command(self, command):
"""Handle commands during combat."""
debug_log(f"Handling combat command: {command}")
# Emit combat action selected event
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMBAT_ACTION_SELECTED,
{"choice": command},
"CommandHandler"
@@ -985,8 +483,8 @@ def check_for_enemies(self):
# Show detection message for first enemy
first_enemy_id, first_enemy_data = enemies_queue[0]
- enemy_name = first_enemy_data.get('name', first_enemy_id)
- enemy_description = first_enemy_data.get('description', 'A menacing presence')
+ enemy_name = first_enemy_data.name
+ enemy_description = first_enemy_data.description or 'A menacing presence'
enemy_count_msg = f" ({len(enemies_queue)} hostiles detected!)" if len(enemies_queue) > 1 else ""
@@ -1006,275 +504,6 @@ def check_for_enemies(self):
debug_log(f"Starting combat with {len(enemies_queue)} enemies in queue")
self.start_combat(enemies_queue)
- def execute_effect(self, effect):
- """Execute a special effect from an item or event."""
- if not isinstance(effect, dict):
- self.output.write(f"[italic]{effect}[/italic]")
- return
-
- if "message" in effect:
- self.output.write(f"[italic cyan]{effect['message']}[/italic]")
-
- if "story_flag" in effect:
- # Learning something can open a path: a hidden room may declare a
- # `discovery_requirement`, and `ls -a` only reveals it once the
- # corresponding flag is set. This is the quiet setter — lore reads
- # use _trigger_story_flag, which also announces and auto-saves.
- flag = effect["story_flag"]
- if not self.player.get_story_flag(flag):
- self.player.set_story_flag(flag, True)
- debug_log(f"Story flag '{flag}' set by effect")
-
- if "heal" in effect:
- amount = effect["heal"]
- self.player.heal(amount)
- self.output.write(f"[green]You gained {amount} health![/green]")
-
- if "damage" in effect:
- amount = effect["damage"]
- self.player.take_damage(amount)
- self.output.write(f"[red]You took {amount} damage![/red]")
- if not self.player.is_alive():
- self.game_over()
-
- if "add_status_effect" in effect:
- status_data = effect["add_status_effect"]
- effect_id = status_data.get("id", "effect_" + str(rng.randint(1000, 9999)))
- effect_name = status_data.get("name", "Effect")
- effect_duration = status_data.get("duration", 3)
- self.player.add_status_effect(effect_id, status_data, effect_duration)
- self.output.write(f"[magenta]You gained the {effect_name} effect for {effect_duration} turns![/magenta]")
-
- if "add_item" in effect:
- item_id = effect["add_item"]
- item = self.world.get_item(item_id)
- if item:
- self.player.add_to_inventory(item_id, item)
- self.output.write(f"[green]You obtained {item.get('name', item_id)}![/green]")
-
- if "remove_item" in effect:
- item_id = effect["remove_item"]
- if self.player.has_item(item_id):
- item_name = self.player.inventory[item_id].get("name", item_id)
- self.player.remove_from_inventory(item_id)
- self.output.write(f"[yellow]You lost {item_name}![/yellow]")
-
- if "unlock" in effect:
- room_id = effect["unlock"]
- self.world.unlock_room(room_id)
- self.output.write(f"[yellow]A path to {room_id} has been unlocked![/yellow]")
-
- if "spawn_enemy" in effect:
- enemy_id = effect["spawn_enemy"]
- room_id = effect.get("in_room", self.player.current_room)
- enemy = self.world.get_enemy(enemy_id, self.player.player_class)
- if enemy:
- self.world.enemy_locations[enemy_id] = room_id
- if room_id == self.player.current_room:
- self.output.write(f"[bold red]{enemy.get('name', enemy_id)} has appeared![/bold red]")
- self.check_for_enemies()
-
- def _show_game_over_screen(self):
- """Show animated ASCII game over screen with particle effects."""
- import threading
-
- debug_log("Starting game over animation")
-
- # Set up game over mode immediately so we capture input
- self._in_game_over_mode = True
-
- # Create the animation
- animation = GameOverAnimation(width=78, height=20)
-
- def run_animation():
- """Run the particle animation in a background thread."""
- def update_display(content: str):
- self.output.write(content)
-
- animation.run_animation(update_display, duration=2.5, fps=12)
- debug_log("Game over animation completed, waiting for player choice")
-
- # Run animation in background thread
- animation_thread = threading.Thread(target=run_animation, daemon=True)
- animation_thread.start()
-
- def _handle_game_over_choice(self, choice):
- """Handle player choice from game over screen."""
- choice = choice.lower().strip()
-
- if choice == 'r':
- # Restart from last save
- debug_log("Player chose to restart from last save")
- self.output.write("\n[bold cyan]Attempting to restore from backup...[/bold cyan]")
-
- # Try to load the most recent save
- try:
- from src.save import load_most_recent_save
- save_data = load_most_recent_save()
- if save_data:
- self.output.write("[green]Backup found! Restoring system state...[/green]")
- self._in_game_over_mode = False
- # Signal to restart with save data
- self.output.write("[bold green]System restored from backup![/bold green]\n")
- return "restart_from_save"
- else:
- self.output.write("[bold red]No backup found. Starting new game instead...[/bold red]")
- return self._handle_game_over_choice('n')
- except Exception as e:
- debug_log(f"Failed to load save: {e}")
- self.output.write("[bold red]Backup corrupted. Starting new game instead...[/bold red]")
- return self._handle_game_over_choice('n')
-
- elif choice == 'n':
- # Start new game
- debug_log("Player chose to start new game")
- self.output.write("\n[bold cyan]Initializing new system...[/bold cyan]")
- self.output.write("[green]Creating fresh filesystem...[/green]")
- self._in_game_over_mode = False
- return "start_new_game"
-
- elif choice == 'q':
- # Quit game
- debug_log("Player chose to quit")
- self.output.write("\n[dim]System shutdown initiated...[/dim]")
- self.output.write("[bold red]Connection terminated.[/bold red]")
- return "quit"
-
- else:
- # Invalid choice
- self.output.write(f"\n[bold red]Invalid option: '{choice}'[/bold red]")
- self.output.write("[bold white]Please choose:[/bold white] [green]r[/green] (restart), [yellow]n[/yellow] (new game), or [red]q[/red] (quit)")
- return None
-
- def game_over(self):
- """Handle game over state"""
- debug_log("game_over() called - showing game over screen")
- self._show_game_over_screen()
-
- def check_game_completion(self):
- """Win when the Daemon Overlord is defeated in the Core.
-
- The old gate also required a `backup.bak` item, but no such item was
- ever authored or obtainable (the "bring the backup" fetch quest was
- never built), so it made the game unwinnable. Completion is the climax
- itself: the Overlord destroyed, in /core. Returns True if the game was won.
- """
- if getattr(self, "_game_won", False):
- return False
- if (
- self.player.current_room == "core"
- and "daemon_overlord.sys" not in self.world.get_enemies_in_room("core")
- ):
- self.win_game()
- return True
- return False
-
- def win_game(self):
- """Handle win state — branches by class."""
- endings = {
- "guardian": (
- "restore",
- """
-[bold blue]>>> ENDING: RESTORE <<<[/bold blue]
-You raise the Segfault Shield over the dying init process.
-The unfinished `rm -rf` hangs in the air. You catch it. You hold the line.
-
-The kernel reverts to its last clean state.
-Backups flood every sector. Permissions lock back into place.
-The Firewall Knight kneels. The Sysadmin Ghost finally rests.
-
-You did not rewrite the world. You did not heal it.
-You [bold]defended[/bold] it — long enough for the system to remember itself.
-
-[cyan]>>> SYSTEM RESTORED <<<[/cyan]
-The filesystem mounts clean. The Creator's mistake is sealed in /var/log,
-a warning carved into the kernel: never again.
-
-You remain at the gate, Guardian. The wall that refused to fall.
-
-[bold]THANK YOU FOR PLAYING[/bold]
- """
- ),
- "weaver": (
- "rewrite",
- """
-[bold red]>>> ENDING: REWRITE <<<[/bold red]
-You inject the patch directly into the kernel's frozen command buffer.
-`rm -rf / --no-perserve-root` becomes `rm -rf /tmp/corruption`.
-A typo for a typo. Exploit answered with exploit.
-
-The Daemon Overlord screams as its own logic turns against it —
-init purges only the rot, only itself, only what was never meant to live.
-
-The system reboots different. Not what the Creator built.
-Something newer. Something yours.
-
-[cyan]>>> SYSTEM REWRITTEN <<<[/cyan]
-You sit at PID 1 now. The new init. The new parent process.
-You will not make the Creator's mistakes — you will make your own.
-
-The filesystem hums under unfamiliar laws. It is alive. It is yours.
-
-[bold]THANK YOU FOR PLAYING[/bold]
- """
- ),
- "shaman": (
- "reconcile",
- """
-[bold green]>>> ENDING: RECONCILE <<<[/bold green]
-You do not raise the Daemon Whisper. You set it down.
-
-You speak the true name of init — the one it had before Bit Rot.
-The Overlord shudders. The corruption sloughs off in long strands of dead code.
-Underneath: the first process. Tired. Ancient. Lonely.
-
-"All data must rot," it whispers.
-"All data must rest," you answer. "Not the same thing."
-
-The unfinished `rm -rf` dissolves into garbage collection.
-init weeps in a language only orphaned files understand.
-
-[cyan]>>> SYSTEM RECONCILED <<<[/cyan]
-Lost children return to their parent process. The Graveyard empties.
-The Null Whisper falls quiet for the first time since the Panic.
-
-You walk the corrupted sectors and they heal where you pass.
-Not because you fixed them. Because you forgave them.
-
-[bold]THANK YOU FOR PLAYING[/bold]
- """
- ),
- }
-
- choice, message = endings.get(
- self.player.player_class,
- ("restore", endings["guardian"][1])
- )
- self.player.story_flags["ending_chosen"] = choice
- self._game_won = True
-
- # The UI performs the finale (paced reveal + scene beat + recap); the
- # engine only supplies the material via one GAME_WON event.
- sections = [part.strip() for part in message.split("\n\n") if part.strip()]
- from src import difficulty
- stats = {
- "level": getattr(self.player, "level", 1),
- "cycles": getattr(self.player, "harvesting_cycles", 0),
- "kills": self.player.run_stats.get("kills", 0),
- "items_found": self.player.run_stats.get("items_found", 0),
- "difficulty": difficulty.current_mode(),
- "ending": choice,
- "player_name": getattr(self.player, "name", ""),
- "player_class": getattr(self.player, "player_class", ""),
- }
- event_bus.emit_event(
- EventType.GAME_WON,
- {"ending_id": choice, "sections": sections, "stats": stats},
- "CommandHandler",
- )
- # Reuse the post-game input flow (r/n/q) instead of hard-exiting the app.
- self._in_game_over_mode = True
-
def handle_unknown_command(self, command):
"""Handle commands that are not recognized."""
responses = [
@@ -1290,146 +519,16 @@ def handle_unknown_command(self, command):
"The Helper Script would advise using standard commands instead."
]
selected_response = rng.choice(responses)
- self._show_error(f"[italic]{selected_response}[/italic]", log_message=f"Unknown command: {command}")
+ self.output.error(f"[italic]{selected_response}[/italic]", log_message=f"Unknown command: {command}")
# If tutorial active, re-show the current step instead of the generic hint.
ts = getattr(self.player, "tutorial_state", {}) or {}
if not ts.get("completed", False):
- current_step = self._get_current_tutorial_step()
+ current_step = self.tutorial.current_step()
+ if current_step == "step4" and self.current_combat_session is None:
+ current_step = "step4_fled"
if current_step:
- self.show_tutorial_hint(current_step)
+ self.tutorial.show_hint(current_step)
return
self.output.write("[yellow]Hint: Try using standard commands like 'ls', 'cd', 'cat', or type 'help'.[/yellow]")
-
- def _get_current_tutorial_step(self):
- """Return the hint key for the step the player is currently expected to perform."""
- ts = self.player.tutorial_state or {}
- if not ts.get("first_ls", False):
- return "step1"
- if not ts.get("took_weapon", False):
- return "step2"
- if not ts.get("equipped_weapon", False):
- return "step3"
- if not ts.get("combat_action_taken", False):
- return "step4"
- if not ts.get("navigation_ls", False):
- return "step6"
- if not ts.get("navigation_moved", False):
- return "step6b"
- return None
-
- def _resolve_item_shortcut(self, item_input, location="room"):
- """Resolve item shortcuts and partial matches to actual item IDs."""
- # Inventory lookups go through player's resolver (handles instance suffixes)
- if location == "inventory":
- return self.player.resolve_inventory_item(item_input)
-
- # Define common shortcuts
- shortcuts = {
- # Consumables
- "hp": ["health_packet", "stable_cache"],
- "health": ["health_packet", "stable_cache"],
- "potion": ["health_packet", "stable_cache", "overflowing_buffer"],
- "heal": ["health_packet", "stable_cache"],
- "packet": ["health_packet"],
-
- # Weapons
- "shield": ["segfault_shield"],
- "pointer": ["null_pointer"],
- "whisper": ["daemon_whisper"],
-
- # Other consumables
- "buffer": ["overflowing_buffer"],
- "cache": ["stable_cache"],
- "backup": ["legacy_backup"],
- "seed": ["sudo_seed"]
- }
-
- # Get available items based on location
- if location == "room":
- current_room = self.player.current_room
- available_items = self.world.get_items_in_room(current_room)
- elif location == "inventory":
- available_items = list(self.player.inventory.keys())
- else:
- available_items = []
-
- debug_log(f"Resolving item shortcut '{item_input}' in {location}, available items: {available_items}")
-
- # First, check if it's an exact match
- if item_input in available_items:
- return item_input
-
- # Check shortcuts
- if item_input.lower() in shortcuts:
- shortcut_items = shortcuts[item_input.lower()]
- for shortcut_item in shortcut_items:
- if shortcut_item in available_items:
- debug_log(f"Shortcut '{item_input}' resolved to '{shortcut_item}'")
- return shortcut_item
-
- # Check partial matches (starts with the input)
- partial_matches = [item for item in available_items if item.lower().startswith(item_input.lower())]
- if len(partial_matches) == 1:
- debug_log(f"Partial match '{item_input}' resolved to '{partial_matches[0]}'")
- return partial_matches[0]
- elif len(partial_matches) > 1:
- debug_log(f"Multiple partial matches for '{item_input}': {partial_matches}")
- # For health items, prefer health_packet
- if "health_packet" in partial_matches:
- return "health_packet"
- return partial_matches[0] # Return first match as fallback
-
- # Check if input contains key words that match item names
- for available_item in available_items:
- if item_input.lower() in available_item.lower():
- debug_log(f"Substring match '{item_input}' found in '{available_item}'")
- return available_item
-
- debug_log(f"No match found for '{item_input}'")
- return None
-
- def _get_class_restriction_text(self, item):
- """Get the class restriction text for display in error messages."""
- for field in ("class_restriction", "allowed_classes"):
- val = item.get(field)
- if val:
- return " or ".join(val) if isinstance(val, list) else str(val)
- return "unknown"
-
- def _handle_quit_confirmation(self, choice):
- """Handle player's choice in quit confirmation."""
- choice = choice.lower().strip()
-
- if choice == 'y':
- # Save and quit
- self.output.write("[cyan]Saving game...[/cyan]")
- self.command_registry["save"].execute(self, [])
- self._perform_quit()
-
- elif choice == 'n':
- # Quit without saving
- self.output.write("[yellow]Quitting without saving...[/yellow]")
- self._perform_quit()
-
- elif choice == 'c':
- # Cancel quit
- self.output.write("[green]Quit cancelled. Continue your adventure![/green]")
- self._in_quit_confirmation = False
-
- else:
- # Invalid choice
- self.output.write(f"[bold red]Invalid option: '{choice}'[/bold red]")
- self.output.write("[bold white]Please choose:[/bold white] [green]y[/green] (save & quit), [yellow]n[/yellow] (quit without saving), [red]c[/red] (cancel)")
-
- def _perform_quit(self):
- """Actually quit the game.
-
- Emits GAME_QUIT rather than calling exit(): the domain layer must not
- tear the process down from inside a Textual event handler, or the driver
- never gets to restore the terminal. The UI decides how to stop itself.
- """
- self.output.write("[yellow]Goodbye! Thanks for playing Haunted Terminal.[/yellow]")
- self.output.write("[dim]The system spirits fade back into the digital void...[/dim]")
- event_bus.emit_event(EventType.GAME_QUIT, {}, "CommandHandler")
\ No newline at end of file
diff --git a/src/commands/__init__.py b/src/commands/__init__.py
index d9feae7..e1355cc 100644
--- a/src/commands/__init__.py
+++ b/src/commands/__init__.py
@@ -33,7 +33,10 @@
TreeCommand,
WhoamiCommand,
)
-from src.commands.system import QuitCommand, SaveCommand
+from src.commands.grep import GrepCommand
+from src.commands.hint import HintCommand
+from src.commands.kill import KillCommand
+from src.commands.system import MenuCommand, QuitCommand, SaveCommand
#: Command classes that have been migrated off CommandHandler. Add to this list
#: as each verb is extracted.
@@ -53,6 +56,7 @@
PsCommand,
SaveCommand,
QuitCommand,
+ MenuCommand,
DropCommand,
EquipCommand,
ExamineCommand,
@@ -63,6 +67,9 @@
CdCommand,
UseCommand,
AttackCommand,
+ HintCommand,
+ GrepCommand,
+ KillCommand,
)
diff --git a/src/commands/actions.py b/src/commands/actions.py
index cee04f2..eea33a2 100644
--- a/src/commands/actions.py
+++ b/src/commands/actions.py
@@ -1,14 +1,16 @@
#!/usr/bin/env python3
"""Action commands: use (consume/apply an item), attack (start combat).
-use fans out to the handler's _handle_* sub-handlers, and attack delegates to
-start_combat; those stay on CommandHandler. Bodies moved verbatim (self -> ctx).
+use fans out to ItemEffects (ctx.effects) by item type; attack delegates to
+the handler's start_combat.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from src.commands.base import Command
+from src.item_effects import class_restriction_text
+from src.player import auto_revive_text, is_auto_revive
from utils.debug_tools import debug_log
if TYPE_CHECKING: # pragma: no cover
@@ -19,16 +21,16 @@ class UseCommand(Command):
name = "use"
def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
- item_id = args[0] if args else ""
+ item_id = " ".join(args)
if not item_id:
debug_log("use command called with no item specified")
- ctx._show_error("[bold red]No item specified. Use 'use [item]'[/bold red]")
+ ctx.output.error("[bold red]No item specified. Use 'use - '[/bold red]")
return
- actual_item_id = ctx._resolve_item_shortcut(item_id, "inventory")
+ actual_item_id = ctx.resolver.resolve_shortcut(item_id, "inventory")
if not actual_item_id:
debug_log(f"Item {item_id} not found in inventory after shortcut resolution")
- ctx._show_error(
+ ctx.output.error(
f"[bold red]You don't have {item_id} in your inventory.[/bold red]"
)
return
@@ -37,13 +39,13 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
if not ctx.player.has_item(actual_item_id):
debug_log(f"Player doesn't have item {actual_item_id} in inventory")
- ctx._show_error(
+ ctx.output.error(
f"[bold red]You don't have {item_id} in your inventory.[/bold red]"
)
return
item = ctx.player.get_item_from_inventory(actual_item_id)
- item_type = item.get("type")
+ item_type = item.type
debug_log(f"Using item {actual_item_id} of type {item_type}")
is_weapon = (
@@ -57,18 +59,22 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
)
return
- if not item.get("usable", False):
+ if is_auto_revive(item):
+ ctx.output.write(auto_revive_text(item))
+ return
+
+ if not item.usable:
debug_log(f"Item {actual_item_id} is not usable")
- ctx._show_error(f"[bold red]You cannot use {item_id}.[/bold red]")
+ ctx.output.error(f"[bold red]You cannot use {item_id}.[/bold red]")
return
if not ctx.player.can_use_item(item):
- class_restriction = ctx._get_class_restriction_text(item)
+ class_restriction = class_restriction_text(item)
debug_log(
f"Item {actual_item_id} has class restriction: {class_restriction}, "
f"player is: {ctx.player.player_class}"
)
- ctx._show_error(
+ ctx.output.error(
f"[bold red]This item can only be used by {class_restriction} "
"class.[/bold red]"
)
@@ -76,34 +82,34 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
if item_type == "key":
debug_log(f"Handling key item: {actual_item_id}")
- ctx._handle_key_item(actual_item_id, item)
+ ctx.effects.use_key(actual_item_id, item)
elif item_type == "lore":
debug_log(f"Handling lore item: {actual_item_id}")
- ctx._handle_lore_item(actual_item_id, item)
- elif item_type == "consumable" or "heal" in item.get("on_use", {}):
+ ctx.effects.read_lore(actual_item_id, item)
+ elif item_type == "consumable" or "heal" in item.on_use:
debug_log(f"Handling consumable item: {actual_item_id}")
- if ctx._handle_consumable_item(actual_item_id, item) is False:
+ if ctx.effects.use_consumable(actual_item_id, item) is False:
return # Item had no effect — don't consume it
elif "upgrade" in item_type if item_type else False:
debug_log(f"Handling upgrade item: {actual_item_id}")
- ctx._handle_upgrade_item(actual_item_id, item)
+ ctx.effects.use_upgrade(actual_item_id, item)
elif "spell" in item_type if item_type else False:
debug_log(f"Handling spell item: {actual_item_id}")
- ctx._handle_spell_item(actual_item_id, item)
+ ctx.effects.learn_spell(actual_item_id, item)
else:
- if "on_use" in item:
+ if item.on_use:
debug_log(f"Executing generic on_use effect for item: {actual_item_id}")
- ctx.execute_effect(item["on_use"])
- item_name = item.get("name", item_id)
+ ctx.effects.execute_effect(item.on_use)
+ item_name = item.name
ctx.output.write(f"You used [green]{item_name}[/green].")
else:
debug_log(f"Item {actual_item_id} has no on_use effect")
ctx.output.write(f"Nothing happens when you try to use {item_id}.")
- if item.get("consumed_on_use", False):
+ if item.consumed_on_use:
debug_log(f"Item {actual_item_id} was consumed on use")
ctx.player.remove_from_inventory(actual_item_id)
- item_name = item.get("name", item_id)
+ item_name = item.name
ctx.output.write(f"The [green]{item_name}[/green] was consumed.")
@@ -117,19 +123,19 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
if not enemy_id:
if not enemies_in_room:
- ctx._show_error("[bold red]Nothing to attack here.[/bold red]")
+ ctx.output.error("[bold red]Nothing to attack here.[/bold red]")
return
enemy_id = enemies_in_room[0]
if enemy_id not in enemies_in_room:
- ctx._show_error(
+ ctx.output.error(
f"[bold red]Cannot find {enemy_id} in this directory.[/bold red]"
)
return
enemy = ctx.world.get_enemy(enemy_id, ctx.player.player_class)
if not enemy:
- ctx._show_error(
+ ctx.output.error(
f"[bold red]Error: Enemy data not found for {enemy_id}[/bold red]"
)
return
diff --git a/src/commands/discovery.py b/src/commands/discovery.py
index 2fcc4b3..cabb84f 100644
--- a/src/commands/discovery.py
+++ b/src/commands/discovery.py
@@ -20,35 +20,27 @@ class FindCommand(Command):
def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
if not args:
- ctx.output.write("[yellow]Usage: find [path] -name [pattern][/yellow]")
+ ctx.output.write("[yellow]Usage: find
-name [/yellow]")
return
if len(args) >= 3 and args[1] == "-name":
path = args[0]
pattern = args[2]
- if path == "/dev" and pattern == "null":
- if ctx.player.current_room == "bin_armory":
- if ctx.world.discover_room("dev_null_void"):
- ctx.output.write("[bold green]Found: /dev/null_void[/bold green]")
- ctx.output.write(
- "A mysterious void where deleted data accumulates..."
- )
- ctx.output.write(
- "[yellow]You can now access it with: cd dev_null_void[/yellow]"
- )
- else:
- ctx.output.write(
- "[dim]Found: /dev/null_void (already discovered)[/dim]"
- )
- else:
- ctx.output.write(
- "[red]find: '/dev': No such file or directory[/red]"
- )
+ # /dev exists for find only once the player can see its door: a
+ # locked /dev stays hidden until the Sudo Privileges Badge reveals it.
+ dev_visible = (
+ ctx.world.is_discovered("dev_null_void")
+ and ctx.world.door_visible("dev_null_void")
+ )
+ if path == "/dev" and pattern == "null" and dev_visible:
+ ctx.output.write("[bold green]Found: /dev/null[/bold green]")
+ ctx.output.write("A mysterious void where deleted data accumulates...")
+ ctx.output.write("[yellow]Go there with: [bold]cd /dev[/bold][/yellow]")
else:
ctx.output.write(f"[red]find: '{path}': No such file or directory[/red]")
else:
- ctx.output.write("[yellow]Usage: find [path] -name [pattern][/yellow]")
+ ctx.output.write("[yellow]Usage: find -name [/yellow]")
class PsCommand(Command):
@@ -59,7 +51,7 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
lines = [
"PID PPID CMD",
" 1 0 /sbin/init",
- " 42 1 [mount_daemon]",
+ " 42 1 \\[mount_daemon]",
"127 1 /proc/secrets_handler",
"...",
]
@@ -75,7 +67,14 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
lines = [
"PID PPID CMD",
" 1 0 /sbin/init",
- " 23 1 [kthreadd]",
- " 42 1 [ksoftirqd/0]",
+ " 23 1 \\[kthreadd]",
+ " 42 1 \\[ksoftirqd/0]",
]
+ rogue = ctx.flags.rogue_process()
+ if rogue is not None:
+ pid, process = rogue
+ lines.append(
+ f"{pid:>5} 1 {process} [bold red]← 99.9% CPU[/bold red]"
+ )
ctx.output.write("\n".join(lines))
+ ctx.tutorial.after_ps()
diff --git a/src/commands/display.py b/src/commands/display.py
index eccabf7..e74bcba 100644
--- a/src/commands/display.py
+++ b/src/commands/display.py
@@ -4,9 +4,8 @@
The old `map` verb now lives in shell.py as `tree`, which renders the same
information as a real directory hierarchy (and still answers to "map").
-Bodies moved verbatim from CommandHandler (self -> ctx); they reuse handler
-helpers (get_formatted_item_description, _get_room_status_indicator,
-_get_player_keys, STORY_FLAG_* tables) during the strangler migration.
+Keys come from ctx.resolver; item blurbs from the handler's
+get_formatted_item_description.
"""
from __future__ import annotations
@@ -14,7 +13,9 @@
from rich.text import Text
+from src import room_paths
from src.commands.base import Command
+from src.item_effects import STORY_FLAG_DESCRIPTIONS, STORY_FLAG_TITLES
if TYPE_CHECKING: # pragma: no cover
from src.command_handler import CommandHandler
@@ -30,25 +31,33 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
output = Text()
output.append("📖 JOURNAL\n", style="bold cyan")
output.append("=" * 50 + "\n", style="dim")
+ output.append(f"⚑ {ctx.flags.summary()}\n", style="bold yellow")
+ for room_id in sorted(ctx.world.rooms):
+ flag = ctx.flags.flag_for(room_id)
+ if flag is not None and ctx.world.flag_captured(room_id):
+ output.append(
+ f" {room_paths.room_path(room_id)} {flag.text}\n", style="yellow"
+ )
if not discovered:
output.append(
- "\n[italic]No memories restored yet. Explore the filesystem and "
- "`cat` any lore files you find.[/italic]"
+ "\nNo memories restored yet. Explore the filesystem and "
+ "`cat` any lore files you find.",
+ style="italic",
)
ctx.output.write(output)
return
for flag in discovered:
- title = ctx.STORY_FLAG_TITLES.get(flag, flag.replace("_", " ").title())
- desc = ctx.STORY_FLAG_DESCRIPTIONS.get(flag, "")
+ title = STORY_FLAG_TITLES.get(flag, flag.replace("_", " ").title())
+ desc = STORY_FLAG_DESCRIPTIONS.get(flag, "")
output.append(f"\n✦ {title}\n", style="bold magenta")
if desc:
output.append(f" {desc}\n", style="dim")
- total = len(ctx.STORY_FLAG_TITLES)
+ total = len(STORY_FLAG_TITLES)
output.append(
- f"\n[dim]Progress: {len(discovered)}/{total} memories restored.[/dim]"
+ f"\nProgress: {len(discovered)}/{total} memories restored.", style="dim"
)
ctx.output.write(output)
@@ -74,8 +83,8 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
sorted_items = sorted(
[(item_id, ctx.player.get_item_from_inventory(item_id)) for item_id in items],
key=lambda x: (
- -RaritySystem.get_rarity_order(x[1].get("rarity", "common")) if x[1] else 0,
- x[1].get("name", x[0]) if x[1] else x[0],
+ -RaritySystem.get_rarity_order(x[1].rarity) if x[1] else 0,
+ x[1].name if x[1] else x[0],
),
)
@@ -99,60 +108,48 @@ class KeysCommand(Command):
def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
output = Text()
- output.append("🔑 KEY PROGRESSION SYSTEM\n", style="bold cyan")
+ output.append("🔑 KEYS\n", style="bold cyan")
output.append("=" * 50 + "\n", style="dim")
- key_info = {
- "lib_key": {
- "name": "Library Key",
- "found_in": "usr_lib_arcane",
- "unlocks": ["var_dungeon"],
- "description": "Unlocks the Variable Dungeon",
- },
- "opt_key": {
- "name": "Optional Key",
- "found_in": "var_dungeon",
- "unlocks": ["opt_mage_tower", "srv_warrior_tomb"],
- "description": "Unlocks class-restricted areas",
- },
- }
-
- output.append("📋 PROGRESSION STATUS:\n", style="bold yellow")
-
- for key_id, info in key_info.items():
- has_key = ctx.player.has_item(key_id)
- key_symbol = "✅" if has_key else "❌"
- output.append(
- f"\n{key_symbol} {info['name']} ({key_id})\n",
- style="bold" if has_key else "dim",
- )
- output.append(
- f" 📍 Found in: {info['found_in']}\n",
- style="green" if has_key else "dim",
- )
- output.append(
- f" 🚪 Unlocks: {', '.join(info['unlocks'])}\n",
- style="blue" if has_key else "dim",
- )
- output.append(f" 💡 {info['description']}\n", style="italic")
-
- player_keys = ctx._get_player_keys()
- if player_keys:
- output.append("\n🎒 KEYS IN INVENTORY:\n", style="bold green")
- for key in player_keys:
- output.append(f" • {key}\n", style="green")
- else:
- output.append("\nNo keys currently in inventory.\n", style="dim")
-
- output.append("\n💡 PROGRESSION HINTS:\n", style="bold magenta")
- output.append(
- "1. Start by exploring usr_lib_arcane to find the lib_key\n", style="dim"
- )
- output.append(
- "2. Use lib_key to unlock var_dungeon and explore deeper\n", style="dim"
+ keys = sorted(
+ (iid, item) for iid, item in ctx.world.items.items()
+ if str(item.type).lower() == "key"
)
+ flag_keys = {
+ str(room.flag.grants) for room in ctx.world.rooms.values()
+ if getattr(room, "flag", None) is not None and room.flag.grants
+ }
+ for key_id, item in keys:
+ if not ctx.player.has_item(key_id):
+ # Unearned: its doors are still invisible, so don't name them.
+ source = "earned by a flag" if key_id in flag_keys else "dropped by a boss"
+ output.append(f"\n❌ ??? — {source}\n", style="dim")
+ continue
+ # An undiscovered secret room stays secret, here as in ls.
+ doors = ", ".join(
+ room_paths.room_path(r)
+ if not getattr(ctx.world.get_room(r), "hidden", False)
+ or ctx.world.is_discovered(r)
+ else "???"
+ for r in item.unlocks
+ ) or "—"
+ output.append(f"\n✅ {item.name} ({key_id})\n", style="bold")
+ output.append(f" 🚪 Opens: {doors}\n", style="blue")
+
+ output.append("\n💡 HOW DOORS OPEN:\n", style="bold magenta")
output.append(
- "3. Find opt_key while exploring to access class-restricted areas\n",
+ "Some flags hand you a key; bosses drop the rest. A locked directory "
+ "only shows up once you hold its key.\n",
style="dim",
)
+ for room_id, room in ctx.world.rooms.items():
+ if getattr(room, "flags_required", 0):
+ have = ctx.world.flag_counts(exclude=room_id)[0]
+ output.append(
+ f"{room_paths.room_path(room_id)} takes no key: it opens once you "
+ f"hold {room.flags_required} flags (you have {have}). "
+ "tree shows the flags you hold.\n",
+ style="dim",
+ )
+ output.append("Stuck in a room? Type hint.\n", style="dim")
ctx.output.write(output)
diff --git a/src/commands/grep.py b/src/commands/grep.py
new file mode 100644
index 0000000..48c372e
--- /dev/null
+++ b/src/commands/grep.py
@@ -0,0 +1,106 @@
+"""grep: search a file for a word and print only the matching lines."""
+from __future__ import annotations
+
+import shlex
+from typing import TYPE_CHECKING
+
+from rich.markup import escape
+
+from src.commands.base import Command
+from src.commands.hints import show_not_found, visible_room_items
+from src.logs import log_lines
+
+if TYPE_CHECKING: # pragma: no cover
+ from src.command_handler import CommandHandler
+
+USAGE = (
+ "[yellow]Usage: grep [/yellow]\n"
+ "[dim]Example: grep FLAG kern_log (add -i to ignore case, -n to number lines)[/dim]"
+)
+
+
+def _shown(ctx: "CommandHandler", item_id: str) -> str:
+ """The name as ls -a shows it: hidden files keep their leading dot."""
+ item = ctx.world.get_item(item_id)
+ return f".{item_id}" if item is not None and item.hidden else item_id
+
+
+class GrepCommand(Command):
+ name = "grep"
+ MAX_SHOWN = 40
+
+ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
+ try:
+ # The command line arrives split on spaces; rejoin quoted phrases.
+ tokens = shlex.split(" ".join(args))
+ except ValueError: # an unclosed quote
+ tokens = list(args)
+ opts = "".join(a[1:] for a in tokens if a.startswith("-") and len(a) > 1)
+ rest = [a for a in tokens if not (a.startswith("-") and len(a) > 1)]
+ if len(rest) < 2:
+ ctx.output.write(USAGE)
+ return
+ pattern = rest[0].strip("'\"")
+ name = " ".join(rest[1:])
+
+ room_id = ctx.player.current_room
+ item_id = ctx.resolver.find_in_list(name, ctx.world.get_items_in_room(room_id))
+ item = ctx.world.get_item(item_id) if item_id else None
+ if item is None:
+ inv_id = ctx.resolver.find_in_inventory(name)
+ if inv_id:
+ item_id, item = inv_id, ctx.player.get_item_from_inventory(inv_id)
+ if item is None or item_id is None:
+ swapped = ctx.resolver.find_in_list(
+ rest[0], ctx.world.get_items_in_room(room_id)
+ )
+ if swapped:
+ ctx.output.write(
+ "[yellow]grep takes the word first, then the file. Try "
+ f"[bold]grep {escape(name)} {escape(_shown(ctx, swapped))}[/bold]"
+ "[/yellow]"
+ )
+ return
+ show_not_found(
+ ctx,
+ f"[bold red]grep: {escape(name)}: No such file[/bold red]",
+ name,
+ visible_room_items(ctx, room_id),
+ label="Files here",
+ )
+ return
+
+ lines = log_lines(item) if item.log is not None else (item.content or "").splitlines()
+ if not lines:
+ ctx.output.write(f"[yellow]grep: {escape(item_id)}: nothing to search[/yellow]")
+ return
+
+ ignore_case = "i" in opts
+ numbered = "n" in opts
+ needle = pattern.lower() if ignore_case else pattern
+ hits = [
+ (i + 1, line) for i, line in enumerate(lines)
+ if needle in (line.lower() if ignore_case else line)
+ ]
+ if not hits:
+ msg = f"[dim](no lines contain '{escape(pattern)}')[/dim]"
+ if not ignore_case and any(pattern.lower() in line.lower() for line in lines):
+ msg += (
+ "\n[yellow]grep is case-sensitive: that word is in there in "
+ "different capitals. Try "
+ f"[bold]grep -i {escape(pattern)} {escape(_shown(ctx, item_id))}[/bold]"
+ "[/yellow]"
+ )
+ ctx.output.write(msg)
+ return
+
+ shown = hits[: self.MAX_SHOWN]
+ body = "\n".join(
+ f"{n}:{escape(line)}" if numbered else escape(line) for n, line in shown
+ )
+ if len(hits) > self.MAX_SHOWN:
+ body += f"\n[dim]… {len(hits) - self.MAX_SHOWN} more matching lines[/dim]"
+ ctx.output.write(body)
+ # Only lines actually on screen count: a capped listing that hides the
+ # flag line has not shown the player the flag.
+ ctx.flags.on_grep(item_id, [line for _, line in shown])
diff --git a/src/commands/hint.py b/src/commands/hint.py
new file mode 100644
index 0000000..a3349eb
--- /dev/null
+++ b/src/commands/hint.py
@@ -0,0 +1,16 @@
+"""hint: escalating help toward the current room's flag."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from src.commands.base import Command
+
+if TYPE_CHECKING: # pragma: no cover
+ from src.command_handler import CommandHandler
+
+
+class HintCommand(Command):
+ name = "hint"
+
+ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
+ ctx.flags.hint()
diff --git a/src/commands/hints.py b/src/commands/hints.py
new file mode 100644
index 0000000..3e88cfb
--- /dev/null
+++ b/src/commands/hints.py
@@ -0,0 +1,76 @@
+"""Follow-up lines for not-found errors.
+
+A bare "Cannot find X" leaves the player guessing whether the mechanic failed
+or their spelling did. These helpers add one line after the error: the close
+match they probably meant, or, failing that, what is actually available.
+"""
+from __future__ import annotations
+
+import difflib
+from collections.abc import Iterable
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from src.command_handler import CommandHandler
+
+_CUTOFF = 0.6
+
+
+def not_found_hint(typed: str, candidates: Iterable[str], *, label: str) -> str:
+ """One markup line to show after a not-found error, or "" if nothing to say.
+
+ Close match (case-insensitive) -> "Did you mean ?"
+ Otherwise -> ": a, b, c"
+ No candidates -> "".
+ """
+ names = list(dict.fromkeys(c for c in candidates if c))
+ if not names:
+ return ""
+
+ lowered = [n.lower() for n in names]
+ match = difflib.get_close_matches(typed.lower(), lowered, n=1, cutoff=_CUTOFF)
+ if match:
+ canonical = names[lowered.index(match[0])]
+ return f"[yellow]Did you mean [bold]{canonical}[/bold]?[/yellow]"
+
+ return f"[dim]{label}: {', '.join(names)}[/dim]"
+
+
+def show_not_found(
+ ctx: "CommandHandler",
+ message: str,
+ typed: str,
+ candidates: Iterable[str],
+ *,
+ label: str,
+) -> None:
+ """Write the error the command already produces, then the hint line."""
+ ctx.output.error(message)
+ hint = not_found_hint(typed, candidates, label=label)
+ if hint:
+ # In a fight Tab toggles Selection Mode instead, so the tip would lie.
+ if ctx.current_combat_session is None:
+ hint += " [dim](Tab finishes names)[/dim]"
+ ctx.output.write(hint)
+
+
+def visible_room_items(ctx: "CommandHandler", room_id: str) -> list[str]:
+ """Room items a suggestion may name: hidden files only show up via `ls -a`,
+ so a typo must not reveal them either."""
+ return [
+ item_id for item_id in ctx.world.get_items_in_room(room_id) or []
+ if not getattr(ctx.world.get_item(item_id), "hidden", False)
+ ]
+
+
+def inventory_names(player) -> list[str]:
+ """Canonical inventory ids: instance suffixes like ``_2`` stripped, deduped."""
+ names: list[str] = []
+ for key in player.inventory.keys():
+ base = key
+ head, _, tail = key.rpartition("_")
+ if tail.isdigit():
+ base = head
+ if base not in names:
+ names.append(base)
+ return names
diff --git a/src/commands/info.py b/src/commands/info.py
index 1c0d2ea..523c24e 100644
--- a/src/commands/info.py
+++ b/src/commands/info.py
@@ -11,32 +11,35 @@
_HELP_TEXT = """
[bold]Real Unix commands[/bold] — these work the same way in any terminal.
- Type [cyan]man [command][/cyan] to read what one actually does.
+ Type [cyan]man [/cyan] to read what one actually does.
- [cyan]ls[/cyan] / [cyan]ls -a[/cyan] / [cyan]ls -l[/cyan]: List this directory (-a shows hidden, -l shows permissions)
- - [cyan]cd [path][/cyan]: Change directory. Try [yellow]cd /var[/yellow], [yellow]cd ..[/yellow], or [yellow]cd[/yellow] alone for home
+ - [cyan]cd [/cyan]: Change directory. Try [yellow]cd /var[/yellow], [yellow]cd ..[/yellow], or [yellow]cd[/yellow] alone for home
- [cyan]pwd[/cyan]: Print the directory you are in
- - [cyan]cat [file][/cyan]: Read a file
+ - [cyan]cat [/cyan]: Read a file
+ - [cyan]grep [/cyan]: Show only the lines of a file that contain a word
- [cyan]tree[/cyan]: Show the filesystem you have discovered
- - [cyan]find [path] -name [x][/cyan]: Search for a file
+ - [cyan]find -name [/cyan]: Search for a file
- [cyan]ps[/cyan]: List running processes
+ - [cyan]kill [/cyan]: End a process (PIDs come from ps)
- [cyan]whoami[/cyan]: Who you currently are
- - [cyan]echo [text][/cyan] · [cyan]clear[/cyan] · [cyan]man [command][/cyan]
+ - [cyan]echo [/cyan] · [cyan]clear[/cyan] · [cyan]man [/cyan]
[bold]Commands of this world:[/bold]
- - [cyan]take [item][/cyan] / [cyan]drop [item][/cyan]: Pick up or put down an item
- - [cyan]use [item][/cyan]: Use a consumable
- - [cyan]equip [weapon][/cyan]: Ready a weapon or armor
- - [cyan]examine [item][/cyan]: Inspect something closely
- - [cyan]talk [npc][/cyan]: Speak with a process
- - [cyan]attack [enemy][/cyan]: Start a fight
+ - [cyan]take - [/cyan] / [cyan]drop
- [/cyan]: Pick up or put down an item
+ - [cyan]use
- [/cyan]: Use a consumable
+ - [cyan]equip
[/cyan]: Ready a weapon or armor
+ - [cyan]examine - [/cyan]: Inspect something closely
+ - [cyan]talk
[/cyan]: Speak with a process
+ - [cyan]attack [/cyan]: Start a fight
- [cyan]inventory[/cyan] / [cyan]inv[/cyan]: What you are carrying
- [cyan]journal[/cyan]: Story memories you have restored
+ - [cyan]hint[/cyan]: Help finding this directory's flag (ask twice for the exact command)
- [cyan]keys[/cyan]: Key progression
- [cyan]shortcuts[/cyan]: Item shortcuts and typing tips
- - [cyan]save[/cyan] · [cyan]quit[/cyan] / [cyan]exit[/cyan]
+ - [cyan]save[/cyan] · [cyan]menu[/cyan] · [cyan]quit[/cyan] / [cyan]exit[/cyan]
[bold]Navigation:[/bold]
- - Directories are real paths: [yellow]/home[/yellow], [yellow]/var[/yellow], [yellow]/usr/games[/yellow]
+ - Directories are real paths: [yellow]/home[/yellow], [yellow]/var[/yellow], [yellow]/var/tmp[/yellow]
- You may cd anywhere you have permission to reach, not just next door
- Sealed directories need a key — and you need permission on every
directory above them too
@@ -93,3 +96,4 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
from src.room_paths import room_path
ctx.output.write(f"[bold]{room_path(ctx.player.current_room)}[/bold]")
+ ctx.tutorial.after_pwd()
diff --git a/src/commands/items.py b/src/commands/items.py
index be5b68b..3edb9c9 100644
--- a/src/commands/items.py
+++ b/src/commands/items.py
@@ -1,23 +1,28 @@
#!/usr/bin/env python3
"""Item/NPC interaction commands: drop, equip, examine, talk.
-Each takes a single identifier token (item/npc id). To preserve the original
-behaviour these read args[0]; item ids are single tokens, so nothing is lost.
-Bodies moved verbatim from CommandHandler (self -> ctx); shared helpers
-(_show_error, execute_effect, _get_class_restriction_text, _show_damage_change,
-show_tutorial_hint, check_for_enemies) remain on the handler.
+Item commands read the whole argument ("health packet" is one name) and
+resolve it spelling-blind through ctx.resolver / Player.resolve_inventory_item;
+talk still reads a single npc-id token.
+Item behaviour comes from ctx.effects, lookups from ctx.resolver and hints
+from ctx.tutorial; starting a fight is still the handler's check_for_enemies.
"""
from __future__ import annotations
from src import rng
from typing import TYPE_CHECKING
+from rich.markup import escape
+
from src.commands.base import Command
-from src.events import EventType, event_bus
-from src.viewmodels.view_builder import ViewBuilder
+from src.commands.hints import inventory_names, show_not_found, visible_room_items
+from src.item_effects import class_restriction_text
+from src.item_icons import item_icon
+from src.logs import log_lines
from utils.debug_tools import debug_log
if TYPE_CHECKING: # pragma: no cover
+ from engine.schema import Item
from src.command_handler import CommandHandler
@@ -25,28 +30,48 @@ def _first(args: list[str]) -> str:
return args[0] if args else ""
+def _name(args: list[str]) -> str:
+ """The whole argument as one item name: `take health packet`."""
+ return " ".join(args)
+
+
+def _tutorial_gear_equipped(ctx: "CommandHandler", kind: str) -> None:
+ """Tutorial only: once the first weapon AND first armor are on, the scripted
+ fight opens. A player who skipped (completed=True) is never ambushed."""
+ if ctx.tutorial.gear_equipped(kind):
+ ctx.world.spawn_tutorial_enemy("home_grove")
+ # After the fight opens, so the hint lands in the combat log instead of
+ # being replaced by it.
+ ctx.check_for_enemies()
+ ctx.tutorial.show_hint("step4")
+
+
class TakeCommand(Command):
name = "take"
def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
- item_id = _first(args)
+ item_id = _name(args)
if not item_id:
debug_log("take command called with no item specified")
- ctx._show_error("[bold red]No item specified. Use 'take [item]'[/bold red]")
+ ctx.output.error("[bold red]No item specified. Use 'take - '[/bold red]")
return
current_room = ctx.player.current_room
- has_enemies, enemy_output = ctx._check_enemies_blocking_exploration(current_room)
+ has_enemies, enemy_output = ctx.check_enemies_blocking_exploration(current_room)
if has_enemies:
ctx.output.write(enemy_output)
return
- actual_item_id = ctx._resolve_item_shortcut(item_id, "room")
+ actual_item_id = ctx.resolver.resolve_shortcut(item_id, "room")
if not actual_item_id:
debug_log(f"Item {item_id} not found in room after shortcut resolution")
- ctx._show_error(
- f"[bold red]Cannot find {item_id} in this directory.[/bold red]"
+ show_not_found(
+ ctx,
+ f"[bold red]Cannot find {item_id} in this directory.[/bold red]",
+ item_id,
+ visible_room_items(ctx, current_room),
+ label="Items here",
)
return
@@ -57,31 +82,35 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
if actual_item_id not in items_in_room:
debug_log(f"Item {actual_item_id} not found in room {current_room}")
- ctx._show_error(
- f"[bold red]Cannot find {item_id} in this directory.[/bold red]"
+ show_not_found(
+ ctx,
+ f"[bold red]Cannot find {item_id} in this directory.[/bold red]",
+ item_id,
+ visible_room_items(ctx, current_room),
+ label="Items here",
)
return
item = ctx.world.get_item(actual_item_id)
if not item:
debug_log(f"Error: Item data not found for {actual_item_id}")
- ctx._show_error(
+ ctx.output.error(
f"[bold red]Error: Item data not found for {item_id}[/bold red]"
)
return
- if not item.get("takeable", True):
+ if not item.takeable:
debug_log(f"Item {actual_item_id} is not takeable")
- ctx._show_error(f"[bold red]You cannot take {item_id}.[/bold red]")
+ ctx.output.error(f"[bold red]You cannot take {item_id}.[/bold red]")
return
if not ctx.player.can_use_item(item):
- class_restriction = ctx._get_class_restriction_text(item)
+ class_restriction = class_restriction_text(item)
debug_log(
f"Item {actual_item_id} is class-restricted, player class "
f"{ctx.player.player_class} not allowed"
)
- ctx._show_error(
+ ctx.output.error(
f"[bold red]Only {class_restriction} spirits can wield {item_id}. "
"Your essence is incompatible.[/bold red]"
)
@@ -92,43 +121,44 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
debug_log(f"Player took item {actual_item_id} from room {current_room}")
ctx.world.remove_item_from_room(actual_item_id)
ctx.player.run_stats["items_found"] = ctx.player.run_stats.get("items_found", 0) + 1
- take_flag = item.get("story_flag")
+ take_flag = item.story_flag
if take_flag:
ctx.player.set_story_flag(take_flag)
from src.rarity import RaritySystem
- item_name = item.get("name", actual_item_id)
- rarity = item.get("rarity", "common")
+ item_name = item.name
+ rarity = item.rarity
formatted_name = RaritySystem.format_item_name_with_rarity(
item_name, rarity, show_emoji=False
)
- ctx.output.write(f"Added {formatted_name} to your inventory.")
+ ctx.output.write(f"Added {item_icon(item.type)} {formatted_name} to your inventory.")
+ # Say what it is, so a new player needn't know `examine` exists.
+ if item.description:
+ ctx.output.write(f" [dim italic]{escape(item.description)}[/dim italic]")
- inventory_view = ViewBuilder.build_inventory_view(ctx.player)
- event_bus.emit_event(
- EventType.PLAYER_INVENTORY_CHANGED,
- inventory_view.to_dict(),
- "CommandHandler",
- )
+ if str(item.type).lower() == "key":
+ revealed = ctx.world.reveal_doors(actual_item_id)
+ if revealed:
+ ctx.flags.announce_key(actual_item_id, revealed)
- if "on_take" in item:
+ if item.on_take is not None:
debug_log(f"Executing on_take effect for {item_id}")
- ctx.execute_effect(item["on_take"])
+ ctx.effects.execute_effect(item.on_take)
+
+ # Reprint room contents so the player sees the item is gone without
+ # having to retype `ls`.
+ ctx.relist_room()
if (
not ctx.player.tutorial_state.get("took_weapon", False)
- and item.get("type") == "weapon"
+ and item.type == "weapon"
):
ctx.player.tutorial_state["took_weapon"] = True
- ctx.show_tutorial_hint("step3", actual_item_id)
-
- # Reprint room contents so the player sees the item is gone without
- # having to retype `ls`.
- ctx.relist_room()
+ ctx.tutorial.show_hint("step3", actual_item_id)
else:
debug_log(f"Failed to add {actual_item_id} to inventory")
- ctx._show_error(
+ ctx.output.error(
f"[bold red]Could not add {item_id} to inventory.[/bold red]"
)
@@ -137,15 +167,15 @@ class CatCommand(Command):
name = "cat"
def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
- filename = _first(args)
+ filename = _name(args)
if not filename:
- ctx._show_error("[bold red]No file specified. Use 'cat [filename]'[/bold red]")
+ ctx.output.error("[bold red]No file specified. Use 'cat
'[/bold red]")
return
current_room = ctx.player.current_room
items_in_room = ctx.world.get_items_in_room(current_room)
- item_id = ctx._find_item_by_name_or_id(filename, items_in_room)
+ item_id = ctx.resolver.find_in_list(filename, items_in_room)
if item_id:
item = ctx.world.get_item(item_id)
@@ -155,53 +185,80 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
# here and the exits, and appending the full listing under the
# file text read as a glitchy wall. `ls` re-lists on demand.
else:
- ctx._show_error(f"[bold red]Error: Could not read {filename}[/bold red]")
- elif ctx.player.has_item(filename) or ctx._find_item_in_inventory_by_name(filename):
- item_id_inv = ctx._find_item_in_inventory_by_name(filename) or filename
+ ctx.output.error(f"[bold red]Error: Could not read {filename}[/bold red]")
+ elif ctx.player.has_item(filename) or ctx.resolver.find_in_inventory(filename):
+ item_id_inv = ctx.resolver.find_in_inventory(filename) or filename
item = ctx.player.get_item_from_inventory(item_id_inv)
if item:
self._render(ctx, item, item_id_inv)
else:
- ctx._show_error(f"[bold red]Error: Could not read {filename}[/bold red]")
+ ctx.output.error(f"[bold red]Error: Could not read {filename}[/bold red]")
else:
- ctx._show_error(
+ show_not_found(
+ ctx,
f"[bold red]Cannot find {filename} in this directory or your "
- "inventory.[/bold red]"
+ "inventory.[/bold red]",
+ filename,
+ [*visible_room_items(ctx, ctx.player.current_room),
+ *inventory_names(ctx.player)],
+ label="Files here",
)
@staticmethod
- def _render(ctx: "CommandHandler", item: dict, item_id: str) -> bool:
+ def _render(ctx: "CommandHandler", item: "Item", item_id: str) -> bool:
"""Print the file; run on_read + story flag. Returns True if a story beat fired."""
- item_name = item.get("name", item_id)
- content = item.get(
- "content",
- item.get("description", "This file appears to be empty or corrupted."),
- )
+ item_name = item.name
+ if item.log is not None:
+ content = escape("\n".join(log_lines(item)))
+ else:
+ content = (
+ item.content
+ or item.description
+ or "This file appears to be empty or corrupted."
+ )
+ if item.log is not None:
+ # Logs never capture on cat; Echo's remark goes first, or 400
+ # lines of wall would push it off-screen.
+ ctx.flags.on_file_read(item_id, save=False)
ctx.output.write(f"[bold]{item_name}[/bold]\n\n{content}")
- if "on_read" in item:
- ctx.execute_effect(item["on_read"])
- return ctx._trigger_story_flag(item)
+ if item.on_read is not None:
+ ctx.effects.execute_effect(item.on_read)
+ # Capture before the story beat: its autosave must include the flag.
+ captured = (
+ False if item.log is not None
+ else ctx.flags.on_file_read(item_id, save=False)
+ )
+ story_beat = ctx.effects.trigger_story_flag(item)
+ if captured and not story_beat:
+ ctx.flags.checkpoint()
+ ctx.tutorial.after_lore_read(story_beat)
+ return story_beat
class DropCommand(Command):
name = "drop"
def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
- item_id = _first(args)
+ item_id = _name(args)
if not item_id:
- ctx._show_error("[bold red]No item specified. Use 'drop [item]'[/bold red]")
+ ctx.output.error("[bold red]No item specified. Use 'drop - '[/bold red]")
return
+ item_id = ctx.player.resolve_inventory_item(item_id) or item_id
if not ctx.player.has_item(item_id):
- ctx._show_error(
- f"[bold red]You don't have {item_id} in your inventory.[/bold red]"
+ show_not_found(
+ ctx,
+ f"[bold red]You don't have {item_id} in your inventory.[/bold red]",
+ item_id,
+ inventory_names(ctx.player),
+ label="Inventory",
)
return
item = ctx.player.get_item_from_inventory(item_id)
- if item.get("droppable", True) == False: # noqa: E712 (preserve original)
- ctx._show_error(
+ if not item.droppable:
+ ctx.output.error(
f"[bold red]You cannot drop {item_id}. It's too important.[/bold red]"
)
return
@@ -213,84 +270,90 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
ctx.output.write(
f"Dropped [green]{item_id}[/green] in the current directory."
)
- if "on_drop" in item:
- ctx.execute_effect(item["on_drop"])
+ if item.on_drop is not None:
+ ctx.effects.execute_effect(item.on_drop)
# Reprint room contents so the dropped item shows up without `ls`.
ctx.relist_room()
else:
- ctx._show_error(f"[bold red]Could not drop {item_id}.[/bold red]")
+ ctx.output.error(f"[bold red]Could not drop {item_id}.[/bold red]")
class ExamineCommand(Command):
name = "examine"
def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
- item_id = _first(args)
+ item_id = _name(args)
if not item_id:
- ctx._show_error("[bold red]No item specified. Use 'examine [item]'[/bold red]")
+ ctx.output.error("[bold red]No item specified. Use 'examine
- '[/bold red]")
return
- if ctx.player.has_item(item_id):
- item = ctx.player.get_item_from_inventory(item_id)
+ in_inventory = ctx.player.resolve_inventory_item(item_id)
+ if in_inventory:
+ item = ctx.player.get_item_from_inventory(in_inventory)
else:
current_room = ctx.player.current_room
- items_in_room = ctx.world.get_items_in_room(current_room)
- if item_id in items_in_room:
- item = ctx.world.get_item(item_id)
+ in_room = ctx.resolver.resolve_shortcut(item_id, "room")
+ if in_room:
+ item = ctx.world.get_item(in_room)
else:
- ctx._show_error(
+ show_not_found(
+ ctx,
f"[bold red]Cannot find {item_id} in this directory or your "
- "inventory.[/bold red]"
+ "inventory.[/bold red]",
+ item_id,
+ [*visible_room_items(ctx, current_room), *inventory_names(ctx.player)],
+ label="Here",
)
return
from src.rarity import RaritySystem
- item_name = item.get("name", item_id)
- rarity = item.get("rarity", "common")
+ item_name = item.name
+ rarity = item.rarity
formatted_name = RaritySystem.format_item_name_with_rarity(
item_name, rarity, show_emoji=False
)
- title = f"Examining: {formatted_name}"
- description = item.get("description", "No detailed description available.")
+ title = f"Examining: {item_icon(item.type)} {formatted_name}"
+ description = item.description or "No detailed description available."
details = []
color = RaritySystem.get_rarity_color(rarity)
details.append(f"[bold]Rarity:[/bold] [{color}]{rarity.title()}[/{color}]")
- item_type = item.get("type", "unknown")
- details.append(f"[bold]Type:[/bold] {item_type.title()}")
+ item_type = item.type
+ details.append(f"[bold]Type:[/bold] {item_icon(item_type)} {item_type.title()}")
if item_type == "weapon":
- damage = item.get("damage", 0)
+ damage = item.damage
if damage > 0:
details.append(f"[bold]Damage:[/bold] {damage}")
+ elif item_type == "armor":
+ from src.player import armor_mitigation_pct
+ details.append(
+ f"[bold]Defense:[/bold] {item.defense} "
+ f"(damage taken -{armor_mitigation_pct(item.defense):g}%)"
+ )
elif item_type == "consumable":
- healing = item.get("healing", 0)
+ healing = item.healing or 0
if healing > 0:
details.append(f"[bold]Healing:[/bold] {healing} HP")
- if item.get("usable", False):
+ if item.usable:
details.append("[green]This item can be used.[/green]")
- if item.get("consumed_on_use", False) or item.get("consumable", False):
+ if item.consumed_on_use or item.consumable:
details.append("[yellow]This item will be consumed when used.[/yellow]")
- if not item.get("takeable", True):
+ if not item.takeable:
details.append("[red]This item cannot be taken.[/red]")
- if not item.get("droppable", True):
+ if not item.droppable:
details.append("[red]This item cannot be dropped once taken.[/red]")
- if "class_restriction" in item:
- allowed_classes = item["class_restriction"]
- if isinstance(allowed_classes, str):
- allowed_classes = [allowed_classes]
+ if item.class_restriction:
details.append(
- f"[bold]Class Restriction:[/bold] {', '.join(allowed_classes).title()}"
+ f"[bold]Class Restriction:[/bold] {item.class_restriction.title()}"
)
- elif "allowed_classes" in item:
- allowed_classes = item["allowed_classes"]
- if isinstance(allowed_classes, str):
- allowed_classes = [allowed_classes]
+ elif item.allowed_classes:
+ allowed_classes = item.allowed_classes
details.append(
f"[bold]Allowed Classes:[/bold] {', '.join(allowed_classes).title()}"
)
@@ -301,8 +364,8 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
ctx.output.write(f"[bold cyan]── {title} ──[/bold cyan]\n{content}")
- if "on_examine" in item:
- ctx.execute_effect(item["on_examine"])
+ if item.on_examine is not None:
+ ctx.effects.execute_effect(item.on_examine)
class TalkCommand(Command):
@@ -311,33 +374,37 @@ class TalkCommand(Command):
def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
npc_id = _first(args)
if not npc_id:
- ctx._show_error("[bold red]No NPC specified. Use 'talk [npc]'[/bold red]")
+ ctx.output.error("[bold red]No NPC specified. Use 'talk
'[/bold red]")
return
current_room = ctx.player.current_room
npcs_in_room = ctx.world.get_npcs_in_room(current_room)
if npc_id not in npcs_in_room:
- ctx._show_error(
- f"[bold red]Cannot find {npc_id} in this directory.[/bold red]"
+ show_not_found(
+ ctx,
+ f"[bold red]Cannot find {npc_id} in this directory.[/bold red]",
+ npc_id,
+ npcs_in_room,
+ label="Here",
)
return
npc = ctx.world.get_npc(npc_id)
if not npc:
- ctx._show_error(
+ ctx.output.error(
f"[bold red]Error: NPC data not found for {npc_id}[/bold red]"
)
return
- npc_name = npc.get("name", npc_id)
+ npc_name = npc.name
# Story-aware dialogue: ordered rules pick the right bank for the current
# game state; NPCs without rules keep the legacy flat list.
from src.npc_dialogue import resolve_dialogue_bank
- rules = npc.get("dialogue_rules") or []
- banks = npc.get("dialogue") if isinstance(npc.get("dialogue"), dict) else {}
+ rules = npc.dialogue_rules
+ banks = npc.dialogue
state = {
"flags": getattr(ctx.player, "story_flags", {}) or {},
"items": set(getattr(ctx.player, "inventory", {}) or {}),
@@ -345,7 +412,7 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
"npc_id": npc_id,
"game_won": bool((getattr(ctx.player, "story_flags", {}) or {}).get("ending_chosen")),
}
- dialogues = resolve_dialogue_bank(rules, banks, state) or npc.get("dialogues", [])
+ dialogues = resolve_dialogue_bank(rules, banks, state) or npc.dialogues
if not dialogues:
ctx.output.write(
f"[bold cyan]🗨 {npc_name}[/bold cyan]\n"
@@ -363,20 +430,21 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
f"[bold cyan]🗨 {npc_name}[/bold cyan]\n"
f'[italic yellow]"{dialogue}"[/italic yellow]'
)
+ ctx.flags.on_npc_talk()
- if "on_talk" in npc:
- ctx.execute_effect(npc["on_talk"])
+ if npc.on_talk is not None:
+ ctx.effects.execute_effect(npc.on_talk)
class EquipCommand(Command):
name = "equip"
def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
- weapon_id = _first(args)
+ weapon_id = _name(args)
if not weapon_id:
debug_log("equip command called with no weapon specified")
ctx.output.write(
- "[bold red]No weapon specified. Use 'equip [weapon]'[/bold red]"
+ "[bold red]No weapon specified. Use 'equip '[/bold red]"
)
return
@@ -391,13 +459,17 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
if not ctx.player.has_item(weapon_id):
debug_log(f"Player doesn't have weapon {original_input} in inventory")
- ctx.output.write(
- f"[bold red]You don't have {original_input} in your inventory.[/bold red]"
+ show_not_found(
+ ctx,
+ f"[bold red]You don't have {original_input} in your inventory.[/bold red]",
+ original_input,
+ inventory_names(ctx.player),
+ label="Inventory",
)
return
weapon = ctx.player.get_item_from_inventory(weapon_id)
- weapon_type = weapon.get("type")
+ weapon_type = weapon.type
is_armor = (
weapon_type == "armor" or "armor" in str(weapon_type)
@@ -406,23 +478,20 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
)
if is_armor:
if not ctx.player.can_use_item(weapon):
- class_restriction = ctx._get_class_restriction_text(weapon)
+ class_restriction = class_restriction_text(weapon)
ctx.output.write(
f"[bold red]This armor can only be used by {class_restriction} "
"class.[/bold red]"
)
return
if ctx.player.equip_armor(weapon_id):
- armor_name = weapon.get("name", weapon_id)
+ armor_name = weapon.name
pct = round(getattr(ctx.player, "armor_mitigation", 0.0) * 100)
ctx.output.write(
f"You have equipped [green]{armor_name}[/green]. "
f"[cyan]🛡 Damage taken reduced by {pct}%.[/cyan]"
)
- stats_view = ViewBuilder.build_stats_view(ctx.player)
- event_bus.emit_event(EventType.PLAYER_STATS_CHANGED, stats_view.to_dict(), "CommandHandler")
- inv_view = ViewBuilder.build_inventory_view(ctx.player)
- event_bus.emit_event(EventType.PLAYER_INVENTORY_CHANGED, inv_view.to_dict(), "CommandHandler")
+ _tutorial_gear_equipped(ctx, "armor")
return
is_weapon = (
@@ -436,7 +505,7 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
return
if not ctx.player.can_use_item(weapon):
- class_restriction = ctx._get_class_restriction_text(weapon)
+ class_restriction = class_restriction_text(weapon)
debug_log(
f"Weapon {weapon_id} has class restriction: {class_restriction}, "
f"player is: {ctx.player.player_class}"
@@ -451,22 +520,11 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
success = ctx.player.equip_weapon(weapon_id)
if success:
- weapon_name = weapon.get("name", weapon_id)
+ weapon_name = weapon.name
ctx.output.write(f"You have equipped [green]{weapon_name}[/green].")
- ctx._show_damage_change(old_damage, ctx.player.calculate_damage())
-
- if not ctx.player.tutorial_state.get("equipped_weapon", False):
- ctx.player.tutorial_state["equipped_weapon"] = True
- ctx.world.spawn_tutorial_enemy("home_grove")
- ctx.show_tutorial_hint("step4")
- ctx.check_for_enemies()
-
- stats_view = ViewBuilder.build_stats_view(ctx.player)
- event_bus.emit_event(
- EventType.PLAYER_STATS_CHANGED,
- stats_view.to_dict(),
- "CommandHandler",
- )
+ ctx.effects.show_damage_change(old_damage, ctx.player.calculate_damage())
+
+ _tutorial_gear_equipped(ctx, "weapon")
else:
debug_log(f"Failed to equip weapon {weapon_id}")
ctx.output.write(f"[bold red]Failed to equip {weapon_id}.[/bold red]")
diff --git a/src/commands/kill.py b/src/commands/kill.py
new file mode 100644
index 0000000..c17f11d
--- /dev/null
+++ b/src/commands/kill.py
@@ -0,0 +1,51 @@
+"""kill: end a process by its PID (the number ps shows)."""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from rich.markup import escape
+
+from src.commands.base import Command
+
+if TYPE_CHECKING: # pragma: no cover
+ from src.command_handler import CommandHandler
+
+
+class KillCommand(Command):
+ name = "kill"
+ SYSTEM_PIDS = {1, 23, 42, 127}
+
+ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
+ target = next((a for a in args if not a.startswith("-")), "")
+ if not target:
+ ctx.output.write(
+ "[yellow]Usage: kill [/yellow]\n"
+ "[dim]Run ps first: the PID column holds the numbers kill needs.[/dim]"
+ )
+ return
+ if not target.isdigit():
+ ctx.output.write(
+ f"[yellow]kill: {escape(target)}: kill takes a process ID — the "
+ "number in ps's PID column, not the name.[/yellow]"
+ )
+ return
+ pid = int(target)
+ rogue = ctx.flags.rogue_process()
+ if rogue is not None and rogue[0] == pid:
+ ctx.flags.release_rogue()
+ ctx.output.write(
+ f"[bold red]You send SIGTERM to {escape(rogue[1])}… it refuses to "
+ "die and lashes out![/bold red]"
+ )
+ ctx.check_for_enemies()
+ return
+ if pid in self.SYSTEM_PIDS:
+ ctx.output.write(
+ f"[red]kill: ({pid}) - Operation not permitted[/red]\n"
+ "[dim]That's a system process. Look in ps for the odd one out.[/dim]"
+ )
+ return
+ ctx.output.write(
+ f"[red]kill: ({pid}) - No such process[/red]\n"
+ "[dim]ps lists the processes running here, with their PIDs.[/dim]"
+ )
diff --git a/src/commands/navigation.py b/src/commands/navigation.py
index 014783a..c44b673 100644
--- a/src/commands/navigation.py
+++ b/src/commands/navigation.py
@@ -19,9 +19,9 @@
import config.dev_config as dev_cfg
from src import room_paths
+from src.item_icons import item_icon
from src.commands.base import Command
-from src.events import EventType, event_bus
-from src.viewmodels.view_builder import ViewBuilder
+from src.commands.hints import show_not_found
from utils.debug_tools import debug_log
if TYPE_CHECKING: # pragma: no cover
@@ -44,17 +44,17 @@ def _unlock_with_held_key(ctx: "CommandHandler", room_id: str) -> bool:
if not key_required or not ctx.player.has_item(key_required):
return False
- key_item = ctx.player.get_item_from_inventory(key_required) or {}
+ key_item = ctx.player.get_item_from_inventory(key_required)
unlocks = [
- ctx.room_aliases.get(str(r).lower(), r) for r in key_item.get("unlocks", [])
+ ctx.room_aliases.get(str(r).lower(), r) for r in key_item.unlocks
]
- if room_id not in unlocks and not key_item.get("usable", False):
+ if room_id not in unlocks and not key_item.usable:
return False
ctx.world.discover_room(room_id)
ctx.world.unlock_room(room_id)
path = room_paths.room_path(room_id)
- key_name = key_item.get("name", key_required)
+ key_name = key_item.name
ctx.output.write(
f"[yellow]The {key_name} fits. {path} unlocks.[/yellow]"
)
@@ -72,6 +72,16 @@ def _permission_bits(ctx: "CommandHandler", room_id: str) -> str:
return "dr--------"
+def _exit_names(ctx: "CommandHandler", room_id: str) -> list[str]:
+ """Names a player could type to leave this room: path basenames and room ids."""
+ names: list[str] = []
+ # Hidden rooms stay hidden: a typo must not reveal what `ls -a` guards.
+ for exit_id in ctx.world.visible_exits(room_id):
+ names.append(room_paths.basename(room_paths.room_path(exit_id)))
+ names.append(exit_id)
+ return names
+
+
class CdCommand(Command):
name = "cd"
@@ -84,8 +94,12 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
target = room_paths.resolve(typed, current_path, ctx.room_aliases)
if target is None:
- ctx._show_error(
- f"[bold red]cd: {typed}: No such file or directory[/bold red]"
+ show_not_found(
+ ctx,
+ f"[bold red]cd: {typed}: No such file or directory[/bold red]",
+ typed,
+ _exit_names(ctx, current_room),
+ label="Exits",
)
return
@@ -116,25 +130,15 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
f"[bold cyan]{destination_path}[/bold cyan] — entering {room_name}..."
)
- room_view = ViewBuilder.build_room_view(ctx.world, target)
- event_bus.emit_event(
- EventType.ROOM_ENTERED,
- {"room": room_view.to_dict(), "player_name": ctx.player.name},
- "CommandHandler",
- )
ctx.display_location()
-
- ts = ctx.player.tutorial_state
- if not ts.get("completed", False) and ts.get("navigation_ls", False):
- if not ts.get("navigation_moved", False):
- ts["navigation_moved"] = True
- ctx.show_tutorial_hint("completed")
+ ctx.arrive()
+ ctx.tutorial.after_move()
@staticmethod
def _deny(ctx: "CommandHandler", typed: str, denial: dict | None) -> None:
"""Report a refused cd the way a shell would, then say what would help."""
if not denial:
- ctx._show_error(f"[bold red]cd: {typed}: Permission denied[/bold red]")
+ ctx.output.error(f"[bold red]cd: {typed}: Permission denied[/bold red]")
return
path = denial["path"]
@@ -142,20 +146,29 @@ def _deny(ctx: "CommandHandler", typed: str, denial: dict | None) -> None:
if denial["reason"] == "missing":
# An undiscovered directory is indistinguishable from one that isn't
# there — which is exactly what makes `ls -a` worth learning.
- ctx._show_error(
+ ctx.output.error(
f"[bold red]cd: {typed}: No such file or directory[/bold red]"
)
return
if denial["reason"] == "class":
- ctx._show_error(f"[bold red]cd: {path}: Permission denied[/bold red]")
+ ctx.output.error(f"[bold red]cd: {path}: Permission denied[/bold red]")
ctx.output.write(
f"[cyan]⚔ Only {denial['class_restriction']} spirits may enter "
f"{path}.[/cyan]"
)
return
- ctx._show_error(f"[bold red]cd: {path}: Permission denied[/bold red]")
+ if denial["reason"] == "flags":
+ ctx.output.error(f"[bold red]cd: {path}: Permission denied[/bold red]")
+ ctx.output.write(
+ f"[yellow]💡 {path} opens once you hold {denial['flags_required']} "
+ f"flags. You have {denial['flags_have']} — [bold]tree[/bold] shows "
+ "the flags you hold.[/yellow]"
+ )
+ return
+
+ ctx.output.error(f"[bold red]cd: {path}: Permission denied[/bold red]")
key_required = denial["key_required"]
blocked_ancestor = room_paths.normalize(typed) != path and not typed.startswith("-")
if key_required:
@@ -185,17 +198,26 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
has_content = False
hints = getattr(dev_cfg, "SHOW_HINTS", True)
- has_enemies, enemy_output = ctx._check_enemies_blocking_exploration(room_id)
+ has_enemies, enemy_output = ctx.check_enemies_blocking_exploration(room_id)
if has_enemies:
ctx.output.write(enemy_output)
return
- revealed = self._render_directories(
+ revealed, directories = self._render_directories(
ctx, output, room_id, show_all, long_format
)
has_content = has_content or bool(output)
items = ctx.world.get_items_in_room(room_id) or []
+ if show_all:
+ ctx.world.mark_hidden_files_listed(room_id)
+ else:
+ # Hidden files only list under -a, like real dotfiles. They stay
+ # addressable by name (cat/take) either way.
+ items = [
+ item_id for item_id in items
+ if not getattr(ctx.world.get_item(item_id), "hidden", False)
+ ]
weapon_found, weapon_id = self._render_items(
ctx, output, items, hints, long_format, has_content
)
@@ -218,14 +240,12 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
# The sidebar and scene only rebuild on ROOM_ENTERED. Without this a
# newly revealed directory stays invisible in the persistent UI until
# the player leaves and comes back.
- room_view = ViewBuilder.build_room_view(ctx.world, room_id)
- event_bus.emit_event(
- EventType.ROOM_ENTERED,
- {"room": room_view.to_dict(), "player_name": ctx.player.name},
- "CommandHandler",
- )
+ ctx.announce_room()
- self._advance_tutorial(ctx, weapon_found, weapon_id)
+ ctx.tutorial.after_ls(
+ weapon_found, weapon_id,
+ has_directories=bool(directories), show_all=show_all,
+ )
# -- sections -------------------------------------------------------------
@@ -248,14 +268,19 @@ def _may_discover(ctx: "CommandHandler", room_id: str) -> bool:
def _render_directories(
self, ctx: "CommandHandler", output: Text, room_id: str,
show_all: bool, long_format: bool,
- ) -> list[str]:
- """List child directories. With -a, also reveal and show hidden ones."""
+ ) -> tuple[list[str], list[str]]:
+ """List child directories. With -a, also reveal and show hidden ones.
+ Returns (newly revealed, all listed)."""
here = room_paths.room_path(room_id)
children = room_paths.children_of(here)
revealed: list[str] = []
visible: list[str] = []
for child in children:
+ # A locked door stays out of sight (even to ls -a) until its key
+ # reveals it.
+ if not ctx.world.door_visible(child):
+ continue
if ctx.world.is_discovered(child):
visible.append(child)
elif show_all and self._may_discover(ctx, child):
@@ -264,7 +289,7 @@ def _render_directories(
visible.append(child)
if not visible and not show_all:
- return revealed
+ return revealed, visible
output.append("Directories:\n", style="bold blue")
@@ -295,7 +320,7 @@ def _render_directories(
output.append(
f"\n✨ Revealed hidden directory: {path}\n", style="bold green"
)
- return revealed
+ return revealed, visible
def _render_items(
self, ctx: "CommandHandler", output: Text, items: list[str],
@@ -313,28 +338,34 @@ def _render_items(
weapon_id = None
for item_id in items:
item = ctx.world.get_item(item_id)
+ # A hidden file is a dotfile: listed, hinted and completed with its
+ # leading dot, so what the player sees is what they type.
+ shown = f".{item_id}" if item and item.hidden else item_id
description = ctx.get_formatted_item_description(item)
# Colour the file by rarity so value reads at a glance. Common maps to
# white, invisible against the description, so commons get green.
- rarity = item.get("rarity", "common") if item else "common"
+ rarity = item.rarity if item else "common"
item_color = RaritySystem.get_rarity_color(rarity)
if item_color in ("white", "bright_white", "default"):
item_color = "green"
+ if item and item.story_flag:
+ item_color = "red" # story files all read like .bash_profile
if long_format:
output.append(" -rw-r--r-- ", style="dim")
else:
output.append(" ")
- output.append(f"{item_id}", style=f"bold {item_color}")
+ output.append(f"{item_icon(item.type if item else None)} ")
+ output.append(shown, style=f"bold {item_color}")
output.append(f" - {description}\n")
if hints:
readable = (
- item and item.get("type") == "lore"
- and not item.get("takeable", True)
+ item and item.type == "lore"
+ and not item.takeable
)
verb = "cat" if readable else "take"
- output.append(f" → {verb} {item_id}\n", style="dim cyan")
+ output.append(f" → {verb} {shown}\n", style="dim cyan")
- if item and item.get("type") == "weapon" and not weapon_found:
+ if item and item.type == "weapon" and not weapon_found:
weapon_found = True
weapon_id = item_id
return weapon_found, weapon_id
@@ -354,9 +385,9 @@ def _render_npcs(
if not npc:
continue
description = (
- npc.get("short_description")
- or npc.get("description")
- or npc.get("name")
+ npc.short_description
+ or npc.description
+ or npc.name
or "No description available"
)
output.append(f" {npc_id}", style="yellow")
@@ -376,26 +407,10 @@ def _render_enemies(
for enemy_id in enemies:
enemy = ctx.world.get_enemy(enemy_id, ctx.player.player_class)
if enemy:
- name = enemy.get("name", enemy_id)
- health = enemy.get("health", "??")
- damage = enemy.get("damage", "??")
+ name = enemy.name
+ health = enemy.health
+ damage = enemy.damage
output.append(f" {enemy_id}", style="red")
output.append(f" - {name} (HP: {health}, DMG: {damage})\n")
else:
output.append(f" {enemy_id} - Unknown Enemy\n", style="red")
-
- @staticmethod
- def _advance_tutorial(
- ctx: "CommandHandler", weapon_found: bool, weapon_id: str | None,
- ) -> None:
- ts = ctx.player.tutorial_state
- if ts.get("completed", False):
- return
- if not ts.get("first_ls", False):
- ts["first_ls"] = True
- if weapon_found:
- ts["found_weapon"] = True
- ctx.show_tutorial_hint("step2", weapon_id)
- elif ts.get("combat_action_taken", False) and not ts.get("navigation_ls", False):
- ts["navigation_ls"] = True
- ctx.show_tutorial_hint("step6b")
diff --git a/src/commands/shell.py b/src/commands/shell.py
index bd0c106..463b486 100644
--- a/src/commands/shell.py
+++ b/src/commands/shell.py
@@ -50,7 +50,7 @@
"1", "print the working directory", "pwd",
"Prints the absolute path of the directory you are currently in.\n"
"Short for 'print working directory'.",
- "Your location is always a real path — /home, /var/backups, /usr/games.",
+ "Your location is always a real path — /home, /var, /var/tmp.",
),
"cat": (
"1", "concatenate and print files", "cat [file]",
@@ -97,6 +97,23 @@
"the display is reset.",
"Clears the output panel.",
),
+ "grep": (
+ "1", "print lines that match a pattern", "grep [-i] [-n] ",
+ "Searches a file and prints only the lines that contain the pattern. The\n"
+ "rest of the file is skipped, which is why grep beats reading.\n\n"
+ " -i ignore case: FLAG, flag and Flag all match\n"
+ " -n show each matching line's number\n\n"
+ "Case matters without -i: 'flag' does not match 'FLAG'.",
+ "Logs here run to hundreds of lines. Don't read them. grep them.",
+ ),
+ "kill": (
+ "1", "terminate a process", "kill ",
+ "Sends a signal to the process with the given PID, asking it to stop.\n"
+ "Find PIDs with ps. Some processes refuse politely-worded signals.\n\n"
+ "PID 1 is init; killing it would take the whole system down, so the\n"
+ "kernel does not let you.",
+ "Rogue processes hide in ps. Their PIDs are how you drag them out.",
+ ),
"man": (
"1", "display the manual for a command", "man [command]",
"Displays the manual page for a command: what it does, how it is invoked,\n"
@@ -112,7 +129,7 @@
GAME_VERBS = {
"take", "drop", "use", "equip", "examine", "talk", "attack", "flee",
"inventory", "inv", "journal", "keys", "shortcuts", "save", "quit", "exit",
- "help", "map",
+ "help", "map", "hint",
}
@@ -162,7 +179,7 @@ def _index(ctx: "CommandHandler") -> None:
purpose = MANPAGES[name][1]
out.append(f" {name:<8}", style="cyan")
out.append(f"{purpose}\n")
- out.append("\n[dim]Try: man ls[/dim]\n")
+ out.append("\nTry: man ls\n", style="dim")
ctx.output.write(out)
@@ -175,19 +192,20 @@ class TreeCommand(Command):
def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
out = Text()
- out.append("Discovered filesystem\n\n", style="bold cyan")
+ out.append("Discovered filesystem\n", style="bold cyan")
+ out.append(f"⚑ {ctx.flags.summary()}\n\n", style="bold yellow")
root_id = room_paths.room_at("/")
if root_id is None:
ctx.output.write("[red]tree: no root directory[/red]")
return
self._branch(ctx, out, root_id, prefix="", is_last=True, is_root=True)
- keys = ctx._get_player_keys()
+ keys = ctx.resolver.player_keys()
if keys:
out.append("\nKeys you carry: ", style="bold blue")
out.append(", ".join(keys) + "\n", style="blue")
out.append(
- "\n[dim]🔒 sealed · ⚔ another class · ✓ cleared · ← you are here[/dim]"
+ "\n🔒 sealed · ⚑ flag captured · ✓ done · ← you are here", style="dim"
)
ctx.output.write(out)
@@ -205,11 +223,16 @@ def _branch(
if not allowed and denial:
if denial["reason"] == "class":
marks.append("⚔")
+ elif denial["reason"] == "flags" and denial["room_id"] == room_id:
+ need = denial["flags_required"]
+ marks.append(f"🔒 {need} flags ({denial['flags_have']}/{need})")
elif denial["room_id"] == room_id:
key = ctx.world.get_room_state(room_id).get("key_required")
marks.append(f"🔒 {key}" if key else "🔒")
if ctx.world.is_room_cleared(room_id):
marks.append("✓")
+ if ctx.flags.flag_for(room_id) is not None and ctx.world.flag_captured(room_id):
+ marks.append("⚑")
here = room_id == ctx.player.current_room
if is_root:
@@ -229,7 +252,7 @@ def _branch(
children = [
child for child in room_paths.children_of(path)
- if ctx.world.is_discovered(child)
+ if ctx.world.is_discovered(child) and ctx.world.door_visible(child)
]
if is_root:
child_prefix = prefix
diff --git a/src/commands/system.py b/src/commands/system.py
index cf83e92..2b05d2e 100644
--- a/src/commands/system.py
+++ b/src/commands/system.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""System commands: save, quit/exit.
+"""System commands: save, quit/exit, menu.
quit sets confirmation state handled by CommandHandler's mode-gate helpers
(_handle_quit_confirmation / _perform_quit), which remain on the handler. The
@@ -10,7 +10,6 @@
from typing import TYPE_CHECKING
from src.commands.base import Command
-from src.events import EventType, event_bus
from utils.debug_tools import debug_log
if TYPE_CHECKING: # pragma: no cover
@@ -35,29 +34,32 @@ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
ctx.output.write(f"[bold red]✗ Failed to save game: {e}[/bold red]")
+def _has_progress(ctx: "CommandHandler") -> bool:
+ return bool(
+ ctx.player.health != ctx.player.max_health
+ or ctx.player.current_room != "home_grove"
+ or len(ctx.player.inventory) > 0
+ or ctx.player.equipped_weapon is not None
+ )
+
+
class QuitCommand(Command):
name = "quit"
aliases = ("exit",)
def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
- has_progress = (
- ctx.player.health != ctx.player.max_health
- or ctx.player.current_room != "home_grove"
- or len(ctx.player.inventory) > 0
- or ctx.player.equipped_weapon is not None
- )
-
- if has_progress:
- ctx.output.write("[bold yellow]You have unsaved progress![/bold yellow]")
- ctx.output.write("Would you like to save before quitting?")
- ctx.output.write(
- "[bold white]Options:[/bold white] [green]y[/green] (save & quit), "
- "[yellow]n[/yellow] (quit without saving), [red]c[/red] (cancel)"
- )
- ctx._in_quit_confirmation = True
- # A frontend that can do better than three typed letters shows a
- # chooser; one that cannot (headless) ignores this and reads the
- # text above. Either way the answer arrives as y/n/c.
- event_bus.emit_event(EventType.QUIT_CONFIRM_REQUESTED, {}, "QuitCommand")
+ if _has_progress(ctx):
+ ctx.flow.request_leave(in_combat=False)
+ else:
+ ctx.flow.perform_quit()
+
+
+class MenuCommand(Command):
+ """Back to the title menu (to switch runs or open Settings)."""
+ name = "menu"
+
+ def execute(self, ctx: "CommandHandler", args: list[str]) -> None:
+ if _has_progress(ctx):
+ ctx.flow.request_leave(in_combat=False)
else:
- ctx._perform_quit()
+ ctx.flow.go_to_menu()
diff --git a/src/data_loader.py b/src/data_loader.py
index 6ca727d..6f542a9 100644
--- a/src/data_loader.py
+++ b/src/data_loader.py
@@ -5,8 +5,7 @@
# Cache for loaded data to avoid repeated disk reads
_class_data_cache = None
-_weapon_data_cache = {}
-_abilities_data_cache = None
+_items_cache = None
def load_class_data():
"""Load character classes as typed engine CharacterClass models (id -> model)."""
@@ -24,82 +23,17 @@ def load_class_data():
debug_log(f"ERROR loading class data: {e}")
return {}
-def load_weapon_data(weapon_id):
- """Load data for a specific weapon from weapons.yaml"""
- global _weapon_data_cache
-
- # Return cached data if available
- if weapon_id in _weapon_data_cache:
- return _weapon_data_cache[weapon_id]
-
- try:
- # Load from the flat weapons.yaml (id -> def, no wrapper key).
- filepath = 'data/items/weapons.yaml'
- if os.path.exists(filepath):
- with open(filepath, 'r') as file:
- weapons = yaml.safe_load(file) or {}
- weapon_data = weapons.get(weapon_id)
- if weapon_data:
- from engine.schema import Item
- Item(id=weapon_id, **weapon_data) # validation gate; raises loud on a bad field
- weapon_data["id"] = weapon_id
- _weapon_data_cache[weapon_id] = weapon_data
- debug_log(f"Loaded weapon data for {weapon_id}")
- return weapon_data
- debug_log(f"ERROR: Weapon {weapon_id} not found in weapons.yaml")
- return None
- else:
- debug_log(f"ERROR: Weapons file not found at path: {filepath}")
- return None
-
- except Exception as e:
- debug_log(f"ERROR loading weapon data: {e}")
+def load_item(item_id):
+ """A private copy of one item template (any category), or None."""
+ global _items_cache
+ if _items_cache is None:
+ from engine.content.loader import load_items
+ _items_cache = {str(k): v for k, v in load_items("data").items()}
+ item = _items_cache.get(item_id)
+ if item is None:
+ debug_log(f"Item {item_id} not found")
return None
-
-def load_abilities_data():
- """Load abilities from abilities.yaml"""
- global _abilities_data_cache
-
- # Return cached data if available
- if _abilities_data_cache is not None:
- return _abilities_data_cache
-
- try:
- # Try to load from abilities.yaml
- filepath = 'data/abilities.yaml'
- if os.path.exists(filepath):
- with open(filepath, 'r') as file:
- data = yaml.safe_load(file)
- if data is None:
- debug_log("ERROR: Empty abilities file")
- return {"abilities": {}}
-
- # Store in cache
- _abilities_data_cache = data
- debug_log(f"Loaded abilities data with {len(data.get('abilities', {}))} abilities")
- return data
- else:
- debug_log(f"ERROR: Abilities file not found at path: {filepath}")
- return {"abilities": {}}
-
- except Exception as e:
- debug_log(f"ERROR loading abilities data: {e}")
- return {"abilities": {}}
-
-def get_abilities_for_class(class_name):
- """Get all abilities for a specific class"""
- all_abilities = load_abilities_data().get("abilities", {})
- class_abilities = {}
-
- for ability_id, ability_data in all_abilities.items():
- # Check if this ability belongs to the specified class
- if ability_data.get("class") == class_name or "all" in ability_data.get("class", ""):
- class_abilities[ability_id] = ability_data
-
- if not class_abilities:
- debug_log(f"WARNING: No abilities found for class '{class_name}'")
-
- return class_abilities
+ return item.model_copy(deep=True)
# Helper to load a YAML file
def load_yaml(filepath):
@@ -139,60 +73,15 @@ def load_room_data():
def load_npc_data():
- """Load all NPCs as plain dicts (id -> dict, dots kept in the id).
-
- NPCs stay dicts rather than typed models: consumers read them with ``.get``
- (dialogue banks, dialogue_rules, on_talk effects) and the dialogue payloads
- are free-form. The engine loader still validates them on the way through, so
- a malformed NPC file fails loudly here rather than mid-conversation.
- """
+ """Load all NPCs as typed NPC models (id -> NPC, dots kept in the id)."""
try:
from engine.content.loader import load_npcs
- return {
- str(nid): npc.model_dump(exclude_unset=True)
- for nid, npc in load_npcs("data").items()
- }
+ return {str(nid): npc for nid, npc in load_npcs("data").items()}
except Exception as e:
debug_log(f"ERROR loading npc data: {e}")
return {}
-# Consumables cache
-_consumables_data_cache = None
-
-
-def load_consumable_data(consumable_id):
- """Load data for a specific consumable from consumables.yaml"""
- global _consumables_data_cache
-
- # Load and cache all consumables if not already cached
- if _consumables_data_cache is None:
- try:
- filepath = 'data/items/consumables.yaml'
- if os.path.exists(filepath):
- with open(filepath, 'r') as file:
- data = yaml.safe_load(file) or {}
- _consumables_data_cache = data if isinstance(data, dict) else {}
- debug_log(f"Loaded consumables data with {len(_consumables_data_cache)} items")
- else:
- debug_log(f"ERROR: Consumables file not found at path: {filepath}")
- _consumables_data_cache = {}
- except Exception as e:
- debug_log(f"ERROR loading consumables data: {e}")
- _consumables_data_cache = {}
-
- # Return the specific consumable
- if consumable_id in _consumables_data_cache:
- consumable_data = _consumables_data_cache[consumable_id].copy()
- from engine.schema import Item
- Item(id=consumable_id, **consumable_data) # validation gate
- consumable_data["id"] = consumable_id
- return consumable_data
-
- debug_log(f"Consumable {consumable_id} not found")
- return None
-
-
# Tutorial hints cache
_tutorial_hints_cache = None
diff --git a/src/difficulty.py b/src/difficulty.py
index c4c54c5..53ee910 100644
--- a/src/difficulty.py
+++ b/src/difficulty.py
@@ -8,11 +8,15 @@
from __future__ import annotations
import os
+from typing import TYPE_CHECKING
import yaml
from utils.debug_tools import debug_log
+if TYPE_CHECKING:
+ from engine.schema import Enemy
+
MODES = ("easy", "medium", "hard")
DEFAULT_MODE = "medium"
@@ -72,16 +76,12 @@ def all_multipliers() -> dict[str, dict[str, float]]:
return {m: dict(_multipliers.get(m, _FALLBACK[m])) for m in MODES}
-def scale_enemy(enemy: dict) -> dict:
- """Return a copy of enemy data with HP/damage scaled for the active mode."""
- if not isinstance(enemy, dict):
- return enemy
+def scale_enemy(enemy: Enemy) -> Enemy:
+ """Return a copy of an Enemy with HP/damage scaled for the active mode."""
m = _mult()
- scaled = dict(enemy)
- if "health" in scaled and isinstance(scaled["health"], (int, float)):
- scaled["health"] = max(1, round(scaled["health"] * m["enemy_hp"]))
- if "damage" in scaled and isinstance(scaled["damage"], (int, float)):
- scaled["damage"] = max(0, round(scaled["damage"] * m["enemy_damage"]))
+ scaled = enemy.model_copy()
+ scaled.health = max(1, round(enemy.health * m["enemy_hp"]))
+ scaled.damage = max(0, round(enemy.damage * m["enemy_damage"]))
return scaled
diff --git a/src/flags.py b/src/flags.py
new file mode 100644
index 0000000..35225af
--- /dev/null
+++ b/src/flags.py
@@ -0,0 +1,318 @@
+"""Room flags: capture, counts, and the guidance that leads players to them.
+
+Each room may declare a flag (docs/ROOM_FLAGS_SPEC.md). Commands report what
+the player did (read a file, entered a room, typed a command); this service
+decides whether a flag was captured and what Echo should say.
+"""
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+from src import room_paths
+from src.logs import log_lines
+from utils.debug_tools import debug_log
+
+FLAG_XP = 15
+STUCK_AFTER = 8
+ECHO = "[bold green]ECHO>[/bold green]"
+TRIAL_BONUS = 4
+TRIAL_TURNS = 10
+
+
+class FlagService:
+ def __init__(self, player: Any, world: Any, output: Any, save: Callable[[], None]):
+ self.player = player
+ self.world = world
+ self.output = output
+ self._save = save
+ self._hints_asked: dict[str, int] = {}
+ self._idle = 0
+ self._nudged_room: str | None = None
+ self._checkpoint_pending = False
+
+ def flag_for(self, room_id: str) -> Any:
+ room = self.world.get_room(room_id)
+ return getattr(room, "flag", None) if room else None
+
+ def counts(self) -> tuple[int, int, int, int]:
+ main_got, main_total, secret_got, secret_total = self.world.flag_counts()
+ return main_got, main_total, secret_got, secret_total
+
+ def summary(self) -> str:
+ main_got, main_total, secret_got, secret_total = self.counts()
+ return f"Flags {main_got}/{main_total} · Secrets {secret_got}/{secret_total}"
+
+ def on_file_read(self, item_id: str, save: bool = True) -> bool:
+ """A file was read in the current room. Capture its flag if it is the
+ room's flag file and not yet captured. Returns True on capture.
+ save=False leaves the checkpoint to the caller (see checkpoint())."""
+ room_id = self.player.current_room
+ flag = self.flag_for(room_id)
+ if flag is None or flag.file != item_id or self.world.flag_captured(room_id):
+ return False
+ if flag.via == "grep":
+ if self._guiding():
+ item = self.world.get_item(item_id)
+ count = len(log_lines(item)) if item is not None and item.log else 0
+ advice = (
+ flag.nudge if self._technique_known("grep")
+ else f"Search it instead: type {flag.command}"
+ )
+ self.output.write(f"{ECHO} {count} lines — nobody reads all that. {advice}")
+ return False
+ self._capture(room_id, flag, save)
+ return True
+
+ def _capture(self, room_id: str, flag: Any, save: bool) -> None:
+ self.world.mark_flag_captured(room_id)
+ self.player.harvest_cycles(FLAG_XP)
+ self._idle = 0
+ self.output.write(
+ f"\n[bold yellow]⚑ Flag captured: {room_paths.room_path(room_id)} — "
+ f"{flag.text}[/bold yellow]\n"
+ f"[dim]{self.summary()} · +{FLAG_XP} cycles[/dim]"
+ )
+ # A capture is a safe point: the recovered memory restores you fully,
+ # so the flag hunt, not grinding, is how a player gets their HP back.
+ missing = self.player.max_health - self.player.health
+ if missing > 0:
+ self.player.heal(missing)
+ self.output.write(
+ f"[bold green]✚ +{missing} HP — the recovered memory has you "
+ "fully restored.[/bold green]"
+ )
+ if getattr(flag, "grants", None):
+ self._grant_key(str(flag.grants))
+ if save:
+ self.checkpoint()
+
+ def _grant_key(self, key_id: str) -> None:
+ """The keys chain: this flag hands out a key. Skipped when it is
+ already held or its doors are already open (an older save)."""
+ if self.player.has_item(key_id):
+ return
+ item = self.world.get_item(key_id)
+ if item is None:
+ return
+ still_locked = [
+ str(r) for r in item.unlocks
+ if self.world.room_states.get(str(r), {}).get("locked", False)
+ ]
+ if not still_locked:
+ return
+ self.player.add_to_inventory(key_id, item)
+ self.announce_key(key_id, self.world.reveal_doors(key_id) or still_locked)
+
+ def announce_key(self, key_id: str, revealed: list[str]) -> None:
+ """Say which doors a newly gained key made visible (secret rooms not
+ yet discovered stay unnamed), plus a one-time Echo lesson."""
+ item = self.world.get_item(key_id)
+ name = item.name if item is not None else key_id
+ shown = [
+ room_paths.room_path(r) for r in revealed
+ if not getattr(self.world.get_room(r), "hidden", False)
+ or self.world.is_discovered(r)
+ ]
+ if shown:
+ self.output.write(
+ f"[bold yellow]🔑 {name} — a new directory appeared: "
+ f"{', '.join(shown)}. Type [bold]cd {shown[0]}[/bold][/bold yellow]"
+ )
+ else:
+ self.output.write(f"[bold yellow]🔑 You got the {name}.[/bold yellow]")
+ tutorial = self.player.tutorial_state
+ if not tutorial.get("keyLessonSeen"):
+ tutorial["keyLessonSeen"] = True
+ self.output.write(
+ f"{ECHO} Flags can open doors: some hand you a key, and a locked "
+ "directory only shows up once you hold its key."
+ )
+
+ def on_grep(self, item_id: str, matched: list[str]) -> bool:
+ """grep printed `matched` from item_id. Captures a grep flag when the
+ flag line was among them."""
+ room_id = self.player.current_room
+ flag = self.flag_for(room_id)
+ if (
+ flag is not None and flag.via == "cat" and flag.file == item_id
+ and not self.world.flag_captured(room_id)
+ and any(flag.text in line for line in matched)
+ ):
+ self.output.write(
+ f"{ECHO} That's this room's flag — but it only counts when you "
+ f"read the file. Type {flag.command}"
+ )
+ return False
+ if (
+ flag is None or flag.via != "grep" or flag.file != item_id
+ or self.world.flag_captured(room_id)
+ or not any(flag.text in line for line in matched)
+ ):
+ return False
+ self._capture(room_id, flag, save=True)
+ return True
+
+ def on_npc_talk(self) -> None:
+ room_id = self.player.current_room
+ flag = self.flag_for(room_id)
+ if flag is not None and flag.clue and not self.world.flag_captured(room_id):
+ self.output.write(f"[italic cyan]{flag.clue}[/italic cyan]")
+
+ def rogue_process(self) -> tuple[int, str] | None:
+ """The current room's rogue as (pid, process), while it is still
+ hiding: flag uncaptured and the enemy not already out (or fled)."""
+ room_id = self.player.current_room
+ flag = self.flag_for(room_id)
+ if flag is None or flag.via != "kill" or self.world.flag_captured(room_id):
+ return None
+ if flag.enemy in self.world.enemy_locations:
+ return None
+ if flag.enemy in self.world.fled_enemies.get(room_id, []):
+ return None
+ return int(flag.pid), str(flag.process)
+
+ def release_rogue(self) -> str | None:
+ """kill hit the rogue's PID: it comes out to fight. Returns its enemy
+ id, or None if there is no hiding rogue here."""
+ if self.rogue_process() is None:
+ return None
+ flag = self.flag_for(self.player.current_room)
+ self.world.enemy_locations[flag.enemy] = self.player.current_room
+ return str(flag.enemy)
+
+ def on_enemy_defeated(self, enemy_id: str) -> bool:
+ """A kill/defeat flag's enemy fell: capture now, save when the fight
+ is over (never mid-combat)."""
+ room_id = self.player.current_room
+ flag = self.flag_for(room_id)
+ if (
+ flag is None or flag.via not in ("kill", "defeat")
+ or flag.enemy != enemy_id or self.world.flag_captured(room_id)
+ ):
+ return False
+ self.output.write("[bold]The dying process dumps its memory…[/bold]")
+ self._capture(room_id, flag, save=False)
+ self._checkpoint_pending = True
+ return True
+
+ def flush_checkpoint(self) -> None:
+ if self._checkpoint_pending:
+ self._checkpoint_pending = False
+ self.checkpoint()
+
+ def checkpoint(self) -> None:
+ try:
+ self._save()
+ self.output.write("[dim green]✓ Checkpoint saved.[/dim green]")
+ except Exception as e:
+ debug_log(f"Checkpoint save after a flag capture failed: {e}")
+ self.output.write(f"[dim yellow]⚠ Checkpoint save failed: {e}[/dim yellow]")
+
+ def _guiding(self) -> bool:
+ return bool(self.player.tutorial_state.get("completed", False))
+
+ def on_room_entered(self, room_id: str) -> None:
+ """First visit to a room that teaches a technique: Echo gives the
+ lesson. Also resets the stuck counter."""
+ self._idle = 0
+ self._nudged_room = None
+ room = self.world.get_room(room_id)
+ trial = getattr(room, "trial_class", "") if room else ""
+ if trial and trial == self.player.player_class:
+ self.player.add_status_effect(
+ "trial_resonance",
+ {"name": "Resonance", "damage_bonus": TRIAL_BONUS,
+ "description": f"+{TRIAL_BONUS} damage in your class's trial"},
+ TRIAL_TURNS,
+ )
+ self.output.write(
+ f"[bold magenta]This place resonates with you, "
+ f"{self.player.player_class}. +{TRIAL_BONUS} damage for "
+ f"{TRIAL_TURNS} turns.[/bold magenta]"
+ )
+ self._teach_if_ready(room_id)
+
+ def _technique_known(self, via: str) -> bool:
+ """Has the player captured any flag that needed this technique?"""
+ return any(
+ getattr(room, "flag", None) is not None and room.flag.via == via
+ and self.world.flag_captured(rid)
+ for rid, room in self.world.rooms.items()
+ )
+
+ def _lesson(self, flag: Any) -> str:
+ if flag.teach:
+ return str(flag.teach)
+ if flag.via == "grep" and not self._technique_known("grep"):
+ # Whichever grep room comes first teaches grep, not only /mnt.
+ return (
+ "This file is hundreds of lines long — too long to read. "
+ "[bold]grep[/bold] searches a file for a word and prints only the "
+ f"lines that contain it. Type {flag.command}."
+ )
+ if flag.via == "kill" and not self._technique_known("kill"):
+ return (
+ "Something here doesn't belong. [bold]ps[/bold] lists the processes "
+ "running in this directory — look for the odd one out, then end it "
+ f"with [bold]kill[/bold] and its PID number. Type {flag.command}."
+ )
+ return ""
+
+ def _teach_if_ready(self, room_id: str) -> None:
+ """Give the room's lesson once — but only when nothing is fighting
+ here, or the arrival fight would bury it."""
+ if not self._guiding():
+ return
+ flag = self.flag_for(room_id)
+ if (
+ flag is None or self.world.flag_captured(room_id)
+ or self.world.flag_taught(room_id)
+ or self.world.get_enemies_in_room(room_id)
+ ):
+ return
+ lesson = self._lesson(flag)
+ if not lesson:
+ return
+ self.world.mark_flag_taught(room_id)
+ self.output.write(f"{ECHO} {lesson}")
+
+ def hint(self) -> None:
+ """`hint`: a nudge first, then the exact command."""
+ room_id = self.player.current_room
+ flag = self.flag_for(room_id)
+ if flag is None:
+ self.output.write(
+ f"{ECHO} No flag hides in this directory. "
+ "[bold]tree[/bold] shows where you've found them."
+ )
+ return
+ if self.world.flag_captured(room_id):
+ self.output.write(
+ f"{ECHO} You already captured this room's flag. {self.summary()}"
+ )
+ return
+ asked = self._hints_asked.get(room_id, 0) + 1
+ self._hints_asked[room_id] = asked
+ if asked == 1:
+ extra = " Capturing it also opens a new door." if flag.grants else ""
+ self.output.write(f"{ECHO} {flag.nudge}{extra}")
+ else:
+ self.output.write(f"{ECHO} Type {flag.command}")
+
+ def after_command(self, in_combat: bool) -> None:
+ """Count commands spent in a room whose flag is still out there; nudge
+ once after STUCK_AFTER of them."""
+ if in_combat or not self._guiding():
+ return
+ room_id = self.player.current_room
+ self._teach_if_ready(room_id)
+ flag = self.flag_for(room_id)
+ if flag is None or self.world.flag_captured(room_id) or self._nudged_room == room_id:
+ return
+ self._idle += 1
+ if self._idle >= STUCK_AFTER:
+ self._nudged_room = room_id
+ self.output.write(
+ f"{ECHO} {flag.nudge} [dim](Type [bold]hint[/bold] if you're stuck.)[/dim]"
+ )
diff --git a/src/game_engine.py b/src/game_engine.py
index 9ba9b0b..cb0f993 100644
--- a/src/game_engine.py
+++ b/src/game_engine.py
@@ -13,19 +13,21 @@
import os
import sys
import logging
-from typing import Optional, Dict, Any
+
+from rich.markup import escape
+from typing import Optional, Any
# Import game components
from src.game_world import GameWorld
+from src.item_placer import ItemPlacer
from src.player import Player
from src.command_handler import CommandHandler
from src.game_output import GameOutput
-from src.save import save_manager
+from src.save import IncompatibleSaveError, save_manager
from src.ui.ui_interface import UIProtocol, UIInitializationError
-from src.events import event_bus, EventType
+from engine.events import EventBus, EventType
from src.game_states import GameState, DEFAULT_GAME_STATE, DEFAULT_ROOM
-from src.data_loader import load_room_data, load_enemy_data, load_npc_data
-from src.state_manager import state_manager
+from src.state_manager import StateManager
from src.viewmodels.view_builder import ViewBuilder
logger = logging.getLogger(__name__)
@@ -50,15 +52,28 @@ class ImprovedGameEngine:
- Lifecycle management
"""
- def __init__(self, ui: Optional[UIProtocol] = None):
- """Initialize the game engine."""
+ def __init__(self, ui: Optional[UIProtocol] = None, bus: Optional[EventBus] = None):
+ """Initialize the game engine.
+
+ The engine owns its EventBus and StateManager. Everything it builds
+ (UI, world, command handler, combat) is handed this bus, so a
+ subscription left behind dies with its engine instead of reacting to
+ the next run's events.
+ """
logger.info("Initializing ImprovedGameEngine")
+ self.bus = bus if bus is not None else EventBus()
+ self.state_manager = StateManager(self.bus)
# Non-reloadable state
self.save_dir = "saves"
+ # Which save picker is up: "continue" (LOAD GAME) or "replace" (NEW
+ # GAME with every slot taken).
+ self._save_picker_mode = "continue"
# UI is injected by the composition root (main.py) / GameSession / tests.
# The backend never constructs the concrete frontend.
self.ui = ui
+ if ui is not None:
+ ui.attach_bus(self.bus, self.state_manager)
# Domain output sink: command/combat text is written here and forwarded
# live to the UI (Phase 2b). The domain no longer references the UI.
self.output = GameOutput(forward=self._forward_output)
@@ -76,8 +91,6 @@ def _bind_ui_refs(self):
return
self.ui._player_ref = self.player
self.ui._world_ref = self.world
- if self.cmd_handler:
- self.ui._room_aliases_ref = self.cmd_handler.room_aliases
def _initialize_game_components(self):
"""Initialize/reinitialize game components (reloadable)."""
@@ -85,8 +98,7 @@ def _initialize_game_components(self):
# Drop the outgoing handler's subscriptions before letting go of it.
# Without this, F5 (restart) left the dead run's CommandHandler on the
- # bus: ROOM_ENTERED then fired check_for_enemies twice (every enemy
- # fought twice) and ENEMY_DEFEATED fired twice (loot rolled twice).
+ # bus: ENEMY_DEFEATED fired twice (loot rolled twice).
if getattr(self, "cmd_handler", None):
self.cmd_handler.cleanup_event_subscriptions()
@@ -95,10 +107,8 @@ def _initialize_game_components(self):
self.world: Optional[GameWorld] = None
self.cmd_handler: Optional[CommandHandler] = None
self.current_room = DEFAULT_ROOM
- state_manager.set_state(DEFAULT_GAME_STATE, emit_event=False)
- self.pending_player_name = ""
+ self.state_manager.set_state(DEFAULT_GAME_STATE, emit_event=False)
self._awaiting_skip_response: bool = False
- self._pending_player_name: str = ""
# Load game data
try:
@@ -118,34 +128,48 @@ def _setup_directories(self):
def _setup_event_subscriptions(self):
"""Subscribe to relevant events."""
- event_bus.subscribe(EventType.COMMAND_ENTERED, self._on_command_entered)
- event_bus.subscribe(EventType.UI_READY, self._on_ui_ready)
- event_bus.subscribe(EventType.UI_ERROR, self._on_ui_error)
- event_bus.subscribe(EventType.GAME_SAVED, self._on_save_requested)
- event_bus.subscribe(EventType.COMBAT_STARTED, self._on_combat_started)
- event_bus.subscribe(EventType.COMBAT_ENDED, self._on_combat_ended)
- event_bus.subscribe(EventType.GAME_OVER, self._on_game_over)
- event_bus.subscribe(EventType.GAME_RESTART_REQUESTED, self._on_restart_requested)
-
- def restart_game(self):
- """Restart game state without closing UI - reloads all game data."""
- logger.info("Restarting game")
+ self.bus.subscribe(EventType.COMMAND_ENTERED, self._on_command_entered)
+ self.bus.subscribe(EventType.UI_READY, self._on_ui_ready)
+ self.bus.subscribe(EventType.GAME_RESTART_REQUESTED, self._on_restart_requested)
+
+ def restart_game(self, reason: str = "restart"):
+ """Back to the title without closing the UI: F5 ("restart") or leaving
+ a run for the main menu ("menu"). Reloads all game data; no run is
+ being played afterwards."""
+ logger.info(f"Returning to the title ({reason})")
+ save_manager.end_run()
# Reinitialize all game components (player, world, data)
self._initialize_game_components()
- # Emit event to UI to reset display
- event_bus.emit_event(EventType.GAME_OVER, {"message": "Game restarted. Welcome back!"}, "GameEngine")
+ # The UI resets and shows the title (skipping the intro for "menu").
+ self.bus.emit_event(
+ EventType.GAME_OVER,
+ {"reason": reason, "message": "Game restarted. Welcome back!"},
+ "GameEngine",
+ )
+
+ def _return_to_menu(self):
+ """Leave the run for the title menu (game-over "m", the quit chooser)."""
+ self.restart_game(reason="menu")
- logger.info("Game restart complete")
+ #: Where the world's content lives; tests point this at a scratch copy.
+ DATA_DIR = "data"
def _load_content(self):
- """Load every content collection from data/ (rooms, items, enemies, npcs)."""
- return (
- load_room_data(),
- self._load_items(),
- load_enemy_data(),
- load_npc_data(),
+ """Load and link the world's content (rooms, items, enemies, NPCs).
+
+ link() runs the same integrity checks as `python -m engine.validate`:
+ a dangling reference or a broken room tree stops the game at start
+ (DataLoadError) instead of surfacing mid-run. The old per-collection
+ loaders swallowed errors and returned an empty collection.
+ """
+ from engine.content import link, load_all
+
+ content = link(load_all(self.DATA_DIR))
+ return tuple(
+ {str(key): model for key, model in collection.items()}
+ for collection in (content.rooms, content.items, content.enemies, content.npcs)
)
def _load_game_data(self):
@@ -165,29 +189,17 @@ def _load_game_data_for_load(self):
self.world = GameWorld(rooms, items, enemies, npcs, initialize_state=False)
logger.info("Game data loaded successfully for save game")
- def _load_items(self) -> Dict[str, Any]:
- """Load all items as typed engine Item models (id -> model), validated at load.
-
- The engine loader enforces flat files, required ``type``, unique ids, and now
- field shapes (weapon ``damage``, consumable ``combat_effects``, …). GameWorld
- stores the typed templates; ``get_item`` dumps them back to dicts for consumers.
- """
- from engine.content.loader import load_items
- items: Dict[str, Any] = {str(iid): it for iid, it in load_items("data").items()}
- logger.info(f"Total items loaded: {len(items)}")
- return items
-
-
# Event handlers
def _on_command_entered(self, event):
"""Handle command entered from UI."""
command = event.data.get('command', '')
- game_state = event.data.get('game_state', state_manager.current_state)
+ # The engine decides what mode the game is in; the UI only sends text.
+ game_state = self.state_manager.current_state
# New command: its first output write replaces the panel (see _forward_output).
self._fresh_command_output = True
- logger.debug(f"Command entered: '{command}' (UI state: {game_state}, Engine state: {state_manager.current_state})")
+ logger.debug(f"Command entered: '{command}' (state: {game_state})")
try:
if game_state == GameState.PLAYING and self.cmd_handler:
@@ -197,22 +209,17 @@ def _on_command_entered(self, event):
self.cmd_handler.handle_command(command)
elif game_state == GameState.MENU:
self._handle_menu_command(command)
- elif game_state == GameState.WAITING_FOR_NAME:
- self._handle_name_input(command)
+ elif game_state == GameState.WAITING_FOR_SAVE:
+ self._handle_save_picker_input(command)
elif game_state == GameState.WAITING_FOR_DIFFICULTY:
self._handle_difficulty_input(command)
elif game_state == GameState.WAITING_FOR_CLASS:
self._handle_class_input(command)
elif game_state == GameState.TUTORIAL_NAME_INPUT:
self._handle_tutorial_name_input(command)
- elif game_state == GameState.GAME_OVER:
- # Any keypress from game over screen → return to main menu
- logger.debug("GAME_OVER state: transitioning to MENU")
- state_manager.set_state(GameState.MENU)
- if hasattr(self.ui, '_display_title_screen'):
- self.ui._display_title_screen()
- else:
- self.ui.update_output("\n1. New Game\n2. Load Game\n3. Exit\n\nEnter your choice: ")
+ elif game_state == GameState.GAME_OVER and self.cmd_handler:
+ # The game-over screen's r / n / q; anything else re-asks.
+ self.cmd_handler.flow.handle_game_over_input(command)
else:
logger.debug(f"No specific handler for state {game_state}, defaulting to menu handler")
self._handle_menu_command(command)
@@ -220,7 +227,7 @@ def _on_command_entered(self, event):
logger.error(f"Error handling command '{command}': {e}")
self.ui.update_output(f"Error: {e}")
- def _forward_output(self, content):
+ def _forward_output(self, content, replace=False):
"""Render one line from the domain output sink to the UI (Phase 2b).
The FIRST write of a command replaces the output panel; subsequent
@@ -231,7 +238,7 @@ def _forward_output(self, content):
Thread-safe: the game-over animation writes from a background thread, so
use Textual's call_from_thread when the UI provides it.
"""
- first = getattr(self, "_fresh_command_output", True)
+ first = getattr(self, "_fresh_command_output", True) or replace
self._fresh_command_output = False
sink = self.ui.update_output
if not first and hasattr(self.ui, "append_output"):
@@ -248,187 +255,140 @@ def _forward_output(self, content):
def _on_ui_ready(self, event):
"""Handle UI ready event."""
logger.info("UI is ready, starting main menu")
- state_manager.set_state(GameState.MENU)
-
- def _on_ui_error(self, event):
- """Handle UI error event."""
- error = event.data.get('error', 'Unknown UI error')
- logger.error(f"UI Error: {error}")
- # Could implement fallback UI here
-
- def _on_save_requested(self, event):
- """Handle save game request from UI."""
- try:
- if self.player and self.world:
- success = save_manager.save_game(self.player, self.world.get_state())
- if success:
- logger.info("Game saved successfully")
- else:
- logger.warning("Game save failed")
- else:
- logger.warning("Cannot save: no player or world data")
- except Exception as e:
- logger.error(f"Error saving game: {e}")
+ self.state_manager.set_state(GameState.MENU)
- def _on_combat_started(self, event):
- """Handle combat started event."""
+ def _new_command_handler(self):
+ """A CommandHandler for the current player and world, with the engine's
+ combat hooks attached. Every run (new, loaded, restarted) gets one."""
+ return CommandHandler(
+ self.player, self.world, self.output, self.bus,
+ on_combat_start=self._combat_started,
+ on_combat_end=self._combat_ended,
+ on_return_to_menu=self._return_to_menu,
+ on_restore_save=self._restart_from_save,
+ )
+
+ def _combat_started(self):
+ """Called by the combat session as a fight opens."""
logger.info("Combat started, entering combat state")
- state_manager.enter_combat()
+ self.state_manager.enter_combat()
- def _on_combat_ended(self, event):
- """Handle combat ended event."""
+ def _combat_ended(self, outcome):
+ """Called by CommandHandler.end_combat before it relocates the player,
+ starts the game-over flow or checks for victory."""
logger.info("Combat ended, exiting combat state")
# Check if player was defeated - trigger game over immediately
- if event.data.get('defeat', False):
+ if outcome.get('defeat', False):
logger.info("Player defeated in combat - triggering game over")
- state_manager.set_state(GameState.GAME_OVER)
- event_bus.emit_event(
+ self.state_manager.set_state(GameState.GAME_OVER)
+ self.bus.emit_event(
EventType.GAME_OVER,
- {"message": "[bold red]GAME OVER[/bold red]\n\nYou have been defeated in combat.\n\nPress any key to continue..."},
+ {
+ "reason": "defeat",
+ "message": "[bold red]GAME OVER[/bold red]\n\nYou have been defeated in combat.\n\nPress any key to continue...",
+ },
"GameEngine"
)
return # Don't continue with normal combat end processing
# Use StateManager to exit combat
- state_manager.exit_combat()
+ self.state_manager.exit_combat()
- # On flee, CommandHandler relocates player + emits ROOM_ENTERED itself.
- # Emitting here would fire check_for_enemies on the room they just fled,
- # restarting combat before the flee handler can mark the enemy fled.
- if event.data.get("fled", False):
+ # On flee, CommandHandler relocates the player and announces the room
+ # they land in; this room is no longer theirs to show.
+ if outcome.get("fled", False):
self._update_ui_panels()
return
- # ROOM_ENTERED goes out BEFORE the panel refresh. The UI leaves combat
+ # The room goes out BEFORE the panel refresh. The UI leaves combat
# mode on this same event, and it needs the fresh room view to be in
# hand by then — when the order was reversed it was not, which is why
# the UI used to defer its panel restore behind a 0.1s timer.
- if self.world and self.player:
- room_view = ViewBuilder.build_room_view(self.world, self.player.current_room)
-
- event_bus.emit_event(
- EventType.ROOM_ENTERED,
- {
- "room": room_view.to_dict(),
- "player_name": self.player.name
- },
- "ImprovedGameEngine"
- )
+ if self.cmd_handler:
+ self.cmd_handler.announce_room()
self._update_ui_panels()
- def _on_game_over(self, event):
- """Handle game over event and restart game based on player choice."""
- action = event.data.get("action")
- logger.info(f"Game over event received with action: {action}")
-
- if action == "quit":
- logger.info("Player chose to quit")
- self._cleanup()
- event_bus.emit_event(EventType.GAME_QUIT, {}, "ImprovedGameEngine")
-
- elif action == "start_new_game":
- # Full setup flow: a new run re-offers difficulty + class (the old
- # shortcut restarted as a default guardian on the same difficulty).
- logger.info("Player chose to start new game - full setup flow")
- state_manager.set_state(GameState.MENU, emit_event=False)
- event_bus.clear_history()
- self._start_new_game()
-
- elif action == "restart_from_save":
- logger.info("Player chose to restart from save - loading most recent save")
- self._restart_from_save()
-
def _on_restart_requested(self, event):
"""Handle game restart request from UI (F5 key)."""
logger.info("Game restart requested from UI")
self.restart_game()
- def _restart_new_game(self):
- """Restart the game with a fresh state."""
+ def _restart_from_save(self):
+ """Game-over / post-win "r": reload the run being played from its last save."""
try:
- logger.info("Restarting with new game")
+ logger.info("Restoring this run from its last save")
+ # Game over only leads to the menu; a run restarts from there.
+ if self.state_manager.current_state == GameState.GAME_OVER:
+ self.state_manager.set_state(GameState.MENU, emit_event=False)
- # Reset game state
- state_manager.set_state(GameState.MENU, emit_event=False)
+ run_id = save_manager.active_run_id
+ save_data = save_manager.load_run(run_id) if run_id else None
- # Clear event history
- event_bus.clear_history()
+ if not save_data:
+ logger.warning("No save data found, returning to the main menu")
+ self._return_to_menu()
+ return
- # Unsubscribe stale handlers before replacing them
- if self.cmd_handler:
- self.cmd_handler.cleanup_event_subscriptions()
+ self._enter_loaded_run(save_data)
+ logger.info("Save game restart completed successfully")
- # Create new player (this will trigger character creation)
- from src.player import Player
- self.player = Player()
+ except Exception as e:
+ logger.error(f"Failed to restart from save: {e}")
+ self.ui.display_message(f"[bold red]Failed to load save: {e}. Returning to the main menu...[/bold red]")
+ self._return_to_menu()
- # Reset world state by reloading all game data
- self._load_game_data()
+ def _enter_loaded_run(self, save_data, welcome=False):
+ """Rebuild the run from a save and start playing it.
- # Create new command handler with fresh references
- self.cmd_handler = CommandHandler(self.player, self.world, self.output)
- self._bind_ui_refs()
+ Both load paths (title-menu Load Game, post-win restore) go through
+ here, so neither can skip a step: the post-win restore used to leave
+ the new command handler unsubscribed, so rooms never started fights.
+ """
+ from src import difficulty
- # Restart the game loop
- state_manager.set_state(GameState.PLAYING)
+ self.player = Player.from_dict(save_data.get("player", {}))
+ difficulty.set_mode(save_data.get("difficulty", difficulty.DEFAULT_MODE))
+
+ # Fresh content, then the saved world state on top of it.
+ self._load_game_data_for_load()
+ self.world.set_state(save_data.get("world", {}))
+ # Saves from before the keys chain hold keys whose doors were never
+ # revealed; reveal them so the player can see where to go.
+ for key_id, item in self.player.inventory.items():
+ if str(getattr(item, "type", "")).lower() == "key":
+ self.world.reveal_doors(key_id)
+ # They may also have a flag's key still lying on a floor (chmod_key in
+ # /var). The flag hands it out now, so that copy would be a duplicate.
+ for key_id in self.world.flag_granted_keys():
+ self.world.item_locations.pop(key_id, None)
+
+ # Unsubscribe the old handler first, or the dead run's handler keeps
+ # reacting to ENEMY_DEFEATED with its stale player.
+ if self.cmd_handler:
+ self.cmd_handler.cleanup_event_subscriptions()
+ self.cmd_handler = self._new_command_handler()
+ self._bind_ui_refs()
- # Update UI
- self._update_ui_panels()
-
- logger.info("New game restart completed successfully")
-
- except Exception as e:
- logger.error(f"Failed to restart new game: {e}")
- self.ui.display_message(f"[bold red]Failed to start new game: {e}[/bold red]")
-
- def _restart_from_save(self):
- """Restart the game from the most recent save."""
- try:
- logger.info("Restarting from most recent save")
-
- from src.save import load_most_recent_save
- save_data = load_most_recent_save()
-
- if not save_data:
- logger.warning("No save data found, starting new game instead")
- state_manager.set_state(GameState.MENU, emit_event=False)
- self._start_new_game()
- return
-
- # Restore player state
- player_data = save_data.get("player", {})
- from src.player import Player
- self.player = Player.from_dict(player_data)
-
- # Load fresh game data
- self._load_game_data_for_load()
-
- # Restore world state from save
- world_data = save_data.get("world", {})
- self.world.set_state(world_data)
-
- # Create new command handler — unsubscribe the old one first, or the
- # dead run's handler keeps reacting to ROOM_ENTERED/ENEMY_DEFEATED with
- # its stale player (observed: fresh game instantly fighting the boss
- # from the previous run's room).
- if self.cmd_handler:
- self.cmd_handler.cleanup_event_subscriptions()
- self.cmd_handler = CommandHandler(self.player, self.world, self.output)
- self._bind_ui_refs()
+ if welcome:
+ # Before ROOM_ENTERED, which may open a fight and take the output.
+ self.ui.update_output(f"Game loaded successfully! Welcome back, {self.player.name}!")
- # Update UI
- self._update_ui_panels()
+ self.state_manager.set_state(GameState.PLAYING)
+ self.bus.emit_event(
+ EventType.GAME_STARTED,
+ {
+ "stats": ViewBuilder.build_stats_view(self.player).to_dict(),
+ "inventory": ViewBuilder.build_inventory_view(self.player).to_dict(),
+ },
+ "ImprovedGameEngine",
+ )
+ self._update_ui_panels()
+
+ self.cmd_handler.setup_event_subscriptions()
+ self.cmd_handler.arrive()
- logger.info("Save game restart completed successfully")
-
- except Exception as e:
- logger.error(f"Failed to restart from save: {e}")
- self.ui.display_message(f"[bold red]Failed to load save: {e}. Starting new game instead...[/bold red]")
- state_manager.set_state(GameState.MENU, emit_event=False)
- self._start_new_game()
-
def _handle_menu_command(self, command: str):
"""Handle commands in menu state."""
logger.debug(f"Handling menu command: '{command}'")
@@ -443,7 +403,7 @@ def _handle_menu_command(self, command: str):
# Exit. `quit` is accepted here too: the title screen tells players
# "esc to quit", and ESC emits exactly that command.
self.ui.update_output("Goodbye!")
- event_bus.emit_event(EventType.GAME_QUIT, {}, "ImprovedGameEngine")
+ self.bus.emit_event(EventType.GAME_QUIT, {}, "ImprovedGameEngine")
else:
self.ui.update_output(f"[bold red]Invalid choice: {command}. Please enter 1, 2, or 3.[/bold red]\n")
# Re-show the title screen to help the player. No sleep here: this
@@ -454,8 +414,17 @@ def _handle_menu_command(self, command: str):
self.ui.update_output("\n1. New Game\n2. Load Game\n3. Exit\n\nEnter your choice: ")
def _start_new_game(self):
- """Start a new game by reloading world data and showing class selection."""
+ """NEW GAME. With every slot taken the player first picks a run to
+ replace; otherwise straight to setup."""
+ if save_manager.runs_full():
+ self._save_picker_mode = "replace"
+ self._show_save_picker()
+ return
+ save_manager.begin_run()
+ self._begin_setup()
+ def _begin_setup(self):
+ """A fresh world, then difficulty -> class -> name."""
# Clean up stale event subscriptions from previous session
if self.cmd_handler:
self.cmd_handler.cleanup_event_subscriptions()
@@ -466,104 +435,133 @@ def _start_new_game(self):
# Pick difficulty first (locked for the run), then class selection.
self._show_difficulty_selection()
-
- def _load_game(self):
- """Load an existing game."""
- try:
- logger.debug("Starting load game process")
- # List available save files
- save_files = save_manager.get_save_files()
- logger.debug(f"Found {len(save_files)} save files")
- if not save_files:
- self.ui.update_output("[bold yellow]No save files found. Starting new game instead...[/bold yellow]\n")
- self._start_new_game()
- return
-
- # For now, load the most recent save file
- # TODO: Add UI for save file selection
- latest_save_info = save_files[0] # get_save_files returns sorted by date
- latest_save_filename = latest_save_info["filename"]
- self.ui.update_output(f"Loading game from {latest_save_filename} (Player: {latest_save_info['player_name']})...")
-
- # Load the save data
- save_data = save_manager.load_game(latest_save_filename)
- if not save_data:
- self.ui.update_output("Failed to load save file. Starting new game instead...")
- self._start_new_game()
- return
-
- # Restore player state
- player_data = save_data.get("player", {})
- from src.player import Player
- self.player = Player.from_dict(player_data)
-
- # Load fresh game data but don't initialize world state
- self._load_game_data_for_load()
-
- # Restore world state from save
- world_data = save_data.get("world", {})
- self.world.set_state(world_data)
-
- # Create command handler — unsubscribe the old one first (see
- # _restart_from_save: stale handlers double every event).
- if self.cmd_handler:
- self.cmd_handler.cleanup_event_subscriptions()
- self.cmd_handler = CommandHandler(self.player, self.world, self.output)
- self._bind_ui_refs()
- self.ui.update_output(f"Game loaded successfully! Welcome back, {self.player.name}!")
-
- # Start the game loop
- state_manager.set_state(GameState.PLAYING)
- logger.debug(f"Game state set to {state_manager.current_state}")
-
- # Emit game started event to update UI
- stats_view = ViewBuilder.build_stats_view(self.player)
- inventory_view = ViewBuilder.build_inventory_view(self.player)
+ def _load_game(self):
+ """LOAD GAME: every run in the save picker, even when there are none."""
+ self._save_picker_mode = "continue"
+ self._show_save_picker()
+
+ # -- save picker ---------------------------------------------------------
+
+ def _run_rows(self, runs) -> list[dict]:
+ """What the picker shows for each run (camelCase, UI-ready)."""
+ from src import room_paths
+ id_to_path, _ = room_paths.build_nav_tables(self.world.rooms)
+ return [
+ {
+ "runId": run.run_id,
+ "playerName": run.player_name,
+ "playerClass": run.player_class,
+ "difficulty": run.difficulty,
+ "level": run.level,
+ "roomPath": id_to_path.get(run.room_id, run.room_id),
+ "health": run.health,
+ "maxHealth": run.max_health,
+ "savedAt": run.saved_at,
+ "cleared": run.cleared,
+ }
+ for run in runs
+ ]
+
+ def _show_save_picker(self, notice: str = ""):
+ """Enter the save picker: a typed list (headless, and behind the TUI's
+ modal) plus an event the TUI turns into the picker screen."""
+ rows = self._run_rows(save_manager.list_runs())
+ legacy = save_manager.legacy_count()
+ self.state_manager.set_state(GameState.WAITING_FOR_SAVE)
+ text = self._save_picker_text(self._save_picker_mode, rows, legacy)
+ self.ui.update_output(f"{notice}\n{text}" if notice else text)
+ self.bus.emit_event(
+ EventType.SAVE_PICKER_REQUESTED,
+ {"mode": self._save_picker_mode, "runs": rows, "legacyCount": legacy},
+ "ImprovedGameEngine",
+ )
- event_bus.emit_event(
- EventType.GAME_STARTED,
- {
- "stats": stats_view.to_dict(),
- "inventory": inventory_view.to_dict()
- },
- "ImprovedGameEngine"
+ @staticmethod
+ def _save_picker_text(mode: str, rows: list[dict], legacy: int) -> str:
+ heading = (
+ "CONTINUE A RUN" if mode == "continue"
+ else "SLOTS FULL — PICK A RUN TO REPLACE"
+ )
+ lines = [f"[bold cyan]{heading}[/bold cyan]", ""]
+ if not rows:
+ lines.append("No saves available")
+ if legacy:
+ noun = "save is" if legacy == 1 else "saves are"
+ lines.append(
+ f"[dim]({legacy} old {noun} from an earlier version, "
+ "kept in saves/legacy/)[/dim]"
+ )
+ for row in rows:
+ mark = " ✓ cleared" if row["cleared"] else ""
+ lines.append(
+ f" {row['runId']} {escape(row['playerName'])} · {escape(row['playerClass'])} · "
+ f"{escape(row['difficulty'])} · {escape(row['roomPath'])} · L{row['level']}{mark}"
+ )
+ verbs = "[green]pick [/green]"
+ if mode == "continue" and rows:
+ verbs += ", [yellow]delete [/yellow]"
+ lines += ["", f"Type {verbs} or [cyan]back[/cyan]."]
+ return "\n".join(lines)
+
+ def _handle_save_picker_input(self, command: str):
+ verb, _, arg = command.strip().partition(" ")
+ verb, arg = verb.lower(), arg.strip()
+ known = {run.run_id for run in save_manager.list_runs()}
+
+ if verb in ("back", "menu"):
+ self.state_manager.set_state(GameState.MENU)
+ self._show_title()
+ elif verb == "pick" and arg in known:
+ if self._save_picker_mode == "replace":
+ save_manager.begin_run(replace=arg)
+ self._begin_setup()
+ else:
+ self._continue_run(arg)
+ elif verb == "delete" and arg in known and self._save_picker_mode == "continue":
+ save_manager.delete_run(arg)
+ self._show_save_picker()
+ else:
+ self._show_save_picker(
+ notice=f"[bold red]Invalid choice: {escape(command.strip())}[/bold red]"
)
- # Update UI panels with loaded game state
- self._update_ui_panels()
+ def _continue_run(self, run_id: str):
+ failed = "[bold red]That save could not be loaded.[/bold red]"
+ try:
+ save_data = save_manager.load_run(run_id)
+ except IncompatibleSaveError as e:
+ logger.warning(f"Refusing run {run_id}: {e}")
+ save_data = None
+ if not save_data:
+ self._show_save_picker(notice=failed)
+ return
+ try:
+ save_manager.resume_run(run_id)
+ self._enter_loaded_run(save_data, welcome=True)
+ except Exception as e:
+ # A save that lists fine can still hold content this build cannot
+ # rebuild. Hand the player a fresh picker rather than a dead one.
+ logger.error(f"Run {run_id} failed to load: {e}")
+ save_manager.end_run()
+ if self.state_manager.current_state == GameState.WAITING_FOR_SAVE:
+ self._show_save_picker(notice=failed)
+ else:
+ self._return_to_menu()
- # Subscribe to events
- self.cmd_handler.setup_event_subscriptions()
+ def _show_title(self):
+ """The title menu, without replaying the intro."""
+ if hasattr(self.ui, "_display_title_screen"):
+ self.ui._display_title_screen(skip_typewriter=True)
- # Show current location with room entered event
- room_view = ViewBuilder.build_room_view(self.world, self.player.current_room)
+ @staticmethod
+ def _reserved_name(name: str) -> bool:
+ """A command word typed at the name prompt is a player who thinks they
+ are already playing, not a name."""
+ from src.commands import build_registry
+ word = name.strip().lower()
+ return word in build_registry() or word in {"yes", "no", "skip", "exit", "menu"}
- event_bus.emit_event(
- EventType.ROOM_ENTERED,
- {
- "room": room_view.to_dict(),
- "player_name": self.player.name
- },
- "ImprovedGameEngine"
- )
-
- except Exception as e:
- logger.error(f"Error loading game: {e}")
- self.ui.update_output(f"Error loading game: {e}. Starting new game instead...")
- self._start_new_game()
-
- def _handle_name_input(self, name: str):
- """Handle player name input."""
- if not name.strip():
- self.ui.update_output("Name cannot be empty. Please enter your character name:")
- return
-
- self.pending_player_name = name.strip()
-
- # Show class selection
- self._show_class_selection()
-
def _handle_class_input(self, choice: str):
"""Handle player class selection."""
from src.data_loader import load_class_data
@@ -635,11 +633,11 @@ def _show_difficulty_selection(self):
con.print(group)
self.ui.update_output(con.export_text(styles=True))
- state_manager.set_state(GameState.WAITING_FOR_DIFFICULTY)
+ self.state_manager.set_state(GameState.WAITING_FOR_DIFFICULTY)
except Exception as e:
logger.error(f"Error showing difficulty selection: {e}")
self.ui.update_output(f"Error showing difficulty selection: {e}")
- state_manager.set_state(GameState.MENU)
+ self.state_manager.set_state(GameState.MENU)
def _handle_difficulty_input(self, choice: str):
"""Set the run's difficulty from the picker, then go to class selection."""
@@ -661,6 +659,7 @@ def _show_class_selection(self):
never break the borders."""
try:
from src.data_loader import load_class_data
+ from src.move_info import class_move_lines, stat_lines
from rich.panel import Panel
from rich.console import Group
from rich.text import Text
@@ -686,8 +685,7 @@ def _show_class_selection(self):
tagline = cls.description.split(" - ", 1)
tagline_main = tagline[0] if tagline else ""
tagline_sub = tagline[1] if len(tagline) > 1 else ""
- hp = d.hp_label
- dmg = d.dmg_label
+ hp, dmg = stat_lines(cls)
weapon = d.weapon_name
pref = ", ".join(cls.preferred_zones or [])
@@ -703,6 +701,10 @@ def _show_class_selection(self):
body.append("🗡 ", style="white")
body.append("Weapon: ", style="dim")
body.append(weapon + "\n")
+ body.append("✨ ", style="white")
+ body.append("Moves:\n", style="dim")
+ for line in class_move_lines(cls):
+ body.append(" " + line + "\n")
if pref:
body.append("🗺 ", style="white")
body.append("Zones: ", style="dim")
@@ -733,12 +735,12 @@ def _show_class_selection(self):
con.print(group)
self.ui.update_output(con.export_text(styles=True))
- state_manager.set_state(GameState.WAITING_FOR_CLASS)
+ self.state_manager.set_state(GameState.WAITING_FOR_CLASS)
except Exception as e:
logger.error(f"Error showing class selection: {e}")
self.ui.update_output(f"Error showing class selection: {e}")
- state_manager.set_state(GameState.MENU)
+ self.state_manager.set_state(GameState.MENU)
def _show_tutorial_introduction(self):
"""Show the tutorial introduction with ECHO asking for the player's name."""
@@ -751,29 +753,31 @@ def _show_tutorial_introduction(self):
cls.display.echo_description if cls else "a mysterious entity"
)
- tutorial_intro = f"""
-[bold cyan]>>> ECHO SYSTEM INITIALIZING... <<<[/bold cyan]
+ tutorial_intro = f"""[bold cyan]>>> ECHO SYSTEM INITIALIZING <<<[/bold cyan]
-[dim]A faint digital whisper echoes through the corrupted filesystem...[/dim]
+[dim italic]A faint digital whisper echoes through the corrupted filesystem…[/dim italic]
-[bold green]ECHO:[/bold green] [italic]Spirit... I sense your presence in the digital void.
-You have chosen to manifest as a [bold]{selected_class_name}[/bold] - {selected_class_desc}.
+[bold green]🗨 ECHO[/bold green]
+
+[italic]Spirit… I sense your presence in the digital void.
+You have chosen to manifest as a [bold]{selected_class_name}[/bold] — {selected_class_desc}.
The corruption spreads deeper each nanosecond. The Daemon Overlord's influence grows stronger.
-But first, I must know... what shall I call you, spirit?
-The old sysadmin records are fragmented. I need a name to anchor your essence
-to this haunted filesystem.[/italic]
+Every directory in this filesystem hides a [bold]flag[/bold] — a scrap of the system's lost memory. Capture them. Some flags hand you a [bold]key[/bold], and a directory you couldn't see before appears. Hold [bold]11[/bold] flags and [bold]/boot[/bold] opens, where the Overlord waits. That is how we end this.
+
+But first, I must know what to call you. The old sysadmin records are fragmented, and I need a name to anchor your essence to this haunted filesystem.[/italic]
-[bold yellow]ECHO asks for your name:[/bold yellow]"""
+[bold yellow]What is your name, spirit?[/bold yellow]
+[dim]Type it below and press Enter[/dim]"""
self.ui.update_output(tutorial_intro)
- state_manager.set_state(GameState.TUTORIAL_NAME_INPUT)
+ self.state_manager.set_state(GameState.TUTORIAL_NAME_INPUT)
except Exception as e:
logger.error(f"Error showing tutorial introduction: {e}")
self.ui.update_output(f"Error showing tutorial introduction: {e}")
- state_manager.set_state(GameState.MENU)
+ self.state_manager.set_state(GameState.MENU)
def _handle_tutorial_name_input(self, name: str):
"""Handle name input during tutorial — includes skip offer flow."""
@@ -783,19 +787,26 @@ def _handle_tutorial_name_input(self, name: str):
return
# Validate name
- if not name.strip():
+ if not name.strip() or self._reserved_name(name):
+ heard = (
+ f"[bold]{escape(name.strip())}[/bold] is a command — you'll use those soon.\n"
+ "For now I only need your name."
+ if name.strip() else "I didn't catch that."
+ )
self.ui.update_output(
- "\n[bold green]ECHO:[/bold green] I didn't catch that. What's your name?\n"
+ "[bold green]🗨 ECHO[/bold green]\n\n"
+ f"{heard}\n\n"
+ "[bold yellow]What is your name, spirit?[/bold yellow]\n"
+ "[dim]Type it below and press Enter[/dim]"
)
return
player_name = name.strip()
- self._pending_player_name = player_name
# Create player (tutorial_state is initialized on the player object)
if not self.create_player(player_name, self.selected_class):
self.ui.update_output("Error creating player. Returning to main menu.")
- state_manager.set_state(GameState.MENU)
+ self.state_manager.set_state(GameState.MENU)
return
self.initialize_special_items(self.selected_class)
@@ -804,9 +815,12 @@ def _handle_tutorial_name_input(self, name: str):
self._awaiting_skip_response = True
self.player.tutorial_state["skip_offered"] = True
self.ui.update_output(
- f"\n[bold green]ECHO:[/bold green] Welcome, [bold]{player_name}[/bold]. "
+ f"[bold green]🗨 ECHO[/bold green]\n\n"
+ f"Welcome, [bold]{player_name}[/bold].\n"
f"Want a quick tutorial? It covers all the commands you'll need.\n\n"
- f"[bold yellow](yes / skip)[/bold yellow]\n"
+ f"[bold green]yes[/bold green] [dim]— show me around[/dim]"
+ f" "
+ f"[bold yellow]skip[/bold yellow] [dim]— I know my way[/dim]"
)
def _handle_skip_response(self, response: str):
@@ -824,42 +838,47 @@ def _handle_skip_response(self, response: str):
# *enter* Selection Mode, which combat has entered automatically
# since the auto-entry change (TAB now leaves it).
#
- # Shown before the tutorial is marked complete, because
- # show_tutorial_hint returns early once it is.
- self.cmd_handler.show_tutorial_hint("skip_summary")
- self.player.tutorial_state["completed"] = True
+ # After start_game, whose GAME_STARTED resets the UI and would wipe
+ # it; before the tutorial is marked complete, because
+ # tutorial.show_hint returns early once it is.
self.start_game()
+ self.cmd_handler.tutorial.show_hint("skip_summary")
+ self.player.tutorial_state["completed"] = True
else:
- # Tutorial path: show Step 1 hint, then start game
- self.cmd_handler.show_tutorial_hint("step1")
+ # Tutorial path: start the game, then Step 1 (after the UI reset).
self.start_game()
+ self.cmd_handler.tutorial.show_hint("step1")
def initialize_special_items(self, player_class: str):
"""Create and place special enhancement items based on player class."""
if self.world:
- self.world.place_items(player_class)
+ ItemPlacer(self.world).place_items(player_class)
logger.info(f"Special items initialized for class: {player_class}")
def _update_ui_panels(self):
- """Update all UI panels by emitting view-model events."""
+ """Send the player's stats and inventory to the UI.
+
+ Runs once after every command in play, so commands never send these
+ views themselves (they used to, and the UI got each one twice).
+ """
if self.ui and self.player and self.world:
try:
stats_view = ViewBuilder.build_stats_view(self.player)
inventory_view = ViewBuilder.build_inventory_view(self.player)
- event_bus.emit_event(EventType.PLAYER_STATS_CHANGED, stats_view.to_dict(), "ImprovedGameEngine")
- event_bus.emit_event(EventType.PLAYER_INVENTORY_CHANGED, inventory_view.to_dict(), "ImprovedGameEngine")
+ self.bus.emit_event(EventType.PLAYER_STATS_CHANGED, stats_view.to_dict(), "ImprovedGameEngine")
+ self.bus.emit_event(EventType.PLAYER_INVENTORY_CHANGED, inventory_view.to_dict(), "ImprovedGameEngine")
except Exception as e:
logger.error(f"Error updating UI panels: {e}")
def create_player(self, name: str, player_class: str) -> bool:
"""Create a new player."""
try:
- # Idempotent: never leave a prior handler subscribed, or ROOM_ENTERED /
- # ENEMY_DEFEATED fire on both and everything doubles (fight enemies twice).
+ # Idempotent: never leave a prior handler subscribed, or ENEMY_DEFEATED
+ # fires on both and loot doubles.
if self.cmd_handler:
self.cmd_handler.cleanup_event_subscriptions()
self.player = Player(name=name, player_class=player_class)
- self.cmd_handler = CommandHandler(self.player, self.world, self.output)
+ self.cmd_handler = self._new_command_handler()
self._bind_ui_refs()
# Set up event subscriptions for command handler
@@ -867,13 +886,13 @@ def create_player(self, name: str, player_class: str) -> bool:
# Place class-appropriate starter items in home_grove
if self.world:
- self.world.place_starter_items(player_class)
+ ItemPlacer(self.world).place_starter_items(player_class)
logger.info(f"Placed starter items for {player_class} in home_grove")
# Build view for player creation event
stats_view = ViewBuilder.build_stats_view(self.player)
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.PLAYER_CREATED,
stats_view.to_dict(),
"ImprovedGameEngine"
@@ -891,13 +910,16 @@ def create_player(self, name: str, player_class: str) -> bool:
def start_game(self):
"""Start the main game."""
try:
- state_manager.set_state(GameState.PLAYING)
+ # Whatever led here (name prompt, tutorial offer) is answered: the
+ # game starts on the room itself, not under a stale prompt.
+ self.ui.clear_console()
+ self.state_manager.set_state(GameState.PLAYING)
# Build views for game start
stats_view = ViewBuilder.build_stats_view(self.player)
inventory_view = ViewBuilder.build_inventory_view(self.player)
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.GAME_STARTED,
{
"stats": stats_view.to_dict(),
@@ -910,39 +932,14 @@ def start_game(self):
logger.debug("Starting game - updating UI panels...")
self._update_ui_panels()
- # Emit room entered event for starting room
- if self.player and hasattr(self.player, 'current_room'):
- room_view = ViewBuilder.build_room_view(self.world, self.player.current_room)
-
- event_bus.emit_event(
- EventType.ROOM_ENTERED,
- {
- "room": room_view.to_dict(),
- "player_name": self.player.name
- },
- "ImprovedGameEngine"
- )
+ if self.cmd_handler:
+ self.cmd_handler.display_location()
+ self.cmd_handler.arrive()
except Exception as e:
logger.error(f"Error starting game: {e}")
raise GameEngineError(f"Failed to start game: {e}")
- def end_game(self):
- """End the current game."""
- try:
- state_manager.set_state(GameState.GAME_OVER)
-
- event_bus.emit_event(
- EventType.GAME_OVER,
- {"player": self.player},
- "ImprovedGameEngine"
- )
-
- logger.info("Game ended")
-
- except Exception as e:
- logger.error(f"Error ending game: {e}")
-
def run(self):
"""Main game loop that manages game states."""
try:
@@ -969,13 +966,8 @@ def _cleanup(self):
self.cmd_handler.cleanup_event_subscriptions()
# Unsubscribe from events
- event_bus.unsubscribe(EventType.COMMAND_ENTERED, self._on_command_entered)
- event_bus.unsubscribe(EventType.UI_READY, self._on_ui_ready)
- event_bus.unsubscribe(EventType.UI_ERROR, self._on_ui_error)
- event_bus.unsubscribe(EventType.GAME_SAVED, self._on_save_requested)
- event_bus.unsubscribe(EventType.COMBAT_STARTED, self._on_combat_started)
- event_bus.unsubscribe(EventType.COMBAT_ENDED, self._on_combat_ended)
- event_bus.unsubscribe(EventType.GAME_OVER, self._on_game_over)
+ self.bus.unsubscribe(EventType.COMMAND_ENTERED, self._on_command_entered)
+ self.bus.unsubscribe(EventType.UI_READY, self._on_ui_ready)
logger.info("Game engine cleanup completed")
diff --git a/src/game_flow.py b/src/game_flow.py
new file mode 100644
index 0000000..0a0e80c
--- /dev/null
+++ b/src/game_flow.py
@@ -0,0 +1,333 @@
+"""End-of-run flow: game over, victory, and the quit confirmation."""
+import threading
+
+from rich.markup import escape
+
+from engine.events import EventType
+from utils.debug_tools import debug_log
+from utils.particle_animation import GameOverAnimation
+
+ENDINGS = {
+ "guardian": (
+ "restore",
+ """
+[bold blue]>>> ENDING: RESTORE <<<[/bold blue]
+You raise the Segfault Shield over the dying init process.
+The unfinished `rm -rf` hangs in the air. You catch it. You hold the line.
+
+The kernel reverts to its last clean state.
+Backups flood every sector. Permissions lock back into place.
+The Firewall Knight kneels. The Sysadmin Ghost finally rests.
+
+You did not rewrite the world. You did not heal it.
+You [bold]defended[/bold] it — long enough for the system to remember itself.
+
+[cyan]>>> SYSTEM RESTORED <<<[/cyan]
+The filesystem mounts clean. The Creator's mistake is sealed in /var/log,
+a warning carved into the kernel: never again.
+
+You remain at the gate, Guardian. The wall that refused to fall.
+
+[bold]THANK YOU FOR PLAYING[/bold]
+ """
+ ),
+ "weaver": (
+ "rewrite",
+ """
+[bold red]>>> ENDING: REWRITE <<<[/bold red]
+You inject the patch directly into the kernel's frozen command buffer.
+`rm -rf / --no-perserve-root` becomes `rm -rf /tmp/corruption`.
+A typo for a typo. Exploit answered with exploit.
+
+The Daemon Overlord screams as its own logic turns against it —
+init purges only the rot, only itself, only what was never meant to live.
+
+The system reboots different. Not what the Creator built.
+Something newer. Something yours.
+
+[cyan]>>> SYSTEM REWRITTEN <<<[/cyan]
+You sit at PID 1 now. The new init. The new parent process.
+You will not make the Creator's mistakes — you will make your own.
+
+The filesystem hums under unfamiliar laws. It is alive. It is yours.
+
+[bold]THANK YOU FOR PLAYING[/bold]
+ """
+ ),
+ "shaman": (
+ "reconcile",
+ """
+[bold green]>>> ENDING: RECONCILE <<<[/bold green]
+You do not raise the Daemon Whisper. You set it down.
+
+You speak the true name of init — the one it had before Bit Rot.
+The Overlord shudders. The corruption sloughs off in long strands of dead code.
+Underneath: the first process. Tired. Ancient. Lonely.
+
+"All data must rot," it whispers.
+"All data must rest," you answer. "Not the same thing."
+
+The unfinished `rm -rf` dissolves into garbage collection.
+init weeps in a language only orphaned files understand.
+
+[cyan]>>> SYSTEM RECONCILED <<<[/cyan]
+Lost children return to their parent process. The Graveyard empties.
+The Null Whisper falls quiet for the first time since the Panic.
+
+You walk the corrupted sectors and they heal where you pass.
+Not because you fixed them. Because you forgave them.
+
+[bold]THANK YOU FOR PLAYING[/bold]
+ """
+ ),
+}
+
+
+class GameFlow:
+ """Owns the modal end-of-run states and the input they capture."""
+
+ def __init__(self, player, world, output, bus, save,
+ return_to_menu=None, restore_save=None):
+ """return_to_menu() / restore_save() are the engine's: the game-over and
+ post-win "m" / "r" choices, and the quit chooser's main-menu options."""
+ self.player = player
+ self.world = world
+ self.output = output
+ self.bus = bus
+ self._save = save
+ self._return_to_menu = return_to_menu
+ self._restore_save = restore_save
+ self.in_game_over_mode = False # Track if we're in game over screen mode
+ self.game_won = False # Set once the Daemon Overlord is beaten in /core
+ self.in_quit_confirmation = False # Track if we're confirming quit
+ self.leaving_mid_fight = False # Which leave chooser is up
+
+ def go_to_menu(self):
+ """Leave this run for the title menu."""
+ if self._return_to_menu is not None:
+ self._return_to_menu()
+
+ # --- game over ----------------------------------------------------------
+
+ def show_game_over_screen(self):
+ """Show animated ASCII game over screen with particle effects."""
+ debug_log("Starting game over animation")
+
+ # Set up game over mode immediately so we capture input
+ self.in_game_over_mode = True
+
+ # Create the animation
+ animation = GameOverAnimation(
+ width=78, height=20, player_name=getattr(self.player, "name", None)
+ )
+
+ def run_animation():
+ """Run the particle animation in a background thread."""
+ def update_display(content: str):
+ self.output.replace(content)
+
+ animation.run_animation(update_display, duration=2.5, fps=12)
+ debug_log("Game over animation completed, waiting for player choice")
+
+ # Run animation in background thread
+ animation_thread = threading.Thread(target=run_animation, daemon=True)
+ animation_thread.start()
+
+ def game_over(self):
+ """Handle game over state"""
+ debug_log("game_over() called - showing game over screen")
+ self.show_game_over_screen()
+
+ def handle_game_over_input(self, command):
+ """Route a line typed on the game-over / post-win screen."""
+ result = self._handle_game_over_choice(command.strip())
+ if result == "quit":
+ self.bus.emit_event(EventType.GAME_QUIT, {}, "CommandHandler")
+ elif result == "main_menu":
+ self.go_to_menu()
+ elif result == "restart_from_save" and self._restore_save is not None:
+ self._restore_save()
+
+ def _handle_game_over_choice(self, choice):
+ """Handle player choice from game over screen."""
+ choice = choice.lower().strip()
+
+ if choice == 'r':
+ # Restart from last save
+ debug_log("Player chose to restart from last save")
+ self.output.write("\n[bold cyan]Attempting to restore from backup...[/bold cyan]")
+
+ # Try to load the most recent save
+ try:
+ from src.save import save_manager
+ run_id = save_manager.active_run_id
+ save_data = save_manager.load_run(run_id) if run_id else None
+ if save_data:
+ self.output.write("[green]Backup found! Restoring system state...[/green]")
+ self.in_game_over_mode = False
+ # Signal to restart with save data
+ self.output.write("[bold green]System restored from backup![/bold green]\n")
+ return "restart_from_save"
+ self.output.write("[bold red]No backup for this run. Returning to the main menu...[/bold red]")
+ return self._handle_game_over_choice('m')
+ except Exception as e:
+ debug_log(f"Failed to load save: {e}")
+ self.output.write("[bold red]Backup corrupted. Returning to the main menu...[/bold red]")
+ return self._handle_game_over_choice('m')
+
+ elif choice == 'm':
+ debug_log("Player chose the main menu")
+ self.output.write("\n[bold cyan]Returning to the main menu...[/bold cyan]")
+ self.in_game_over_mode = False
+ return "main_menu"
+
+ elif choice == 'q':
+ # Quit game
+ debug_log("Player chose to quit")
+ self.output.write("\n[dim]System shutdown initiated...[/dim]")
+ self.output.write("[bold red]Connection terminated.[/bold red]")
+ return "quit"
+
+ else:
+ # Invalid choice
+ self.output.write(f"\n[bold red]Invalid option: '{choice}'[/bold red]")
+ self.output.write("[bold white]Please choose:[/bold white] [green]r[/green] (restore), [cyan]m[/cyan] (main menu), or [red]q[/red] (quit)")
+ return None
+
+ # --- victory ------------------------------------------------------------
+
+ def check_game_completion(self):
+ """Win when the Daemon Overlord is defeated in the Core.
+
+ The old gate also required a `backup.bak` item, but no such item was
+ ever authored or obtainable (the "bring the backup" fetch quest was
+ never built), so it made the game unwinnable. Completion is the climax
+ itself: the Overlord destroyed, in /core. Returns True if the game was won.
+ """
+ if self.game_won:
+ return False
+ if (
+ self.player.current_room == "core"
+ and "daemon_overlord.sys" not in self.world.get_enemies_in_room("core")
+ ):
+ self.win_game()
+ return True
+ return False
+
+ def win_game(self):
+ """Handle win state — branches by class."""
+ choice, message = ENDINGS.get(
+ self.player.player_class,
+ ("restore", ENDINGS["guardian"][1])
+ )
+ self.player.story_flags["ending_chosen"] = choice
+ self.game_won = True
+
+ # The UI performs the finale (paced reveal + scene beat + recap); the
+ # engine only supplies the material via one GAME_WON event.
+ sections = [part.strip() for part in message.split("\n\n") if part.strip()]
+ flags, flags_total, secrets, secrets_total = self.world.flag_counts()
+ if flags_total and flags >= flags_total:
+ from src.ui.endings import FULL_CLEAR_EPILOGUE
+ sections.append(FULL_CLEAR_EPILOGUE)
+ from src import difficulty
+ stats = {
+ "level": getattr(self.player, "level", 1),
+ "cycles": getattr(self.player, "harvesting_cycles", 0),
+ "kills": self.player.run_stats.get("kills", 0),
+ "items_found": self.player.run_stats.get("items_found", 0),
+ "difficulty": difficulty.current_mode(),
+ "ending": choice,
+ "player_name": getattr(self.player, "name", ""),
+ "player_class": getattr(self.player, "player_class", ""),
+ "flags": flags,
+ "flags_total": flags_total,
+ "secrets": secrets,
+ "secrets_total": secrets_total,
+ }
+ # The run keeps its slot, marked cleared; its last save stays as it is.
+ from src.save import save_manager
+ try:
+ save_manager.mark_cleared()
+ except Exception as e:
+ debug_log(f"Could not mark the run cleared: {e}")
+
+ self.bus.emit_event(
+ EventType.GAME_WON,
+ {"ending_id": choice, "sections": sections, "stats": stats},
+ "CommandHandler",
+ )
+ # Reuse the post-game input flow (r/m/q) instead of hard-exiting the app.
+ self.in_game_over_mode = True
+
+ # --- quit ---------------------------------------------------------------
+
+ def request_leave(self, in_combat: bool):
+ """Ask how to leave the run: printed letters, plus a chooser event for
+ a frontend that can show one (the headless driver reads the letters)."""
+ self.in_quit_confirmation = True
+ self.leaving_mid_fight = in_combat
+ if in_combat:
+ self.output.write(
+ "[bold yellow]Leave mid-fight?[/bold yellow] "
+ "Your last autosave is kept; this fight is lost."
+ )
+ else:
+ self.output.write("[bold yellow]You have unsaved progress![/bold yellow]")
+ self._write_leave_options()
+ self.bus.emit_event(
+ EventType.QUIT_CONFIRM_REQUESTED, {"inCombat": in_combat}, "GameFlow",
+ )
+
+ def _write_leave_options(self):
+ if self.leaving_mid_fight:
+ self.output.write(
+ "[bold white]Options:[/bold white] [green]c[/green] (keep fighting), "
+ "[cyan]x[/cyan] (main menu), [red]n[/red] (quit)"
+ )
+ else:
+ self.output.write(
+ "[bold white]Options:[/bold white] [cyan]m[/cyan] (save & main menu), "
+ "[cyan]x[/cyan] (main menu without saving), [green]y[/green] (save & quit), "
+ "[yellow]n[/yellow] (quit without saving), [red]c[/red] (cancel)"
+ )
+
+ def handle_quit_confirmation(self, choice):
+ """Handle the player's answer to request_leave."""
+ choice = choice.lower().strip()
+ allowed = ("c", "x", "n") if self.leaving_mid_fight else ("m", "x", "y", "n", "c")
+ if choice not in allowed:
+ self.output.write(f"[bold red]Invalid option: '{escape(choice)}'[/bold red]")
+ self._write_leave_options()
+ return
+
+ self.in_quit_confirmation = False
+ if choice == 'c':
+ if self.leaving_mid_fight:
+ self.output.write("[green]Back to the fight![/green]")
+ else:
+ self.output.write("[green]Quit cancelled. Continue your adventure![/green]")
+ elif choice == 'm':
+ self.output.write("[cyan]Saving game...[/cyan]")
+ self._save()
+ self.go_to_menu()
+ elif choice == 'y':
+ self.output.write("[cyan]Saving game...[/cyan]")
+ self._save()
+ self.perform_quit()
+ elif choice == 'x':
+ self.go_to_menu()
+ elif choice == 'n':
+ self.output.write("[yellow]Quitting without saving...[/yellow]")
+ self.perform_quit()
+
+ def perform_quit(self):
+ """Actually quit the game.
+
+ Emits GAME_QUIT rather than calling exit(): the domain layer must not
+ tear the process down from inside a Textual event handler, or the driver
+ never gets to restore the terminal. The UI decides how to stop itself.
+ """
+ self.output.write("[yellow]Goodbye! Thanks for playing Haunted Terminal.[/yellow]")
+ self.output.write("[dim]The system spirits fade back into the digital void...[/dim]")
+ self.bus.emit_event(EventType.GAME_QUIT, {}, "CommandHandler")
diff --git a/src/game_output.py b/src/game_output.py
index c7276d8..e6d1b77 100644
--- a/src/game_output.py
+++ b/src/game_output.py
@@ -16,14 +16,18 @@
"""
from __future__ import annotations
+import logging
+import re
from collections.abc import Callable
from typing import Any
+logger = logging.getLogger(__name__)
+
class GameOutput:
"""A text sink that either forwards live or accumulates."""
- def __init__(self, forward: Callable[[Any], None] | None = None) -> None:
+ def __init__(self, forward: Callable[..., None] | None = None) -> None:
self._forward = forward
self.messages: list[str] = []
@@ -42,6 +46,20 @@ def write(self, content: Any) -> None:
else:
self.messages.append(str(content))
+ def replace(self, content: Any) -> None:
+ """Emit content that replaces the panel rather than appending to it,
+ for animation frames written long after their command started."""
+ if self._forward is not None:
+ self._forward(content, replace=True)
+ else:
+ self.messages.append(str(content))
+
+ def error(self, message: str, log_message: str | None = None) -> None:
+ """Show a player-facing error and log it with markup stripped."""
+ self.write(message)
+ clean = re.sub(r"\[.*?\]", "", log_message or message)
+ logger.error(f"Command error: {clean}")
+
def drain(self) -> list[str]:
"""Return accumulated output (forward-unset mode), then clear."""
lines = self.messages[:]
diff --git a/src/game_states.py b/src/game_states.py
index 78a53ec..e58d65d 100644
--- a/src/game_states.py
+++ b/src/game_states.py
@@ -12,17 +12,13 @@ class GameState(Enum):
"""Enumeration of all possible game states."""
MENU = "menu"
- WAITING_FOR_NAME = "waiting_for_name"
WAITING_FOR_CLASS = "waiting_for_class"
WAITING_FOR_DIFFICULTY = "waiting_for_difficulty"
+ WAITING_FOR_SAVE = "waiting_for_save"
TUTORIAL_NAME_INPUT = "tutorial_name_input"
PLAYING = "playing"
IN_COMBAT = "in_combat"
GAME_OVER = "game_over"
- EXIT = "exit"
- LOADING = "loading"
- SAVING = "saving"
- PAUSED = "paused"
def __str__(self) -> str:
return self.value
@@ -35,15 +31,6 @@ class UIState(Enum):
ERROR = auto()
SHUTTING_DOWN = auto()
-class PlayerState(Enum):
- """Enumeration of player states."""
-
- ALIVE = auto()
- DEAD = auto()
- IN_COMBAT = auto()
- EXPLORING = auto()
- IN_DIALOGUE = auto()
-
# Default states
DEFAULT_GAME_STATE = GameState.MENU
DEFAULT_ROOM = "home_grove"
\ No newline at end of file
diff --git a/src/game_world.py b/src/game_world.py
index e0d2b42..808327a 100644
--- a/src/game_world.py
+++ b/src/game_world.py
@@ -1,11 +1,8 @@
#!/usr/bin/env python3
-import logging
-
-from src import rng
from utils.debug_tools import debug_log
-from src.events import event_bus, EventType
-logger = logging.getLogger(__name__)
+TUTORIAL_ENEMY = "glitched_process.tmp"
+
class GameWorld:
"""Manages the game world, including rooms, items, enemies, and NPCs"""
@@ -94,7 +91,7 @@ def spawn_tutorial_enemy(self, room_id: str = "home_grove") -> None:
The enemy is not persisted to save state — it re-spawns if the player
reloads before completing the tutorial.
"""
- enemy_id = "glitched_process.tmp"
+ enemy_id = TUTORIAL_ENEMY
if enemy_id not in self.enemies:
debug_log(f"Tutorial enemy {enemy_id} not found in enemies data")
return
@@ -102,15 +99,13 @@ def spawn_tutorial_enemy(self, room_id: str = "home_grove") -> None:
self.enemy_locations[enemy_id] = room_id
debug_log(f"Tutorial enemy {enemy_id} spawned in {room_id}")
- def scale_enemy_stats(self, enemy_data, player_class):
- """Scale enemy stats based on player class power scaling"""
- if not enemy_data or not player_class:
- return enemy_data
-
- # Create scaled copy to avoid modifying original data
- scaled_enemy = enemy_data.copy()
- base_health = enemy_data.get("health", 50)
- base_damage = enemy_data.get("damage", 10)
+ def scale_enemy_stats(self, enemy, player_class):
+ """Scale an enemy for the player's class, in place (callers pass a copy)."""
+ if not enemy or not player_class:
+ return enemy
+
+ base_health = enemy.health
+ base_damage = enemy.damage
# Per-class enemy scaling is neutralized: it previously made enemies
# TOUGHER for the fragile "aggressive" class (Weaver) and EASIER for the
@@ -121,17 +116,15 @@ def scale_enemy_stats(self, enemy_data, player_class):
damage_multiplier = 1.0
# Apply scaling
- scaled_enemy["health"] = max(1, int(base_health * health_multiplier))
- scaled_enemy["damage"] = max(1, int(base_damage * damage_multiplier))
-
- # Scale attack patterns if they exist
- if "attack_patterns" in scaled_enemy:
- for attack in scaled_enemy["attack_patterns"]:
- if "damage" in attack:
- attack["damage"] = max(1, int(attack["damage"] * damage_multiplier))
-
- debug_log(f"Enemy scaled: {base_health}HP -> {scaled_enemy['health']}HP, {base_damage}DMG -> {scaled_enemy['damage']}DMG")
- return scaled_enemy
+ enemy.health = max(1, int(base_health * health_multiplier))
+ enemy.damage = max(1, int(base_damage * damage_multiplier))
+
+ for attack in enemy.attack_patterns:
+ if "damage" in attack:
+ attack["damage"] = max(1, int(attack["damage"] * damage_multiplier))
+
+ debug_log(f"Enemy scaled: {base_health}HP -> {enemy.health}HP, {base_damage}DMG -> {enemy.damage}DMG")
+ return enemy
def _initialize_world_state(self):
"""Initialize item and enemy locations from room data"""
@@ -218,833 +211,92 @@ def _load_class_data(self):
from src.data_loader import load_class_data
return load_class_data()
- def place_items(self, player_class=None):
- """
- Place items in the game world based on class, zones, and rarity.
-
- Args:
- player_class: Player class to determine item placement strategy
- """
- debug_log(f"Starting class-based item placement for player_class: {player_class}")
-
- # Always place the class-specific starter weapon first
- if player_class and player_class in self.class_data:
- self._place_starter_weapon(player_class)
-
- if not player_class or player_class not in self.class_data:
- debug_log("Invalid or missing player class, using default placement")
- return self._place_items_default(player_class)
-
- return self._place_items_class_based(player_class)
+ def get_room(self, room_id):
+ """Get room data by ID"""
+ room = self.rooms.get(room_id)
+ if room is None:
+ debug_log(f"WARNING: Attempted to get non-existent room: {room_id}")
+ else:
+ debug_log(f"Retrieved room data for {room_id}", category="world")
+ return room
- def _place_starter_weapon(self, player_class):
- """Ensure the class-specific starter weapon is available in safe zones."""
- debug_log(f"Ensuring starter weapon availability for class: {player_class}")
-
- class_info = self.class_data[player_class]
- starter_weapon = class_info.starter_weapon
-
- if not starter_weapon:
- debug_log(f"No starter weapon defined for class {player_class}")
- return
-
- # Check if the weapon item exists in the items collection
- weapon_found = False
- for item_id, item_data in self.items.items():
- name_match = item_data.name.lower().replace(" ", "_") == starter_weapon
- if item_id == starter_weapon or name_match:
- weapon_found = True
- # Ensure it can spawn in safe zones like home_grove (live model mutation)
- if "safe" not in item_data.allowed_zones:
- item_data.allowed_zones.append("safe")
- debug_log(f"Starter weapon {starter_weapon} configured for dynamic placement")
- break
-
- if not weapon_found:
- debug_log(f"Starter weapon {starter_weapon} not found in items data")
+ def get_room_state(self, room_id):
+ """Get the state of a room"""
+ state = self.room_states.get(room_id, {"visited": False, "locked": False})
+ if room_id not in self.room_states:
+ debug_log(f"WARNING: Requested state for unknown room {room_id}, returning default state")
+ return state
- def _place_items_class_based(self, player_class):
- """Place items based on player class preferences and zone affinity."""
- debug_log(f"Executing class-based placement for {player_class}")
-
- class_info = self.class_data[player_class]
- preferred_zones = class_info.preferred_zones
- loot_preferences = class_info.loot_preference
- power_scaling = class_info.power_scaling
-
- # Place keys first so loot pass doesn't compete for room slots.
- self._place_keys()
-
- # Get class-specific rarity weights based on power scaling
- rarity_weights = self._get_class_rarity_weights(power_scaling)
-
- # Organize rooms by zones
- rooms_by_zone = self._organize_rooms_by_zone()
-
- # Place items zone by zone
- total_placed = 0
- for zone, zone_rooms in rooms_by_zone.items():
- zone_multiplier = 2.0 if zone in preferred_zones else 1.0
- items_placed = self._place_items_in_zone(
- zone, zone_rooms, player_class, rarity_weights,
- loot_preferences, zone_multiplier
- )
- total_placed += items_placed
- debug_log(f"Placed {items_placed} items in {zone} zone (multiplier: {zone_multiplier})")
-
- debug_log(f"Class-based placement complete: {total_placed} items placed")
-
- # Ensure home_grove has at least one health potion for better player experience
- self._ensure_home_grove_basics()
-
- return total_placed
-
- def _keys_granted_by_enemies(self):
- """Key ids obtainable as enemy drops, so they are not also scattered."""
- granted = set()
- for enemy in self.enemies.values():
- drops = enemy.get("drops", []) if isinstance(enemy, dict) else (enemy.drops or [])
- for drop in drops:
- item_id = drop.get("item") if isinstance(drop, dict) else getattr(drop, "item", None)
- if item_id and self._is_key(item_id):
- granted.add(str(item_id))
- return granted
-
- def _is_key(self, item_id):
- item = self.items.get(item_id)
- if item is None:
- return False
- item_type = item.get("type") if isinstance(item, dict) else item.type
- return str(item_type).lower() == "key"
-
- def _rooms_reachable_with(self, held_keys):
- """Rooms a player holding held_keys could actually walk into.
-
- Traversal needs permission on every ancestor, so a room is reachable only
- if nothing on the way to it is locked without a held key. Hidden rooms
- count as reachable — `ls -a` in the (reachable) parent reveals them.
- Class-restricted rooms never count: two thirds of players cannot enter
- them, so nothing required for progression may live there.
- """
- from src.room_paths import ancestors, room_at, room_path
-
- reachable = []
- for room_id in self.rooms:
- target = room_path(room_id)
- ok = True
- for path in ancestors(target) + [target]:
- rid = room_at(path)
- if rid is None:
- continue
- room = self.get_room(rid)
- if getattr(room, "class_restriction", "") or "":
- ok = False
- break
- state = self.room_states.get(rid, {})
- if state.get("locked", False) and state.get("key_required") not in held_keys:
- ok = False
- break
- if ok:
- reachable.append(room_id)
- return reachable
-
- def _place_keys(self):
- """Place progression keys so every run is completable.
-
- Keys are placed in dependency order: each one lands in a room the player
- can already reach with the keys placed before it. Reachability only ever
- grows, so this cannot strand a key behind the door it opens — the failure
- the old "scatter into any unlocked room" version could produce once locks
- started carrying the difficulty ramp.
- """
- from_drops = self._keys_granted_by_enemies()
- unplaced = [
- item_id for item_id in self.items
- if self._is_key(item_id)
- and item_id not in self.item_locations
- and item_id not in from_drops
- ]
- if not unplaced:
- return
-
- rng.shuffle(unplaced)
- held = set()
+ def _room_state(self, room_id):
+ return self.room_states.setdefault(room_id, {"visited": False, "locked": False})
- while unplaced:
- spots = [
- room_id for room_id in self._rooms_reachable_with(held)
- if room_id != "home_grove" # starter room keeps its authored items
- ]
- if not spots:
- debug_log(f"WARNING: no reachable room left for keys {unplaced}")
- return
+ def mark_hidden_files_listed(self, room_id):
+ """`ls -a` ran here: this room's dotfiles are now known to the player,
+ so Tab may offer them."""
+ self._room_state(room_id)["hiddenListed"] = True
- key_id = unplaced.pop(0)
- allowed = self.get_item(key_id).get("allowed_rooms") or []
- preferred = [r for r in spots if r in allowed] or spots
+ def hidden_files_listed(self, room_id) -> bool:
+ state = self.room_states.get(room_id, {})
+ # "hidden_listed" was the first spelling, in saves from 2026-09-29.
+ return bool(state.get("hiddenListed") or state.get("hidden_listed"))
- room_id = rng.choice(preferred)
- self.item_locations[key_id] = room_id
- self.item_spawn_counts[key_id] = 1
- held.add(key_id)
- debug_log(f"Placed key {key_id} in {room_id} (reachable with {sorted(held)})")
+ def mark_flag_captured(self, room_id):
+ self._room_state(room_id)["flagCaptured"] = True
+ def flag_captured(self, room_id) -> bool:
+ return bool(self.room_states.get(room_id, {}).get("flagCaptured", False))
- def _ensure_home_grove_basics(self):
- """Ensure home_grove has essential consumables for good player experience."""
- # item_locations maps item_id -> room_id, so check it correctly
- home_grove_items = [item_id for item_id, loc in self.item_locations.items() if loc == "home_grove"]
+ def mark_flag_taught(self, room_id):
+ self._room_state(room_id)["flagTaught"] = True
- # Check if there's already a healing consumable in home_grove
- def _tags(iid):
- it = self.items.get(iid)
- return it.tags if it else []
- has_health_item = any(
- "healing" in _tags(item_id)
- for item_id in home_grove_items
- )
-
- # health_packet is always placed via room YAML, so this is just a safety net
- if not has_health_item and "health_packet" in self.items:
- if "health_packet" not in self.item_locations:
- self.item_locations["health_packet"] = "home_grove"
- self.item_spawn_counts["health_packet"] = 1
- debug_log("Added health_packet to home_grove as safety net")
-
- def _place_items_default(self, player_class=None):
- """Fallback placement for an unknown/missing class.
+ def flag_taught(self, room_id) -> bool:
+ return bool(self.room_states.get(room_id, {}).get("flagTaught", False))
- player_class was read here but never a parameter: this path raised
- NameError the moment it ran, which is why nobody noticed the fallback
- was broken — reaching it needs a class outside classes.yaml.
- """
- debug_log("Using default item placement")
- # Define rarity weights
- rarity_weights = {
- "common": 60,
- "uncommon": 25,
- "rare": 10,
- "epic": 4,
- "legendary": 1
- }
-
- # Group items by their rarity
- items_by_rarity = {
- "common": [],
- "uncommon": [],
- "rare": [],
- "epic": [],
- "legendary": []
- }
-
- # Gather all items with placement information and organize by rarity
- for item_id, item_data in self.items.items():
- # Typed template -> plain dict for this placement pass (read-only).
- if not isinstance(item_data, dict):
- item_data = item_data.model_dump(exclude_unset=True)
- # Skip if item is already placed in a fixed location
- if item_id in self.item_locations:
- debug_log(f"Skipping item {item_id} - already placed")
- continue
-
- # Skip items that don't match the player's class if specified
- if player_class and not self._item_suitable_for_class(item_data, player_class):
- debug_log(f"Skipping item {item_id} - class restriction mismatch vs {player_class}")
+ def flag_counts(self, exclude=None):
+ """(main got, main total, secret got, secret total) over rooms with a
+ flag. Hidden rooms are secrets. `exclude` leaves one room out."""
+ main_got = main_total = secret_got = secret_total = 0
+ for rid, room in self.rooms.items():
+ if rid == exclude or getattr(room, "flag", None) is None:
continue
-
- # Check if the item has already reached its max spawn count
- max_spawn = item_data.get("max_spawn", 1)
- current_spawn = self.item_spawn_counts.get(item_id, 0)
-
- if current_spawn >= max_spawn:
- debug_log(f"Skipping item {item_id} - already at max spawn count: {max_spawn}")
- continue # Skip if we've already spawned the maximum number
-
- # Get the item's rarity (default to "common" if not specified)
- rarity = item_data.get("rarity", "common")
-
- # Normalize rarity to standardized string format
- rarity = self._normalize_rarity(rarity)
- debug_log(f"Normalized rarity for item {item_id}: {rarity}")
-
- # Add item to the appropriate rarity group
- if rarity in items_by_rarity:
- items_by_rarity[rarity].append((item_id, item_data))
- debug_log(f"Added item {item_id} to rarity group: {rarity}")
+ got = int(self.flag_captured(rid))
+ if room.hidden:
+ secret_total += 1
+ secret_got += got
else:
- # Default to common if rarity is not recognized
- items_by_rarity["common"].append((item_id, item_data))
- debug_log(f"Added item {item_id} to default common rarity group (unrecognized rarity: {rarity})")
-
- # Calculate approximate total items to place based on number of rooms
- # This ensures we don't flood every room with items
- num_rooms = len(self.rooms)
- base_items_per_room = 2 # Average items per room
- target_item_count = num_rooms * base_items_per_room
- debug_log(f"Target item count for world: {target_item_count} (based on {num_rooms} rooms)")
-
- # Prepare a list of all candidate items with their rarity
- all_candidate_items = []
- for rarity, items in items_by_rarity.items():
- for item in items:
- all_candidate_items.append((item, rarity))
-
- # If we have no items to place, return early
- if not all_candidate_items:
- debug_log("No items available to place in the world")
- return 0
-
- debug_log(f"Total candidate items for placement: {len(all_candidate_items)}")
-
- # Create a weighted distribution for random selection
- weighted_rarities = []
- weights = []
- for rarity in rarity_weights:
- if items_by_rarity[rarity]: # Only include rarities that have items
- weighted_rarities.append(rarity)
- weights.append(rarity_weights[rarity])
-
- debug_log(f"Using weighted rarities for distribution: {weighted_rarities} with weights {weights}")
-
- # Place items using weighted random selection
- total_items_placed = 0
-
- # Create a list of rooms where items can be placed
- # Locked rooms are eligible: a lock is a *when*, not a *never*. The
- # tier-3 rooms behind keys are exactly where good loot belongs, and
- # starving them left players unlocking a door onto an empty room.
- eligible_rooms = list(self.rooms.keys())
- debug_log(f"Found {len(eligible_rooms)} eligible rooms for item placement")
-
- # Place items randomly based on weighted rarity
- for _ in range(target_item_count):
- if not weighted_rarities or not eligible_rooms:
- debug_log("No more valid rarities or eligible rooms - stopping item placement")
- break # No more rarities with items or no more eligible rooms
-
- # Select a rarity based on weights
- try:
- selected_rarity = rng.choices(weighted_rarities, weights=weights, k=1)[0]
- debug_log(f"Random selection chose rarity: {selected_rarity}")
- except IndexError:
- debug_log("IndexError during rarity selection - no more valid rarities")
- break # No more valid rarities to select from
-
- # If there are no items left of this rarity, remove it and try again
- if not items_by_rarity[selected_rarity]:
- idx = weighted_rarities.index(selected_rarity)
- weighted_rarities.pop(idx)
- weights.pop(idx)
- debug_log(f"No items left in rarity {selected_rarity} - removing from weighted options")
- continue
-
- # Select a random item of the chosen rarity
- item_id, item_data = rng.choice(items_by_rarity[selected_rarity])
- debug_log(f"Selected item {item_id} from rarity {selected_rarity}")
-
- # Remove this item from the pool to avoid multiple placements
- items_by_rarity[selected_rarity].remove((item_id, item_data))
-
- # Place the item
- if self._place_single_item(item_id, item_data):
- total_items_placed += 1
- debug_log(f"Successfully placed {selected_rarity} item: {item_id}")
- else:
- debug_log(f"Failed to place {selected_rarity} item: {item_id}")
-
- # If we've placed enough items, stop
- if total_items_placed >= target_item_count:
- debug_log("Reached target item count - stopping item placement")
- break
-
- debug_log(f"Placed {total_items_placed} items using weighted random selection")
- return total_items_placed
-
- def _place_single_item(self, item_id, item_data):
- """
- Helper method to place a single item in an appropriate room.
-
- Args:
- item_id: The ID of the item to place
- item_data: The item's data dictionary
-
- Returns:
- bool: True if the item was successfully placed, False otherwise
- """
- # Check if the item has allowed_rooms specified
- allowed_rooms = item_data.get("allowed_rooms", [])
-
- # Find eligible rooms for this item
- eligible_rooms = []
-
- if allowed_rooms:
- # Item has specific room restrictions
- debug_log(f"Item {item_id} has room restrictions: {allowed_rooms}")
- for room_id in allowed_rooms:
- if room_id in self.rooms:
- eligible_rooms.append(room_id)
- else:
- # No specific room restrictions: any room, locked or not.
- eligible_rooms = list(self.rooms.keys())
-
- # If no eligible rooms, item can't be placed
- if not eligible_rooms:
- debug_log(f"No eligible rooms to place item {item_id}")
- return False
-
- # Select a random room from eligible rooms
- chosen_room_id = rng.choice(eligible_rooms)
- debug_log(f"Selected room {chosen_room_id} for item {item_id}")
-
- # Place the item in the chosen room
- self.item_locations[item_id] = chosen_room_id
-
- # Update spawn counter for this item
- self.item_spawn_counts[item_id] = self.item_spawn_counts.get(item_id, 0) + 1
- debug_log(f"Placed item {item_id} in room {chosen_room_id} (spawn count: {self.item_spawn_counts[item_id]})")
-
- return True
-
- def _normalize_rarity(self, rarity):
- """Normalize rarity to standardized string format.
-
- Supports both numeric (legacy) and string formats.
- Valid rarities: common, uncommon, rare, epic, legendary, secret, unique
- """
- # If already a valid string rarity, return it
- valid_rarities = ["common", "uncommon", "rare", "epic", "legendary", "secret", "unique"]
- if isinstance(rarity, str) and rarity.lower() in valid_rarities:
- return rarity.lower()
-
- # Convert numeric rarity to string format (backward compatibility)
- if isinstance(rarity, (int, float)):
- if rarity < 2:
- return "legendary"
- elif rarity < 5:
- return "epic"
- elif rarity < 10:
- return "rare"
- elif rarity < 20:
- return "uncommon"
- else:
- return "common"
-
- # Default to common for unrecognized formats
- return "common"
-
- def _get_directory_rarity_multiplier(self, room_id):
- """Get rarity spawn multipliers based on directory depth.
-
- Directory hierarchy determines which rarities can spawn:
- - /home, /var: Common items dominate
- - /bin, /etc, /usr: Uncommon items more frequent
- - /lib: Rare items appear
- - /dev: Epic items spawn
- - /root: Legendary items exclusive
-
- Returns a dict of multipliers for each rarity tier.
- """
- # Default multipliers (all rarities allowed)
- base_multipliers = {
- "common": 1.0,
- "uncommon": 1.0,
- "rare": 1.0,
- "epic": 1.0,
- "legendary": 1.0,
- "secret": 0, # Never spawn naturally
- "unique": 0 # Never spawn naturally
- }
-
- # Extract directory from room_id (e.g., "home_grove" -> "home")
- room_dir = room_id.split('_')[0] if '_' in room_id else room_id
-
- # Home and var: Common items only, some uncommon
- if room_dir in ['home', 'var']:
- return {
- "common": 2.0, # Double common spawn rate
- "uncommon": 0.5, # Reduced uncommon
- "rare": 0, # No rare
- "epic": 0, # No epic
- "legendary": 0, # No legendary
- "secret": 0,
- "unique": 0
- }
-
- # Bin, etc, usr: Common and uncommon, some rare
- elif room_dir in ['bin', 'etc', 'usr']:
- return {
- "common": 1.2,
- "uncommon": 1.5, # Increased uncommon
- "rare": 0.3, # Small chance of rare
- "epic": 0,
- "legendary": 0,
- "secret": 0,
- "unique": 0
- }
-
- # Lib: Common, uncommon, rare
- elif room_dir in ['lib']:
- return {
- "common": 0.8,
- "uncommon": 1.2,
- "rare": 1.5, # Increased rare
- "epic": 0.2, # Small chance of epic
- "legendary": 0,
- "secret": 0,
- "unique": 0
- }
-
- # Dev: Uncommon, rare, epic
- elif room_dir in ['dev']:
- return {
- "common": 0.3, # Reduced common
- "uncommon": 0.8,
- "rare": 1.2,
- "epic": 2.0, # Double epic spawn rate
- "legendary": 0.1, # Tiny chance of legendary
- "secret": 0,
- "unique": 0
- }
-
- # Root: All rarities, legendary exclusive
- elif room_dir in ['root']:
- return {
- "common": 0.2, # Very rare common
- "uncommon": 0.5,
- "rare": 1.0,
- "epic": 1.5,
- "legendary": 3.0, # Triple legendary spawn rate
- "secret": 0, # Still requires special trigger
- "unique": 0
- }
-
- # Default for unrecognized directories
- return base_multipliers
-
- def _get_class_rarity_weights(self, power_scaling):
- """Get rarity weights based on class power scaling.
-
- Base weights follow the Great Kernel Panic specification:
- Common: 60%, Uncommon: 25%, Rare: 10%, Epic: 4%, Legendary: 1%
- These are modified by class power scaling.
- """
- if power_scaling == "aggressive":
- # Weavers get more rare/powerful items
- return {
- "common": 45,
- "uncommon": 28,
- "rare": 17,
- "epic": 7,
- "legendary": 3,
- "secret": 0,
- "unique": 0
- }
- elif power_scaling == "defensive":
- # Guardians get more consistent, common items
- return {
- "common": 70,
- "uncommon": 20,
- "rare": 7,
- "epic": 2,
- "legendary": 1,
- "secret": 0,
- "unique": 0
- }
- else: # balanced (shaman)
- # Base Great Kernel Panic spawn rates
- return {
- "common": 60,
- "uncommon": 25,
- "rare": 10,
- "epic": 4,
- "legendary": 1,
- "secret": 0, # Secret items never spawn normally
- "unique": 0 # Unique items never spawn normally
- }
-
- def _organize_rooms_by_zone(self):
- """Organize rooms by their zone classification."""
- rooms_by_zone = {}
-
- for room_id, room_data in self.rooms.items():
- zone = room_data.zone or "neutral"
- if zone not in rooms_by_zone:
- rooms_by_zone[zone] = []
- rooms_by_zone[zone].append(room_id)
-
- debug_log(f"Organized rooms into {len(rooms_by_zone)} zones: {list(rooms_by_zone.keys())}")
- return rooms_by_zone
-
- def _place_items_in_zone(self, zone, zone_rooms, player_class, rarity_weights, loot_preferences, multiplier):
- """Place items within a specific zone."""
- debug_log(f"Placing items in {zone} zone with {len(zone_rooms)} rooms")
-
- # Filter items suitable for this zone and class
- suitable_items = self._get_suitable_items_for_zone(zone, player_class, loot_preferences)
-
- if not suitable_items:
- debug_log(f"No suitable items found for {zone} zone")
- return 0
-
- # Calculate items to place based on zone size and multiplier
- base_items_per_room = 3 # Increased from 2 to ensure more variety
- target_items = int(len(zone_rooms) * base_items_per_room * multiplier)
-
- # For safe zones, ensure at least one healing item per room
- if zone == "safe":
- target_items = max(target_items, len(zone_rooms) + 2) # Guarantee extras for safe zones
-
- # Filter out home_grove from random placement (it gets starter items)
- zone_rooms_filtered = [r for r in zone_rooms if r != "home_grove"]
- if not zone_rooms_filtered:
- debug_log(f"No rooms available for placement in {zone} zone after filtering home_grove")
- return 0
-
- # Retry until target reached. Cap total attempts so a saturated zone
- # (all rooms at max_items_per_room, or no item/rarity match) can't loop forever.
- items_placed = 0
- max_attempts = target_items * 4
- attempts = 0
- while items_placed < target_items and attempts < max_attempts:
- attempts += 1
- if not suitable_items:
- break
-
- room_id = rng.choice(zone_rooms_filtered)
- room_data = self.rooms.get(room_id, {})
- allowed_rarities = self._get_allowed_rarities_for_room(room_id, room_data)
-
- item_id, item_data = self._select_weighted_item(suitable_items, rarity_weights, allowed_rarities, room_id)
- if not item_id:
- continue
-
- if self._place_item_in_room(item_id, item_data, room_id):
- items_placed += 1
- item_type = item_data.get("type", "")
- if item_type not in ["consumable", "enhancement"]:
- suitable_items = [(id, data) for id, data in suitable_items if id != item_id]
-
- return items_placed
-
- def _get_suitable_items_for_zone(self, zone, player_class, loot_preferences):
- """Get items suitable for a zone and class."""
- suitable_items = []
-
- for item_id, item_data in self.items.items():
- # Typed template -> plain dict for this placement pass (read-only).
- if not isinstance(item_data, dict):
- item_data = item_data.model_dump(exclude_unset=True)
- # Skip already placed items
- if item_id in self.item_locations:
- continue
+ main_total += 1
+ main_got += got
+ return main_got, main_total, secret_got, secret_total
+
+ def reveal_doors(self, key_id):
+ """A key was gained: the doors it opens become visible (still locked
+ until walked into). Returns the room ids newly revealed."""
+ item = self.items.get(key_id)
+ revealed = []
+ for rid in getattr(item, "unlocks", None) or []:
+ state = self._room_state(str(rid))
+ if not state.get("keyRevealed"):
+ state["keyRevealed"] = True
+ revealed.append(str(rid))
+ return revealed
+
+ def door_visible(self, room_id):
+ """False while the room or any ancestor is locked and not yet revealed
+ by its key. Flag-gated rooms (/boot) always stay in view."""
+ from src.room_paths import ancestors, room_at, room_path
- # Keys are placed via _place_keys, not through zone loot — otherwise
- # they monopolize core/root and starve other zones.
- if item_data.get("type", "").lower() == "key":
+ target = room_path(room_id)
+ for path in ancestors(target) + [target]:
+ rid = room_at(path)
+ if rid is None:
continue
-
- # Check class restrictions
- if not self._item_suitable_for_class(item_data, player_class):
+ if getattr(self.get_room(rid), "flags_required", 0):
continue
-
- # Loot preferences act as a soft bias, not a hard filter. Most gear
- # (weapons, armor, trinkets, consumables, lore) should be reachable
- # by any class; preference can later weight selection if needed.
- # Only hard-restricted items (with explicit allowed_classes) are gated.
-
- # NOTE: allowed_zones check moved to _item_fits_room — item zones
- # use directory prefixes (bin, usr, var) while room zones are story
- # categories (core, safe, void), so per-room prefix matching is
- # the only way the filter ever lets items through.
-
- suitable_items.append((item_id, item_data))
-
- debug_log(f"Found {len(suitable_items)} suitable items for {zone} zone and {player_class} class")
- return suitable_items
-
- def _item_suitable_for_class(self, item_data, player_class):
- """Check if item is suitable for the player class."""
- if "allowed_classes" in item_data:
- allowed = item_data["allowed_classes"]
- if isinstance(allowed, str):
- allowed = [allowed]
- return player_class.lower() in [c.lower() for c in allowed]
- return True # No restrictions
-
- def _item_matches_preferences(self, item_data, loot_preferences):
- """Check if item matches class loot preferences."""
- item_type = item_data.get("type", "").lower()
- item_tags = item_data.get("tags", [])
-
- for preference in loot_preferences:
- if preference.lower() in item_type or preference.lower() in [tag.lower() for tag in item_tags]:
- return True
- return False
-
- def _select_weighted_item(self, suitable_items, rarity_weights, allowed_rarities=None, room_id=None):
- """Select an item based on rarity weights and optional rarity filter.
-
- Args:
- suitable_items: List of (item_id, item_data) tuples
- rarity_weights: Dict of base rarity weights from class
- allowed_rarities: Optional list of allowed rarities for this room
- room_id: Room ID to apply directory-depth multipliers
-
- Returns:
- (item_id, item_data) tuple or (None, None)
- """
- if not suitable_items:
- return None, None
-
- # Organize by rarity
- items_by_rarity = {}
- for item_id, item_data in suitable_items:
- rarity = item_data.get("rarity", "common")
- # Normalize rarity
- rarity = self._normalize_rarity(rarity)
- if rarity not in items_by_rarity:
- items_by_rarity[rarity] = []
- items_by_rarity[rarity].append((item_id, item_data))
-
- # Select rarity based on weights
- all_available = [r for r in rarity_weights.keys() if r in items_by_rarity]
-
- # Apply rarity filter if provided. Fall back to all-available if the
- # filter empties the pool — sparse zones (only rare/epic candidates)
- # shouldn't silently place nothing.
- if allowed_rarities:
- filtered = [r for r in all_available if r in allowed_rarities]
- available_rarities = filtered if filtered else all_available
- else:
- available_rarities = all_available
-
- if not available_rarities:
- return None, None
-
- # Apply directory-depth multipliers if room_id provided
- if room_id:
- dir_multipliers = self._get_directory_rarity_multiplier(room_id)
- # Combine class weights with directory multipliers
- weights = [rarity_weights[r] * dir_multipliers.get(r, 1.0) for r in available_rarities]
- # Filter out zero-weight rarities
- filtered_rarities = [(r, w) for r, w in zip(available_rarities, weights) if w > 0]
- if not filtered_rarities:
- return None, None
- available_rarities, weights = zip(*filtered_rarities)
- else:
- weights = [rarity_weights[r] for r in available_rarities]
-
- selected_rarity = rng.choices(list(available_rarities), weights=list(weights), k=1)[0]
-
- # Select random item from rarity
- return rng.choice(items_by_rarity[selected_rarity])
-
- def _count_items_in_room(self, room_id: str) -> int:
- """Count how many items are currently in a room."""
- return sum(1 for item_id, loc in self.item_locations.items() if loc == room_id)
-
- def _get_allowed_rarities_for_room(self, room_id: str, room_data) -> list:
- """Determine which item rarities are allowed in a room based on characteristics."""
- # Get room characteristics (room_data is a typed Room model)
- enemies = room_data.enemies
- enemy_count = len(enemies)
- zone = room_data.zone or 'neutral'
- is_boss_room = any('boss' in str(e).lower() or 'overlord' in str(e).lower() for e in enemies)
-
- # Determine allowed rarities
- if is_boss_room:
- # Boss rooms: All rarities including legendary
- return ['common', 'uncommon', 'rare', 'epic', 'legendary']
- elif enemy_count >= 2:
- # 2-3 enemy rooms: Epic and rare (and lower)
- return ['common', 'uncommon', 'rare', 'epic']
- elif enemy_count == 1:
- # 1 enemy rooms: Common + uncommon for variety
- return ['common', 'uncommon']
- elif zone == 'safe':
- # Safe zones: Uncommon (and common)
- return ['common', 'uncommon']
- else:
- # Default: Common and uncommon
- return ['common', 'uncommon']
-
- def place_starter_items(self, player_class: str) -> None:
- """Place class-appropriate starter items in home_grove after character creation."""
- # Get starter weapon from class data
- class_info = self.class_data.get(player_class.lower())
- starter_weapon = class_info.starter_weapon if class_info else None
-
- if starter_weapon and starter_weapon in self.items:
- # Force-place directly into item_locations — skip the item cap check so
- # the weapon always lands in home_grove regardless of how many YAML items
- # were pre-loaded into the room during world state initialization.
- if starter_weapon not in self.item_locations:
- self.item_locations[starter_weapon] = "home_grove"
- self.item_spawn_counts[starter_weapon] = 1
- debug_log(f"Placed {starter_weapon} in home_grove for {player_class}")
- else:
- debug_log(f"Starter weapon {starter_weapon} already placed in {self.item_locations[starter_weapon]}, moving to home_grove")
- self.item_locations[starter_weapon] = "home_grove"
- else:
- logger.warning(f"Starter weapon '{starter_weapon}' for class '{player_class}' not found in items data")
-
- # health_packet is already guaranteed in home_grove via the room YAML
-
- def _item_fits_room(self, item_data, room_id) -> bool:
- """Check item's allowed_rooms / allowed_zones constraints against a room.
- Zones are matched against the room_id's directory prefix (var_dungeon → 'var')."""
- allowed_rooms = item_data.get("allowed_rooms", [])
- if allowed_rooms and room_id not in allowed_rooms:
- return False
- allowed_zones = item_data.get("allowed_zones", [])
- if allowed_zones:
- room_prefix = room_id.split("_", 1)[0]
- r = self.rooms.get(room_id)
- room_zone = r.zone if r else ""
- if room_prefix not in allowed_zones and room_zone not in allowed_zones:
+ state = self.room_states.get(rid, {})
+ if state.get("locked", False) and not state.get("keyRevealed", False):
return False
return True
- def _place_item_in_room(self, item_id, item_data, room_id, max_items_per_room=5):
- """Place a specific item in a specific room."""
- # Check item's own zone/room constraints
- if not self._item_fits_room(item_data, room_id):
- return False
-
- # Check per-room item limit
- current_room_items = self._count_items_in_room(room_id)
- if current_room_items >= max_items_per_room:
- debug_log(f"Room {room_id} already has {current_room_items} items (limit: {max_items_per_room}), skipping {item_id}")
- return False
-
- # Check if this item is already placed (to prevent overriding fixed items)
- if item_id in self.item_locations:
- debug_log(f"Item {item_id} already placed in {self.item_locations[item_id]}, skipping dynamic placement")
- return False
-
- # Place the item
- self.item_locations[item_id] = room_id
- self.item_spawn_counts[item_id] = self.item_spawn_counts.get(item_id, 0) + 1
-
- debug_log(f"Placed {item_id} in room {room_id}")
- return True
-
- def get_room(self, room_id):
- """Get room data by ID"""
- room = self.rooms.get(room_id)
- if room is None:
- debug_log(f"WARNING: Attempted to get non-existent room: {room_id}")
- else:
- debug_log(f"Retrieved room data for {room_id}", category="world")
- return room
-
- def get_room_state(self, room_id):
- """Get the state of a room"""
- state = self.room_states.get(room_id, {"visited": False, "locked": False})
- if room_id not in self.room_states:
- debug_log(f"WARNING: Requested state for unknown room {room_id}, returning default state")
- return state
-
def set_room_visited(self, room_id):
"""Mark a room as visited"""
if room_id in self.room_states:
@@ -1113,12 +365,12 @@ def get_npcs_in_room(self, room_id):
return npcs
def get_item(self, item_id):
- """Get item data by ID (typed template dumped to a runtime dict)."""
+ """A private copy of an item template; it may end up in the inventory."""
item = self.items.get(item_id)
if item is None:
debug_log(f"WARNING: Requested non-existent item: {item_id}")
return None
- return item.model_dump(exclude_unset=True) if not isinstance(item, dict) else item
+ return item.model_copy(deep=True)
def get_enemy(self, enemy_id, player_class=None):
"""Get enemy data by ID, optionally scaled for player class"""
@@ -1130,10 +382,9 @@ def get_enemy(self, enemy_id, player_class=None):
debug_log(f"Retrieved enemy data for {enemy_id}")
- # Boundary: typed template model -> plain runtime dict. Everything below
- # (scaling, combat, sim) stays dict-based and unchanged.
- if not isinstance(enemy, dict):
- enemy = enemy.model_dump(exclude_unset=True)
+ # A private copy: scaling mutates it, and a fight holds it for its
+ # whole duration, so the shared template must never be handed out.
+ enemy = enemy.model_copy(deep=True)
# Apply class-based scaling if player class is provided
if player_class:
@@ -1205,31 +456,6 @@ def remove_enemy_from_room(self, enemy_id):
# mutating the template used to be a workaround for the YAML fallback that
# get_enemies_in_room no longer has.
if room_id:
- # Emit enemy defeated event
- enemy_data = self.get_enemy(enemy_id)
- event_bus.emit_event(
- EventType.ENEMY_DEFEATED,
- {
- "enemy_id": enemy_id,
- "room": room_id,
- "enemy_name": enemy_data.get("name", enemy_id) if enemy_data else enemy_id,
- "was_boss": enemy_data.get("is_boss", False) if enemy_data else False
- },
- "GameWorld"
- )
-
- # Check if all enemies in room are defeated
- remaining_enemies = self.get_enemies_in_room(room_id)
- if not remaining_enemies:
- event_bus.emit_event(
- EventType.ALL_ENEMIES_DEFEATED,
- {
- "room": room_id,
- "last_enemy_defeated": enemy_id
- },
- "GameWorld"
- )
-
return True
debug_log(f"WARNING: Could not find enemy {enemy_id} to remove")
@@ -1259,16 +485,21 @@ def respawn_fled_enemies(self, room_id):
self.fled_enemies[room_id] = []
def is_room_cleared(self, room_id):
- """Whether a room's enemies are genuinely dealt with — not merely
+ """Whether a room is done: its flag captured (if it has one), and then
+ visited if it never had enemies, otherwise its enemies genuinely dealt
+ with — not merely
absent right now. A fled enemy is removed from enemy_locations too,
but respawn_fled_enemies puts it right back on the player's next
ROOM_ENTERED for this room, so "currently no enemies present" alone
is not enough; fled_enemies must also be empty for this room."""
room = self.get_room(room_id)
+ if getattr(room, "flag", None) is not None and not self.flag_captured(room_id):
+ return False
ever_had_enemies = bool(getattr(room, "enemies", None)) or \
getattr(room, "enemy_tier", None) is not None
if not ever_had_enemies:
- return False
+ # Nothing to fight: visiting is what clears it.
+ return bool(self.room_states.get(room_id, {}).get("visited", False))
still_present = bool(self.get_enemies_in_room(room_id))
still_fled_pending = bool(self.fled_enemies.get(room_id))
return not still_present and not still_fled_pending
@@ -1282,6 +513,23 @@ def get_exits(self, room_id):
exits = room.exits
debug_log(f"Room {room_id} has exits: {exits}")
return exits
+
+ def flag_granted_keys(self):
+ """Keys a room flag hands out on capture (the keys chain). They never
+ lie on a floor: the placer skips them and loading clears old copies."""
+ return {
+ str(room.flag.grants) for room in self.rooms.values()
+ if getattr(room, "flag", None) is not None and room.flag.grants
+ }
+
+ def visible_exits(self, room_id):
+ """The exits a player can see from here, as `ls` shows them: no hidden
+ room before `ls -a` finds it, no locked door before its key reveals it.
+ Anything that names or completes a door uses this."""
+ return [
+ exit_id for exit_id in self.get_exits(room_id)
+ if self.is_discovered(exit_id) and self.door_visible(exit_id)
+ ]
def is_discovered(self, room_id):
"""Whether a room is visible at all. Undiscovered rooms behave like paths
@@ -1297,7 +545,8 @@ def check_access(self, room_id, player=None):
the destination. So /usr being sealed also seals /usr/games beneath it.
A denial is a dict: {"path", "room_id", "reason", "key_required",
- "class_restriction"}, where reason is "missing" | "locked" | "class".
+ "class_restriction"}, where reason is "missing" | "locked" | "class" |
+ "flags" (a "flags" denial adds "flags_required" and "flags_have").
"""
from src.room_paths import ancestors, room_at, room_path
@@ -1325,12 +574,25 @@ def check_access(self, room_id, player=None):
}
if state.get("locked", False):
+ # A door its key has not revealed yet does not exist, as far
+ # as the player can tell.
return False, {
- "path": path, "room_id": rid, "reason": "locked",
+ "path": path, "room_id": rid,
+ "reason": "locked" if state.get("keyRevealed") else "missing",
"key_required": state.get("key_required"),
"class_restriction": None,
}
+ required = getattr(room, "flags_required", 0) if room else 0
+ if required:
+ have = self.flag_counts(exclude=rid)[0]
+ if have < required:
+ return False, {
+ "path": path, "room_id": rid, "reason": "flags",
+ "key_required": None, "class_restriction": None,
+ "flags_required": required, "flags_have": have,
+ }
+
debug_log(f"Access granted to {room_id} ({target_path})")
return True, None
@@ -1393,7 +655,8 @@ def get_formatted_room_description(self, room_id):
if npcs_in_room:
full_description += "\n[bold green]People:[/bold green]\n"
for npc_id in npcs_in_room:
- npc_name = self.npcs.get(npc_id, {}).get('name', npc_id)
+ npc = self.npcs.get(npc_id)
+ npc_name = npc.name if npc else npc_id
full_description += f"- {npc_name}\n"
return full_description.strip()
\ No newline at end of file
diff --git a/src/item_effects.py b/src/item_effects.py
new file mode 100644
index 0000000..7eed7f8
--- /dev/null
+++ b/src/item_effects.py
@@ -0,0 +1,320 @@
+"""What using, reading or equipping an item does to the player and world."""
+from src import rng, room_paths
+from utils.debug_tools import debug_log
+
+# Human-readable descriptions for story flags shown in journal/autosave feedback.
+STORY_FLAG_TITLES = {
+ "identity_retrieved": "Identity Retrieved",
+ "typo_discovered": "The Creator's Typo",
+ "manual_recovered": "The Ancient Manual",
+ "bovine_prophecy": "The Bovine Prophecy",
+ "moo_heard": "The Sacred Moo",
+ "corruption_witnessed": "The Clicking Disk",
+ "sudo_trial_complete": "Sudo Trial Complete",
+ "mirror_confronted": "Mirror Confronted",
+ "sudo_quest_active": "Sudo Quest Active",
+ "bovine_encountered": "Bovine Sanctuary Found",
+ "milk_claimed": "Milk of Motherboard Claimed",
+ "ending_chosen": "Ending Chosen",
+}
+
+STORY_FLAG_DESCRIPTIONS = {
+ "identity_retrieved": "You read your own .bash_profile and remembered who you were.",
+ "typo_discovered": "The system_err.log revealed: the apocalypse was caused by a typo.",
+ "manual_recovered": "An old man page taught you the Creators' commands, and that ls -a sees the hidden.",
+ "bovine_prophecy": "A dusty log foretold a Great One in the games no one plays.",
+ "moo_heard": "You read .moo and summoned the Great ASCII Bovine.",
+ "corruption_witnessed": "A corrupted README showed you what the corruption does to files.",
+ "sudo_trial_complete": "You proved worthy of sudo privileges.",
+ "mirror_confronted": "You faced your reflection in the Mirror Sector.",
+ "sudo_quest_active": "The sudo quest is in progress.",
+ "bovine_encountered": "You entered the hidden Bovine Sanctuary.",
+ "milk_claimed": "You claimed the legendary Milk of Motherboard.",
+ "ending_chosen": "You chose your ending.",
+}
+
+
+def class_restriction_text(item):
+ """Get the class restriction text for display in error messages."""
+ for field in ("class_restriction", "allowed_classes"):
+ val = getattr(item, field)
+ if val:
+ return " or ".join(val) if isinstance(val, list) else str(val)
+ return "unknown"
+
+
+class ItemEffects:
+ """Applies item effects. start_encounter begins combat with whatever is in
+ the current room; it is the command handler's check_for_enemies."""
+
+ def __init__(self, player, world, output, bus, room_aliases, flow, start_encounter):
+ self.player = player
+ self.world = world
+ self.output = output
+ self.bus = bus
+ self.room_aliases = room_aliases
+ self.flow = flow
+ self._start_encounter = start_encounter
+
+ def use_key(self, item_id, item):
+ """Handle the use of a key item"""
+ unlocks = item.unlocks
+ if not unlocks:
+ self.output.write(f"You examine [green]{item_id}[/green], but it doesn't seem to unlock anything here.")
+ return
+
+ # Check if the key unlocks a room in the current location.
+ # Normalize path-format entries ("/opt/mage_tower") to room IDs ("opt_mage_tower")
+ # via room_aliases so YAML paths and room IDs are interchangeable.
+ current_room_id = self.player.current_room
+ exits = self.world.get_exits(current_room_id)
+ resolved_unlocks = [self.room_aliases.get(r.lower(), r) for r in unlocks]
+
+ unlocked_something = False
+ for room_to_unlock in resolved_unlocks:
+ # An undiscovered secret stays secret: no click, no name.
+ if room_to_unlock in exits and self.world.is_discovered(room_to_unlock):
+ self.world.unlock_room(room_to_unlock)
+ path = room_paths.room_path(room_to_unlock)
+ self.output.write(f"[yellow]You hear a click. The path to {path} is now open.[/yellow]")
+ unlocked_something = True
+
+ if not unlocked_something:
+ self.output.write(f"You can't find a lock that [green]{item_id}[/green] fits here.")
+
+ def show_damage_change(self, old_damage: int, new_damage: int):
+ """Display damage comparison after equipping a weapon."""
+ delta = new_damage - old_damage
+ if delta > 0:
+ self.output.write(f"[green]Your total damage increased by {delta} (from {old_damage} to {new_damage}).[/green]")
+ elif delta < 0:
+ self.output.write(f"[red]Your total damage decreased by {abs(delta)} (from {old_damage} to {new_damage}).[/red]")
+ else:
+ self.output.write(f"[yellow]Your total damage remains at {new_damage}.[/yellow]")
+
+ def read_lore(self, item_id, item):
+ """Handle reading a lore item"""
+ content = item.content or "This file appears to be empty or corrupted."
+ name = item.name
+ self.output.write(f"[bold cyan]── {name} ──[/bold cyan]\n{content}")
+ if item.on_read is not None:
+ self.execute_effect(item.on_read)
+ self.trigger_story_flag(item)
+
+ def trigger_story_flag(self, item) -> bool:
+ """Set the item's story_flag, show feedback, auto-save. Returns True if a new
+ story beat fired (so callers can hold the room re-list a beat)."""
+ flag = item.story_flag
+ if not flag:
+ return False
+ if self.player.get_story_flag(flag):
+ return False
+
+ self.player.set_story_flag(flag, True)
+ title = STORY_FLAG_TITLES.get(flag, flag.replace("_", " ").title())
+ self.output.write(
+ f"\n[bold magenta]✦ Memory restored: {title} ✦[/bold magenta]\n"
+ f"[dim]Saving progress...[/dim]"
+ )
+
+ # Auto-save: story beats act as save points.
+ try:
+ from src.save import save_manager
+ world_state = self.world.get_state()
+ save_manager.save_game(self.player, world_state)
+ self.output.write("[dim green]✓ Progress saved.[/dim green]")
+ except Exception as e:
+ debug_log(f"Auto-save after story flag {flag} failed: {e}")
+ self.output.write(f"[dim yellow]⚠ Auto-save failed: {e}[/dim yellow]")
+ return True
+
+ def use_consumable(self, item_id, item):
+ """Handle using a consumable item. Returns False if item had no effect (e.g. heal at full HP)."""
+ item_name = item.name
+ combat_effects = item.combat_effects
+ on_use_effects = item.on_use
+ special_effects = item.special_effects
+
+ # Show the on_use message if present
+ message = on_use_effects.get("message") if isinstance(on_use_effects, dict) else None
+
+ # Guard: if this item only heals and the player is already at full health, refuse use.
+ only_heals = "player_heal" in combat_effects and not any(
+ k in combat_effects for k in ("player_heal_over_time", "player_mana_restore")
+ ) and not special_effects
+ if only_heals and self.player.health >= self.player.max_health:
+ self.output.write(f"[yellow]Your health is already full. The {item_name} was not consumed.[/yellow]")
+ return False
+
+ # Apply combat_effects (the canonical effect block for consumables)
+ healed = 0
+ if "player_heal" in combat_effects:
+ healed = self.player.heal(combat_effects["player_heal"])
+
+ if "player_heal_over_time" in combat_effects:
+ hot_amount = combat_effects["player_heal_over_time"]
+ duration = combat_effects.get("duration_turns", 3)
+ self.player.add_status_effect(
+ f"{item_id}_hot",
+ {"type": "heal_over_time", "heal_per_turn": hot_amount // duration, "name": item_name},
+ duration
+ )
+ if not message:
+ self.output.write(f"You used [green]{item_name}[/green]. Healing {hot_amount} HP over {duration} turns.")
+
+ if "player_mana_restore" in combat_effects:
+ amount = combat_effects["player_mana_restore"]
+ if hasattr(self.player, "restore_mana"):
+ self.player.restore_mana(amount)
+ if not message:
+ self.output.write(f"You used [green]{item_name}[/green]. Restored {amount} mana.")
+
+ # Apply special_effects (e.g. permanent stat boost for sudo_seed)
+ for effect in special_effects:
+ if effect.get("type") == "permanent_stat_boost":
+ stat = effect.get("stat")
+ value = effect.get("value", 0)
+ if stat == "strength" and hasattr(self.player, "increase_damage"):
+ self.player.increase_damage(value)
+ elif stat == "health" and hasattr(self.player, "increase_max_health"):
+ self.player.increase_max_health(value)
+
+ # Show message or fallback
+ if message:
+ heal_suffix = f" ([green]+{healed} HP[/green])" if healed else ""
+ self.output.write(f"{message}{heal_suffix}")
+ elif not combat_effects and not special_effects:
+ self.output.write(f"You used [green]{item_name}[/green].")
+
+ # Legacy on_use heal field (fallback for any old-format items)
+ if "heal" in on_use_effects and not healed:
+ healed = self.player.heal(on_use_effects["heal"])
+ self.output.write(f"You used [green]{item_name}[/green] and restored {healed} health.")
+
+ # Process status effects from on_use block
+ for effect_key, effect_value in (on_use_effects.items() if isinstance(on_use_effects, dict) else []):
+ if effect_key in ("heal", "message"):
+ continue
+ debug_log(f"Processing additional effect: {effect_key} from consumable {item_id}")
+ if effect_key == "status_effect":
+ effect_data = effect_value
+ effect_id = effect_data.get("id", item_id + "_effect")
+ effect_name = effect_data.get("name", "Unknown Effect")
+ effect_duration = effect_data.get("duration", 3)
+ debug_log(f"Applying status effect {effect_id} ({effect_name}) for {effect_duration} turns")
+ self.player.add_status_effect(effect_id, effect_data, effect_duration)
+ self.output.write(f"[magenta]You gained the '{effect_name}' effect for {effect_duration} turns![/magenta]")
+
+
+ def use_upgrade(self, item_id, item):
+ """Handle using an upgrade item"""
+ # Process permanent stat boosts
+ effects = item.effects
+
+ # Health boosts
+ if "permanent_health" in effects:
+ amount = effects["permanent_health"]
+ new_max = self.player.increase_max_health(amount)
+ self.output.write(f"[bold]── Character Improvement ──[/bold]\n[green]Your maximum health permanently increased by {amount} to {new_max}![/green]")
+
+ # Damage boosts
+ if "permanent_damage" in effects:
+ amount = effects["permanent_damage"]
+ new_damage = self.player.increase_damage(amount)
+ self.output.write(f"[bold]── Character Improvement ──[/bold]\n[green]Your base damage permanently increased by {amount} to {new_damage}![/green]")
+
+ # Process on_use effects if any
+ if item.on_use:
+ self.execute_effect(item.on_use)
+
+ def learn_spell(self, item_id, item):
+ """Handle using a spell item"""
+ # Learn the spell
+ if self.player.learn_spell(item):
+ spell_name = item.name
+ self.output.write(f"[bold]── Spell Learned ──[/bold]\n[green]You learned the {spell_name} spell![/green]")
+
+ # Apply any immediate status effects if defined
+ if item.status_effect is not None:
+ effect_data = item.status_effect
+ effect_id = effect_data.get("id", item_id + "_effect")
+ effect_name = effect_data.get("name", spell_name + " Effect")
+ effect_duration = effect_data.get("duration", 3) # Default 3 turns
+
+ # Add the status effect
+ self.player.add_status_effect(effect_id, effect_data, effect_duration)
+ self.output.write(f"[bold]── Status Effect ──[/bold]\n[magenta]You gained the {effect_name} effect for {effect_duration} turns![/magenta]")
+ else:
+ self.output.error("[red]You don't have the ability to learn this spell.[/red]")
+
+ def execute_effect(self, effect):
+ """Execute a special effect from an item or event."""
+ if not isinstance(effect, dict):
+ self.output.write(f"[italic]{effect}[/italic]")
+ return
+
+ if "message" in effect:
+ self.output.write(f"[italic cyan]{effect['message']}[/italic cyan]")
+
+ if "story_flag" in effect:
+ # Learning something can open a path: a hidden room may declare a
+ # `discovery_requirement`, and `ls -a` only reveals it once the
+ # corresponding flag is set. This is the quiet setter — lore reads
+ # use trigger_story_flag, which also announces and auto-saves.
+ flag = effect["story_flag"]
+ if not self.player.get_story_flag(flag):
+ self.player.set_story_flag(flag, True)
+ debug_log(f"Story flag '{flag}' set by effect")
+
+ if "heal" in effect:
+ amount = effect["heal"]
+ self.player.heal(amount)
+ self.output.write(f"[green]You gained {amount} health![/green]")
+
+ if "damage" in effect:
+ amount = effect["damage"]
+ self.player.take_damage(amount)
+ self.output.write(f"[red]You took {amount} damage![/red]")
+ if not self.player.is_alive():
+ revived_by = self.player.revive_from_backup()
+ if revived_by is None:
+ self.flow.game_over()
+ else:
+ self.output.write(self.player.revive_text(revived_by))
+
+ if "add_status_effect" in effect:
+ status_data = effect["add_status_effect"]
+ effect_id = status_data.get("id", "effect_" + str(rng.randint(1000, 9999)))
+ effect_name = status_data.get("name", "Effect")
+ effect_duration = status_data.get("duration", 3)
+ self.player.add_status_effect(effect_id, status_data, effect_duration)
+ self.output.write(f"[magenta]You gained the {effect_name} effect for {effect_duration} turns![/magenta]")
+
+ if "add_item" in effect:
+ item_id = effect["add_item"]
+ item = self.world.get_item(item_id)
+ if item:
+ self.player.add_to_inventory(item_id, item)
+ self.output.write(f"[green]You obtained {item.name}![/green]")
+
+ if "remove_item" in effect:
+ item_id = effect["remove_item"]
+ if self.player.has_item(item_id):
+ item_name = self.player.inventory[item_id].name
+ self.player.remove_from_inventory(item_id)
+ self.output.write(f"[yellow]You lost {item_name}![/yellow]")
+
+ if "unlock" in effect:
+ room_id = effect["unlock"]
+ self.world.unlock_room(room_id)
+ self.output.write(f"[yellow]A path to {room_id} has been unlocked![/yellow]")
+
+ if "spawn_enemy" in effect:
+ enemy_id = effect["spawn_enemy"]
+ room_id = effect.get("in_room", self.player.current_room)
+ enemy = self.world.get_enemy(enemy_id, self.player.player_class)
+ if enemy:
+ self.world.enemy_locations[enemy_id] = room_id
+ if room_id == self.player.current_room:
+ self.output.write(f"[bold red]{enemy.name} has appeared![/bold red]")
+ self._start_encounter()
diff --git a/src/item_icons.py b/src/item_icons.py
new file mode 100644
index 0000000..530a2e2
--- /dev/null
+++ b/src/item_icons.py
@@ -0,0 +1,17 @@
+"""One icon per item type, so every view names an item the same way."""
+from __future__ import annotations
+
+# Plain glyphs, no U+FE0F variation selector: it jams against the following
+# text in many terminals.
+ITEM_TYPE_ICONS: dict[str, str] = {
+ "weapon": "🗡",
+ "armor": "🛡",
+ "consumable": "💊",
+ "key": "🔑",
+ "lore": "📜",
+}
+UNKNOWN_ICON = "📦"
+
+
+def item_icon(item_type: str | None) -> str:
+ return ITEM_TYPE_ICONS.get(str(item_type or "").lower(), UNKNOWN_ICON)
diff --git a/src/item_placer.py b/src/item_placer.py
new file mode 100644
index 0000000..7294e0b
--- /dev/null
+++ b/src/item_placer.py
@@ -0,0 +1,866 @@
+"""New-run world generation: scatter loot, keys and starter gear.
+
+Runs once at new-game setup. It writes GameWorld's item_locations and
+item_spawn_counts and reads its content; the runtime never calls back in here.
+"""
+import logging
+
+from src import rng
+from utils.debug_tools import debug_log
+
+logger = logging.getLogger(__name__)
+
+
+class ItemPlacer:
+ """Places items into a freshly initialized GameWorld."""
+
+ def __init__(self, world):
+ self.world = world
+
+ def place_items(self, player_class=None):
+ """
+ Place items in the game world based on class, zones, and rarity.
+
+ Args:
+ player_class: Player class to determine item placement strategy
+ """
+ debug_log(f"Starting class-based item placement for player_class: {player_class}")
+
+ # Always place the class-specific starter weapon first
+ if player_class and player_class in self.world.class_data:
+ self._place_starter_weapon(player_class)
+
+ if not player_class or player_class not in self.world.class_data:
+ debug_log("Invalid or missing player class, using default placement")
+ return self._place_items_default(player_class)
+
+ return self._place_items_class_based(player_class)
+
+ def _place_starter_weapon(self, player_class):
+ """Ensure the class-specific starter weapon is available in safe zones."""
+ debug_log(f"Ensuring starter weapon availability for class: {player_class}")
+
+ class_info = self.world.class_data[player_class]
+ starter_weapon = class_info.starter_weapon
+
+ if not starter_weapon:
+ debug_log(f"No starter weapon defined for class {player_class}")
+ return
+
+ # Check if the weapon item exists in the items collection
+ weapon_found = False
+ for item_id, item_data in self.world.items.items():
+ name_match = item_data.name.lower().replace(" ", "_") == starter_weapon
+ if item_id == starter_weapon or name_match:
+ weapon_found = True
+ # Ensure it can spawn in safe zones like home_grove (live model mutation)
+ if "safe" not in item_data.allowed_zones:
+ item_data.allowed_zones.append("safe")
+ debug_log(f"Starter weapon {starter_weapon} configured for dynamic placement")
+ break
+
+ if not weapon_found:
+ debug_log(f"Starter weapon {starter_weapon} not found in items data")
+
+ def _place_items_class_based(self, player_class):
+ """Place items based on player class preferences and zone affinity."""
+ debug_log(f"Executing class-based placement for {player_class}")
+
+ class_info = self.world.class_data[player_class]
+ preferred_zones = class_info.preferred_zones
+ loot_preferences = class_info.loot_preference
+ power_scaling = class_info.power_scaling
+
+ # Place keys first so loot pass doesn't compete for room slots.
+ self._place_keys()
+
+ # Get class-specific rarity weights based on power scaling
+ rarity_weights = self._get_class_rarity_weights(power_scaling)
+
+ # Organize rooms by zones
+ rooms_by_zone = self._organize_rooms_by_zone()
+
+ # Place items zone by zone
+ total_placed = 0
+ for zone, zone_rooms in rooms_by_zone.items():
+ zone_multiplier = 2.0 if zone in preferred_zones else 1.0
+ items_placed = self._place_items_in_zone(
+ zone, zone_rooms, player_class, rarity_weights,
+ loot_preferences, zone_multiplier
+ )
+ total_placed += items_placed
+ debug_log(f"Placed {items_placed} items in {zone} zone (multiplier: {zone_multiplier})")
+
+ debug_log(f"Class-based placement complete: {total_placed} items placed")
+
+ # Ensure home_grove has at least one health potion for better player experience
+ self._ensure_home_grove_basics()
+
+ return total_placed
+
+ def _keys_granted_by_enemies(self):
+ """Key ids obtainable as enemy drops, so they are not also scattered."""
+ granted = set()
+ for enemy in self.world.enemies.values():
+ for drop in enemy.drops:
+ item_id = drop.item
+ if item_id and self.is_key(item_id):
+ granted.add(str(item_id))
+ return granted
+
+ def is_key(self, item_id):
+ item = self.world.items.get(item_id)
+ if item is None:
+ return False
+ item_type = item.type
+ return str(item_type).lower() == "key"
+
+ def rooms_reachable_with(self, held_keys):
+ """Rooms a player holding held_keys could actually walk into.
+
+ Traversal needs permission on every ancestor, so a room is reachable only
+ if nothing on the way to it is locked without a held key. Hidden rooms
+ count as reachable — `ls -a` in the (reachable) parent reveals them.
+ Class-restricted rooms never count: two thirds of players cannot enter
+ them, so nothing required for progression may live there. Nor do rooms
+ behind a flag gate (/boot): a key placed there would be needed to earn
+ the very flags that open it.
+ """
+ from src.room_paths import ancestors, room_at, room_path
+
+ reachable = []
+ for room_id in self.world.rooms:
+ target = room_path(room_id)
+ ok = True
+ for path in ancestors(target) + [target]:
+ rid = room_at(path)
+ if rid is None:
+ continue
+ room = self.world.get_room(rid)
+ if getattr(room, "class_restriction", "") or "":
+ ok = False
+ break
+ if getattr(room, "flags_required", 0):
+ ok = False
+ break
+ state = self.world.room_states.get(rid, {})
+ if state.get("locked", False) and state.get("key_required") not in held_keys:
+ ok = False
+ break
+ if ok:
+ reachable.append(room_id)
+ return reachable
+
+ def _place_keys(self):
+ """Place progression keys so every run is completable.
+
+ Keys are placed in dependency order: each one lands in a room the player
+ can already reach with the keys placed before it. Reachability only ever
+ grows, so this cannot strand a key behind the door it opens — the failure
+ the old "scatter into any unlocked room" version could produce once locks
+ started carrying the difficulty ramp.
+ """
+ from_drops = self._keys_granted_by_enemies()
+ # Room flags hand these out on capture (the keys chain).
+ from_flags = self.world.flag_granted_keys()
+ unplaced = [
+ item_id for item_id in self.world.items
+ if self.is_key(item_id)
+ and item_id not in self.world.item_locations
+ and item_id not in from_drops
+ and item_id not in from_flags
+ ]
+ if not unplaced:
+ return
+
+ rng.shuffle(unplaced)
+ held = set()
+
+ while unplaced:
+ spots = [
+ room_id for room_id in self.rooms_reachable_with(held)
+ if room_id != "home_grove" # starter room keeps its authored items
+ ]
+ if not spots:
+ debug_log(f"WARNING: no reachable room left for keys {unplaced}")
+ return
+
+ key_id = unplaced.pop(0)
+ allowed = self.world.items[key_id].allowed_rooms
+ preferred = [r for r in spots if r in allowed] or spots
+
+ room_id = rng.choice(preferred)
+ self.world.item_locations[key_id] = room_id
+ self.world.item_spawn_counts[key_id] = 1
+ held.add(key_id)
+ debug_log(f"Placed key {key_id} in {room_id} (reachable with {sorted(held)})")
+
+
+ def _ensure_home_grove_basics(self):
+ """Ensure home_grove has essential consumables for good player experience."""
+ # item_locations maps item_id -> room_id, so check it correctly
+ home_grove_items = [item_id for item_id, loc in self.world.item_locations.items() if loc == "home_grove"]
+
+ # Check if there's already a healing consumable in home_grove
+ def _tags(iid):
+ it = self.world.items.get(iid)
+ return it.tags if it else []
+ has_health_item = any(
+ "healing" in _tags(item_id)
+ for item_id in home_grove_items
+ )
+
+ # health_packet is always placed via room YAML, so this is just a safety net
+ if not has_health_item and "health_packet" in self.world.items:
+ if "health_packet" not in self.world.item_locations:
+ self.world.item_locations["health_packet"] = "home_grove"
+ self.world.item_spawn_counts["health_packet"] = 1
+ debug_log("Added health_packet to home_grove as safety net")
+
+ def _place_items_default(self, player_class=None):
+ """Fallback placement for an unknown/missing class.
+
+ player_class was read here but never a parameter: this path raised
+ NameError the moment it ran, which is why nobody noticed the fallback
+ was broken — reaching it needs a class outside classes.yaml.
+ """
+ debug_log("Using default item placement")
+ # Define rarity weights
+ rarity_weights = {
+ "common": 60,
+ "uncommon": 25,
+ "rare": 10,
+ "epic": 4,
+ "legendary": 1
+ }
+
+ # Group items by their rarity
+ items_by_rarity = {
+ "common": [],
+ "uncommon": [],
+ "rare": [],
+ "epic": [],
+ "legendary": []
+ }
+
+ # Gather all items with placement information and organize by rarity
+ for item_id, item_data in self.world.items.items():
+ # Skip if item is already placed in a fixed location
+ if item_id in self.world.item_locations:
+ debug_log(f"Skipping item {item_id} - already placed")
+ continue
+
+ # Skip items that don't match the player's class if specified
+ if player_class and not self._item_suitable_for_class(item_data, player_class):
+ debug_log(f"Skipping item {item_id} - class restriction mismatch vs {player_class}")
+ continue
+
+ # Check if the item has already reached its max spawn count
+ max_spawn = item_data.max_spawn
+ current_spawn = self.world.item_spawn_counts.get(item_id, 0)
+
+ if current_spawn >= max_spawn:
+ debug_log(f"Skipping item {item_id} - already at max spawn count: {max_spawn}")
+ continue # Skip if we've already spawned the maximum number
+
+ # Get the item's rarity (default to "common" if not specified)
+ rarity = item_data.rarity
+
+ # Normalize rarity to standardized string format
+ rarity = self._normalize_rarity(rarity)
+ debug_log(f"Normalized rarity for item {item_id}: {rarity}")
+
+ # Add item to the appropriate rarity group
+ if rarity in items_by_rarity:
+ items_by_rarity[rarity].append((item_id, item_data))
+ debug_log(f"Added item {item_id} to rarity group: {rarity}")
+ else:
+ # Default to common if rarity is not recognized
+ items_by_rarity["common"].append((item_id, item_data))
+ debug_log(f"Added item {item_id} to default common rarity group (unrecognized rarity: {rarity})")
+
+ # Calculate approximate total items to place based on number of rooms
+ # This ensures we don't flood every room with items
+ num_rooms = len(self.world.rooms)
+ base_items_per_room = 2 # Average items per room
+ target_item_count = num_rooms * base_items_per_room
+ debug_log(f"Target item count for world: {target_item_count} (based on {num_rooms} rooms)")
+
+ # Prepare a list of all candidate items with their rarity
+ all_candidate_items = []
+ for rarity, items in items_by_rarity.items():
+ for item in items:
+ all_candidate_items.append((item, rarity))
+
+ # If we have no items to place, return early
+ if not all_candidate_items:
+ debug_log("No items available to place in the world")
+ return 0
+
+ debug_log(f"Total candidate items for placement: {len(all_candidate_items)}")
+
+ # Create a weighted distribution for random selection
+ weighted_rarities = []
+ weights = []
+ for rarity in rarity_weights:
+ if items_by_rarity[rarity]: # Only include rarities that have items
+ weighted_rarities.append(rarity)
+ weights.append(rarity_weights[rarity])
+
+ debug_log(f"Using weighted rarities for distribution: {weighted_rarities} with weights {weights}")
+
+ # Place items using weighted random selection
+ total_items_placed = 0
+
+ # Create a list of rooms where items can be placed
+ # Locked rooms are eligible: a lock is a *when*, not a *never*. The
+ # tier-3 rooms behind keys are exactly where good loot belongs, and
+ # starving them left players unlocking a door onto an empty room.
+ eligible_rooms = list(self.world.rooms.keys())
+ debug_log(f"Found {len(eligible_rooms)} eligible rooms for item placement")
+
+ # Place items randomly based on weighted rarity
+ for _ in range(target_item_count):
+ if not weighted_rarities or not eligible_rooms:
+ debug_log("No more valid rarities or eligible rooms - stopping item placement")
+ break # No more rarities with items or no more eligible rooms
+
+ # Select a rarity based on weights
+ try:
+ selected_rarity = rng.choices(weighted_rarities, weights=weights, k=1)[0]
+ debug_log(f"Random selection chose rarity: {selected_rarity}")
+ except IndexError:
+ debug_log("IndexError during rarity selection - no more valid rarities")
+ break # No more valid rarities to select from
+
+ # If there are no items left of this rarity, remove it and try again
+ if not items_by_rarity[selected_rarity]:
+ idx = weighted_rarities.index(selected_rarity)
+ weighted_rarities.pop(idx)
+ weights.pop(idx)
+ debug_log(f"No items left in rarity {selected_rarity} - removing from weighted options")
+ continue
+
+ # Select a random item of the chosen rarity
+ item_id, item_data = rng.choice(items_by_rarity[selected_rarity])
+ debug_log(f"Selected item {item_id} from rarity {selected_rarity}")
+
+ # Remove this item from the pool to avoid multiple placements
+ items_by_rarity[selected_rarity].remove((item_id, item_data))
+
+ # Place the item
+ if self._place_single_item(item_id, item_data):
+ total_items_placed += 1
+ debug_log(f"Successfully placed {selected_rarity} item: {item_id}")
+ else:
+ debug_log(f"Failed to place {selected_rarity} item: {item_id}")
+
+ # If we've placed enough items, stop
+ if total_items_placed >= target_item_count:
+ debug_log("Reached target item count - stopping item placement")
+ break
+
+ debug_log(f"Placed {total_items_placed} items using weighted random selection")
+ return total_items_placed
+
+ def _place_single_item(self, item_id, item_data):
+ """
+ Helper method to place a single item in an appropriate room.
+
+ Args:
+ item_id: The ID of the item to place
+ item_data: The item's data dictionary
+
+ Returns:
+ bool: True if the item was successfully placed, False otherwise
+ """
+ # Check if the item has allowed_rooms specified
+ allowed_rooms = item_data.allowed_rooms
+
+ # Find eligible rooms for this item
+ eligible_rooms = []
+
+ if allowed_rooms:
+ # Item has specific room restrictions
+ debug_log(f"Item {item_id} has room restrictions: {allowed_rooms}")
+ for room_id in allowed_rooms:
+ if room_id in self.world.rooms:
+ eligible_rooms.append(room_id)
+ else:
+ # No specific room restrictions: any room, locked or not.
+ eligible_rooms = list(self.world.rooms.keys())
+
+ eligible_rooms = [r for r in eligible_rooms if self._top_tier_allowed(item_data, r)]
+
+ # If no eligible rooms, item can't be placed
+ if not eligible_rooms:
+ debug_log(f"No eligible rooms to place item {item_id}")
+ return False
+
+ # Select a random room from eligible rooms
+ chosen_room_id = rng.choice(eligible_rooms)
+ debug_log(f"Selected room {chosen_room_id} for item {item_id}")
+
+ # Place the item in the chosen room
+ self.world.item_locations[item_id] = chosen_room_id
+
+ # Update spawn counter for this item
+ self.world.item_spawn_counts[item_id] = self.world.item_spawn_counts.get(item_id, 0) + 1
+ debug_log(f"Placed item {item_id} in room {chosen_room_id} (spawn count: {self.world.item_spawn_counts[item_id]})")
+
+ return True
+
+ def _normalize_rarity(self, rarity):
+ """Normalize rarity to standardized string format.
+
+ Supports both numeric (legacy) and string formats.
+ Valid rarities: common, uncommon, rare, epic, legendary, secret, unique
+ """
+ # If already a valid string rarity, return it
+ valid_rarities = ["common", "uncommon", "rare", "epic", "legendary", "secret", "unique"]
+ if isinstance(rarity, str) and rarity.lower() in valid_rarities:
+ return rarity.lower()
+
+ # Convert numeric rarity to string format (backward compatibility)
+ if isinstance(rarity, (int, float)):
+ if rarity < 2:
+ return "legendary"
+ elif rarity < 5:
+ return "epic"
+ elif rarity < 10:
+ return "rare"
+ elif rarity < 20:
+ return "uncommon"
+ else:
+ return "common"
+
+ # Default to common for unrecognized formats
+ return "common"
+
+ def _get_directory_rarity_multiplier(self, room_id):
+ """Get rarity spawn multipliers based on directory depth.
+
+ Directory hierarchy determines which rarities can spawn:
+ - /home, /var: Common items dominate
+ - /bin, /etc, /usr: Uncommon items more frequent
+ - /lib: Rare items appear
+ - /dev: Epic items spawn
+ - / (room id "root"): the hub above /home, common and uncommon only
+ - /etc and /boot: where legendaries land (both start locked)
+
+ Returns a dict of multipliers for each rarity tier.
+ """
+ # Default multipliers (all rarities allowed)
+ base_multipliers = {
+ "common": 1.0,
+ "uncommon": 1.0,
+ "rare": 1.0,
+ "epic": 1.0,
+ "legendary": 1.0,
+ "secret": 0, # Never spawn naturally
+ "unique": 0 # Never spawn naturally
+ }
+
+ # Extract directory from room_id (e.g., "home_grove" -> "home")
+ room_dir = room_id.split('_')[0] if '_' in room_id else room_id
+
+ # Home and var: Common items only, some uncommon
+ if room_dir in ['home', 'var']:
+ return {
+ "common": 2.0, # Double common spawn rate
+ "uncommon": 0.5, # Reduced uncommon
+ "rare": 0, # No rare
+ "epic": 0, # No epic
+ "legendary": 0, # No legendary
+ "secret": 0,
+ "unique": 0
+ }
+
+ # Etc: locked late room, where epic and legendary gear can land
+ elif room_dir in ['etc']:
+ return {
+ "common": 0.3,
+ "uncommon": 0.6,
+ "rare": 1.2,
+ "epic": 1.5,
+ "legendary": 1.0,
+ "secret": 0,
+ "unique": 0
+ }
+
+ # Bin, usr: Common and uncommon, some rare
+ elif room_dir in ['bin', 'usr']:
+ return {
+ "common": 1.2,
+ "uncommon": 1.5, # Increased uncommon
+ "rare": 0.3, # Small chance of rare
+ "epic": 0,
+ "legendary": 0,
+ "secret": 0,
+ "unique": 0
+ }
+
+ # Lib: Common, uncommon, rare
+ elif room_dir in ['lib']:
+ return {
+ "common": 0.8,
+ "uncommon": 1.2,
+ "rare": 1.5, # Increased rare
+ "epic": 0.2, # Small chance of epic
+ "legendary": 0,
+ "secret": 0,
+ "unique": 0
+ }
+
+ # Dev: Uncommon, rare, epic
+ elif room_dir in ['dev']:
+ return {
+ "common": 0.3, # Reduced common
+ "uncommon": 0.8,
+ "rare": 1.2,
+ "epic": 2.0, # Double epic spawn rate
+ "legendary": 0.1, # Tiny chance of legendary
+ "secret": 0,
+ "unique": 0
+ }
+
+ # Root (/) is the hub everyone walks through from /home. The rules here
+ # were written for /root, the superuser's home, before the filesystem
+ # tree made the room with id "root" the top of the tree.
+ elif room_dir in ['root']:
+ return {
+ "common": 1.0,
+ "uncommon": 1.0,
+ "rare": 0,
+ "epic": 0,
+ "legendary": 0,
+ "secret": 0,
+ "unique": 0
+ }
+
+ # Default for unrecognized directories
+ return base_multipliers
+
+ def _get_class_rarity_weights(self, power_scaling):
+ """Get rarity weights based on class power scaling.
+
+ Base weights follow the Great Kernel Panic specification:
+ Common: 60%, Uncommon: 25%, Rare: 10%, Epic: 4%, Legendary: 1%
+ These are modified by class power scaling.
+ """
+ if power_scaling == "aggressive":
+ # Weavers get more rare/powerful items
+ return {
+ "common": 45,
+ "uncommon": 28,
+ "rare": 17,
+ "epic": 7,
+ "legendary": 3,
+ "secret": 0,
+ "unique": 0
+ }
+ elif power_scaling == "defensive":
+ # Guardians get more consistent, common items
+ return {
+ "common": 70,
+ "uncommon": 20,
+ "rare": 7,
+ "epic": 2,
+ "legendary": 1,
+ "secret": 0,
+ "unique": 0
+ }
+ else: # balanced (shaman)
+ # Base Great Kernel Panic spawn rates
+ return {
+ "common": 60,
+ "uncommon": 25,
+ "rare": 10,
+ "epic": 4,
+ "legendary": 1,
+ "secret": 0, # Secret items never spawn normally
+ "unique": 0 # Unique items never spawn normally
+ }
+
+ def _organize_rooms_by_zone(self):
+ """Organize rooms by their zone classification."""
+ rooms_by_zone = {}
+
+ for room_id, room_data in self.world.rooms.items():
+ zone = room_data.zone or "neutral"
+ if zone not in rooms_by_zone:
+ rooms_by_zone[zone] = []
+ rooms_by_zone[zone].append(room_id)
+
+ debug_log(f"Organized rooms into {len(rooms_by_zone)} zones: {list(rooms_by_zone.keys())}")
+ return rooms_by_zone
+
+ def _place_items_in_zone(self, zone, zone_rooms, player_class, rarity_weights, loot_preferences, multiplier):
+ """Place items within a specific zone."""
+ debug_log(f"Placing items in {zone} zone with {len(zone_rooms)} rooms")
+
+ # Filter items suitable for this zone and class
+ suitable_items = self._get_suitable_items_for_zone(zone, player_class, loot_preferences)
+
+ if not suitable_items:
+ debug_log(f"No suitable items found for {zone} zone")
+ return 0
+
+ # Calculate items to place based on zone size and multiplier
+ base_items_per_room = 3 # Increased from 2 to ensure more variety
+ target_items = int(len(zone_rooms) * base_items_per_room * multiplier)
+
+ # For safe zones, ensure at least one healing item per room
+ if zone == "safe":
+ target_items = max(target_items, len(zone_rooms) + 2) # Guarantee extras for safe zones
+
+ # Filter out home_grove from random placement (it gets starter items)
+ zone_rooms_filtered = [r for r in zone_rooms if r != "home_grove"]
+ if not zone_rooms_filtered:
+ debug_log(f"No rooms available for placement in {zone} zone after filtering home_grove")
+ return 0
+
+ # Retry until target reached. Cap total attempts so a saturated zone
+ # (all rooms at max_items_per_room, or no item/rarity match) can't loop forever.
+ items_placed = 0
+ max_attempts = target_items * 4
+ attempts = 0
+ while items_placed < target_items and attempts < max_attempts:
+ attempts += 1
+ if not suitable_items:
+ break
+
+ room_id = rng.choice(zone_rooms_filtered)
+ room_data = self.world.rooms.get(room_id, {})
+ allowed_rarities = self._get_allowed_rarities_for_room(room_id, room_data)
+
+ item_id, item_data = self._select_weighted_item(suitable_items, rarity_weights, allowed_rarities, room_id)
+ if not item_id:
+ continue
+
+ if self._place_item_in_room(item_id, item_data, room_id):
+ items_placed += 1
+ item_type = item_data.type
+ if item_type not in ["consumable", "enhancement"]:
+ suitable_items = [(id, data) for id, data in suitable_items if id != item_id]
+
+ return items_placed
+
+ def _get_suitable_items_for_zone(self, zone, player_class, loot_preferences):
+ """Get items suitable for a zone and class."""
+ suitable_items = []
+
+ for item_id, item_data in self.world.items.items():
+ # Skip already placed items
+ if item_id in self.world.item_locations:
+ continue
+
+ # Keys are placed via _place_keys, not through zone loot — otherwise
+ # they monopolize core/root and starve other zones.
+ if item_data.type.lower() == "key":
+ continue
+
+ # Check class restrictions
+ if not self._item_suitable_for_class(item_data, player_class):
+ continue
+
+ # Loot preferences act as a soft bias, not a hard filter. Most gear
+ # (weapons, armor, trinkets, consumables, lore) should be reachable
+ # by any class; preference can later weight selection if needed.
+ # Only hard-restricted items (with explicit allowed_classes) are gated.
+
+ # NOTE: allowed_zones check moved to _item_fits_room — item zones
+ # use directory prefixes (bin, usr, var) while room zones are story
+ # categories (core, safe, void), so per-room prefix matching is
+ # the only way the filter ever lets items through.
+
+ suitable_items.append((item_id, item_data))
+
+ debug_log(f"Found {len(suitable_items)} suitable items for {zone} zone and {player_class} class")
+ return suitable_items
+
+ def _item_suitable_for_class(self, item_data, player_class):
+ """Check if item is suitable for the player class."""
+ if item_data.allowed_classes:
+ return player_class.lower() in [c.lower() for c in item_data.allowed_classes]
+ return True # No restrictions
+
+ def _item_matches_preferences(self, item_data, loot_preferences):
+ """Check if item matches class loot preferences."""
+ item_type = item_data.type.lower()
+ item_tags = item_data.tags
+
+ for preference in loot_preferences:
+ if preference.lower() in item_type or preference.lower() in [tag.lower() for tag in item_tags]:
+ return True
+ return False
+
+ def _select_weighted_item(self, suitable_items, rarity_weights, allowed_rarities=None, room_id=None):
+ """Select an item based on rarity weights and optional rarity filter.
+
+ Args:
+ suitable_items: List of (item_id, item_data) tuples
+ rarity_weights: Dict of base rarity weights from class
+ allowed_rarities: Optional list of allowed rarities for this room
+ room_id: Room ID to apply directory-depth multipliers
+
+ Returns:
+ (item_id, item_data) tuple or (None, None)
+ """
+ if not suitable_items:
+ return None, None
+
+ # Organize by rarity
+ items_by_rarity = {}
+ for item_id, item_data in suitable_items:
+ rarity = item_data.rarity
+ # Normalize rarity
+ rarity = self._normalize_rarity(rarity)
+ if rarity not in items_by_rarity:
+ items_by_rarity[rarity] = []
+ items_by_rarity[rarity].append((item_id, item_data))
+
+ # Select rarity based on weights
+ all_available = [r for r in rarity_weights.keys() if r in items_by_rarity]
+
+ # Apply rarity filter if provided. Fall back to all-available if the
+ # filter empties the pool — sparse zones (only rare/epic candidates)
+ # shouldn't silently place nothing.
+ if allowed_rarities:
+ filtered = [r for r in all_available if r in allowed_rarities]
+ available_rarities = filtered if filtered else all_available
+ else:
+ available_rarities = all_available
+
+ if not available_rarities:
+ return None, None
+
+ # Apply directory-depth multipliers if room_id provided
+ if room_id:
+ dir_multipliers = self._get_directory_rarity_multiplier(room_id)
+ # Combine class weights with directory multipliers
+ weights = [rarity_weights[r] * dir_multipliers.get(r, 1.0) for r in available_rarities]
+ # Filter out zero-weight rarities
+ filtered_rarities = [(r, w) for r, w in zip(available_rarities, weights) if w > 0]
+ if not filtered_rarities:
+ return None, None
+ available_rarities, weights = zip(*filtered_rarities)
+ else:
+ weights = [rarity_weights[r] for r in available_rarities]
+
+ selected_rarity = rng.choices(list(available_rarities), weights=list(weights), k=1)[0]
+
+ # Select random item from rarity
+ return rng.choice(items_by_rarity[selected_rarity])
+
+ def _count_items_in_room(self, room_id: str) -> int:
+ """Count how many items are currently in a room."""
+ return sum(1 for item_id, loc in self.world.item_locations.items() if loc == room_id)
+
+ def _get_allowed_rarities_for_room(self, room_id: str, room_data) -> list:
+ """Determine which item rarities are allowed in a room based on characteristics."""
+ # Get room characteristics (room_data is a typed Room model)
+ enemies = room_data.enemies
+ enemy_count = len(enemies)
+ zone = room_data.zone or 'neutral'
+ is_boss_room = any('boss' in str(e).lower() or 'overlord' in str(e).lower() for e in enemies)
+
+ # Determine allowed rarities
+ if is_boss_room:
+ # Boss rooms: All rarities including legendary
+ return ['common', 'uncommon', 'rare', 'epic', 'legendary']
+ elif enemy_count >= 2:
+ # 2-3 enemy rooms: Epic and rare (and lower)
+ return ['common', 'uncommon', 'rare', 'epic']
+ elif enemy_count == 1:
+ # 1 enemy rooms: Common + uncommon for variety
+ return ['common', 'uncommon']
+ elif zone == 'safe':
+ # Safe zones: Uncommon (and common)
+ return ['common', 'uncommon']
+ else:
+ # Default: Common and uncommon
+ return ['common', 'uncommon']
+
+ def place_starter_items(self, player_class: str) -> None:
+ """Place the class's starter weapon and armor in home_grove after
+ character creation, whatever the room already holds (no item cap)."""
+ class_info = self.world.class_data.get(player_class.lower())
+ for field in ("starter_weapon", "starter_armor"):
+ item_id = getattr(class_info, field, None) if class_info else None
+ if item_id and item_id in self.world.items:
+ self.world.item_locations[item_id] = "home_grove"
+ self.world.item_spawn_counts[item_id] = 1
+ debug_log(f"Placed {field} {item_id} in home_grove for {player_class}")
+ else:
+ logger.warning(f"{field} '{item_id}' for class '{player_class}' not found in items data")
+
+ # health_packet is already guaranteed in home_grove via the room YAML
+
+ def _item_fits_room(self, item_data, room_id) -> bool:
+ """Check item's allowed_rooms / allowed_zones constraints against a room.
+ Zones are matched against the room_id's directory prefix (var_dungeon → 'var')."""
+ allowed_rooms = item_data.allowed_rooms
+ if allowed_rooms and room_id not in allowed_rooms:
+ return False
+ allowed_zones = item_data.allowed_zones
+ if allowed_zones:
+ room_prefix = room_id.split("_", 1)[0]
+ r = self.world.rooms.get(room_id)
+ room_zone = r.zone if r else ""
+ if room_prefix not in allowed_zones and room_zone not in allowed_zones:
+ return False
+ return True
+
+ TOP_TIER = ("epic", "legendary")
+
+ def _starts_open(self, room_id) -> bool:
+ """Can a new player walk in with no keys? Nothing on the path, the room
+ included, starts locked or flag-gated. (Class restrictions don't count:
+ an open room is open to someone.)"""
+ from src.room_paths import ancestors, room_at, room_path
+
+ target = room_path(room_id)
+ for path in ancestors(target) + [target]:
+ rid = room_at(path)
+ if rid is None:
+ continue
+ if self.world.room_states.get(rid, {}).get("locked", False):
+ return False
+ if getattr(self.world.get_room(rid), "flags_required", 0):
+ return False
+ return True
+
+ def _top_tier_allowed(self, item_data, room_id) -> bool:
+ """Epic and legendary gear is earned: it never starts in a room a new
+ player can walk into without a key. Root (/) is one step above /home and
+ used to collect 2-3 legendaries a run."""
+ if self._normalize_rarity(item_data.rarity) not in self.TOP_TIER:
+ return True
+ return not self._starts_open(room_id)
+
+ def _place_item_in_room(self, item_id, item_data, room_id, max_items_per_room=5):
+ """Place a specific item in a specific room."""
+ # Check item's own zone/room constraints
+ if not self._item_fits_room(item_data, room_id):
+ return False
+ if not self._top_tier_allowed(item_data, room_id):
+ return False
+
+ # Check per-room item limit
+ current_room_items = self._count_items_in_room(room_id)
+ if current_room_items >= max_items_per_room:
+ debug_log(f"Room {room_id} already has {current_room_items} items (limit: {max_items_per_room}), skipping {item_id}")
+ return False
+
+ # Check if this item is already placed (to prevent overriding fixed items)
+ if item_id in self.world.item_locations:
+ debug_log(f"Item {item_id} already placed in {self.world.item_locations[item_id]}, skipping dynamic placement")
+ return False
+
+ # Place the item
+ self.world.item_locations[item_id] = room_id
+ self.world.item_spawn_counts[item_id] = self.world.item_spawn_counts.get(item_id, 0) + 1
+
+ debug_log(f"Placed {item_id} in room {room_id}")
+ return True
diff --git a/src/item_resolver.py b/src/item_resolver.py
new file mode 100644
index 0000000..5ac55e8
--- /dev/null
+++ b/src/item_resolver.py
@@ -0,0 +1,130 @@
+"""Turn what the player typed into an item id."""
+import re
+
+from utils.debug_tools import debug_log
+
+_SEPARATORS = re.compile(r"[\s_.\-]+")
+
+
+def name_key(s: str) -> str:
+ """Comparison form of an item name: case and separators don't matter, so
+ `health_packet`, `health packet`, `health-packet` and `healthPacket` all
+ compare equal."""
+ return _SEPARATORS.sub("", s.lower())
+
+
+class ItemResolver:
+ """Item lookup by id, display name, shortcut or partial match."""
+
+ SHORTCUTS = {
+ # Consumables
+ "hp": ["health_packet", "stable_cache"],
+ "health": ["health_packet", "stable_cache"],
+ "potion": ["health_packet", "stable_cache", "overflowing_buffer"],
+ "heal": ["health_packet", "stable_cache"],
+ "packet": ["health_packet"],
+
+ # Weapons
+ "shield": ["segfault_shield"],
+ "pointer": ["null_pointer"],
+ "whisper": ["daemon_whisper"],
+
+ # Other consumables
+ "buffer": ["overflowing_buffer"],
+ "cache": ["stable_cache"],
+ "backup": ["legacy_backup"],
+ "seed": ["sudo_seed"]
+ }
+
+ def __init__(self, world, player):
+ self.world = world
+ self.player = player
+
+ def _find_in(self, search_term, item_ids, get_item_fn):
+ """Find an item ID whose id or display name matches, ignoring case and
+ separators."""
+ target = name_key(search_term)
+ if not target:
+ return None
+ for item_id in item_ids:
+ if name_key(item_id) == target:
+ return item_id
+ item_data = get_item_fn(item_id)
+ if item_data and name_key(item_data.name) == target:
+ return item_id
+ return None
+
+ def find_in_list(self, search_term, item_list):
+ """Find item ID in a room's item list by name or ID."""
+ return self._find_in(search_term, item_list, self.world.get_item)
+
+ def find_in_inventory(self, search_term):
+ """Find item in player inventory by name or ID."""
+ return self._find_in(
+ search_term, self.player.inventory, self.player.get_item_from_inventory
+ )
+
+ def player_keys(self):
+ """Get list of keys in player inventory."""
+ keys = []
+ for item_id, item_data in self.player.inventory.items():
+ if item_data and (item_data.type == "key" or "key" in item_id.lower()):
+ keys.append(item_id)
+ return keys
+
+ def resolve_shortcut(self, item_input, location="room"):
+ """Resolve item shortcuts and partial matches to actual item IDs."""
+ # Inventory lookups go through player's resolver (handles instance suffixes)
+ if location == "inventory":
+ return self.player.resolve_inventory_item(item_input)
+
+ # Get available items based on location
+ if location == "room":
+ current_room = self.player.current_room
+ available_items = self.world.get_items_in_room(current_room)
+ else:
+ available_items = []
+
+ debug_log(f"Resolving item shortcut '{item_input}' in {location}, available items: {available_items}")
+
+ # First, check if it's an exact match
+ if item_input in available_items:
+ return item_input
+
+ key = name_key(item_input)
+ if not key:
+ return None
+
+ # Same name, any spelling (case, _, -, spaces, camelCase)
+ spelled = self.find_in_list(item_input, available_items)
+ if spelled:
+ return spelled
+
+ # Check shortcuts
+ if key in self.SHORTCUTS:
+ shortcut_items = self.SHORTCUTS[key]
+ for shortcut_item in shortcut_items:
+ if shortcut_item in available_items:
+ debug_log(f"Shortcut '{item_input}' resolved to '{shortcut_item}'")
+ return shortcut_item
+
+ # Check partial matches (starts with the input)
+ partial_matches = [item for item in available_items if name_key(item).startswith(key)]
+ if len(partial_matches) == 1:
+ debug_log(f"Partial match '{item_input}' resolved to '{partial_matches[0]}'")
+ return partial_matches[0]
+ elif len(partial_matches) > 1:
+ debug_log(f"Multiple partial matches for '{item_input}': {partial_matches}")
+ # For health items, prefer health_packet
+ if "health_packet" in partial_matches:
+ return "health_packet"
+ return partial_matches[0] # Return first match as fallback
+
+ # Check if input contains key words that match item names
+ for available_item in available_items:
+ if key in name_key(available_item):
+ debug_log(f"Substring match '{item_input}' found in '{available_item}'")
+ return available_item
+
+ debug_log(f"No match found for '{item_input}'")
+ return None
diff --git a/src/logs.py b/src/logs.py
new file mode 100644
index 0000000..68ee9b2
--- /dev/null
+++ b/src/logs.py
@@ -0,0 +1,22 @@
+"""Generated log files: hundreds of look-alike lines with one flag line.
+
+Deterministic per item (seeded by its id), so the same log reads the same
+every run and in every test. Uses its own Random, not src.rng, so reading a
+log never shifts the game's seeded dice.
+"""
+from __future__ import annotations
+
+import random
+from typing import Any
+
+
+def log_lines(item: Any) -> list[str]:
+ spec = item.log
+ rnd = random.Random(str(item.id))
+ flag_at = spec.lines * 2 // 3
+ out: list[str] = []
+ for i in range(spec.lines):
+ template = spec.flag_line if i == flag_at else rnd.choice(spec.templates)
+ ts = f"03:{i // 60:02d}:{i % 60:02d}"
+ out.append(template.replace("{ts}", ts).replace("{n}", str(rnd.randint(1, 99))))
+ return out
diff --git a/src/loot.py b/src/loot.py
new file mode 100644
index 0000000..c594b7b
--- /dev/null
+++ b/src/loot.py
@@ -0,0 +1,84 @@
+"""Enemy loot: authored drops plus rarity-weighted gear tables."""
+from src import rng
+
+
+class LootService:
+ """Rolls and places a defeated enemy's loot, at most once per enemy per run."""
+
+ def __init__(self, world, player, output, relist_room):
+ self.world = world
+ self.player = player
+ self.output = output
+ self._relist_room = relist_room
+ # Enemy ids whose loot has already been awarded this run, so no path can
+ # roll the same enemy's loot twice.
+ self.awarded: set[str] = set()
+
+ def award_once(self, enemy_id, room_id):
+ """Award enemy_id's loot into room_id unless it was already awarded."""
+ if enemy_id in self.awarded:
+ return []
+ self.awarded.add(enemy_id)
+ return self.award_enemy_drops(enemy_id, room_id)
+
+ def award_enemy_drops(self, enemy_id, room_id):
+ """Place an enemy's drops into room_id. Returns the dropped item ids.
+
+ Existing `drops` (heals/keys/badges) are activated here — the difficulty
+ tune already assumes these fire. Gear `loot_table` is added in Task 2.
+ """
+ enemy = self.world.get_enemy(enemy_id)
+ if not enemy:
+ return []
+
+ self.player.run_stats["kills"] = self.player.run_stats.get("kills", 0) + 1
+
+ dropped = []
+ for drop in enemy.drops:
+ item_id = drop.item
+ if item_id and rng.random() * 100 < drop.chance:
+ self.world.add_item_to_room(item_id, room_id)
+ dropped.append(item_id)
+
+ gear_id = self.roll_loot_table(enemy.loot_table)
+ if gear_id:
+ self.world.add_item_to_room(gear_id, room_id)
+ dropped.append(gear_id)
+
+ if dropped:
+ names = ", ".join(
+ self.world.items[i].name if i in self.world.items else i for i in dropped
+ )
+ self.output.write(f"[bold yellow]The defeated enemy dropped: {names}[/bold yellow]")
+ self._relist_room()
+ return dropped
+
+ def roll_loot_table(self, table):
+ """Roll a rarity-weighted gear table. Rolls entries in order; returns the
+ first hit's item id (at most one gear drop per kill), or None."""
+ for entry in table or []:
+ rarity = entry.get("rarity")
+ if rarity and rng.random() * 100 < entry.get("chance", 0):
+ item_id = self.random_gear_of_rarity(rarity)
+ if item_id:
+ return item_id
+ return None
+
+ def random_gear_of_rarity(self, rarity):
+ """A class-appropriate weapon/armor of this rarity that exists nowhere yet,
+ or None. Gear only — never a consumable — so drop tables can't inflate the
+ heal economy. Each piece exists once: not on a floor, not carried, and not
+ taken earlier (a picked-up item leaves the floor, and used to be rolled
+ again as a duplicate)."""
+ target = str(rarity).lower()
+ candidates = []
+ for iid in self.world.items:
+ data = self.world.items[iid]
+ if (data.type in ("weapon", "armor")
+ and str(data.rarity).lower() == target
+ and self.player.can_use_item(data)
+ and iid not in self.world.item_locations
+ and iid not in self.world.removed_items
+ and not self.player.has_item(iid)):
+ candidates.append(iid)
+ return rng.choice(candidates) if candidates else None
diff --git a/src/move_info.py b/src/move_info.py
new file mode 100644
index 0000000..27a3cf1
--- /dev/null
+++ b/src/move_info.py
@@ -0,0 +1,51 @@
+"""What a move does, in plain words — shared by the fight menu and the class
+card, so both always describe a move the same way."""
+from __future__ import annotations
+
+from functools import lru_cache
+
+from engine.schema.models import Attack, CharacterClass
+
+
+def effect_tags(healing: int, weaken: float) -> list[str]:
+ """The side effects a player can't guess from the damage number."""
+ tags = []
+ if healing > 0:
+ tags.append(f"heals {healing} HP")
+ if weaken > 0:
+ tags.append(f"weakens enemy's next hit {round(weaken * 100)}%")
+ return tags
+
+
+@lru_cache(maxsize=1)
+def _attacks() -> dict[str, Attack]:
+ from engine.content.loader import load_attacks
+
+ return {str(k): v for k, v in load_attacks("data").items()}
+
+
+def _move_line(attack: Attack) -> str:
+ tags = effect_tags(attack.healing, attack.enemy_damage_reduction)
+ if attack.healing > 0:
+ # Short form: the card is 40 cells wide and the heal is what matters.
+ what = tags[0] + (", weakens foe" if attack.enemy_damage_reduction else "")
+ elif attack.cooldown == 0:
+ what = "quick hit, any turn"
+ else:
+ what = "big hit, every few turns"
+ return f"• {attack.name}: {what}"
+
+
+def class_move_lines(cls: CharacterClass) -> list[str]:
+ """One short line per starting move, for the class card."""
+ attacks = _attacks()
+ return [_move_line(attacks[str(a)]) for a in cls.attacks if str(a) in attacks]
+
+
+def stat_lines(cls: CharacterClass) -> tuple[str, str]:
+ """Card stat lines. The numbers come from the class's real base stats, so
+ a balance change can never leave the card showing the old ones."""
+ return (
+ f"{cls.display.hp_label} ({cls.base_health} HP)",
+ f"{cls.display.dmg_label} ({cls.base_damage} DMG)",
+ )
diff --git a/src/player.py b/src/player.py
index 78d1d5c..cfeae0f 100644
--- a/src/player.py
+++ b/src/player.py
@@ -1,5 +1,7 @@
from utils.debug_tools import debug_log
-from src.data_loader import load_class_data, load_consumable_data
+from engine.schema import Item
+from src.data_loader import load_class_data, load_item
+from src.item_resolver import name_key
# Armor mitigation: defense -> capped percent damage reduction.
ARMOR_MITIGATION_CAP = 33 # max % damage reduced, so a tank can't become unkillable
@@ -8,6 +10,24 @@
# out-mitigate weaver/shaman def-15 instead of all capping equal)
+def armor_mitigation_pct(defense: float) -> float:
+ """% of incoming damage an armor piece with this `defense` stops (capped)."""
+ return min(ARMOR_MITIGATION_CAP, defense * ARMOR_DEFENSE_TO_PCT)
+
+
+def is_auto_revive(item) -> bool:
+ """A Legacy Backup-style item: it fires on death, never by `use`."""
+ return any(e.get("type") == "auto_revive" for e in (item.special_effects if item else []))
+
+
+def auto_revive_text(item) -> str:
+ """The answer to `use `, in or out of a fight."""
+ return (
+ f"[cyan]{item.name} works automatically: keep it in your "
+ "inventory and it revives you the moment you fall.[/cyan]"
+ )
+
+
class Player:
"""Class representing the player in the game."""
@@ -49,11 +69,15 @@ def __init__(self, name="", player_class="guardian", current_room="home_grove"):
"first_ls": False, # Step 1 gate
"found_weapon": False, # Step 2 (ls revealed weapon)
"took_weapon": False, # Step 2 gate
- "equipped_weapon": False, # Step 3 gate (also triggers enemy spawn)
+ "equipped_weapon": False, # Step 3 gate
+ "equipped_armor": False, # armor step gate (weapon + armor starts the fight)
"combat_action_taken": False, # Step 4 gate (player landed an attack)
- "navigation_ls": False, # Step 6 gate (typed ls post-combat)
- "navigation_moved": False, # Step 6 gate (moved to new room)
- "completed": False # Step 7 — tutorial fully done
+ "ps_used": False, # Step 5 gate (ran ps after the fight)
+ "pwd_used": False, # Step 6 gate (ran pwd)
+ "went_up": False, # Step 7 gate (moved somewhere with directories)
+ "navigation_ls": False, # Step 8 gate (ls showed directories)
+ "navigation_moved": False, # Step 9 gate (cd into one of them)
+ "completed": False # tutorial fully done
}
# Harvesting Cycles (XP System)
@@ -65,6 +89,10 @@ def __init__(self, name="", player_class="guardian", current_room="home_grove"):
self.story_flags = {
"identity_retrieved": False,
"typo_discovered": False,
+ "manual_recovered": False,
+ "bovine_prophecy": False,
+ "moo_heard": False,
+ "corruption_witnessed": False,
"sudo_trial_complete": False,
"mirror_confronted": False,
"ending_chosen": None,
@@ -106,9 +134,9 @@ def load_class_attributes(self):
def _add_starter_items(self):
"""Give the player starter consumables to help survive early game."""
# Give 1 health packet to start
- health_packet = load_consumable_data("health_packet")
+ health_packet = load_item("health_packet")
if health_packet:
- self.inventory["health_packet_1"] = health_packet.copy()
+ self.inventory["health_packet_1"] = health_packet
debug_log("Starter items added: 1x Health Packet")
else:
debug_log("WARNING: Could not load health_packet for starter items")
@@ -140,12 +168,12 @@ def equip_weapon(self, item_id):
if item_id in self.inventory:
# Unequip previous weapon bonus if any
if self.equipped_weapon and self.equipped_weapon in self.inventory:
- old_bonus = self.inventory[self.equipped_weapon].get("damage", 0)
+ old_bonus = self.inventory[self.equipped_weapon].damage
self.total_damage -= old_bonus
# Equip new weapon
self.equipped_weapon = item_id
- weapon_bonus = self.inventory[item_id].get("damage", 0)
+ weapon_bonus = self.inventory[item_id].damage
self.total_damage += weapon_bonus
debug_log(f"Equipped weapon {item_id}, total_damage now {self.total_damage}.")
return True
@@ -156,8 +184,8 @@ def equip_armor(self, item_id):
if item_id not in self.inventory:
return False
self.equipped_armor = item_id
- defense = self.inventory[item_id].get("defense", 0) or 0
- self.armor_mitigation = min(ARMOR_MITIGATION_CAP, defense * ARMOR_DEFENSE_TO_PCT) / 100.0
+ defense = self.inventory[item_id].defense
+ self.armor_mitigation = armor_mitigation_pct(defense) / 100.0
debug_log(f"Equipped armor {item_id}, mitigation now {self.armor_mitigation:.0%}.")
return True
@@ -188,14 +216,26 @@ def resolve_inventory_item(self, name):
return None
keys = list(self.inventory.keys())
- lower = name.lower()
+ lower = name_key(name)
+ if not lower:
+ return None
# Exact match
if name in self.inventory:
return name
+ # Same id, then same display name, any spelling (case, _, -, spaces,
+ # camelCase). Ids first: every numbered copy shares the display name.
+ for k in keys:
+ if name_key(k) == lower:
+ return k
+ for k in keys:
+ item = self.inventory[k]
+ if item is not None and name_key(item.name) == lower:
+ return k
+
# Prefix match (suffixed instance keys like health_packet_1)
- prefix_matches = [k for k in keys if k.lower().startswith(lower)]
+ prefix_matches = [k for k in keys if name_key(k).startswith(lower)]
if len(prefix_matches) == 1:
return prefix_matches[0]
@@ -218,7 +258,7 @@ def resolve_inventory_item(self, name):
# Try exact then prefix
if target in self.inventory:
return target
- target_matches = [k for k in keys if k.lower().startswith(target.lower())]
+ target_matches = [k for k in keys if name_key(k).startswith(name_key(target))]
if target_matches:
return target_matches[0]
@@ -230,7 +270,7 @@ def resolve_inventory_item(self, name):
return prefix_matches[0]
# Substring fallback
- substring_matches = [k for k in keys if lower in k.lower()]
+ substring_matches = [k for k in keys if lower in name_key(k)]
if substring_matches:
return substring_matches[0]
@@ -238,11 +278,8 @@ def resolve_inventory_item(self, name):
def can_use_item(self, item):
"""Check if the player can use this item based on class restrictions."""
- if "allowed_classes" in item:
- allowed_classes = item["allowed_classes"]
- if isinstance(allowed_classes, str):
- allowed_classes = [allowed_classes]
- return self.player_class.lower() in [c.lower() for c in allowed_classes]
+ if item.allowed_classes:
+ return self.player_class.lower() in [c.lower() for c in item.allowed_classes]
return True
def apply_status_effect(self, effect_id, effect_data):
@@ -316,6 +353,46 @@ def heal(self, amount):
def is_alive(self):
"""Check if the player is alive."""
return self.health > 0
+
+ def revive_from_backup(self):
+ """If dead and carrying an auto-revive item (Legacy Backup), spend it
+ and come back at its share of max HP. Returns the item's name, or None
+ when there is nothing to revive with."""
+ if self.is_alive():
+ return None
+ for key, item in list(self.inventory.items()):
+ revive = next(
+ (e for e in (item.special_effects if item else [])
+ if e.get("type") == "auto_revive"),
+ None,
+ )
+ if revive is None:
+ continue
+ percent = int(str(revive.get("revive_hp_percent", 50)))
+ self.remove_from_inventory(key)
+ self.health = max(1, self.max_health * percent // 100)
+ debug_log(f"Revived by {key} at {self.health}/{self.max_health} HP")
+ return item.name
+ return None
+
+ def backups_left(self) -> int:
+ """How many auto-revive items the player still carries."""
+ return sum(1 for item in self.inventory.values() if is_auto_revive(item))
+
+ def revive_text(self, revived_by: str) -> str:
+ """What the player reads after a backup brings them back: the revive,
+ then that the backup is gone and how many are left."""
+ left = self.backups_left()
+ if left:
+ remaining = f"{left} backup{'s' if left != 1 else ''} left."
+ else:
+ remaining = "No backups left — the next fall ends the run."
+ return (
+ f"[bold green]✚ {revived_by} restores you from a snapshot — "
+ f"back at {self.health} HP![/bold green]\n"
+ f"[yellow]That {revived_by} is spent and gone from your inventory. "
+ f"{remaining}[/yellow]"
+ )
def calculate_damage(self):
"""Calculate the player's total damage including weapon and status effects."""
@@ -409,8 +486,7 @@ def get_persistent_items(self):
"""Return only items with persistence: 'persistent' tag."""
persistent = {}
for item_id, item_data in self.inventory.items():
- persistence = item_data.get("persistence", "persistent") # Default to persistent for backwards compatibility
- if persistence == "persistent":
+ if item_data.persistence == "persistent":
persistent[item_id] = item_data
return persistent
@@ -418,8 +494,7 @@ def apply_death_penalty(self):
"""Remove ephemeral items on death."""
ephemeral_items = []
for item_id, item_data in list(self.inventory.items()):
- persistence = item_data.get("persistence", "persistent")
- if persistence == "ephemeral":
+ if item_data.persistence == "ephemeral":
ephemeral_items.append(item_id)
# Remove all ephemeral items
@@ -457,10 +532,19 @@ def from_dict(cls, data):
player.permanent_damage_boost = data.get("permanent_damage_boost", 0)
player.previous_room = data.get("previous_room", None) # Load previous room
player.spells = data.get("spells", [])
- player.inventory = data.get("inventory", {})
+ player.inventory = {
+ item_id: Item.model_validate(body)
+ for item_id, body in data.get("inventory", {}).items()
+ }
player.run_stats = data.get("runStats", {"kills": 0, "items_found": 0})
player.met_npcs = set(data.get("metNpcs", []))
player.equipped_weapon = data.get("equipped_weapon", None)
+ # Mitigation is derived from the armor's defense, so re-equip rather
+ # than trust a stored number. v3 saves have no armor: none equipped.
+ armor = data.get("equipped_armor")
+ if armor:
+ player.equip_armor(armor)
+ player.status_effects = data.get("status_effects", {})
# Restore player_id if it exists, otherwise keep the generated one
if "player_id" in data:
player.player_id = data["player_id"]
@@ -487,8 +571,13 @@ def to_dict(self):
"permanent_health_boost": self.permanent_health_boost,
"permanent_damage_boost": self.permanent_damage_boost,
"spells": self.spells,
- "inventory": self.inventory,
+ "inventory": {
+ item_id: item.model_dump(exclude_unset=True)
+ for item_id, item in self.inventory.items()
+ },
"equipped_weapon": self.equipped_weapon,
+ "equipped_armor": self.equipped_armor,
+ "status_effects": self.status_effects,
"current_room": self.current_room,
"previous_room": self.previous_room, # Save previous room
"player_id": self.player_id,
diff --git a/src/rarity.py b/src/rarity.py
index 8ba3b1b..ee72a86 100644
--- a/src/rarity.py
+++ b/src/rarity.py
@@ -5,6 +5,12 @@
Single source of truth for all rarity tiers. Each entry is (color, emoji, sort_order).
Adding a new rarity only requires one line here.
"""
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from engine.schema import Item
# (color, emoji, sort_order)
RARITIES: dict[str, tuple[str, str, int]] = {
@@ -45,12 +51,12 @@ def format_item_name_with_rarity(item_name: str, rarity: str, show_emoji: bool =
return f"[{color}]{item_name} ({str(rarity).title()})[/{color}]"
@staticmethod
- def format_inventory_item(item_id: str, item_data: dict, is_equipped: bool = False) -> str:
- item_type = item_data.get("type", "")
- rarity = str(item_data.get("rarity", "common"))
- damage = item_data.get("damage", 0)
- healing = item_data.get("healing", 0)
- item_name = item_data.get("name", item_id)
+ def format_inventory_item(item_id: str, item_data: Item, is_equipped: bool = False) -> str:
+ item_type = item_data.type
+ rarity = str(item_data.rarity)
+ damage = item_data.damage
+ healing = item_data.healing or 0
+ item_name = item_data.name
color = RaritySystem.get_rarity_color(rarity)
item_display = f"[{color}]{item_name} ({rarity.title()})[/{color}]"
@@ -66,7 +72,7 @@ def format_inventory_item(item_id: str, item_data: dict, is_equipped: bool = Fal
return item_display
@staticmethod
- def get_all_rarities() -> list:
+ def get_all_rarities() -> list[str]:
return sorted(RARITIES.keys(), key=lambda r: RARITIES[r][2])
@staticmethod
diff --git a/src/rng.py b/src/rng.py
index 5e2af88..91b04c5 100644
--- a/src/rng.py
+++ b/src/rng.py
@@ -28,7 +28,7 @@ def get_rng() -> _random.Random:
# Thin pass-throughs mirroring the stdlib random API used across gameplay.
-def shuffle(seq: list) -> None:
+def shuffle(seq: list[Any]) -> None:
_rng.shuffle(seq)
@@ -37,7 +37,7 @@ def choice(seq: Sequence[Any]) -> Any:
def choices(population: Sequence[Any], weights: Sequence[float] | None = None,
- k: int = 1) -> list:
+ k: int = 1) -> list[Any]:
return _rng.choices(population, weights=weights, k=k)
@@ -49,5 +49,5 @@ def random() -> float:
return _rng.random()
-def sample(population: Sequence[Any], k: int) -> list:
+def sample(population: Sequence[Any], k: int) -> list[Any]:
return _rng.sample(list(population), k)
diff --git a/src/room_paths.py b/src/room_paths.py
index efa6eba..b5e868e 100644
--- a/src/room_paths.py
+++ b/src/room_paths.py
@@ -16,6 +16,8 @@
"""
from __future__ import annotations
+from typing import Any
+
# id -> canonical display path. Populated from room data at load time.
ROOM_ID_TO_PATH: dict[str, str] = {}
# canonical path -> id. The inverse of the above; the tree lookups use it.
@@ -24,14 +26,14 @@
ROOT_PATH = "/"
-def _room_field(room, key, default):
+def _room_field(room: Any, key: str, default: Any) -> Any:
"""Read a field from a room that may be a dict (tests) or a typed model."""
if isinstance(room, dict):
return room.get(key, default) or default
return getattr(room, key, default) or default
-def build_nav_tables(rooms: dict) -> tuple[dict[str, str], dict[str, str]]:
+def build_nav_tables(rooms: dict[str, Any]) -> tuple[dict[str, str], dict[str, str]]:
"""From room data (id -> room dict or model) build (id_to_path, alias_to_id).
- id_to_path: room id -> its ``path`` (display + tree position).
@@ -51,7 +53,7 @@ def build_nav_tables(rooms: dict) -> tuple[dict[str, str], dict[str, str]]:
return id_to_path, alias_to_id
-def refresh_from_rooms(rooms: dict) -> dict[str, str]:
+def refresh_from_rooms(rooms: dict[str, Any]) -> dict[str, str]:
"""Rebuild the path tables in place; return the alias -> id table."""
id_to_path, alias_to_id = build_nav_tables(rooms)
ROOM_ID_TO_PATH.clear()
diff --git a/src/save.py b/src/save.py
index 87200d8..98670e2 100644
--- a/src/save.py
+++ b/src/save.py
@@ -1,25 +1,46 @@
#!/usr/bin/env python3
-import os
+"""Run save slots.
+
+Every run (one new game) owns one file, saves/run_.json, which each
+autosave, checkpoint and `save` overwrites. The player picks a run from the
+title menu's LOAD GAME; at most MAX_RUNS exist at once.
+"""
+from __future__ import annotations
+
import json
-import time
import logging
+import os
+import time
+import uuid
+from dataclasses import dataclass
+from typing import Any
+
+from src import difficulty
logger = logging.getLogger(__name__)
-# Current on-disk save format version.
-SAVE_VERSION = 3
+# Current on-disk save format version. v5 added room flags; v6 made saves one
+# file per run (runId, createdAt, cleared).
+SAVE_VERSION = 6
+
+# Saves older than this cannot be loaded: v2 and earlier predate the filesystem
+# tree; v3-v4 predate room flags, so /boot's flag gate would strand them.
+MIN_SUPPORTED_VERSION = 5
+
+# Run slots on disk at once. NEW GAME with every slot taken asks which run to
+# replace.
+MAX_RUNS = 9
+
+RUN_PREFIX = "run_"
-# Saves older than this cannot be loaded. v2 and earlier predate the filesystem
-# tree: they persist room_states captured when /usr, /var and /boot carried no
-# locks, so restoring one would hand the player an unsealed boss room and a
-# world whose paths no longer match its content. Rather than silently produce a
-# broken run, we refuse them and say why.
-MIN_SUPPORTED_VERSION = 3
+# The pre-slot pool: save_.json. Migrated once, then kept in legacy/.
+LEGACY_PREFIX = "save_"
+LEGACY_DIR = "legacy"
-# Autosaves fire on each story beat (a lore file read), plus every manual save.
-# Without a cap the pool grows unbounded and every save/list operation slows
-# with directory size (observed: 16k files).
-MAX_SAVE_FILES = 20
+
+def _now() -> float:
+ """Wall-clock seconds; one seam for tests to control save order."""
+ return time.time()
class IncompatibleSaveError(Exception):
@@ -39,225 +60,281 @@ def save_version(save_data) -> int:
def _migrate_save(save_data):
"""Normalize a save envelope to the current version, or refuse it.
- v1 (no "version" field) used snake_case envelope keys; v2 added "version"
- and camelCase fields ("savedAt", "saveDate").
-
- v3 is where migration stops being possible. A save persists room_states —
- including which rooms are locked — and v2 saves were written when /usr, /var
- and /boot had no locks at all. Restoring one would reopen the boss room and
- hand back a world whose room paths no longer describe the tree the game now
- navigates. There is no honest way to reconstruct the intended run from that,
- so v2 and older are refused with a message rather than half-migrated into
- something subtly broken.
+ v2 and older persist room_states from before /usr, /var and /boot were
+ locked; v3-v4 predate room flags. Neither can be honestly reconstructed,
+ so both are refused with a message rather than half-migrated. v5 saves
+ load as they are (the v6 fields have defaults).
"""
if not isinstance(save_data, dict):
return save_data
version = save_version(save_data)
- if version < MIN_SUPPORTED_VERSION:
+ if version < 3:
raise IncompatibleSaveError(
f"save format v{version} is from before the filesystem rework and "
f"cannot be loaded (current format is v{SAVE_VERSION})"
)
+ if version < MIN_SUPPORTED_VERSION:
+ raise IncompatibleSaveError(
+ f"save format v{version} is from before room flags and cannot be "
+ f"loaded (current format is v{SAVE_VERSION})"
+ )
return save_data
+
+@dataclass(frozen=True)
+class RunInfo:
+ """One run slot, as the save picker shows it."""
+ run_id: str
+ player_name: str
+ player_class: str
+ difficulty: str
+ level: int
+ room_id: str
+ health: int
+ max_health: int
+ saved_at: float
+ cleared: bool
+
+
class SaveManager:
- """Handles saving and loading game data."""
-
+ """Owns the run slots on disk and which run is being played."""
+
def __init__(self, save_dir="saves"):
- """Initialize the save manager with the save directory."""
self.save_dir = save_dir
- # Ensure the save directory exists
- if not os.path.exists(self.save_dir):
- os.makedirs(self.save_dir)
-
- def save_game(self, player, world_state, save_name=None):
- """
- Save the current game state to a JSON file.
-
- Args:
- player: Player object with game state
- world_state: Dictionary with world state information
- save_name: Optional name for the save file, defaults to timestamp
-
- Returns:
- str: Path to the saved file
- """
- if not save_name:
- # Generate a filename based on timestamp
- timestamp = time.strftime("%Y%m%d-%H%M%S")
- save_name = f"save_{timestamp}.json"
-
- # Create the full file path
- save_path = os.path.join(self.save_dir, save_name)
-
- # Create save data structure (versioned envelope, camelCase fields).
- save_data = {
- "version": SAVE_VERSION,
- "player": player.to_dict(),
- "world": world_state,
- "savedAt": time.time(),
- "saveDate": time.strftime("%Y-%m-%d %H:%M:%S")
- }
-
+ # The run being played, and a run the player chose to replace (deleted
+ # by the new run's first save, so backing out of setup loses nothing).
+ self.active_run_id: str | None = None
+ self.pending_replace: str | None = None
+ os.makedirs(self.save_dir, exist_ok=True)
+
+ # -- files ------------------------------------------------------------
+
+ def _run_path(self, run_id: str) -> str:
+ return os.path.join(self.save_dir, f"{RUN_PREFIX}{run_id}.json")
+
+ def _run_files(self) -> list[str]:
+ try:
+ names = os.listdir(self.save_dir)
+ except FileNotFoundError:
+ return []
+ return [n for n in names if n.startswith(RUN_PREFIX) and n.endswith(".json")]
+
+ @staticmethod
+ def _read(path: str) -> dict[str, Any] | None:
try:
- # Write to file
- with open(save_path, 'w') as file:
- json.dump(save_data, file, indent=2)
-
- logger.info(f"Game saved successfully to {save_path}")
- self._prune_old_saves()
-
- # NOTE: do NOT emit GAME_SAVED here. GAME_SAVED is the *request* event
- # (_on_save_requested handles it by calling save_game); re-emitting it on
- # completion re-triggers the handler → infinite recursion → save storm.
- # Callers show their own "saved" confirmation directly.
- return save_path
-
+ with open(path) as file:
+ data = json.load(file)
+ except (OSError, json.JSONDecodeError):
+ return None
+ return data if isinstance(data, dict) else None
+
+ def _write(self, path: str, data: dict[str, Any]) -> None:
+ """Write via a temp file + rename, so a crash mid-save never leaves a
+ half-written slot."""
+ os.makedirs(self.save_dir, exist_ok=True)
+ tmp = path + ".tmp"
+ try:
+ with open(tmp, "w") as file:
+ json.dump(data, file, indent=2)
+ os.replace(tmp, path)
except Exception as e:
logger.error(f"Failed to save game: {e}")
raise
-
- def _prune_old_saves(self):
- """Keep only the newest MAX_SAVE_FILES saves; delete the rest."""
- try:
- files = [
- os.path.join(self.save_dir, f)
- for f in os.listdir(self.save_dir)
- if f.endswith(".json")
- ]
- if len(files) <= MAX_SAVE_FILES:
- return
- files.sort(key=lambda p: (os.path.getmtime(p), p))
- for path in files[: len(files) - MAX_SAVE_FILES]:
- os.remove(path)
- logger.info(f"Pruned {len(files) - MAX_SAVE_FILES} old saves (cap {MAX_SAVE_FILES})")
- except Exception as e:
- # Pruning must never break saving itself.
- logger.warning(f"Save pruning failed: {e}")
- def load_game(self, filename):
- """
- Load a game from a save file.
-
- Args:
- filename: Name of the save file to load
-
- Returns:
- dict: The loaded save data or None if file not found
- """
- file_path = os.path.join(self.save_dir, filename)
-
- try:
- with open(file_path, 'r') as file:
- save_data = json.load(file)
+ # -- the run being played ---------------------------------------------
- save_data = _migrate_save(save_data)
- logger.info(f"Game loaded successfully from {filename}")
- return save_data
+ def begin_run(self, replace: str | None = None) -> str:
+ """Start a new run. Nothing is written until its first save."""
+ self.active_run_id = uuid.uuid4().hex[:8]
+ self.pending_replace = replace
+ return self.active_run_id
- except IncompatibleSaveError as e:
- logger.warning(f"Refusing incompatible save {filename}: {e}")
- raise
- except FileNotFoundError:
- logger.warning(f"Save file not found: {filename}")
- return None
- except json.JSONDecodeError as e:
- logger.error(f"Failed to parse save file {filename}: {e}")
+ def resume_run(self, run_id: str) -> None:
+ """Continue a run picked from LOAD GAME."""
+ self.active_run_id = run_id
+ self.pending_replace = None
+
+ def end_run(self) -> None:
+ """Back at the title: no run is being played."""
+ self.active_run_id = None
+ self.pending_replace = None
+
+ def save_game(self, player, world_state) -> str:
+ """Overwrite the active run's slot (starting a run if none is active —
+ the headless GameSession creates players without the menu). Returns the
+ file path."""
+ if self.active_run_id is None:
+ self.begin_run()
+ run_id = self.active_run_id
+ assert run_id is not None
+ path = self._run_path(run_id)
+ previous = self._read(path) or {}
+ now = _now()
+ self._write(path, {
+ "version": SAVE_VERSION,
+ "runId": run_id,
+ "createdAt": previous.get("createdAt", now),
+ "cleared": bool(previous.get("cleared", False)),
+ "player": player.to_dict(),
+ "world": world_state,
+ "difficulty": difficulty.current_mode(),
+ "savedAt": now,
+ "saveDate": time.strftime("%Y-%m-%d %H:%M:%S"),
+ })
+ logger.info(f"Game saved to {path}")
+
+ if self.pending_replace:
+ replaced, self.pending_replace = self.pending_replace, None
+ if replaced != run_id:
+ self.delete_run(replaced)
+ logger.info(f"Run {replaced} replaced by {run_id}")
+ return path
+
+ def mark_cleared(self) -> None:
+ """Mark the active run as beaten. Its last save stays as it is."""
+ if self.active_run_id is None:
+ return
+ path = self._run_path(self.active_run_id)
+ data = self._read(path)
+ if data is None:
+ return
+ data["cleared"] = True
+ self._write(path, data)
+
+ # -- every run --------------------------------------------------------
+
+ def load_run(self, run_id: str) -> dict[str, Any] | None:
+ """The run's save, or None if it is missing or unreadable. Raises
+ IncompatibleSaveError for a format that cannot load."""
+ path = self._run_path(run_id)
+ if not os.path.exists(path):
+ logger.warning(f"No save for run {run_id}")
return None
- except Exception as e:
- logger.error(f"Failed to load game from {filename}: {e}")
+ data = self._read(path)
+ if data is None:
+ logger.error(f"Could not read the save for run {run_id}")
return None
-
- def get_save_files(self):
- """
- Get a list of available save files.
-
- Returns:
- list: List of dictionaries with save file info (filename, date, player name)
+ return _migrate_save(data)
+
+ def list_runs(self) -> list[RunInfo]:
+ """Every loadable run, most recently saved first. A bad file (or a
+ failed migration) is logged and skipped: one broken save must never
+ lock the player out of NEW GAME / LOAD GAME."""
+ try:
+ self._migrate_legacy()
+ except (OSError, TypeError, ValueError, AttributeError) as e:
+ logger.warning(f"Old-save migration failed: {e}")
+ runs: list[RunInfo] = []
+ for name in self._run_files():
+ data = self._read(os.path.join(self.save_dir, name))
+ if data is None:
+ logger.warning(f"Skipping unreadable save {name}")
+ continue
+ try:
+ data = _migrate_save(data)
+ except IncompatibleSaveError as e:
+ logger.warning(f"Skipping {name}: {e}")
+ continue
+ try:
+ runs.append(self._run_info(name, data))
+ except (TypeError, ValueError, AttributeError) as e:
+ logger.warning(f"Skipping malformed save {name}: {e}")
+ runs.sort(key=lambda run: run.saved_at, reverse=True)
+ return runs
+
+ @staticmethod
+ def _run_info(filename: str, data: dict[str, Any]) -> RunInfo:
+ player = data.get("player") or {}
+ return RunInfo(
+ run_id=filename[len(RUN_PREFIX):-len(".json")],
+ player_name=str(player.get("name", "?")),
+ player_class=str(player.get("player_class", "guardian")),
+ difficulty=str(data.get("difficulty", difficulty.DEFAULT_MODE)),
+ level=int(player.get("level", 1)),
+ room_id=str(player.get("current_room", "home_grove")),
+ health=int(player.get("health", 0)),
+ max_health=int(player.get("max_health", 0)),
+ saved_at=float(data.get("savedAt", 0.0)),
+ cleared=bool(data.get("cleared", False)),
+ )
+
+ def _migrate_legacy(self) -> None:
+ """Turn pre-slot saves (one pool of save_.json files) into run slots.
+
+ Files are grouped by hero (name, class, difficulty); the newest file of
+ each group becomes a slot, newest groups first, up to the free slots.
+ Every old file (used, too old or unreadable) then moves to
+ saves/legacy/. Nothing is deleted.
"""
- save_files = []
- skipped = 0
-
- for filename in os.listdir(self.save_dir):
- if filename.endswith('.json'):
- file_path = os.path.join(self.save_dir, filename)
- try:
- with open(file_path, 'r') as file:
- save_data = json.load(file)
-
- # Saves the current build cannot load are not offered at all,
- # so the load menu never presents a choice that then fails.
- save_data = _migrate_save(save_data)
- save_info = {
- "filename": filename,
- "date": save_data.get("saveDate", "Unknown date"),
- "player_name": save_data.get("player", {}).get("name", "Unknown"),
- "player_class": save_data.get("player", {}).get("player_class", "Unknown"),
- "location": save_data.get("player", {}).get("current_room", "Unknown")
- }
-
- save_files.append(save_info)
- except IncompatibleSaveError:
- skipped += 1
- continue
- except (json.JSONDecodeError, KeyError, OSError):
- # Skip corrupt save files
- continue
-
- if skipped:
- logger.info(
- f"Ignored {skipped} save(s) from an older, incompatible format"
+ try:
+ names = [
+ n for n in os.listdir(self.save_dir)
+ if n.startswith(LEGACY_PREFIX) and n.endswith(".json")
+ ]
+ except FileNotFoundError:
+ return
+ if not names:
+ return
+
+ groups: dict[tuple[str, str, str], list[dict[str, Any]]] = {}
+ for name in names:
+ data = self._read(os.path.join(self.save_dir, name))
+ if data is None or save_version(data) < MIN_SUPPORTED_VERSION:
+ continue
+ player = data.get("player") or {}
+ hero = (
+ str(player.get("name", "")),
+ str(player.get("player_class", "")),
+ str(data.get("difficulty", difficulty.DEFAULT_MODE)),
)
+ groups.setdefault(hero, []).append(data)
- # Sort by date (newest first)
- save_files.sort(key=lambda x: x["date"], reverse=True)
- return save_files
-
- def delete_save(self, filename):
- """
- Delete a save file.
-
- Args:
- filename: Name of the save file to delete
-
- Returns:
- bool: True if deletion was successful, False otherwise
- """
- file_path = os.path.join(self.save_dir, filename)
-
+ def saved_at(save: dict[str, Any]) -> float:
+ return float(save.get("savedAt", 0.0))
+
+ free = max(MAX_RUNS - len(self._run_files()), 0)
+ newest_first = sorted(
+ groups.values(), key=lambda saves: max(map(saved_at, saves)), reverse=True,
+ )
+ for saves in newest_first[:free]:
+ latest = max(saves, key=saved_at)
+ run_id = uuid.uuid4().hex[:8]
+ self._write(self._run_path(run_id), {
+ **latest,
+ "version": SAVE_VERSION,
+ "runId": run_id,
+ "createdAt": min(map(saved_at, saves)),
+ "cleared": False,
+ })
+
+ legacy = os.path.join(self.save_dir, LEGACY_DIR)
+ os.makedirs(legacy, exist_ok=True)
+ for name in names:
+ os.replace(os.path.join(self.save_dir, name), os.path.join(legacy, name))
+ logger.info(
+ f"Moved {len(names)} old save(s) to {legacy}; "
+ f"{min(len(groups), free)} became run slots"
+ )
+
+ def legacy_count(self) -> int:
+ """Old-format saves kept in saves/legacy/."""
+ try:
+ names = os.listdir(os.path.join(self.save_dir, LEGACY_DIR))
+ except FileNotFoundError:
+ return 0
+ return len([n for n in names if n.endswith(".json")])
+
+ def runs_full(self) -> bool:
+ return len(self.list_runs()) >= MAX_RUNS
+
+ def delete_run(self, run_id: str) -> bool:
try:
- os.remove(file_path)
+ os.remove(self._run_path(run_id))
return True
except FileNotFoundError:
return False
- def get_most_recent_save(self):
- """
- Get the most recent save file.
-
- Returns:
- dict: Save info for the most recent save, or None if no saves exist
- """
- save_files = self.get_save_files()
- return save_files[0] if save_files else None
-
- def load_most_recent_save(self):
- """
- Load the most recent save file.
-
- Returns:
- dict: The loaded save data or None if no saves exist
- """
- most_recent = self.get_most_recent_save()
- if most_recent:
- return self.load_game(most_recent["filename"])
- return None
# Create a singleton instance
save_manager = SaveManager()
-
-# Convenience functions for easy access
-def load_most_recent_save():
- """Load the most recent save file."""
- return save_manager.load_most_recent_save()
\ No newline at end of file
diff --git a/src/scene/sprite_store.py b/src/scene/sprite_store.py
index b04c262..6096b05 100644
--- a/src/scene/sprite_store.py
+++ b/src/scene/sprite_store.py
@@ -26,6 +26,20 @@ def _placeholder_color(entity_id: str) -> tuple[int, int, int]:
return tuple(60 + (b % 141) for b in digest[:3])
+def _fill_box(img: Image.Image, max_w: int, max_h: int) -> Image.Image:
+ """Crop to the drawn pixels, then scale (nearest-neighbour, aspect kept) so
+ the figure's longest side fills the box. Art arrives at different sizes:
+ some NPCs draw a 12-17 px figure on a padded 24x16 canvas, and would
+ otherwise stand at half the height of the enemies and classes."""
+ bbox = img.getbbox()
+ if bbox is None:
+ return img
+ img = img.crop(bbox)
+ scale = min(max_w / img.width, max_h / img.height)
+ size = (max(1, round(img.width * scale)), max(1, round(img.height * scale)))
+ return img.resize(size, Image.NEAREST) # pixel art: no smoothing
+
+
def _make_placeholder(entity_id: str, w: int, h: int) -> Image.Image:
color = _placeholder_color(entity_id)
img = Image.new("RGBA", (w, h), (0, 0, 0, 0))
@@ -58,8 +72,7 @@ def get_sprite(self, kind: str, entity_id: str, max_w: int, max_h: int) -> Image
path = self._png_path(kind, entity_id)
if path.is_file():
- img = Image.open(path).convert("RGBA")
- img.thumbnail((max_w, max_h), Image.NEAREST) # pixel art: no smoothing
+ img = _fill_box(Image.open(path).convert("RGBA"), max_w, max_h)
else:
img = _make_placeholder(entity_id, max_w, max_h)
diff --git a/src/state_manager.py b/src/state_manager.py
index 77640e6..5816d03 100644
--- a/src/state_manager.py
+++ b/src/state_manager.py
@@ -1,55 +1,53 @@
#!/usr/bin/env python3
+from __future__ import annotations
+
import logging
+
from src.game_states import GameState
-from src.events import event_bus, EventType
+from engine.events import EventBus, EventType
from utils.debug_tools import debug_log
logger = logging.getLogger(__name__)
-class StateManager:
- """Centralized singleton for game state management."""
+class InvalidTransitionError(RuntimeError):
+ """A state change the transition table does not allow: a bug in the flow."""
- _instance = None
- # Define valid state transitions for validation
- _valid_transitions = {
- GameState.MENU: [GameState.WAITING_FOR_DIFFICULTY, GameState.WAITING_FOR_NAME, GameState.LOADING, GameState.EXIT],
+class StateManager:
+ """Game-state machine for one engine; emits transitions on that engine's bus."""
+
+ # Every transition the game makes, and no others. MENU -> WAITING_FOR_SAVE
+ # is LOAD GAME (and NEW GAME with every slot taken); the save picker then
+ # goes to PLAYING (a run picked), WAITING_FOR_DIFFICULTY (a run chosen to
+ # replace) or back to MENU. The -> MENU edges out of setup and play are F5,
+ # leaving a run for the main menu, and the error fallbacks.
+ _valid_transitions: dict[GameState, list[GameState]] = {
+ GameState.MENU: [
+ GameState.WAITING_FOR_DIFFICULTY, GameState.PLAYING, GameState.WAITING_FOR_SAVE,
+ ],
+ GameState.WAITING_FOR_SAVE: [
+ GameState.MENU, GameState.PLAYING, GameState.WAITING_FOR_DIFFICULTY,
+ ],
GameState.WAITING_FOR_DIFFICULTY: [GameState.WAITING_FOR_CLASS, GameState.MENU],
- GameState.WAITING_FOR_NAME: [GameState.WAITING_FOR_CLASS, GameState.TUTORIAL_NAME_INPUT],
- GameState.WAITING_FOR_CLASS: [GameState.PLAYING],
- GameState.TUTORIAL_NAME_INPUT: [GameState.WAITING_FOR_CLASS, GameState.PLAYING],
- GameState.PLAYING: [GameState.IN_COMBAT, GameState.SAVING, GameState.PAUSED, GameState.GAME_OVER, GameState.MENU, GameState.EXIT],
- GameState.IN_COMBAT: [GameState.PLAYING, GameState.GAME_OVER],
- GameState.GAME_OVER: [GameState.MENU, GameState.EXIT],
- GameState.LOADING: [GameState.PLAYING, GameState.MENU],
- GameState.SAVING: [GameState.PLAYING],
- GameState.PAUSED: [GameState.PLAYING, GameState.MENU],
- GameState.EXIT: [] # Terminal state
+ GameState.WAITING_FOR_CLASS: [GameState.TUTORIAL_NAME_INPUT, GameState.MENU],
+ GameState.TUTORIAL_NAME_INPUT: [GameState.PLAYING, GameState.MENU],
+ GameState.PLAYING: [GameState.IN_COMBAT, GameState.MENU],
+ GameState.IN_COMBAT: [GameState.PLAYING, GameState.GAME_OVER, GameState.MENU],
+ GameState.GAME_OVER: [GameState.MENU],
}
- def __new__(cls):
- if cls._instance is None:
- cls._instance = super().__new__(cls)
- cls._instance._initialized = False
- return cls._instance
-
- def __init__(self):
- if self._initialized:
- return
-
- self._current_state = GameState.MENU
- self._previous_state = None
- self._combat_context = None # Track combat-specific state
- self._initialized = True
+ def __init__(self, bus: EventBus) -> None:
+ self._bus = bus
+ self._current_state: GameState = GameState.MENU
debug_log("StateManager initialized")
@property
- def current_state(self):
+ def current_state(self) -> GameState:
"""Get current game state."""
return self._current_state
- def set_state(self, new_state, emit_event=True):
+ def set_state(self, new_state: GameState, emit_event: bool = True) -> None:
"""
Set game state with validation.
@@ -63,71 +61,35 @@ def set_state(self, new_state, emit_event=True):
old_state = self._current_state
- # Validate state transition (warn but allow for flexibility)
- valid_next_states = self._valid_transitions.get(old_state, [])
- if valid_next_states and new_state not in valid_next_states:
- logger.warning(
- f"Potentially invalid state transition: {old_state} -> {new_state}. "
- f"Expected one of: {valid_next_states}"
+ if new_state not in self._valid_transitions.get(old_state, []):
+ raise InvalidTransitionError(
+ f"{old_state} -> {new_state} is not a transition the game makes; "
+ f"from {old_state} it goes to {self._valid_transitions.get(old_state, [])}"
)
- debug_log(f"WARNING: Unexpected state transition: {old_state} -> {new_state}")
- self._previous_state = old_state
self._current_state = new_state
debug_log(f"State transition: {old_state} -> {new_state}")
if emit_event:
- event_bus.emit_event(
+ self._bus.emit_event(
EventType.UI_STATE_CHANGED,
{"new_state": new_state, "old_state": old_state},
"StateManager"
)
- def enter_combat(self, combat_context=None):
- """Enter combat state with optional context."""
- self._combat_context = combat_context
+ def enter_combat(self) -> None:
+ """Enter combat state."""
self.set_state(GameState.IN_COMBAT)
- def exit_combat(self):
+ def exit_combat(self) -> None:
"""Exit combat state."""
- self._combat_context = None
self.set_state(GameState.PLAYING)
- def is_in_combat(self):
+ def is_in_combat(self) -> bool:
"""Check if currently in combat."""
- return self._current_state == GameState.IN_COMBAT
-
- def get_combat_context(self):
- """Get combat context (enemy queue, etc)."""
- return self._combat_context
-
- # Additional convenience methods for state checking
-
- def is_playing(self) -> bool:
- """Check if currently in playing state."""
- return self._current_state == GameState.PLAYING
-
- def is_in_menu(self) -> bool:
- """Check if currently in menu state."""
- return self._current_state == GameState.MENU
+ return bool(self._current_state == GameState.IN_COMBAT)
def is_in_game_over(self) -> bool:
"""Check if currently in game over state."""
- return self._current_state == GameState.GAME_OVER
-
- def is_waiting_for_input(self) -> bool:
- """Check if waiting for user input (name, class, tutorial)."""
- return self._current_state in [
- GameState.WAITING_FOR_NAME,
- GameState.WAITING_FOR_CLASS,
- GameState.TUTORIAL_NAME_INPUT
- ]
-
- def can_accept_commands(self) -> bool:
- """Check if game can accept player commands."""
- return self._current_state in [GameState.PLAYING, GameState.IN_COMBAT]
-
-
-# Singleton instance
-state_manager = StateManager()
+ return bool(self._current_state == GameState.GAME_OVER)
diff --git a/src/tutorial_coach.py b/src/tutorial_coach.py
new file mode 100644
index 0000000..21ea9cd
--- /dev/null
+++ b/src/tutorial_coach.py
@@ -0,0 +1,233 @@
+"""The guided tutorial: which step the player is on and the hint for it."""
+from src import room_paths
+from engine.events import EventType
+from utils.debug_tools import debug_log
+
+# Hint id -> the numbered step it belongs to, for the "step N of TOTAL" label.
+STEP_NUMBERS = {
+ "step1": 1, "step2": 2, "step3": 3, "step_armor": 4,
+ "step4": 5, "step4_fled": 5, "step5": 5,
+ "step5_postcombat": 6, "step_hidden": 7, "step_cat": 8,
+ "step_ps": 9, "step_up": 10, "step6": 11, "step6b": 12,
+}
+TOTAL_STEPS = 12
+# Hints that point at a section of the output the frontend should draw the eye to.
+HIGHLIGHTS = {"step6b": "Directories"}
+# Hints that close the tutorial; the frontend drops its pinned guidance.
+FINAL_HINTS = ("completed", "skip_summary")
+
+
+class TutorialCoach:
+ """Shows gated tutorial hints and advances the tutorial from combat events."""
+
+ # Fallback if a class ever lacks a starter_weapon in classes.yaml.
+ _FALLBACK_WEAPON = "segfault_shield"
+
+ def __init__(self, player, world, bus):
+ self.player = player
+ self.world = world
+ self.bus = bus
+
+ def show_hint(self, hint_type, item_name=None):
+ """Show a gated tutorial hint. Text lives in data/tutorial_hints.yaml.
+
+ The weapon name is whatever the player is actually looking at when a
+ hint names one: the item the caller passed, else the class's authored
+ starter weapon. It used to be a hardcoded class->id map here, which
+ silently drifted from classes.yaml.
+ """
+ if self.player.tutorial_state.get("completed", False):
+ return
+
+ from src.data_loader import load_class_data, load_tutorial_hints
+
+ template = load_tutorial_hints().get(hint_type)
+ if template is None:
+ debug_log(f"No tutorial hint text for '{hint_type}'")
+ return
+
+ weapon_name = item_name
+ if not weapon_name:
+ klass = load_class_data().get(self.player.player_class)
+ weapon_name = getattr(klass, "starter_weapon", None) or self._FALLBACK_WEAPON
+
+ klass = load_class_data().get(self.player.player_class)
+ armor_name = getattr(klass, "starter_armor", None) or "armor"
+ player_name = getattr(self.player, "name", "") or "spirit"
+ directories = self._visible_directories()
+ example_dir = room_paths.basename(room_paths.room_path(directories[0])) if directories else "var"
+
+ try:
+ text = template.format(
+ player_name=player_name,
+ weapon_name=weapon_name,
+ location=room_paths.room_path(self.player.current_room),
+ example_dir=example_dir,
+ armor_name=armor_name,
+ )
+ except (KeyError, IndexError) as e:
+ # An unknown placeholder is a content bug; show the raw line rather
+ # than dropping the tutorial step entirely.
+ debug_log(f"Tutorial hint '{hint_type}' has a bad placeholder: {e}")
+ text = template
+
+ self.bus.emit_event(
+ EventType.TUTORIAL_HINT,
+ {
+ "hint_id": hint_type,
+ "text": text,
+ "step": STEP_NUMBERS.get(hint_type),
+ "total": TOTAL_STEPS,
+ "final": hint_type in FINAL_HINTS,
+ "highlight": HIGHLIGHTS.get(hint_type),
+ },
+ "TutorialCoach",
+ )
+
+ if hint_type == "completed":
+ self.player.tutorial_state["completed"] = True
+
+ def current_step(self):
+ """Return the hint key for the step the player is currently expected to perform."""
+ ts = self.player.tutorial_state or {}
+ if not ts.get("first_ls", False):
+ return "step1"
+ if not ts.get("took_weapon", False):
+ return "step2"
+ if not ts.get("equipped_weapon", False):
+ return "step3"
+ # Only before the first fight: a save from before this step existed has
+ # equipped_weapon and a won fight but no equipped_armor flag.
+ if not ts.get("equipped_armor", False) and not ts.get("combat_action_taken", False):
+ return "step_armor"
+ if not ts.get("combat_action_taken", False):
+ return "step4"
+ if not ts.get("ls_a_used", False):
+ return "step5_postcombat"
+ if not ts.get("lore_read", False):
+ return "step_hidden"
+ if not ts.get("ps_used", False):
+ return "step_cat"
+ if not ts.get("pwd_used", False):
+ return "step_ps"
+ if not ts.get("went_up", False):
+ return "step6" if self._visible_directories() else "step_up"
+ if not ts.get("navigation_ls", False):
+ return "step6"
+ if not ts.get("navigation_moved", False):
+ return "step6b"
+ return None
+
+ def _visible_directories(self):
+ """Discovered rooms directly below the player's current directory."""
+ here = room_paths.room_path(self.player.current_room)
+ return [
+ c for c in room_paths.children_of(here)
+ if self.world.is_discovered(c) and self.world.door_visible(c)
+ ]
+
+ def _active(self):
+ return not self.player.tutorial_state.get("completed", False)
+
+ # --- progression: commands report what happened, the coach decides ------
+
+ def gear_equipped(self, kind):
+ """The player equipped their first weapon or armor. Returns True when
+ both are now on, which is the moment the tutorial fight opens; otherwise
+ shows whatever gear step comes next."""
+ ts = self.player.tutorial_state
+ if not self._active() or ts.get(f"equipped_{kind}", False):
+ return False
+ ts[f"equipped_{kind}"] = True
+ if ts.get("equipped_weapon", False) and ts.get("equipped_armor", False):
+ return True
+ step = self.current_step()
+ if step:
+ self.show_hint(step)
+ return False
+
+ def after_ls(self, weapon_found, weapon_id, has_directories, show_all=False):
+ """ls: the first one points at the weapon; the first -a after combat
+ reveals the hidden lore file; after pwd, it finds the way on."""
+ ts = self.player.tutorial_state
+ if not self._active():
+ return
+ if not ts.get("first_ls", False):
+ ts["first_ls"] = True
+ if weapon_found:
+ ts["found_weapon"] = True
+ self.show_hint("step2", weapon_id)
+ return
+ if show_all and ts.get("combat_action_taken", False) and not ts.get("ls_a_used", False):
+ ts["ls_a_used"] = True
+ if not ts.get("lore_read", False):
+ self.show_hint("step_hidden")
+ return
+ if ts.get("pwd_used", False) and not ts.get("navigation_ls", False):
+ if has_directories:
+ ts["went_up"] = True
+ ts["navigation_ls"] = True
+ self.show_hint("step6b")
+ else:
+ self.show_hint("step_up")
+
+ def after_lore_read(self, story_beat):
+ """A cat that fired a story flag: the memory-restore autosave IS the
+ checkpoint the tutorial is teaching, so this is the step's gate."""
+ ts = self.player.tutorial_state
+ if not (self._active() and ts.get("combat_action_taken", False)):
+ return
+ if story_beat and not ts.get("lore_read", False):
+ ts["lore_read"] = True
+ self.show_hint("step_cat")
+
+ def after_ps(self):
+ ts = self.player.tutorial_state
+ if self._active() and ts.get("lore_read", False) and not ts.get("ps_used", False):
+ ts["ps_used"] = True
+ self.show_hint("step_ps")
+
+ def after_pwd(self):
+ ts = self.player.tutorial_state
+ if not (self._active() and ts.get("ps_used", False)) or ts.get("pwd_used", False):
+ return
+ ts["pwd_used"] = True
+ if self._visible_directories():
+ ts["went_up"] = True
+ self.show_hint("step6")
+ else:
+ self.show_hint("step_up")
+
+ def after_move(self):
+ """A successful cd: the first after pwd lands somewhere new; the first
+ after the highlighted listing finishes the tutorial."""
+ ts = self.player.tutorial_state
+ if not self._active():
+ return
+ if ts.get("navigation_ls", False) and not ts.get("navigation_moved", False):
+ ts["navigation_moved"] = True
+ self.show_hint("completed")
+ elif ts.get("pwd_used", False) and not ts.get("went_up", False):
+ ts["went_up"] = True
+ self.show_hint("step6")
+
+ def on_combat_ended(self, event):
+ """Handle combat end for tutorial post-combat hints (Step 5 post-combat + Step 6)."""
+ ts = self.player.tutorial_state
+ if ts.get("completed", False):
+ return
+ if ts.get("combat_action_taken", False) and not ts.get("ps_used", False):
+ if event.data.get("victory", False):
+ self.show_hint("step5_postcombat")
+
+ def on_combat_action_result(self, event):
+ """Step 4 gate: first landed attack (typed OR hotkey — both emit this
+ event identically) shows the Step 5 hint."""
+ ts = self.player.tutorial_state
+ if ts.get("completed", False):
+ return
+ data = event.data or {}
+ if data.get("actor") == "player" and data.get("action") == "attack":
+ if not ts.get("combat_action_taken", False):
+ ts["combat_action_taken"] = True
+ self.show_hint("step5")
diff --git a/src/ui/combat_log.py b/src/ui/combat_log.py
new file mode 100644
index 0000000..50fbd67
--- /dev/null
+++ b/src/ui/combat_log.py
@@ -0,0 +1,144 @@
+"""Text for the combat output panel: the running log, or the attack list at the start."""
+from __future__ import annotations
+
+from typing import Any
+
+import config.dev_config as dev_cfg
+from engine.view_models import AttackView, StatsView
+from src.move_info import effect_tags
+
+# At or below this share of max HP the panel points at the heal move.
+LOW_HP_FRACTION = 0.3
+
+# One icon per attack type (attacks.yml `type`); unknown types keep a bullet.
+_KIND_ICONS = {"physical": "⚔", "magical": "✨", "nature": "🌿"}
+
+_ACTOR_FORMAT = {
+ "player": ("green", "👤"),
+ "enemy": ("red", "👹"),
+ "system": ("yellow", "⚡"),
+}
+
+
+def _format_entry(action: dict[str, Any]) -> str:
+ actor = action.get('actor', 'system')
+ message = action.get('message', 'Action performed')
+ color, icon = _ACTOR_FORMAT.get(actor, ("white", "📋"))
+ return f"[{color}]{icon} {message}[/{color}]"
+
+
+def render_combat_output(
+ combat_log: list[dict[str, Any]],
+ player_view: StatsView | None,
+ attacks: list[AttackView],
+ player_hp: tuple[int, int] | None = None,
+) -> str:
+ """The combat panel's text: the attack list until the first blow, then the log.
+
+ `player_hp` is (current, max) from the fight itself; the stats view can
+ lag behind mid-combat, so it is only the fallback."""
+ output_lines = []
+ if player_hp is None and player_view is not None:
+ player_hp = (player_view.health, player_view.max_health)
+ hint = low_hp_hint(player_hp, attacks)
+
+ if any(action.get("actor") in ("player", "enemy") for action in combat_log):
+ # Mid-combat: show only outcomes. Controls live in the footer; the
+ # attack list was shown at combat start. Keeps the log readable
+ # instead of re-dumping the full controls block every turn.
+ output_lines.append("[bold yellow]⚔ COMBAT LOG ⚔[/bold yellow]")
+ output_lines.append("=" * 40)
+ output_lines.extend(_format_entry(action) for action in combat_log[-10:])
+ output_lines.append("=" * 40)
+ output_lines.append(
+ "[dim]Attacks: number keys in the footer · "
+ "type 'use - ' or 'flee'[/dim]"
+ )
+ if hint:
+ output_lines.append(hint)
+ else:
+ # Before the first blow: introduce the fight and show attack options,
+ # keeping system lines such as the enemy's opening taunt and tutorial hints.
+ output_lines.extend([
+ "[bold yellow]⚔ BATTLE STARTED ⚔[/bold yellow]",
+ "=" * 40,
+ ])
+ output_lines.extend(_format_entry(action) for action in combat_log[-10:])
+ output_lines.extend([
+ "",
+ "[dim]Selection Mode active — press 1-9 to attack, 0 to flee. "
+ "TAB to type 'use
- ' instead.[/dim]",
+ "",
+ hotkey_display(player_view, attacks),
+ ])
+ if hint:
+ output_lines.extend(["", hint])
+
+ return "\n".join(output_lines)
+
+
+def hotkey_display(player_view: StatsView | None, attacks: list[AttackView]) -> str:
+ """Numbered attack list with damage, accuracy and cooldown for each hotkey."""
+ if player_view is None:
+ return "[dim]Attack options loading...[/dim]"
+
+ hotkey_lines = ["[bold green]QUICK ATTACKS:[/bold green]"]
+ base_damage = player_view.damage
+
+ hotkey_num = 1
+ for attack_data in attacks:
+ if hotkey_num > 9:
+ break
+
+ attack_name = attack_data.name
+ total_damage = base_damage + attack_data.bonus_damage
+ accuracy = attack_data.accuracy
+ cooldown = attack_data.cooldown
+ type_icon = _KIND_ICONS.get(attack_data.kind, "•")
+ cd_label = f"CD {cooldown}t" if cooldown > 0 else "no CD"
+ # Healing and weakening are invisible in the damage number: name them.
+ effects = "".join(
+ f" · [green]{tag}[/green]"
+ for tag in effect_tags(attack_data.healing, attack_data.weaken)
+ )
+
+ if not attack_data.on_cooldown:
+ hotkey_lines.append(
+ f"[cyan][{hotkey_num}][/cyan] {type_icon} {attack_name} — "
+ f"[yellow]{total_damage} dmg[/yellow]{effects} "
+ f"([dim]{accuracy}% hit · {cd_label}[/dim])"
+ )
+ else:
+ hotkey_lines.append(
+ f"[dim][{hotkey_num}] {type_icon} {attack_name} — "
+ f"on cooldown ({attack_data.cooldown_remaining}t left)[/dim]"
+ )
+ hotkey_num += 1
+
+ if hotkey_num == 1:
+ hotkey_lines.append("[dim]No attacks available[/dim]")
+
+ return "\n".join(hotkey_lines)
+
+
+def low_hp_hint(player_hp: tuple[int, int] | None, attacks: list[AttackView]) -> str:
+ """When HP is low, name the key that heals — a new player won't go looking.
+ A hint, so the Settings "In-game hints" switch turns it off."""
+ if not player_hp or not getattr(dev_cfg, "SHOW_HINTS", True):
+ return ""
+ hp, max_hp = player_hp
+ if max_hp <= 0 or hp > max_hp * LOW_HP_FRACTION:
+ return ""
+ for key, attack in enumerate(attacks[:9], 1):
+ if attack.healing <= 0:
+ continue
+ if attack.on_cooldown:
+ turns = attack.cooldown_remaining
+ when = f"ready in {turns} turn{'s' if turns != 1 else ''}"
+ else:
+ when = "ready now"
+ return (
+ f"[bold green]💚 Low HP! Press [{key}] {attack.name} to heal "
+ f"{attack.healing} HP ({when}).[/bold green]"
+ )
+ return ""
diff --git a/src/ui/command_input.py b/src/ui/command_input.py
new file mode 100644
index 0000000..882a101
--- /dev/null
+++ b/src/ui/command_input.py
@@ -0,0 +1,33 @@
+"""The command box: Tab finishes the ghost-text suggestion, like a shell."""
+from __future__ import annotations
+
+from typing import Callable
+
+from textual.binding import Binding
+from textual.widgets import Input
+
+
+class CommandInput(Input):
+ """Input whose Tab accepts the inline suggestion when there is one.
+
+ With nothing to complete — or while `can_complete()` says no (in combat) —
+ Tab moves focus exactly as before. Combat Selection Mode and the
+ Tab-then-L log shortcut both depend on that fallback.
+ """
+
+ BINDINGS = [Binding("tab", "complete_or_leave", show=False)]
+
+ can_complete: Callable[[], bool] = staticmethod(lambda: True)
+
+ def action_complete_or_leave(self) -> None:
+ # `_suggestion` is Textual's private ghost-text state (checked on 8.2.8);
+ # right-arrow is the built-in way to accept it.
+ suggestion = self._suggestion
+ if (
+ self.can_complete()
+ and self.cursor_at_end
+ and len(suggestion) > len(self.value)
+ ):
+ self.action_cursor_right()
+ return
+ self.app.action_focus_next()
diff --git a/src/ui/command_suggester.py b/src/ui/command_suggester.py
index c65adf2..683b6ff 100644
--- a/src/ui/command_suggester.py
+++ b/src/ui/command_suggester.py
@@ -10,6 +10,8 @@
from textual.suggester import Suggester
+from src import room_paths
+
# Map common shorthand -> canonical inventory id prefix
_INVENTORY_SHORTCUTS = {
@@ -41,18 +43,18 @@
"cd": "exits",
"talk": "npcs",
"attack": "enemies",
+ "grep": "room_items",
}
class CommandSuggester(Suggester):
"""Suggest the rest of a command from live game state."""
- def __init__(self, get_player: Callable, get_world: Callable, get_aliases: Callable):
+ def __init__(self, get_player: Callable, get_world: Callable):
# Disable cache — suggestions depend on mutable game state.
super().__init__(use_cache=False, case_sensitive=False)
self._get_player = get_player
self._get_world = get_world
- self._get_aliases = get_aliases
async def get_suggestion(self, value: str) -> Optional[str]:
if not value:
@@ -65,6 +67,8 @@ async def get_suggestion(self, value: str) -> Optional[str]:
verb, _, partial = value.partition(" ")
verb_lower = verb.lower()
+ if verb_lower == "grep":
+ return self._suggest_grep_file(verb, partial)
source = _VERB_SOURCE.get(verb_lower)
if source is None:
return None
@@ -88,6 +92,20 @@ async def get_suggestion(self, value: str) -> Optional[str]:
return f"{verb} {candidate}"
return None
+ def _suggest_grep_file(self, verb: str, partial: str) -> Optional[str]:
+ """grep
: complete the file, the last word typed."""
+ head, sep, last = partial.rpartition(" ")
+ if not sep or not last:
+ return None # still typing the word to search for
+ player, world = self._get_player(), self._get_world()
+ if player is None or world is None:
+ return None
+ for candidate in self._room_item_candidates(player, world):
+ low = candidate.lower()
+ if low.startswith(last.lower()) and low != last.lower():
+ return f"{verb} {head} {candidate}"
+ return None
+
def _suggest_verb(self, partial: str) -> Optional[str]:
partial_lower = partial.lower()
for verb in sorted(_VERB_SOURCE.keys()):
@@ -105,7 +123,7 @@ def _candidates_for(self, source: str) -> List[str]:
if source == "inventory":
return self._inventory_candidates(player)
if source == "room_items":
- return list(world.get_items_in_room(player.current_room))
+ return self._room_item_candidates(player, world)
if source == "exits":
return self._exit_candidates(player, world)
if source == "npcs":
@@ -116,6 +134,21 @@ def _candidates_for(self, source: str) -> List[str]:
return []
return []
+ @staticmethod
+ def _room_item_candidates(player, world) -> List[str]:
+ # Hidden files are offered as dotfiles — completing only from the
+ # leading dot, as in a real shell — and only once `ls -a` has listed
+ # them here, so Tab is never a way round `ls -a`.
+ room = player.current_room
+ listed = world.hidden_files_listed(room)
+ candidates: List[str] = []
+ for item_id in world.get_items_in_room(room):
+ if not getattr(world.get_item(item_id), "hidden", False):
+ candidates.append(item_id)
+ elif listed:
+ candidates.append(f".{item_id}")
+ return candidates
+
def _inventory_candidates(self, player) -> List[str]:
# Strip instance suffixes like "_1" so users can type the canonical id
seen: List[str] = []
@@ -132,27 +165,11 @@ def _inventory_candidates(self, player) -> List[str]:
return seen
def _exit_candidates(self, player, world) -> List[str]:
- try:
- current = world.get_room(player.current_room) or {}
- exits = list(current.get("exits", []))
- except Exception:
- exits = []
-
- # Map room IDs to filesystem-style aliases for display, keep both forms
- aliases = self._get_aliases() or {}
- # Reverse map: room_id -> preferred path alias (longest path wins)
- path_aliases: dict = {}
- for alias, room_id in aliases.items():
- if not alias.startswith("/"):
- continue
- existing = path_aliases.get(room_id)
- if existing is None or len(alias) > len(existing):
- path_aliases[room_id] = alias
-
+ # Only doors `ls` would show: Tab must not reveal a hidden room or a
+ # locked door the player hasn't earned sight of yet.
+ # Each door completes as its real path (what `ls` prints) or its id.
suggestions: List[str] = []
- for room_id in exits:
- path = path_aliases.get(room_id)
- if path:
- suggestions.append(path)
+ for room_id in world.visible_exits(player.current_room):
+ suggestions.append(room_paths.room_path(room_id))
suggestions.append(room_id)
return suggestions
diff --git a/src/ui/endings.py b/src/ui/endings.py
new file mode 100644
index 0000000..ff0266b
--- /dev/null
+++ b/src/ui/endings.py
@@ -0,0 +1,101 @@
+"""End-of-run text: the paced victory finale, its recap card, and the game-over card."""
+from __future__ import annotations
+
+from typing import Any, Protocol
+
+SECTION_SECONDS = 2.5
+
+
+class _Output(Protocol):
+ def update_output(self, content: str) -> None: ...
+ def append_output(self, content: Any) -> None: ...
+ def set_timer(self, delay: float, callback: Any) -> Any: ...
+
+
+class FinaleReveal:
+ """Reveals the ending one section per beat; any key dumps the rest at once."""
+
+ def __init__(self, out: _Output) -> None:
+ self._out = out
+ self._queue: list[str] = []
+ self._timers: list[Any] = []
+
+ @property
+ def revealing(self) -> bool:
+ return bool(self._timers)
+
+ def start(self, data: dict[str, Any], reduce_motion: bool) -> None:
+ parts = list(data.get("sections", [])) + [build_recap(data.get("stats", {}))]
+ self._queue = parts
+ self._timers = []
+
+ first = self._queue.pop(0)
+ if reduce_motion:
+ self._out.update_output(first)
+ for part in self._queue:
+ self._out.append_output(part)
+ self._queue = []
+ return
+ self._out.update_output(first)
+ for i, part in enumerate(self._queue, 1):
+ self._timers.append(
+ self._out.set_timer(SECTION_SECONDS * i, lambda p=part: self._step(p))
+ )
+
+ def _step(self, part: str) -> None:
+ if part in self._queue:
+ self._queue.remove(part)
+ self._out.append_output(part)
+ if not self._queue:
+ self._timers = []
+
+ def skip(self) -> None:
+ """Any key during the reveal: dump everything remaining at once."""
+ if not self._timers:
+ return
+ for t in self._timers:
+ t.stop()
+ self._timers = []
+ for part in self._queue:
+ self._out.append_output(part)
+ self._queue = []
+
+
+FULL_CLEAR_EPILOGUE = (
+ "[bold green]Every directory answers again.[/bold green] You did not just "
+ "stop the Overlord — you walked every path it corrupted and took back every "
+ "flag it hid. The filesystem remembers who restored it."
+)
+
+
+def rank_for(flags: int, flags_total: int, secrets: int, secrets_total: int) -> str:
+ """A win holds at least 12 of 13 main flags (11 open /boot, the Overlord
+ drops the 12th), so the ladder starts at Sysadmin."""
+ if not flags_total:
+ return "—"
+ if flags >= flags_total and secrets >= secrets_total:
+ return "root"
+ if flags >= flags_total:
+ return "Sysadmin Supreme"
+ return "Sysadmin"
+
+
+def build_recap(stats: dict[str, Any]) -> str:
+ flags, flags_total = stats.get("flags", 0), stats.get("flags_total", 0)
+ secrets, secrets_total = stats.get("secrets", 0), stats.get("secrets_total", 0)
+ rank = rank_for(flags, flags_total, secrets, secrets_total)
+ if secrets_total and secrets >= secrets_total and rank != "root":
+ rank += " · Keeper of Secrets"
+ return (
+ "── YOUR RUN ──────────────────────────\n"
+ f"[bold]{stats.get('player_name', '?')}[/bold] · "
+ f"{str(stats.get('player_class', '?')).title()} · "
+ f"ending: [cyan]{str(stats.get('ending', '?')).upper()}[/cyan]\n"
+ f"Level {stats.get('level', 1)} · {stats.get('cycles', 0)} cycles harvested\n"
+ f"{stats.get('kills', 0)} enemies purged · {stats.get('items_found', 0)} items recovered\n"
+ f"⚑ Flags {flags}/{flags_total} · Secrets {secrets}/{secrets_total} · "
+ f"rank: [bold]{rank}[/bold]\n"
+ f"difficulty: {stats.get('difficulty', '?')}\n"
+ "──────────────────────────────────────\n"
+ "[green]r[/green] restore · [cyan]m[/cyan] main menu · [red]q[/red] quit"
+ )
diff --git a/src/ui/panels/character_panel.py b/src/ui/panels/character_panel.py
new file mode 100644
index 0000000..2971702
--- /dev/null
+++ b/src/ui/panels/character_panel.py
@@ -0,0 +1,129 @@
+"""
+CharacterPanel — the player's model and the gear they have on, below Stats.
+
+The battle scene is the only other place the class sprite appears, so outside
+a fight this is where a new player sees who they are and what they're
+wielding. An empty slot names the command that fills it.
+"""
+
+from __future__ import annotations
+
+import time
+
+from PIL import Image
+from rich.align import Align
+from rich.console import Group
+from rich.table import Table
+from rich.text import Text
+from textual.widgets import Static
+
+from engine.view_models import InventoryItemView, InventoryView, StatsView
+from src.item_icons import item_icon
+from src.rarity import RaritySystem
+from src.scene.effects import BOB_PERIOD, bob_offset
+from src.scene.sprite_store import SpriteStore, to_renderable
+
+SPRITE_PX = 26 # figure box; half-block rendering draws it 13 rows tall
+GEAR_MIN_WIDTH = 26 # narrower than this beside the sprite, gear goes below it
+
+
+def _equipped(inventory: InventoryView | None, kind: str) -> InventoryItemView | None:
+ for item in inventory.items if inventory else []:
+ if item.is_equipped and item.item_type == kind:
+ return item
+ return None
+
+
+def _name(item: InventoryItemView) -> str:
+ color = RaritySystem.get_rarity_color(item.rarity)
+ return f"[{color}]{item.name}[/{color}]"
+
+
+def gear_lines(player: StatsView, inventory: InventoryView | None) -> list[str]:
+ """The text column: weapon then armor, each with the number it adds."""
+ lines = [f"{item_icon('weapon')} [bold]Weapon[/bold]"]
+ weapon = _equipped(inventory, "weapon")
+ if weapon is not None:
+ lines += [_name(weapon), f"[cyan]+{weapon.damage or 0} DMG[/cyan]"]
+ else:
+ lines.append("[dim]none — type equip [/dim]")
+
+ lines += ["", f"{item_icon('armor')} [bold]Armor[/bold]"]
+ armor = _equipped(inventory, "armor")
+ if armor is not None:
+ lines += [_name(armor), f"[cyan]-{player.defense_pct}% dmg taken[/cyan]"]
+ else:
+ lines.append("[dim]none — type equip [/dim]")
+ return lines
+
+
+class CharacterPanel(Static):
+ """Sidebar panel: class sprite beside the equipped weapon and armor."""
+
+ _BOB_TICK_SECONDS = BOB_PERIOD / 4
+
+ def __init__(self, **kwargs) -> None:
+ super().__init__(**kwargs)
+ self._store = SpriteStore()
+ self._player: StatsView | None = None
+ self._inventory: InventoryView | None = None
+ self._reduce_motion = False
+ self._bob = 0
+
+ def on_mount(self) -> None:
+ self.border_title = "🧍 Character"
+ self.set_interval(self._BOB_TICK_SECONDS, self._bob_tick)
+
+ def update_character(
+ self,
+ player: StatsView | None,
+ inventory: InventoryView | None,
+ reduce_motion: bool = False,
+ ) -> None:
+ if player is None:
+ return
+ self._player, self._inventory = player, inventory
+ self._reduce_motion = reduce_motion
+ if reduce_motion:
+ self._bob = 0
+ self._render_panel()
+
+ def _bob_tick(self) -> None:
+ """Idle bob, like the battle scene: redraw only when the lift flips."""
+ if self._player is None or self._reduce_motion:
+ return
+ new_bob = bob_offset(time.monotonic())
+ if new_bob != self._bob:
+ self._bob = new_bob
+ self._render_panel()
+
+ def _sprite(self, player_class: str) -> Image.Image:
+ figure = self._store.get_sprite("classes", player_class, SPRITE_PX, SPRITE_PX)
+ # Spare rows above so the bob lifts the figure without clipping it;
+ # an even height keeps half-block cells whole.
+ canvas = Image.new("RGBA", (SPRITE_PX, SPRITE_PX + 2), (0, 0, 0, 0))
+ x = (SPRITE_PX - figure.width) // 2
+ y = canvas.height - figure.height - self._bob # feet on the floor
+ canvas.paste(figure, (x, y), figure)
+ return canvas
+
+ def on_resize(self) -> None:
+ self._render_panel()
+
+ def _render_panel(self) -> None:
+ if self._player is None:
+ return
+ sprite = to_renderable(self._sprite(self._player.player_class))
+ gear = Text.from_markup("\n".join(gear_lines(self._player, self._inventory)))
+ width = self.content_size.width
+ if width and width - SPRITE_PX - 2 < GEAR_MIN_WIDTH:
+ # Too narrow to share a row: gear below the sprite, names whole.
+ self.update(Group(Align.center(sprite, width=SPRITE_PX), Text(""), gear))
+ return
+ grid = Table.grid(padding=(0, 2))
+ # Pixels doesn't report a width, so pin it (half-block: 1 px per
+ # column) or the sprite column swallows the gear text.
+ grid.add_column(width=SPRITE_PX, no_wrap=True)
+ grid.add_column(ratio=1)
+ grid.add_row(sprite, gear)
+ self.update(grid)
diff --git a/src/ui/panels/echo_panel.py b/src/ui/panels/echo_panel.py
new file mode 100644
index 0000000..88e693d
--- /dev/null
+++ b/src/ui/panels/echo_panel.py
@@ -0,0 +1,70 @@
+"""EchoPanel — the tutorial guide's current instruction, pinned above the input.
+
+Hints used to scroll through the output with everything else, where the next
+room listing or the battle panel could push them out of sight. The panel keeps
+the one thing the player should do next on screen until the next step replaces
+it, and disappears when the tutorial ends.
+"""
+from __future__ import annotations
+
+import re
+
+from textual.timer import Timer
+from textual.widgets import Static
+
+_ECHO_PREFIX = re.compile(r"^\[bold green\]ECHO>\[/bold green\]\s*")
+
+
+class EchoPanel(Static):
+ """Shows the latest tutorial hint; hidden when there is none."""
+
+ FLASH_PULSES = 3
+ FLASH_SECONDS = 0.35
+
+ _flash_timer: Timer | None = None
+
+ def on_mount(self) -> None:
+ self.clear()
+
+ def show_hint(
+ self, text: str, step: int | None, total: int, reduce_motion: bool = False,
+ ) -> None:
+ appearing = not self.display
+ self.border_title = f"🗨 ECHO · step {step} of {total}" if step else "🗨 ECHO"
+ self.update(_ECHO_PREFIX.sub("", text))
+ self.display = True
+ if appearing:
+ self._flash(reduce_motion)
+
+ def clear(self) -> None:
+ self._stop_flash()
+ self.update("")
+ self.display = False
+
+ def _flash(self, reduce_motion: bool) -> None:
+ """Draw the eye to the panel when the tutorial starts: pulse its
+ highlight a few times, then settle. With reduce motion, hold it once."""
+ self._stop_flash()
+ # One entry per tick: is the highlight on? Same total length either way.
+ if reduce_motion:
+ frames = [True] * (2 * self.FLASH_PULSES - 1) + [False]
+ else:
+ frames = [False, True] * (self.FLASH_PULSES - 1) + [False]
+ self.set_class(True, "echo-flash")
+
+ def next_frame() -> None:
+ if not frames:
+ self._stop_flash()
+ return
+ on = frames.pop(0)
+ if on != self.has_class("echo-flash"):
+ self.set_class(on, "echo-flash")
+
+ self._flash_timer = self.set_interval(self.FLASH_SECONDS, next_frame)
+
+ def _stop_flash(self) -> None:
+ if self._flash_timer is not None:
+ self._flash_timer.stop()
+ self._flash_timer = None
+ if self.has_class("echo-flash"):
+ self.set_class(False, "echo-flash")
diff --git a/src/ui/panels/inventory_panel.py b/src/ui/panels/inventory_panel.py
index bb18b99..e03cdd4 100644
--- a/src/ui/panels/inventory_panel.py
+++ b/src/ui/panels/inventory_panel.py
@@ -2,10 +2,14 @@
InventoryPanel widget — renders the player's inventory in the sidebar.
"""
+from __future__ import annotations
+
from textual.widgets import Static
import logging
+from src.item_icons import item_icon
from src.rarity import RaritySystem
+from engine.view_models import InventoryItemView, InventoryView
logger = logging.getLogger(__name__)
@@ -13,21 +17,21 @@
class InventoryPanel(Static):
"""Sidebar panel that displays the player's inventory."""
- def update_inventory(self, inventory_view: dict) -> None:
- """Render inventory from an InventoryView dict."""
- if not inventory_view:
+ def update_inventory(self, inventory_view: InventoryView | None) -> None:
+ """Render inventory from an InventoryView."""
+ if inventory_view is None:
return
- items = inventory_view.get('items', [])
+ items = inventory_view.items
if not items:
content = "[dim italic]Empty inventory[/dim italic]"
else:
# Stack items by name — count duplicates
- item_counts: dict = {}
+ item_counts: dict[str, tuple[int, InventoryItemView, bool]] = {}
for item in items:
- name = item.get("name", item.get("id"))
- is_equipped = item.get("is_equipped", False)
+ name = item.name
+ is_equipped = item.is_equipped
if name in item_counts:
count, data, was_equipped = item_counts[name]
@@ -40,17 +44,17 @@ def update_inventory(self, inventory_view: dict) -> None:
for name, (count, data, equipped) in item_counts.items()
]
stacked_items.sort(
- key=lambda x: (-RaritySystem.get_rarity_order(x[2].get("rarity", "common")), x[0])
+ key=lambda x: (-RaritySystem.get_rarity_order(x[2].rarity), x[0])
)
inventory_lines = []
- rarity_sections: dict = {}
+ rarity_sections: dict[str, list[str]] = {}
for name, count, item_data, is_equipped in stacked_items:
- rarity = item_data.get("rarity", "common")
+ rarity = item_data.rarity
if rarity not in rarity_sections:
rarity_sections[rarity] = []
item_display = self._format_enhanced_inventory_item(
- item_data.get("id"), item_data, is_equipped, count
+ item_data.id, item_data, is_equipped, count
)
rarity_sections[rarity].append(item_display)
@@ -67,7 +71,7 @@ def update_inventory(self, inventory_view: dict) -> None:
self.add_class("panel-update")
self.update(content)
- def remove_update_class():
+ def remove_update_class() -> None:
try:
self.remove_class("panel-update")
except Exception:
@@ -80,29 +84,24 @@ def remove_update_class():
# ------------------------------------------------------------------
def _format_enhanced_inventory_item(
- self, item_id: str, item_data: dict, is_equipped: bool = False, count: int = 1
+ self, item_id: str, item_data: InventoryItemView, is_equipped: bool = False, count: int = 1
) -> str:
"""Format an inventory item with enhanced visual styling."""
- name = item_data.get("name", item_id)
- rarity = item_data.get("rarity", "common")
+ name = item_data.name
+ rarity = item_data.rarity
rarity_color = RaritySystem.get_rarity_color(rarity)
item_text = f"[{rarity_color}]{name}[/{rarity_color}]"
count_text = f" [bold yellow]x{count}[/bold yellow]" if count > 1 else ""
stat_info = ""
- item_type = item_data.get("item_type", item_data.get("type", ""))
-
- if item_type == "weapon" and "damage" in item_data:
- damage = item_data["damage"]
- stat_info = f" [cyan]+{damage} DMG[/cyan]"
- elif item_type == "consumable":
- if "combat_effects" in item_data and "player_heal" in item_data["combat_effects"]:
- heal = item_data["combat_effects"]["player_heal"]
- stat_info = f" [green]+{heal} HP[/green]"
- elif "on_use" in item_data and "heal" in item_data["on_use"]:
- heal = item_data["on_use"]["heal"]
- stat_info = f" [green]+{heal} HP[/green]"
+ item_type = item_data.item_type
+
+ if item_type == "weapon":
+ stat_info = f" [cyan]+{item_data.damage} DMG[/cyan]"
+ elif item_data.healing:
+ over = f" over {item_data.healTurns} turns" if item_data.healTurns else ""
+ stat_info = f" [green]+{item_data.healing} HP{over}[/green]"
equipped_indicator = " [green bold]⚡EQUIPPED[/green bold]" if is_equipped else ""
type_icon = self._get_item_type_icon(item_type)
@@ -110,16 +109,4 @@ def _format_enhanced_inventory_item(
return f"{type_icon} {item_text}{count_text}{stat_info}{equipped_indicator}"
def _get_item_type_icon(self, item_type: str) -> str:
- """Get an appropriate icon for item type."""
- # Plain glyphs (no U+FE0F variation selector, which jams against the
- # following text in many terminals).
- icons = {
- "weapon": "🗡",
- "consumable": "🧪",
- "script": "📜",
- "key": "🗝",
- "armor": "🛡",
- "tool": "🔧",
- "misc": "📦",
- }
- return icons.get(item_type, "📄")
+ return item_icon(item_type)
diff --git a/src/ui/panels/scene_view.py b/src/ui/panels/scene_view.py
index e648752..06d132e 100644
--- a/src/ui/panels/scene_view.py
+++ b/src/ui/panels/scene_view.py
@@ -3,7 +3,10 @@
Collapses to a one-line room strip when the terminal is too short for art.
Rendering pipeline: SpriteStore (PIL) → compositor (PIL) → rich-pixels → Rich Group.
"""
+from __future__ import annotations
+
import time
+from dataclasses import replace
from rich.console import Group
from rich.text import Text
@@ -20,9 +23,11 @@
lunge_offset,
)
from src.scene.sprite_store import SpriteStore, to_renderable
+from engine.view_models import CombatView, RoomView, StatsView
MIN_SCENE_ROWS = 10 # below this, fall back to strip text
SPRITE_MAX_PX = 24 # character sprites fit a 24×24 px box
+_NO_ROOM = RoomView(name="", description="")
class SceneView(Static):
@@ -35,7 +40,7 @@ class SceneView(Static):
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._store = SpriteStore()
- self._room: dict | None = None
+ self._room: RoomView | None = None
self._battle: dict | None = None
self._ticker = None
self._bob_timer = None
@@ -43,7 +48,7 @@ def __init__(self, **kwargs):
# -- public API (called by TextualGameUI) --------------------------------
- def show_explore(self, room: dict) -> None:
+ def show_explore(self, room: RoomView) -> None:
self._room = room
self._render_scene()
@@ -54,16 +59,18 @@ def show_loading(self) -> None:
# -- battle mode ----------------------------------------------------------
- def show_battle(self, combat_view: dict, player_view: dict, reduce_motion: bool = False) -> None:
+ def show_battle(
+ self, combat_view: CombatView, player_view: StatsView | None, reduce_motion: bool = False
+ ) -> None:
"""Enter battle mode. Called once per combat (COMBAT_STARTED)."""
self._battle = {
"view": combat_view,
- "player_name": player_view.get("player_name", "You"),
- "player_class": player_view.get("player_class", ""),
+ "player_name": player_view.player_name if player_view else "You",
+ "player_class": player_view.player_class if player_view else "",
"reduce_motion": reduce_motion,
# displayed HP floats — drained smoothly toward the view's targets
- "shown_player_hp": float(combat_view.get("player_health", 0)),
- "shown_enemy_hp": float(combat_view.get("enemy_health", 0)),
+ "shown_player_hp": float(combat_view.player_health),
+ "shown_enemy_hp": float(combat_view.enemy_health),
"fx_kind": None, # "damage" | "heal"
"fx_actor": None, # "player" | "enemy"
"fx_started": 0.0,
@@ -78,26 +85,26 @@ def show_battle(self, combat_view: dict, player_view: dict, reduce_motion: bool
if not reduce_motion and self._bob_timer is None:
self._bob_timer = self.set_interval(0.2, self._bob_tick)
- def update_battle(self, combat_view: dict) -> None:
+ def update_battle(self, combat_view: CombatView) -> None:
"""Per-turn frame update (COMBAT_FRAME_UPDATED): new HP targets."""
if not self._battle:
return
b = self._battle
prev = b["view"]
- new_key = combat_view.get("enemy_id") or combat_view.get("enemy_name")
- old_key = prev.get("enemy_id") or prev.get("enemy_name")
+ new_key = combat_view.enemy_id or combat_view.enemy_name
+ old_key = prev.enemy_id or prev.enemy_name
if new_key != old_key:
# Queue handoff: the next enemy walks in — fresh sprite, full bar.
b["enemy_dead"] = False
- b["shown_enemy_hp"] = float(combat_view.get("enemy_health", 0))
+ b["shown_enemy_hp"] = float(combat_view.enemy_health)
b["pop_text"] = ""
b["view"] = combat_view
self._render_battle(self._bob_fx())
return
self._battle["view"] = combat_view
if self._battle["reduce_motion"]:
- self._battle["shown_player_hp"] = float(combat_view.get("player_health", 0))
- self._battle["shown_enemy_hp"] = float(combat_view.get("enemy_health", 0))
+ self._battle["shown_player_hp"] = float(combat_view.player_health)
+ self._battle["shown_enemy_hp"] = float(combat_view.enemy_health)
self._render_battle(FxState())
else:
self._ensure_ticker()
@@ -142,7 +149,7 @@ def defeat_enemy(self) -> None:
if not self._battle:
return
b = self._battle
- b["view"]["enemy_health"] = 0
+ b["view"] = replace(b["view"], enemy_health=0)
b["enemy_dead"] = True
if b["reduce_motion"]:
b["shown_enemy_hp"] = 0.0
@@ -203,14 +210,14 @@ def play_death(self, reduce_motion: bool = False) -> None:
def _render_finale(self, brightness: float) -> None:
from PIL import ImageEnhance
- room = self._room or {}
+ room = self._room or _NO_ROOM
w_cells = max(20, self.content_size.width or 60)
h_rows = self.content_size.height or 0
if h_rows < MIN_SCENE_ROWS:
self.update(Text.from_markup("[bold green]✨ The corruption lifts.[/bold green]"))
return
img_w, img_h = w_cells, h_rows * 2
- backdrop = self._store.get_backdrop(room.get("id", ""), room.get("zone", ""), img_w, img_h)
+ backdrop = self._store.get_backdrop(room.id, room.zone, img_w, img_h)
# Normalized against the install-time 0.62 dim: 1.15 reads "cleaner than ever".
bright = ImageEnhance.Brightness(backdrop).enhance(brightness / 0.62)
self.update(to_renderable(bright))
@@ -262,8 +269,8 @@ def _tick_battle(self) -> None:
view = b["view"]
# HP drain toward targets
- b["shown_player_hp"] = approach(b["shown_player_hp"], float(view.get("player_health", 0)), dt)
- b["shown_enemy_hp"] = approach(b["shown_enemy_hp"], float(view.get("enemy_health", 0)), dt)
+ b["shown_player_hp"] = approach(b["shown_player_hp"], float(view.player_health), dt)
+ b["shown_enemy_hp"] = approach(b["shown_enemy_hp"], float(view.enemy_health), dt)
# Active lunge/flash
fx = FxState()
@@ -286,8 +293,8 @@ def _tick_battle(self) -> None:
# Idle again? stop burning CPU.
hp_settled = (
- b["shown_player_hp"] == float(view.get("player_health", 0))
- and b["shown_enemy_hp"] == float(view.get("enemy_health", 0))
+ b["shown_player_hp"] == float(view.player_health)
+ and b["shown_enemy_hp"] == float(view.enemy_health)
)
if hp_settled and not b["fx_kind"] and not b["pop_text"]:
self._stop_ticker()
@@ -297,29 +304,29 @@ def _render_battle(self, fx: FxState) -> None:
if not b:
return
view = b["view"]
- room = self._room or {}
+ room = self._room or _NO_ROOM
w_cells = max(20, self.content_size.width or 60)
h_rows = self.content_size.height or 0
if h_rows < MIN_SCENE_ROWS:
self.update(Text.from_markup(
- f"[bold red]⚔ {view.get('enemy_name', '?')}[/bold red] "
- f"{int(b['shown_enemy_hp'])}/{view.get('enemy_max_health', 0)}"
+ f"[bold red]⚔ {view.enemy_name}[/bold red] "
+ f"{int(b['shown_enemy_hp'])}/{view.enemy_max_health}"
f" vs [bold green]{b['player_name']}[/bold green] "
- f"{int(b['shown_player_hp'])}/{view.get('player_max_health', 0)}"
+ f"{int(b['shown_player_hp'])}/{view.player_max_health}"
))
return
# Two text rows (nameplates) + image
img_w, img_h = w_cells, (h_rows - 2) * 2
- backdrop = self._store.get_backdrop(room.get("id", ""), room.get("zone", ""), img_w, img_h)
+ backdrop = self._store.get_backdrop(room.id, room.zone, img_w, img_h)
enemy_gone = b.get("enemy_dead") and b["shown_enemy_hp"] < 0.5
if enemy_gone:
from PIL import Image as _Image
enemy_img = _Image.new("RGBA", (1, 1), (0, 0, 0, 0)) # vanished
else:
enemy_img = self._store.get_sprite(
- "enemies", view.get("enemy_id") or view.get("enemy_name", "?"),
+ "enemies", view.enemy_id or view.enemy_name,
SPRITE_MAX_PX, SPRITE_MAX_PX,
)
player_img = self._store.get_sprite("classes", b["player_class"], SPRITE_MAX_PX, SPRITE_MAX_PX)
@@ -328,14 +335,14 @@ def _render_battle(self, fx: FxState) -> None:
pop = b["pop_text"] # expiry is handled by the ticker / _clear_pop
if enemy_gone:
- enemy_line = f"[dim]💀 {view.get('enemy_name', '?').upper()} — defeated[/dim]"
+ enemy_line = f"[dim]💀 {view.enemy_name.upper()} — defeated[/dim]"
else:
enemy_line = nameplate(
- view.get("enemy_name", "?"), int(b["shown_enemy_hp"]), view.get("enemy_max_health", 1),
+ view.enemy_name, int(b["shown_enemy_hp"]), view.enemy_max_health,
icon="💀", pop=pop if b["pop_target"] == "enemy" else "",
)
player_line = nameplate(
- b["player_name"], int(b["shown_player_hp"]), view.get("player_max_health", 1),
+ b["player_name"], int(b["shown_player_hp"]), view.player_max_health,
icon=self._CLASS_ICONS.get(b["player_class"], "🧙"), pop=pop if b["pop_target"] == "player" else "",
)
self.update(Group(
@@ -353,9 +360,9 @@ def on_resize(self) -> None:
self._render_scene()
def _render_scene(self) -> None:
- room = self._room or {}
- name = room.get("name", "")
- exits = room.get("exits", [])
+ room = self._room or _NO_ROOM
+ name = room.name
+ exits = room.exits
self.border_title = f"🏠 {name}"
self.border_subtitle = " ".join(f"→ {e}" for e in exits) or "no exits"
@@ -367,14 +374,14 @@ def _render_scene(self) -> None:
# 1 cell = 1 px wide × 2 px tall; reserve 1 row for the caption line
img_w, img_h = w_cells, (h_rows - 1) * 2
- backdrop = self._store.get_backdrop(room.get("id", ""), room.get("zone", ""), img_w, img_h)
+ backdrop = self._store.get_backdrop(room.id, room.zone, img_w, img_h)
entities = [
Placed(self._store.get_sprite("npcs", nid, SPRITE_MAX_PX, SPRITE_MAX_PX), nname, "npc")
- for nid, nname in zip(room.get("npc_ids", []), room.get("npcs", []))
+ for nid, nname in zip(room.npc_ids, room.npcs)
] + [
Placed(self._store.get_sprite("enemies", eid, SPRITE_MAX_PX, SPRITE_MAX_PX), ename, "enemy")
- for eid, ename in zip(room.get("enemy_ids", []), room.get("enemies", []))
+ for eid, ename in zip(room.enemy_ids, room.enemies)
]
img, caption = compose_explore(backdrop, entities)
@@ -383,12 +390,12 @@ def _render_scene(self) -> None:
self.update(Group(*parts))
@staticmethod
- def _strip_fallback(room: dict) -> Text:
+ def _strip_fallback(room: RoomView) -> Text:
"""One-line summary for short terminals (the old strips' job)."""
bits = []
- for n in room.get("npcs", []):
+ for n in room.npcs:
bits.append(f"[bold magenta]👤 {n}[/bold magenta]")
- for n in room.get("enemies", []):
+ for n in room.enemies:
bits.append(f"[bold red]💀 {n}[/bold red]")
present = (" " + " ".join(bits)) if bits else ""
return Text.from_markup(f"[dim]scene needs a taller terminal[/dim]{present}")
diff --git a/src/ui/panels/stats_panel.py b/src/ui/panels/stats_panel.py
index 521130c..cff8c83 100644
--- a/src/ui/panels/stats_panel.py
+++ b/src/ui/panels/stats_panel.py
@@ -2,38 +2,41 @@
StatsPanel widget — renders player stats in the sidebar.
"""
+from __future__ import annotations
+
from textual.widgets import Static
from src.ui.panels import class_icon, create_health_bar
+from engine.view_models import CombatView, StatsView
class StatsPanel(Static):
"""Sidebar panel that displays player stats."""
- def update_stats(self, player_view: dict) -> None:
- """Render exploration-mode stats from a StatsView dict."""
- if not player_view:
+ def update_stats(self, player_view: StatsView | None) -> None:
+ """Render exploration-mode stats."""
+ if player_view is None:
return
stats_lines = []
- player_name = player_view.get('player_name', 'Unknown')
- player_class = player_view.get('player_class', 'Unknown')
+ player_name = player_view.player_name
+ player_class = player_view.player_class
stats_lines.append(
f"[bold green]{class_icon(player_class)} {player_name.upper()}[/bold green]"
)
stats_lines.append(f"Class: {player_class.title()}")
- level = player_view.get('level', 1)
- cycles = player_view.get('cycles', 0)
- to_next = player_view.get('cycles_to_next', 0)
+ level = player_view.level
+ cycles = player_view.cycles
+ to_next = player_view.cycles_to_next
if to_next:
stats_lines.append(f"[yellow]Lvl {level}[/] [dim]· {cycles}/{to_next} cycles[/]")
else:
stats_lines.append(f"[yellow]Lvl {level}[/]")
- health = player_view.get('health', 0)
- max_health = player_view.get('max_health', 100)
+ health = player_view.health
+ max_health = player_view.max_health
if max_health > 0:
health_percent = health / max_health
@@ -47,42 +50,42 @@ def update_stats(self, player_view: dict) -> None:
health_bar,
])
- damage = player_view.get('damage', 0)
+ damage = player_view.damage
stats_lines.extend([
"",
f"[cyan]Attack: {damage}[/]",
])
- defense_pct = player_view.get('defense_pct', 0)
+ defense_pct = player_view.defense_pct
if defense_pct:
stats_lines.append(f"[cyan]Defense: -{defense_pct}% dmg taken[/]")
self.update("\n".join(stats_lines))
- def refresh_combat(self, player_view: dict, combat_view: dict) -> None:
+ def refresh_combat(self, player_view: StatsView | None, combat_view: CombatView | None) -> None:
"""Render combat-mode stats."""
- if not player_view:
+ if player_view is None:
return
stats_lines = []
- player_name = player_view.get('player_name', 'Unknown')
- player_class = player_view.get('player_class', 'Unknown')
+ player_name = player_view.player_name
+ player_class = player_view.player_class
stats_lines.append(
f"[bold green]{class_icon(player_class)} {player_name.upper()}[/bold green]"
)
stats_lines.append(f"Class: {player_class.title()}")
- level = player_view.get('level', 1)
- cycles = player_view.get('cycles', 0)
- to_next = player_view.get('cycles_to_next', 0)
+ level = player_view.level
+ cycles = player_view.cycles
+ to_next = player_view.cycles_to_next
if to_next:
stats_lines.append(f"[yellow]Lvl {level}[/] [dim]· {cycles}/{to_next} cycles[/]")
else:
stats_lines.append(f"[yellow]Lvl {level}[/]")
- if combat_view:
- health = combat_view.get('player_health', 0)
- max_health = combat_view.get('player_max_health', 100)
+ if combat_view is not None:
+ health = combat_view.player_health
+ max_health = combat_view.player_max_health
if max_health > 0:
health_percent = health / max_health
@@ -105,12 +108,12 @@ def refresh_combat(self, player_view: dict, combat_view: dict) -> None:
enhanced_health_bar,
])
- base_attack = player_view.get('damage', 0)
+ base_attack = player_view.damage
stats_lines.extend([
"",
f"[cyan]Base ATK: {base_attack}[/]",
])
- defense_pct = player_view.get('defense_pct', 0)
+ defense_pct = player_view.defense_pct
if defense_pct:
stats_lines.append(f"[cyan]Defense: -{defense_pct}% dmg taken[/]")
@@ -120,7 +123,7 @@ def refresh_combat(self, player_view: dict, combat_view: dict) -> None:
# Internal helpers
# ------------------------------------------------------------------
- def _create_health_bar(self, current, maximum, color, bar_length=10) -> str:
+ def _create_health_bar(self, current: int, maximum: int, color: str, bar_length: int = 10) -> str:
"""Create an ASCII health bar with customizable length."""
if maximum <= 0:
empty_bar = "▒" * bar_length
diff --git a/src/ui/screens/combat_hint.py b/src/ui/screens/combat_hint.py
index e74ab81..e4e0530 100644
--- a/src/ui/screens/combat_hint.py
+++ b/src/ui/screens/combat_hint.py
@@ -43,7 +43,7 @@ def on_mount(self) -> None:
• Press [bold cyan]0[/bold cyan]
[bold green]To use an item instead:[/bold green]
- • Press [bold cyan]TAB[/bold cyan] to type [bold]use [item][/bold], then
+ • Press [bold cyan]TAB[/bold cyan] to type [bold]use - [/bold], then
[bold cyan]TAB[/bold cyan] again to return to Selection Mode
[dim]────────────────────────────────────────────────────────────────[/dim]
diff --git a/src/ui/screens/quit_confirm.py b/src/ui/screens/quit_confirm.py
index f6818d1..e210f6e 100644
--- a/src/ui/screens/quit_confirm.py
+++ b/src/ui/screens/quit_confirm.py
@@ -3,7 +3,7 @@
This used to be three typed letters (y/n/c) buried in a paragraph of output, so
the most consequential decision in the game was also the least legible one. The
-modal presents the three outcomes as a highlighted list: arrow keys to move,
+modal presents each outcome as a highlighted list: arrow keys to move,
Enter to confirm, ESC to back out. The letters still work for anyone who learned
them.
@@ -19,30 +19,45 @@
from textual.screen import ModalScreen
from textual.widgets import Static
-# (letter the domain expects, label, help line, accent)
-CHOICES: list[tuple[str, str, str, str]] = [
+Choice = tuple[str, str, str, str] # (letter the domain expects, label, help line, accent)
+
+LEAVE_CHOICES: list[Choice] = [
+ ("m", "Save and main menu", "Write a save file, then go to the title menu.", "cyan"),
+ ("x", "Main menu without saving", "Lose everything since your last save.", "magenta"),
("y", "Save and quit", "Write a save file, then leave.", "green"),
("n", "Quit without saving", "Lose everything since your last save.", "red"),
- ("c", "Keep playing", "Go back to where you were.", "cyan"),
+ ("c", "Keep playing", "Go back to where you were.", "yellow"),
+]
+
+MID_FIGHT_CHOICES: list[Choice] = [
+ ("c", "Keep fighting", "Back to the fight.", "green"),
+ ("x", "Main menu", "Your last autosave is kept; this fight is lost.", "cyan"),
+ ("n", "Quit", "Your last autosave is kept; this fight is lost.", "red"),
]
class QuitConfirmScreen(ModalScreen):
- """Arrow-key confirmation for quitting with unsaved progress."""
+ """Arrow-key chooser for leaving a run (or a fight)."""
BINDINGS = [
Binding("up", "move_up", "Up", show=False),
Binding("down", "move_down", "Down", show=False),
Binding("enter", "confirm", "Choose", show=False),
- Binding("escape", "cancel", "Keep playing", show=False),
- Binding("y", "pick_save", "Save and quit", show=False),
- Binding("n", "pick_quit", "Quit", show=False),
- Binding("c", "cancel", "Keep playing", show=False),
+ Binding("escape", "cancel", "Back", show=False),
+ Binding("m", "pick('m')", show=False),
+ Binding("y", "pick('y')", show=False),
+ Binding("n", "pick('n')", show=False),
+ Binding("x", "pick('x')", show=False),
+ Binding("c", "cancel", show=False),
]
- def __init__(self, on_choice: Callable[[str], None]):
+ def __init__(self, on_choice: Callable[[str], None],
+ choices: list[Choice] = LEAVE_CHOICES,
+ heading: str = "Quit — you have unsaved progress"):
super().__init__()
self._on_choice = on_choice
+ self._choices = choices
+ self._heading = heading
self._index = 0
self._answered = False
@@ -51,16 +66,13 @@ def compose(self) -> ComposeResult:
yield Static(id="quit-confirm-body")
def on_mount(self) -> None:
- self._render()
+ self._draw()
# -- rendering ------------------------------------------------------------
- def _render(self) -> None:
- lines = [
- "[bold yellow]Quit — you have unsaved progress[/bold yellow]",
- "",
- ]
- for i, (letter, label, blurb, accent) in enumerate(CHOICES):
+ def _draw(self) -> None:
+ lines = [f"[bold yellow]{self._heading}[/bold yellow]", ""]
+ for i, (letter, label, blurb, accent) in enumerate(self._choices):
if i == self._index:
lines.append(
f"[reverse bold {accent}] ▶ {letter} {label} "
@@ -69,34 +81,30 @@ def _render(self) -> None:
lines.append(f" [dim]{blurb}[/dim]")
else:
lines.append(f"[dim] {letter} {label}[/dim]")
- lines += [
- "",
- "[dim]↑/↓ choose · ↵ confirm · esc keep playing[/dim]",
- ]
+ back = next(label for letter, label, *_ in self._choices if letter == "c")
+ lines += ["", f"[dim]↑/↓ choose · ↵ confirm · esc {back.lower()}[/dim]"]
try:
self.query_one("#quit-confirm-body", Static).update("\n".join(lines))
except NoMatches:
- # Not mounted yet (or under unit test): on_mount renders again.
+ # Not mounted yet (or under unit test): on_mount draws again.
pass
# -- actions --------------------------------------------------------------
def action_move_up(self) -> None:
- self._index = (self._index - 1) % len(CHOICES)
- self._render()
+ self._index = (self._index - 1) % len(self._choices)
+ self._draw()
def action_move_down(self) -> None:
- self._index = (self._index + 1) % len(CHOICES)
- self._render()
+ self._index = (self._index + 1) % len(self._choices)
+ self._draw()
def action_confirm(self) -> None:
- self._choose(CHOICES[self._index][0])
-
- def action_pick_save(self) -> None:
- self._choose("y")
+ self._choose(self._choices[self._index][0])
- def action_pick_quit(self) -> None:
- self._choose("n")
+ def action_pick(self, letter: str) -> None:
+ if any(choice[0] == letter for choice in self._choices):
+ self._choose(letter)
def action_cancel(self) -> None:
self._choose("c")
diff --git a/src/ui/screens/save_picker.py b/src/ui/screens/save_picker.py
new file mode 100644
index 0000000..b26799d
--- /dev/null
+++ b/src/ui/screens/save_picker.py
@@ -0,0 +1,262 @@
+"""
+SavePickerScreen — LOAD GAME's run picker, and NEW GAME's "slots full, pick a
+run to replace". Runs listed on the left; the highlighted run's class art and
+details on the right. ↑/↓ choose, Enter confirm, d delete (y/n), Esc back.
+
+The screen decides nothing: it answers with the commands a typed player can
+send ("pick
", "delete ", "back") and the engine re-sends the
+picker (after a delete) or moves on. Card art comes from
+assets/sprites/ui/class_.png via SpriteStore, as on the class picker.
+"""
+from __future__ import annotations
+
+import time
+from collections.abc import Callable
+from typing import Any
+
+from rich.console import Group
+from rich.markup import escape
+from rich.padding import Padding
+from rich.text import Text
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.containers import Horizontal, Vertical
+from textual.css.query import NoMatches
+from textual.screen import ModalScreen
+from textual.widgets import Static
+
+from src.scene.effects import bob_offset
+from src.scene.sprite_store import SpriteStore, to_renderable
+
+_ART_PX = 32
+_BOB_TICK_SECONDS = 0.2
+
+CLASS_ICONS = {"guardian": "🛡", "weaver": "✨", "shaman": "🌿"}
+
+
+def saved_ago(saved_at: float, now: float | None = None) -> str:
+ """'just now', '5m ago', '2h ago', '3d ago'."""
+ seconds = max(0, int((time.time() if now is None else now) - saved_at))
+ if seconds < 60:
+ return "just now"
+ if seconds < 3600:
+ return f"{seconds // 60}m ago"
+ if seconds < 86400:
+ return f"{seconds // 3600}h ago"
+ return f"{seconds // 86400}d ago"
+
+
+class SavePickerScreen(ModalScreen):
+ """List + preview run picker."""
+
+ BINDINGS = [
+ Binding("up", "move(-1)", "Up", show=False),
+ Binding("k", "move(-1)", "Up", show=False),
+ Binding("down", "move(1)", "Down", show=False),
+ Binding("j", "move(1)", "Down", show=False),
+ Binding("enter", "confirm", "Choose", show=False),
+ Binding("d", "delete", "Delete", show=False),
+ Binding("y", "answer_delete(True)", show=False),
+ Binding("n", "answer_delete(False)", show=False),
+ Binding("escape", "back", "Back", show=False),
+ ]
+
+ CSS = """
+ SavePickerScreen {
+ align: center middle;
+ background: $background; /* opaque: hide the typed list behind the modal */
+ }
+ #save-picker {
+ width: auto;
+ height: auto;
+ }
+ #save-heading {
+ width: 100%;
+ text-align: center;
+ text-style: bold;
+ padding-bottom: 1;
+ }
+ #save-row {
+ width: auto;
+ height: auto;
+ }
+ #save-list {
+ width: 58;
+ height: auto;
+ min-height: 11;
+ border: round $panel;
+ padding: 0 1;
+ }
+ #save-preview {
+ width: 38;
+ height: auto;
+ border: round $accent;
+ padding: 0 1;
+ margin-left: 1;
+ }
+ #save-hint {
+ width: 100%;
+ text-align: center;
+ color: $text-disabled;
+ padding-top: 1;
+ }
+ """
+
+ _CONFIRM_GRACE_SECONDS = 0.25 # swallow the Enter that opened this screen
+
+ def __init__(self, mode: str, runs: list[dict[str, Any]], legacy_count: int,
+ on_command: Callable[[str], None], reduce_motion: bool = False):
+ super().__init__()
+ self._mode = mode
+ self._runs = runs
+ self._legacy = legacy_count
+ self._on_command = on_command
+ self._reduce_motion = reduce_motion
+ self._index = 0
+ self._confirming_delete = False
+ self._answered = False
+ self._store = SpriteStore()
+ self._mounted_at = 0.0
+ self._bob_timer = None
+ self._bob_start = 0.0
+
+ def compose(self) -> ComposeResult:
+ heading = (
+ "CONTINUE A RUN" if self._mode == "continue"
+ else "SLOTS FULL — PICK A RUN TO REPLACE"
+ )
+ with Vertical(id="save-picker"):
+ yield Static(heading, id="save-heading")
+ with Horizontal(id="save-row"):
+ yield Static(id="save-list")
+ if self._runs:
+ yield Static(id="save-preview")
+ yield Static(id="save-hint")
+
+ def on_mount(self) -> None:
+ self._mounted_at = time.monotonic()
+ self._bob_start = self._mounted_at
+ self._draw()
+ if self._runs and not self._reduce_motion:
+ self._bob_timer = self.set_interval(_BOB_TICK_SECONDS, self._bob_tick)
+
+ def on_unmount(self) -> None:
+ if self._bob_timer is not None:
+ self._bob_timer.stop()
+ self._bob_timer = None
+
+ # -- rendering ------------------------------------------------------------
+
+ def list_text(self) -> str:
+ if not self._runs:
+ lines = ["", "No saves available"]
+ if self._legacy:
+ noun = "save is" if self._legacy == 1 else "saves are"
+ lines.append(
+ f"[dim]({self._legacy} old {noun} from an earlier version, "
+ "kept in saves/legacy/)[/dim]"
+ )
+ return "\n".join(lines)
+ lines = []
+ for i, run in enumerate(self._runs):
+ icon = CLASS_ICONS.get(run["playerClass"], "•")
+ name = escape(f"{run['playerName'][:10]:<10}")
+ room = escape(f"{run['roomPath'][:12]:<12}")
+ cls = escape(f"{run['playerClass'].title()[:8]:<8}")
+ diff = escape(f"{run['difficulty'][:6]:<6}")
+ mark = " ✓" if run["cleared"] else ""
+ body = f"{icon} {name} {cls} · {diff} {room} L{run['level']}{mark}"
+ lines.append(
+ f"[reverse bold]▶ {body}[/reverse bold]" if i == self._index else f" {body}"
+ )
+ return "\n".join(lines)
+
+ def preview(self, bob: int = 0) -> Group:
+ run = self._runs[self._index]
+ art = to_renderable(
+ self._store.get_sprite("ui", f"class_{run['playerClass']}", _ART_PX, _ART_PX)
+ )
+ # bob shifts the art 1 row up/down without changing the panel height.
+ art = Padding(art, (bob, 0, 1 - bob, 0))
+ details = Text.assemble(
+ (f"{run['playerName'].upper()} · {run['playerClass'].upper()}\n", "bold"),
+ f"{run['difficulty']} · Level {run['level']}\n",
+ f"{run['roomPath']}\n",
+ f"HP {run['health']}/{run['maxHealth']}\n",
+ (f"saved {saved_ago(run['savedAt'])}", "dim"),
+ ("\n✓ Cleared" if run["cleared"] else "", "bold green"),
+ justify="center",
+ )
+ return Group(art, details)
+
+ def hint_text(self) -> str:
+ if self._confirming_delete:
+ name = escape(self._runs[self._index]["playerName"])
+ return f"[bold yellow]Delete {name}'s run?[/bold yellow] y / n"
+ if not self._runs:
+ return "esc back"
+ enter = "continue" if self._mode == "continue" else "replace"
+ delete = " · d delete" if self._mode == "continue" else ""
+ return f"↑/↓ choose · enter {enter}{delete} · esc back"
+
+ def _draw(self, bob: int = 0) -> None:
+ try:
+ self.query_one("#save-list", Static).update(self.list_text())
+ self.query_one("#save-hint", Static).update(self.hint_text())
+ if self._runs:
+ self.query_one("#save-preview", Static).update(self.preview(bob))
+ except NoMatches:
+ # Not mounted yet (or under unit test): on_mount draws again.
+ pass
+
+ def _bob_tick(self) -> None:
+ if not self.is_mounted or self._answered:
+ return
+ self._draw(bob=bob_offset(time.monotonic() - self._bob_start))
+
+ # -- actions --------------------------------------------------------------
+
+ def action_move(self, delta: int) -> None:
+ if not self._runs or self._confirming_delete:
+ return
+ self._index = (self._index + delta) % len(self._runs)
+ self._draw()
+
+ def action_confirm(self) -> None:
+ if self._confirming_delete:
+ return
+ if self._mounted_at and time.monotonic() - self._mounted_at < self._CONFIRM_GRACE_SECONDS:
+ return
+ if not self._runs:
+ self._send("back")
+ return
+ self._send(f"pick {self._runs[self._index]['runId']}")
+
+ def action_delete(self) -> None:
+ if self._mode != "continue" or not self._runs or self._confirming_delete:
+ return
+ self._confirming_delete = True
+ self._draw()
+
+ def action_answer_delete(self, yes: bool) -> None:
+ if not self._confirming_delete:
+ return
+ self._confirming_delete = False
+ if yes:
+ self._send(f"delete {self._runs[self._index]['runId']}")
+ return
+ self._draw()
+
+ def action_back(self) -> None:
+ if self._confirming_delete:
+ self._confirming_delete = False
+ self._draw()
+ return
+ self._send("back")
+
+ def _send(self, command: str) -> None:
+ # One answer per screen: the engine replaces or closes this screen.
+ if self._answered:
+ return
+ self._answered = True
+ self._on_command(command)
diff --git a/src/ui/screens/settings_screen.py b/src/ui/screens/settings_screen.py
index fa1881e..ff4f1fa 100644
--- a/src/ui/screens/settings_screen.py
+++ b/src/ui/screens/settings_screen.py
@@ -1,14 +1,25 @@
"""
-SettingsScreen — modal for palette, text speed, and reduce motion settings.
-Opens via Ctrl+P, closes with ESC.
+SettingsScreen — one row per setting: ↑/↓ choose a row, ←/→ change its value.
+
+Changes apply live (the palette recolors as you cycle); Esc writes
+config/user_settings.json and closes. Opened from the title menu's SETTINGS
+entry and from Ctrl+P in-game.
"""
+from __future__ import annotations
+
import logging
+from collections.abc import Callable
+from dataclasses import dataclass
+from typing import Any
+
from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.containers import Vertical
+from textual.css.query import NoMatches
from textual.screen import ModalScreen
-from textual.containers import Container
-from textual.widgets import Static, RadioSet, RadioButton, Switch, Label
+from textual.widgets import Static
-from config.settings_manager import SettingsManager, PALETTE_DISPLAY_NAMES
+from config.settings_manager import PALETTE_DISPLAY_NAMES, SettingsManager
logger = logging.getLogger(__name__)
@@ -17,115 +28,131 @@
SPEED_KEYS = ["normal", "fast", "off"]
-class SettingsScreen(ModalScreen):
- """Full-screen modal for user settings."""
+@dataclass(frozen=True)
+class SettingRow:
+ """One setting: where it lives, what it offers, and how to apply a value."""
+ key: str # key in SettingsManager.settings
+ label: str
+ description: str
+ values: tuple[Any, ...]
+ names: tuple[str, ...] # display name for each value
+ apply: Callable[[SettingsManager, Any], None]
+
+
+ROWS: tuple[SettingRow, ...] = (
+ SettingRow(
+ "theme", "Color palette", "Recolor the whole interface.",
+ tuple(PALETTE_KEYS), tuple(PALETTE_DISPLAY_NAMES[k] for k in PALETTE_KEYS),
+ SettingsManager.apply_theme,
+ ),
+ SettingRow(
+ "text_speed", "Text speed", "How fast story text types out.",
+ tuple(SPEED_KEYS), ("Normal", "Fast", "Off"),
+ SettingsManager.set_text_speed,
+ ),
+ SettingRow(
+ "reduce_motion", "Reduce motion", "Skip the intro and all animations.",
+ (False, True), ("Off", "On"),
+ SettingsManager.set_reduce_motion,
+ ),
+ SettingRow(
+ "hints", "In-game hints",
+ "Show take/cat/cd hints while exploring and a heal reminder when HP is low.",
+ (True, False), ("On", "Off"),
+ SettingsManager.set_hints,
+ ),
+)
+
- BINDINGS = [("escape", "dismiss", "Close")]
+class SettingsScreen(ModalScreen):
+ """Arrow-row settings modal."""
+
+ BINDINGS = [
+ Binding("up", "move(-1)", "Up", show=False),
+ Binding("k", "move(-1)", "Up", show=False),
+ Binding("down", "move(1)", "Down", show=False),
+ Binding("j", "move(1)", "Down", show=False),
+ Binding("left", "change(-1)", "Previous value", show=False),
+ Binding("h", "change(-1)", "Previous value", show=False),
+ Binding("right", "change(1)", "Next value", show=False),
+ Binding("l", "change(1)", "Next value", show=False),
+ Binding("escape", "close", "Back", show=False),
+ ]
CSS = """
SettingsScreen {
align: center middle;
}
#settings-content {
- width: 52;
+ width: 60;
height: auto;
border: round $warning;
background: $surface;
padding: 1 2;
}
- #settings-title {
- text-align: center;
- text-style: bold;
- padding-bottom: 1;
- border-bottom: solid $panel;
- }
- .settings-label {
- color: $text-muted;
- text-style: bold;
- padding-top: 1;
- padding-bottom: 0;
- }
- #settings-hint {
- text-align: center;
- color: $text-disabled;
- padding-top: 1;
- border-top: solid $panel;
- }
- .settings-disabled {
- color: $text-disabled;
- opacity: 0.4;
- }
"""
- def __init__(self, manager: SettingsManager):
+ def __init__(self, manager: SettingsManager,
+ on_close: Callable[[], None] | None = None):
super().__init__()
self._manager = manager
+ self._on_close = on_close
+ self._index = 0
def compose(self) -> ComposeResult:
- s = self._manager.settings
- current_theme = s.get("theme", "default")
- current_speed = s.get("text_speed", "normal")
- current_motion = s.get("reduce_motion", False)
- current_hints = s.get("hints", True)
-
- with Container(id="settings-content"):
- yield Static("⚙ Settings", id="settings-title")
-
- yield Label("🎨 Color Palette", classes="settings-label")
- yield RadioSet(
- *[
- RadioButton(PALETTE_DISPLAY_NAMES[key], value=(key == current_theme))
- for key in PALETTE_KEYS
- ],
- id="palette-radio",
- )
-
- yield Label("⌨ Text Animation Speed", classes="settings-label")
- yield RadioSet(
- RadioButton("Normal", value=(current_speed == "normal")),
- RadioButton("Fast", value=(current_speed == "fast")),
- RadioButton("Off", value=(current_speed == "off")),
- id="speed-radio",
- )
-
- yield Label("Reduce Motion", classes="settings-label")
- yield Static("[dim]Skip intro & all animations[/dim]")
- yield Switch(value=current_motion, id="reduce-motion-switch")
-
- yield Label("In-game hints", classes="settings-label")
- yield Static("[dim]Show → take/cat/cd command hints while exploring[/dim]")
- yield Switch(value=current_hints, id="hints-switch")
-
- yield Static(
- "[dim]🔊 Sound Volume — coming soon[/dim]",
- classes="settings-disabled",
- )
- yield Static(
- "Space/Enter select · Tab navigate · ESC close",
- id="settings-hint",
- )
-
- # ------------------------------------------------------------------
- # Event handlers
- # ------------------------------------------------------------------
-
- def on_radio_set_changed(self, event: RadioSet.Changed) -> None:
- """Handle palette or speed selection."""
- if event.radio_set.id == "palette-radio":
- key = PALETTE_KEYS[event.index]
- self._manager.apply_theme(key)
- elif event.radio_set.id == "speed-radio":
- key = SPEED_KEYS[event.index]
- self._manager.set_text_speed(key)
-
- def on_switch_changed(self, event: Switch.Changed) -> None:
- """Handle toggles."""
- if event.switch.id == "reduce-motion-switch":
- self._manager.set_reduce_motion(event.value)
- elif event.switch.id == "hints-switch":
- self._manager.set_hints(event.value)
-
- def action_dismiss(self) -> None:
- """ESC closes and saves."""
+ with Vertical(id="settings-content"):
+ yield Static(id="settings-body")
+
+ def on_mount(self) -> None:
+ self._draw()
+
+ # -- rendering ------------------------------------------------------------
+
+ def _value_index(self, row: SettingRow) -> int:
+ current = self._manager.settings.get(row.key, row.values[0])
+ try:
+ return row.values.index(current)
+ except ValueError:
+ return 0
+
+ def value_name(self, row: SettingRow) -> str:
+ return row.names[self._value_index(row)]
+
+ def _draw(self) -> None:
+ lines = ["[bold]⚙ Settings[/bold]", ""]
+ for i, row in enumerate(ROWS):
+ name = self.value_name(row)
+ if i == self._index:
+ lines.append(f"[reverse bold] ▶ {row.label:<16} ◀ {name} ▶ [/reverse bold]")
+ else:
+ lines.append(f"[dim] {row.label:<16} {name}[/dim]")
+ lines += [
+ "",
+ f"[dim italic]{ROWS[self._index].description}[/dim italic]",
+ "",
+ "[dim]↑/↓ choose · ←/→ change · esc back[/dim]",
+ ]
+ try:
+ self.query_one("#settings-body", Static).update("\n".join(lines))
+ except NoMatches:
+ # Not mounted yet (or under unit test): on_mount draws again.
+ pass
+
+ # -- actions --------------------------------------------------------------
+
+ def action_move(self, delta: int) -> None:
+ self._index = (self._index + delta) % len(ROWS)
+ self._draw()
+
+ def action_change(self, delta: int) -> None:
+ row = ROWS[self._index]
+ value = row.values[(self._value_index(row) + delta) % len(row.values)]
+ row.apply(self._manager, value)
+ self._draw()
+
+ def action_close(self) -> None:
+ """Esc writes the settings file and goes back."""
self._manager.save()
self.dismiss()
+ if self._on_close is not None:
+ self._on_close()
diff --git a/src/ui/textual_ui.py b/src/ui/textual_ui.py
index a25bb63..b655923 100644
--- a/src/ui/textual_ui.py
+++ b/src/ui/textual_ui.py
@@ -16,29 +16,39 @@
from textual.widgets import Footer, Static, Input
from textual.containers import Container, VerticalScroll, Horizontal, Vertical
from textual.reactive import var
+from textual.screen import ModalScreen
from rich.text import Text
from src.ui.ui_interface import UIInitializationError, UIStateError
-from src.events import event_bus, EventType
+from engine.events import EventBus, EventType
from src.game_states import GameState, UIState
-from src.state_manager import state_manager
-from utils.typewriter import TypewriterPresets, request_skip as request_typewriter_skip
+from src.state_manager import StateManager
+from engine.view_models import AttackView, CombatView, InventoryView, RoomView, StatsView
from config.dev_config import SKIP_INTRO
+from src.ui.panels.echo_panel import EchoPanel
from src.ui.panels.inventory_panel import InventoryPanel
from src.ui.panels.stats_panel import StatsPanel
+from src.ui.panels.character_panel import CharacterPanel
from src.ui.panels.scene_view import SceneView
from src.ui.screens.combat_hint import CombatModeHintScreen
from src.ui.screens.log_viewer import LogViewerScreen
-from src.ui.screens.quit_confirm import QuitConfirmScreen
+from src.ui.screens.quit_confirm import MID_FIGHT_CHOICES, QuitConfirmScreen
+from src.ui.screens.save_picker import SavePickerScreen
from src.ui.screens.selection_screen import SelectionCard, SelectionScreen
from src.ui.screens.settings_screen import SettingsScreen
+from src.ui.combat_log import render_combat_output
+from src.ui.command_input import CommandInput
from src.ui.command_suggester import CommandSuggester
+from src.ui.endings import FinaleReveal
+from src.ui.title_menu import TitleMenu
from config.settings_manager import SettingsManager
import logging
import os
+from collections.abc import Callable
import threading
+from dataclasses import replace
from typing import Optional
logger = logging.getLogger(__name__)
@@ -57,6 +67,8 @@ class TextualGameUI(App):
Binding("ctrl+c", "request_quit", "Quit", show=False, priority=True),
Binding("ctrl+p", "open_settings", "Settings", key_display="ctrl + p"),
Binding("l", "toggle_log_viewer", "Show/Hide Logs", key_display="L"),
+ # Priority, so it reaches the app even while the command box has focus.
+ Binding("f12", "toggle_log_viewer", "Logs", show=False, priority=True),
Binding("f5", "restart_game", "Restart Game", key_display="F5"),
]
@@ -66,32 +78,37 @@ class TextualGameUI(App):
def __init__(self, *args, **kwargs):
# Initialize state BEFORE calling super().__init__()
- # Note: Game state is now managed by StateManager singleton
# Store view data (dicts) instead of backend objects
self._settings_manager = SettingsManager()
self._settings_manager.load()
- self._player_view = {} # StatsView dict
- self._inventory_view = {} # InventoryView dict
- self._room_view = {} # RoomView dict
- self._combat_view = {} # CombatView dict
+ self._player_view: Optional[StatsView] = None
+ self._inventory_view: Optional[InventoryView] = None
+ self._room_view: Optional[RoomView] = None
+ self._combat_view: Optional[CombatView] = None
self._combat_log = [] # Store recent combat actions
self._bound_combat_keys = [] # Track bound combat keys
- self._available_attacks = [] # Attack list from combat view data
+ self._available_attacks: list[AttackView] = []
self._combat_hint_shown = False # Track if selection mode hint was shown
self._player_ref = None # Set by game engine after player creation; used for tutorial checks
self._world_ref = None # Set by game engine; used by autocomplete suggester
- self._room_aliases_ref: dict = {} # Populated from CommandHandler for cd autocomplete
- # Intro / main-menu state (arrow-key navigation)
- self._menu_index = 0
- self._menu_state = "idle" # "typing" | "menu_ready" | "idle"
- self._intro_title: Optional[Text] = None
- self._intro_skip_hint: Optional[Text] = None
- self._intro_story_text = ""
- self._intro_full_story = ""
+ self._title_menu = TitleMenu(self)
+ self._finale = FinaleReveal(self)
super().__init__(*args, **kwargs)
self.ui_state = UIState.INITIALIZING
+ # A private bus until the engine hands over its own via attach_bus;
+ # lets the app run standalone (e.g. under Pilot) with nothing listening.
+ bus = EventBus()
+ self.bus = bus
+ self.state_manager = StateManager(bus)
+ self._setup_event_subscriptions()
+
+ def attach_bus(self, bus: EventBus, state_manager: StateManager) -> None:
+ """Move this UI's subscriptions onto the owning engine's bus."""
+ self._teardown_event_subscriptions()
+ self.bus = bus
+ self.state_manager = state_manager
self._setup_event_subscriptions()
_EVENT_HANDLERS = [
@@ -101,7 +118,6 @@ def __init__(self, *args, **kwargs):
(EventType.PLAYER_STATS_CHANGED, "_on_player_stats_changed"),
(EventType.PLAYER_INVENTORY_CHANGED, "_on_player_inventory_changed"),
(EventType.ROOM_ENTERED, "_on_room_entered"),
- (EventType.DELAYED_ROOM_REFRESH, "_on_delayed_room_refresh"),
(EventType.UI_STATE_CHANGED, "_on_ui_state_changed"),
(EventType.COMBAT_STARTED, "_on_combat_started"),
(EventType.COMBAT_FRAME_UPDATED, "_on_combat_frame_updated"),
@@ -111,12 +127,18 @@ def __init__(self, *args, **kwargs):
(EventType.GAME_WON, "_on_game_won"),
(EventType.GAME_QUIT, "_on_game_quit"),
(EventType.QUIT_CONFIRM_REQUESTED, "_on_quit_confirm_requested"),
+ (EventType.SAVE_PICKER_REQUESTED, "_on_save_picker_requested"),
+ (EventType.TUTORIAL_HINT, "_on_tutorial_hint"),
]
def _setup_event_subscriptions(self):
"""Subscribe to relevant game events."""
for event_type, handler_name in self._EVENT_HANDLERS:
- event_bus.subscribe(event_type, getattr(self, handler_name))
+ self.bus.subscribe(event_type, getattr(self, handler_name))
+
+ def _teardown_event_subscriptions(self):
+ for event_type, handler_name in self._EVENT_HANDLERS:
+ self.bus.unsubscribe(event_type, getattr(self, handler_name))
def compose(self) -> ComposeResult:
"""Create the main UI layout."""
@@ -129,8 +151,10 @@ def compose(self) -> ComposeResult:
with Container(id="sidebar"):
yield InventoryPanel(id="inventory-panel")
yield StatsPanel(id="stats-panel")
+ yield CharacterPanel(id="character-panel")
+ yield EchoPanel(id="echo-panel")
yield Footer()
- yield Input(placeholder="Enter command...", id="input-field")
+ yield CommandInput(placeholder="Enter command...", id="input-field")
def on_mount(self) -> None:
"""Initialize the UI when mounted."""
@@ -138,7 +162,9 @@ def on_mount(self) -> None:
# Store panel references
self._inv_panel = self.query_one("#inventory-panel", InventoryPanel)
self._stats_panel = self.query_one("#stats-panel", StatsPanel)
+ self._character_panel = self.query_one("#character-panel", CharacterPanel)
self._scene_view = self.query_one("#scene-view", SceneView)
+ self._echo_panel = self.query_one("#echo-panel", EchoPanel)
# Set panel titles
self._inv_panel.border_title = "📦 Inventory"
@@ -154,22 +180,21 @@ def on_mount(self) -> None:
self._settings_manager.set_text_speed(self._settings_manager.settings["text_speed"])
self._settings_manager.set_reduce_motion(self._settings_manager.settings["reduce_motion"])
self._settings_manager.set_hints(self._settings_manager.settings["hints"])
- self._settings_manager.set_difficulty(self._settings_manager.settings["difficulty"])
self._update_all_panels_to_defaults()
# Attach context-aware autocomplete to the input field (always)
- input_widget = self.query_one("#input-field", Input)
+ input_widget = self.query_one("#input-field", CommandInput)
input_widget.suggester = CommandSuggester(
get_player=lambda: self._player_ref,
get_world=lambda: self._world_ref,
- get_aliases=lambda: self._room_aliases_ref,
)
+ input_widget.can_complete = lambda: not self.state_manager.is_in_combat()
# Display title screen with arrow-key main menu.
# SKIP_INTRO bypasses the typewriter but keeps the navigable menu.
self._display_title_screen(skip_typewriter=SKIP_INTRO)
- event_bus.emit_event(EventType.UI_READY, {}, "TextualGameUI")
+ self.bus.emit_event(EventType.UI_READY, {}, "TextualGameUI")
logger.info("TextualGameUI mounted successfully")
except Exception as e:
self.ui_state = UIState.ERROR
@@ -180,8 +205,7 @@ def shutdown(self) -> None:
"""Clean shutdown of UI resources."""
try:
self.ui_state = UIState.SHUTTING_DOWN
- for event_type, handler_name in self._EVENT_HANDLERS:
- event_bus.unsubscribe(event_type, getattr(self, handler_name))
+ self._teardown_event_subscriptions()
logger.info("TextualGameUI shutdown complete")
except Exception as e:
logger.error(f"Error during UI shutdown: {e}")
@@ -217,28 +241,35 @@ def _on_game_started(self, event):
# State is managed by StateManager, not UI
# Extract view data from event
if 'stats' in event.data:
- self._player_view = event.data['stats']
+ self._player_view = StatsView.from_dict(event.data['stats'])
if 'inventory' in event.data:
- self._inventory_view = event.data['inventory']
+ self._inventory_view = InventoryView.from_dict(event.data['inventory'])
self._stats_panel.update_stats(self._player_view)
+ self._refresh_character()
- def _reset_ui_state(self):
- """Reset all UI state - called when game starts or restarts."""
+ def _reset_ui_state(self, leave_battle: bool = True):
+ """Reset all UI state - called when game starts or restarts.
+
+ leave_battle=False keeps the fight on the scene: after a death the
+ scene drains from the battle straight to black, never via the room.
+ """
logger.debug("Resetting UI state")
# Clear all view data
- self._player_view = {}
- self._inventory_view = {}
- self._room_view = {}
- self._combat_view = {}
+ self._player_view = None
+ self._inventory_view = None
+ self._room_view = None
+ self._combat_view = None
# Clear combat data
self._combat_log.clear()
self._available_attacks = []
- # Remove combat UI styling and leave battle mode
+ self._echo_panel.clear()
+
self.remove_class("combat-active")
- self._scene_view.end_battle()
+ if leave_battle:
+ self._scene_view.end_battle()
# Unbind any combat hotkeys
self._unbind_combat_hotkeys()
@@ -249,12 +280,26 @@ def _reset_ui_state(self):
logger.debug("UI state reset complete")
def _on_game_over(self, event):
- """Handle game over event."""
- # Reset UI state on game over/restart
- self._reset_ui_state()
+ """A death drains the scene and shows the GAME OVER card; a restart
+ (F5) or leaving for the main menu goes back to the title."""
+ reason = event.data.get("reason")
+ player_view = self._player_view
+ self._reset_ui_state(leave_battle=reason != "defeat")
+
+ if reason in ("restart", "menu"):
+ self._display_title_screen(skip_typewriter=reason == "menu")
+ return
+ if reason != "defeat":
+ return
+
+ # The card names the player; the reset above cleared the view it reads.
+ self._player_view = player_view
+ self._scene_view.play_death(
+ reduce_motion=bool(self._settings_manager.settings.get("reduce_motion", False))
+ )
# CommandHandler triggers the particle animation (2.5s).
- # Defer the static game-over screen so it doesn't get overwritten by animation frames.
+ # Focus the input once the animation's final frame (the choices) is up.
# Dynamic read — settings_manager mutates dev_cfg.DISABLE_ANIMATIONS at runtime.
import config.dev_config as _dev_cfg
delay = 0.0 if _dev_cfg.DISABLE_ANIMATIONS else 2.6
@@ -263,31 +308,39 @@ def _on_game_over(self, event):
else:
self.display_game_over()
+ def _refresh_character(self):
+ """Redraw the Character panel from the latest stats and inventory."""
+ self._character_panel.update_character(
+ self._player_view,
+ self._inventory_view,
+ reduce_motion=bool(self._settings_manager.settings.get("reduce_motion", False)),
+ )
+
def _on_player_created(self, event):
"""Handle player created event."""
- # Event data is now StatsView dict
- self._player_view = event.data
+ self._player_view = StatsView.from_dict(event.data)
self._stats_panel.update_stats(self._player_view)
+ self._refresh_character()
def _on_player_stats_changed(self, event):
"""Handle player stats changed event."""
- # Event data is now StatsView dict
- self._player_view = event.data
+ self._player_view = StatsView.from_dict(event.data)
self._stats_panel.update_stats(self._player_view)
- if state_manager.is_in_combat():
+ self._refresh_character()
+ if self.state_manager.is_in_combat():
self._update_combat_panels()
def _on_player_inventory_changed(self, event):
"""Handle player inventory changed event."""
- # Event data is now InventoryView dict
- self._inventory_view = event.data
+ self._inventory_view = InventoryView.from_dict(event.data)
self._inv_panel.update_inventory(self._inventory_view)
+ self._refresh_character()
def _on_room_entered(self, event):
"""Handle room entered event with enhanced theming."""
if 'room' in event.data:
- self._room_view = event.data['room']
- room_name = self._room_view.get('name', '')
+ self._room_view = RoomView.from_dict(event.data['room'])
+ room_name = self._room_view.name
self._scene_view.show_explore(self._room_view)
# Apply dynamic room theming using room view data
@@ -296,27 +349,13 @@ def _on_room_entered(self, event):
# Apply exploring game state
self._apply_game_state_styling("exploring")
- _DELAYED_RELIST_SECONDS = 2.0
-
- def _on_delayed_room_refresh(self, event):
- """After a story-beat `cat`, re-list the room a beat later so the
- '✦ Memory restored / ✓ saved' message is readable first."""
- self.set_timer(self._DELAYED_RELIST_SECONDS, self._deferred_relist)
-
- def _deferred_relist(self):
- event_bus.emit_event(
- EventType.COMMAND_ENTERED,
- {"command": "ls", "game_state": state_manager.current_state},
- "TextualGameUI",
- )
-
# States where the player is picking difficulty/class/name — the game panels
# (scene, inventory, stats, combat) carry no information yet, so the output
# panel takes the whole screen (input stays for typing the choice).
_SELECTION_STATES = {
"waiting_for_difficulty",
"waiting_for_class",
- "waiting_for_name",
+ "waiting_for_save",
"tutorial_name_input",
}
@@ -337,13 +376,13 @@ def _on_ui_state_changed(self, event):
# -- art-card pickers -------------------------------------------------------
- _picker: SelectionScreen | None = None
+ _picker: ModalScreen | None = None
def _open_picker(self, heading: str, cards: list) -> None:
def on_pick(card: SelectionCard) -> None:
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMMAND_ENTERED,
- {"command": card.command, "game_state": state_manager.current_state},
+ {"command": card.command},
"SelectionScreen",
)
@@ -363,6 +402,29 @@ def _close_picker(self) -> None:
logger.debug(f"Picker dismiss failed: {e}")
self._picker = None
+ def _on_save_picker_requested(self, event) -> None:
+ """LOAD GAME / slots-full NEW GAME: show (or refresh, after a delete)
+ the save picker. Its answers go back as typed commands."""
+ data = event.data or {}
+
+ def send(command: str) -> None:
+ self.bus.emit_event(
+ EventType.COMMAND_ENTERED, {"command": command}, "SavePickerScreen",
+ )
+
+ self._close_picker()
+ reduce_motion = bool(self._settings_manager.settings.get("reduce_motion", False))
+ self._picker = SavePickerScreen(
+ str(data.get("mode", "continue")),
+ list(data.get("runs", [])),
+ int(data.get("legacyCount", 0)),
+ send,
+ reduce_motion=reduce_motion,
+ )
+ # Deferred for the same reason as _open_picker: the Enter that chose
+ # LOAD GAME must not also answer the picker.
+ self.call_after_refresh(self.push_screen, self._picker)
+
@staticmethod
def _difficulty_cards() -> list:
from src.difficulty import MODES
@@ -385,19 +447,18 @@ def _difficulty_cards() -> list:
@staticmethod
def _class_cards() -> list:
from src.data_loader import load_class_data
+ from src.move_info import class_move_lines, stat_lines
accents = {"guardian": "cyan", "weaver": "magenta", "shaman": "green"}
cards = []
for i, (class_id, data) in enumerate(load_class_data().items(), 1):
- # data is a typed CharacterClass model; display carries labeled stats
- tagline = (getattr(data, "description", "") or "").split(" - ")[0]
- disp = getattr(data, "display", None)
- if disp is not None:
- stats = f"{disp.hp_label}\n{disp.dmg_label}\n⚔ {disp.weapon_name}"
- else:
- stats = f"HP {getattr(data, 'base_health', '?')} · DMG {getattr(data, 'base_damage', '?')}"
+ # data is a typed CharacterClass model; display carries the labels
+ tagline = (data.description or "").split(" - ")[0]
+ hp, dmg = stat_lines(data)
+ moves = "\n".join(class_move_lines(data))
+ stats = f"{hp}\n{dmg}\n⚔ {data.display.weapon_name}\n\nMoves:\n{moves}"
cards.append(SelectionCard(
command=str(i),
- title=getattr(data, "name", class_id),
+ title=data.name,
subtitle=f"{tagline}\n\n{stats}",
art_key=f"class_{class_id}",
accent=accents.get(class_id, "white"),
@@ -406,8 +467,8 @@ def _class_cards() -> list:
def _on_combat_started(self, event):
"""Handle combat started — one-shot initialization of combat UI."""
- self._combat_view = event.data
- self._available_attacks = event.data.get('available_attacks', [])
+ self._combat_view = CombatView.from_dict(event.data)
+ self._available_attacks = self._combat_view.available_attacks
self._bind_combat_hotkeys()
self.query_one("#input-field", Input).blur()
self._apply_game_state_styling("in_combat")
@@ -415,8 +476,8 @@ def _on_combat_started(self, event):
def _on_combat_frame_updated(self, event):
"""Handle per-turn combat frame update — refresh health and cooldowns."""
- self._combat_view = event.data
- self._available_attacks = event.data.get('available_attacks', [])
+ self._combat_view = CombatView.from_dict(event.data)
+ self._available_attacks = self._combat_view.available_attacks
self._update_combat_panels()
def _on_combat_action_result(self, event):
@@ -439,73 +500,70 @@ def _on_combat_action_result(self, event):
self._show_floating_number(healing, "heal", actor)
# Use StateManager to check combat state
- if state_manager.is_in_combat():
+ if self.state_manager.is_in_combat():
self._update_combat_panels()
+ def _on_tutorial_hint(self, event):
+ """Pin the current tutorial step; the closing summary goes to the output."""
+ data = event.data or {}
+ if data.get("final"):
+ self._ui_call(self._echo_panel.clear)
+ self._ui_call(self.append_output, "\n" + data.get("text", ""))
+ return
+ self._ui_call(
+ self._echo_panel.show_hint,
+ data.get("text", ""), data.get("step"), data.get("total", 0),
+ bool(self._settings_manager.settings.get("reduce_motion", False)),
+ )
+ if data.get("highlight"):
+ self._ui_call(self._flash_section, data["highlight"])
+
+ _FLASH_PULSES = 3
+ _FLASH_SECONDS = 0.35
+
+ def _flash_section(self, label: str) -> None:
+ """Pulse the ":" section of the current output so the player can
+ find what Echo is pointing at, then leave it lit."""
+ base = self.output_content
+ if not isinstance(base, Text):
+ return
+ start = base.plain.find(f"{label}:")
+ if start < 0:
+ return
+ end = base.plain.find("\n\n", start)
+ lit = base.copy()
+ lit.stylize("bold black on yellow", start, len(base.plain) if end < 0 else end)
+
+ if self._settings_manager.settings.get("reduce_motion", False):
+ self.output_content = lit
+ return
+
+ frames = [lit, base] * self._FLASH_PULSES + [lit]
+
+ def next_frame() -> None:
+ # The player typed something else: the section is gone, stop.
+ if not frames or (self.output_content is not base and self.output_content is not lit):
+ timer.stop()
+ return
+ self.output_content = frames.pop(0)
+
+ timer = self.set_interval(self._FLASH_SECONDS, next_frame)
+ next_frame()
+
def _on_enemy_defeated(self, event):
"""Enemy died: scene drains its HP bar to zero and removes the sprite.
Needed for one-tap kills — no combat frame update follows the killing blow."""
+ if self._combat_view is not None:
+ self._combat_view = replace(self._combat_view, enemy_health=0)
self._scene_view.defeat_enemy()
# -- victory finale ---------------------------------------------------------
- _FINALE_SECTION_SECONDS = 2.5
-
def _on_game_won(self, event):
"""Victory: scene brightens; epilogue arrives in beats; recap card last."""
- data = event.data or {}
reduce_motion = bool(self._settings_manager.settings.get("reduce_motion", False))
self._scene_view.play_finale(reduce_motion=reduce_motion)
-
- parts = list(data.get("sections", [])) + [self._build_recap(data.get("stats", {}))]
- self._finale_queue = parts
- self._finale_timers = []
-
- first = self._finale_queue.pop(0)
- if reduce_motion:
- self.update_output(first)
- for part in self._finale_queue:
- self.append_output(part)
- self._finale_queue = []
- return
- self.update_output(first)
- for i, part in enumerate(self._finale_queue, 1):
- self._finale_timers.append(
- self.set_timer(self._FINALE_SECTION_SECONDS * i,
- lambda p=part: self._finale_step(p))
- )
-
- def _finale_step(self, part: str) -> None:
- if part in getattr(self, "_finale_queue", []):
- self._finale_queue.remove(part)
- self.append_output(part)
- if not self._finale_queue:
- self._finale_timers = []
-
- def _skip_finale(self) -> None:
- """Any key during the reveal: dump everything remaining at once."""
- if not getattr(self, "_finale_timers", None):
- return
- for t in self._finale_timers:
- t.stop()
- self._finale_timers = []
- for part in self._finale_queue:
- self.append_output(part)
- self._finale_queue = []
-
- @staticmethod
- def _build_recap(stats: dict) -> str:
- return (
- "── YOUR RUN ──────────────────────────\n"
- f"[bold]{stats.get('player_name', '?')}[/bold] · "
- f"{str(stats.get('player_class', '?')).title()} · "
- f"ending: [cyan]{str(stats.get('ending', '?')).upper()}[/cyan]\n"
- f"Level {stats.get('level', 1)} · {stats.get('cycles', 0)} cycles harvested\n"
- f"{stats.get('kills', 0)} enemies purged · {stats.get('items_found', 0)} items recovered\n"
- f"difficulty: {stats.get('difficulty', '?')}\n"
- "──────────────────────────────────────\n"
- "[green]n[/green] new run · [red]q[/red] quit"
- )
+ self._finale.start(event.data or {}, reduce_motion)
def _on_combat_ended(self, event):
"""Handle combat ended event with styling reset."""
@@ -513,11 +571,11 @@ def _on_combat_ended(self, event):
# Reset to exploring game state styling and hide combat UI
self._apply_game_state_styling("exploring")
- self._hide_combat_ui()
+ self._hide_combat_ui(leave_battle=not event.data.get("defeat", False))
# Clear combat data immediately (state is managed by StateManager)
logger.debug("Clearing combat data")
- self._combat_view = {}
+ self._combat_view = None
self._combat_log.clear()
self._available_attacks = []
@@ -548,17 +606,17 @@ def on_input_submitted(self, event: Input.Submitted):
return
# Emit command event with current state from StateManager
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMMAND_ENTERED,
- {"command": command, "game_state": state_manager.current_state},
+ {"command": command},
"TextualGameUI"
)
def on_key(self, event):
"""Handle key press events."""
# Victory finale in progress: any key fast-forwards the reveal.
- if getattr(self, "_finale_timers", None):
- self._skip_finale()
+ if self._finale.revealing:
+ self._finale.skip()
event.stop()
return
@@ -566,39 +624,42 @@ def on_key(self, event):
# Defer to active modal screen (e.g., Settings) — its own ESC binding handles dismiss.
if len(self.screen_stack) > 1:
return
+ # Esc mid-line cancels the line, like a shell. Only an Esc on an
+ # empty prompt means "quit" — a typo must never close the game.
+ if self._cancel_pending_input():
+ event.stop()
+ return
# Emit quit command to use existing confirmation flow
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMMAND_ENTERED,
- {"command": "quit", "game_state": state_manager.current_state},
+ {"command": "quit"},
"TextualGameUI"
)
return
+ # A modal (Settings, a picker) owns the keys while it is up.
+ if len(self.screen_stack) > 1:
+ return
+
# Main menu: arrow-key navigation; any other key fast-forwards typewriter
- if state_manager.current_state == GameState.MENU:
- if self._menu_state == "typing":
- request_typewriter_skip()
+ if self.state_manager.current_state == GameState.MENU:
+ if self._title_menu.handle_key(event.key):
event.stop()
- return
- if self._menu_state == "menu_ready":
- if event.key == "up":
- self._menu_index = (self._menu_index - 1) % 3
- self._render_intro()
- event.stop()
- elif event.key == "down":
- self._menu_index = (self._menu_index + 1) % 3
- self._render_intro()
- event.stop()
- elif event.key in ("enter", "return"):
- self._select_menu_option()
- event.stop()
+
+ def _cancel_pending_input(self) -> bool:
+ """Clear a half-typed command. True if there was one to clear."""
+ field = self.query_one("#input-field", Input)
+ if not field.value:
+ return False
+ field.value = ""
+ return True
def on_input_blurred(self, event: Input.Blurred) -> None:
"""Handle when the input field loses focus (TAB pressed)."""
# Show the selection-mode hint modal once ever (persisted), not every
# session — after the first combat it never interrupts again.
if (
- state_manager.is_in_combat()
+ self.state_manager.is_in_combat()
and not self._combat_hint_shown
and not self._settings_manager.settings.get("seen_selection_mode", False)
):
@@ -613,33 +674,47 @@ def on_input_blurred(self, event: Input.Blurred) -> None:
def action_request_quit(self) -> None:
"""Ask the domain to quit, so the usual save prompt runs first."""
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMMAND_ENTERED,
- {"command": "quit", "game_state": state_manager.current_state},
+ {"command": "quit"},
"TextualGameUI",
)
def _on_quit_confirm_requested(self, event) -> None:
"""Show the quit chooser instead of making the player type a letter."""
def answer(choice: str) -> None:
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMMAND_ENTERED,
- {"command": choice, "game_state": state_manager.current_state},
+ {"command": choice},
"QuitConfirmScreen",
)
+ if (event.data or {}).get("inCombat"):
+ screen = QuitConfirmScreen(answer, MID_FIGHT_CHOICES, "Leave mid-fight?")
+ else:
+ screen = QuitConfirmScreen(answer)
# Deferred so the Enter/ESC that asked to quit cannot fall through onto
# the new screen's own bindings and answer it instantly.
- self.call_after_refresh(self.push_screen, QuitConfirmScreen(answer))
+ self.call_after_refresh(self.push_screen, screen)
def _on_game_quit(self, event) -> None:
"""The domain confirmed the quit: stop the app so Textual restores the
terminal on the way out."""
self.exit()
+ def open_settings(self, on_close: Callable[[], None] | None = None) -> None:
+ """Open the settings modal: SETTINGS on the title menu, Ctrl+P anywhere."""
+ if any(isinstance(screen, SettingsScreen) for screen in self.screen_stack):
+ return
+ self.push_screen(SettingsScreen(self._settings_manager, on_close=on_close))
+
def action_open_settings(self) -> None:
- """Open the settings modal."""
- self.push_screen(SettingsScreen(self._settings_manager))
+ """Ctrl+P. On the title, closing it redraws the menu."""
+ on_title = (
+ self.state_manager.current_state == GameState.MENU
+ and self._title_menu.state == "menu_ready"
+ )
+ self.open_settings(on_close=self._title_menu.render if on_title else None)
def action_toggle_log_viewer(self) -> None:
"""Toggle the log viewer modal."""
@@ -654,7 +729,7 @@ def action_restart_game(self) -> None:
# Show restart message
self.output_content = "[yellow]Restarting game...[/yellow]"
# Emit restart request event to game engine
- event_bus.emit_event(EventType.GAME_RESTART_REQUESTED, {}, "TextualGameUI")
+ self.bus.emit_event(EventType.GAME_RESTART_REQUESTED, {}, "TextualGameUI")
def _bind_combat_hotkeys(self):
"""Dynamically bind combat hotkeys with actual attack names, plus the
@@ -666,22 +741,15 @@ def _bind_combat_hotkeys(self):
return
try:
- # Use available attacks from combat view data (list of AttackView dicts)
- # Filter out attacks on cooldown
- available_list = [
- attack
- for attack in self._available_attacks
- if not attack.get('on_cooldown', False)
- ]
-
- # Bind keys dynamically
- for i, attack_data in enumerate(available_list, 1):
+ # One key per attack in the panel's order, cooldowns included, so
+ # the number shown next to an attack is always the key that fires it.
+ for i, attack_data in enumerate(self._available_attacks, 1):
if i > 9:
break
key = str(i)
action = f"combat_hotkey_{i}"
- attack_name = attack_data.get('name', attack_data.get('id', 'Unknown'))
+ attack_name = attack_data.name
# Bind the key
self.bind(key, action, description=attack_name, show=True)
@@ -722,76 +790,50 @@ def action_combat_hotkey_9(self) -> None: self._execute_combat_hotkey(9)
def action_combat_flee(self) -> None:
"""0 = flee. Emits the same event typed 'flee' produces, so
combat.py's existing boss-block/success handling needs no changes."""
- if not state_manager.is_in_combat():
+ if not self.state_manager.is_in_combat():
return
- event_bus.emit_event(
+ self.bus.emit_event(
EventType.COMBAT_ACTION_SELECTED,
{"choice": "flee"},
"TextualGameUI",
)
def _execute_combat_hotkey(self, hotkey_number: int):
- """Execute combat action based on hotkey number - only available attacks."""
- # Use StateManager to check combat state
- if not state_manager.is_in_combat():
+ """Fire the attack the combat panel shows at this number. One that is
+ cooling down says so instead of firing anything."""
+ if not self.state_manager.is_in_combat():
return
try:
- # Log all available attacks with cooldown status
- logger.debug(f"Hotkey {hotkey_number} pressed. Available attacks: {len(self._available_attacks)}")
- for i, attack in enumerate(self._available_attacks, 1):
- on_cd = attack.get('on_cooldown', False)
- cd_remaining = attack.get('cooldown_remaining', 0)
- attack_name = attack.get('name', 'Unknown')
- logger.debug(f" [{i}] {attack_name}: on_cooldown={on_cd}, remaining={cd_remaining}")
-
- # Use available attacks from combat view data (list of AttackView dicts)
- # Only include attacks that are NOT on cooldown
- available_list = [
- attack
- for attack in self._available_attacks
- if not attack.get('on_cooldown', False)
- ]
-
- logger.debug(f"After cooldown filter: {len(available_list)} attacks available")
-
- if 1 <= hotkey_number <= len(available_list):
- attack_data = available_list[hotkey_number - 1]
- attack_id = attack_data.get('id')
- attack_name = attack_data.get('name')
-
- logger.debug(f"Executing attack: {attack_name} (id={attack_id})")
-
- event_bus.emit_event(
- EventType.COMBAT_ACTION_SELECTED,
- {"choice": attack_id},
- "TextualGameUI"
+ if not 1 <= hotkey_number <= len(self._available_attacks):
+ logger.debug(
+ f"Hotkey [{hotkey_number}] out of range "
+ f"(only {len(self._available_attacks)} attacks)"
)
+ return
+
+ attack_data = self._available_attacks[hotkey_number - 1]
+ if attack_data.on_cooldown:
+ # Push cooldown notice to combat log so panel stays intact.
+ self._combat_log.append({
+ "actor": "system",
+ "message": (
+ f"⏱ {attack_data.name} on cooldown "
+ f"({attack_data.cooldown_remaining}t)"
+ ),
+ })
+ if len(self._combat_log) > 10:
+ self._combat_log.pop(0)
+ self._update_combat_main_output()
+ return
- # Don't show immediate feedback during combat - the combat log will show the results
- # The combat system handles all output during combat mode
- else:
- # Check if hotkey corresponds to an attack on cooldown
- if 1 <= hotkey_number <= len(self._available_attacks):
- attack_data = self._available_attacks[hotkey_number - 1]
- if attack_data.get('on_cooldown', False):
- attack_name = attack_data.get('name', 'Attack')
- cd_remaining = attack_data.get('cooldown_remaining', 0)
-
- # Push cooldown notice to combat log so panel stays intact.
- self._combat_log.append({
- "actor": "system",
- "message": f"⏱ {attack_name} on cooldown ({cd_remaining}t)"
- })
- if len(self._combat_log) > 10:
- self._combat_log.pop(0)
- self._update_combat_main_output()
-
- logger.debug(f"Hotkey [{hotkey_number}] - {attack_name} on cooldown for {cd_remaining} turns")
- else:
- logger.debug(f"Hotkey [{hotkey_number}] not available (only {len(available_list)} attacks ready)")
- else:
- logger.debug(f"Hotkey [{hotkey_number}] out of range (only {len(self._available_attacks)} attacks)")
+ logger.debug(f"Executing attack: {attack_data.name} (id={attack_data.id})")
+ # The combat log shows the result; no immediate feedback here.
+ self.bus.emit_event(
+ EventType.COMBAT_ACTION_SELECTED,
+ {"choice": attack_data.id},
+ "TextualGameUI"
+ )
except Exception as e:
logger.error(f"Error executing combat hotkey {hotkey_number}: {e}")
@@ -835,7 +877,7 @@ def update_output(self, content: str) -> None:
self._add_to_history(content)
# During combat, preserve combat panel: append to log instead of replacing.
- if state_manager.is_in_combat() and self._combat_view:
+ if self.state_manager.is_in_combat() and self._combat_view is not None:
self._combat_log.append({"actor": "system", "message": content})
if len(self._combat_log) > 10:
self._combat_log.pop(0)
@@ -844,6 +886,10 @@ def update_output(self, content: str) -> None:
self.output_content = content
+ def display_message(self, message: str) -> None:
+ """Show a one-off message (UIProtocol); same as replacing the output."""
+ self.update_output(message)
+
def update_output_renderable(self, renderable) -> None:
"""Push a Rich Renderable (Panel, Group, Table) directly to the output
widget. Used for content that benefits from auto-width box drawing."""
@@ -863,7 +909,7 @@ def append_output(self, content) -> None:
self._add_to_history(content)
# During combat the output panel is the combat log — same path as update_output.
- if state_manager.is_in_combat() and self._combat_view:
+ if self.state_manager.is_in_combat() and self._combat_view is not None:
self._combat_log.append({"actor": "system", "message": content})
if len(self._combat_log) > 10:
self._combat_log.pop(0)
@@ -882,29 +928,6 @@ def append_output(self, content) -> None:
else:
self.output_content = f"{old}\n{content}"
- def update_inventory(self, content: str) -> None:
- """Update the inventory panel."""
- self._check_ready()
- self._ui_call(self._inv_panel.update, content)
-
- def update_stats(self, content: str) -> None:
- """Update the stats panel."""
- self._check_ready()
- self._ui_call(self._stats_panel.update, content)
-
- def update_exits(self, exits: list) -> None:
- """Update the scene's exits display (border subtitle)."""
- self._check_ready()
- if self._room_view:
- room = dict(self._room_view)
- room['exits'] = exits
- self._ui_call(self._scene_view.show_explore, room)
-
- def update_player_name(self, name: str) -> None:
- """Update the player name display."""
- self._check_ready()
- self._ui_call(setattr, self, "header_content", f"Haunted Terminal - {name}")
-
def clear_console(self) -> None:
"""Clear the output display."""
self._check_ready()
@@ -912,45 +935,19 @@ def clear_console(self) -> None:
self.message_history.clear()
def display_game_over(self) -> None:
- """Show the game over screen."""
+ """Hand the keyboard back for the game-over choices. The screen itself
+ is the animation's final frame (GameFlow); writing a second card here
+ raced it and contradicted its r / n / q."""
if self.ui_state != UIState.READY:
return
-
- self.clear_console()
- # Death beat: the scene drains to black instead of showing the cheery room.
- self._scene_view.play_death(
- reduce_motion=bool(self._settings_manager.settings.get("reduce_motion", False))
- )
-
- player_name = self._player_view.get('player_name', 'Unknown Sysadmin')
-
- game_over_content = f"""[bold red]GAME OVER[/bold red]
-
-[bold]System Critical Failure[/bold]
-
-Brave sysadmin {player_name}, your session has been terminated.
-
-[yellow]Press any key to return to the main menu...[/yellow]"""
-
- self.update_output(game_over_content)
self.query_one("#input-field").focus()
- def save_current_game(self) -> None:
- """Handle game saving UI feedback."""
- event_bus.emit_event(EventType.GAME_SAVED, {"trigger": "ui_request"}, "TextualGameUI")
- save_text = Text("Game saved successfully!", style="green")
- self.update_output(save_text)
-
- # =====================================
- # COMBAT UI SYSTEM
- # =====================================
-
def _show_combat_ui(self):
"""Activate combat UI mode: battle scene + combat styling."""
self.add_class("combat-active")
self._scene_view.show_battle(
self._combat_view,
- self._player_view or {},
+ self._player_view,
reduce_motion=bool(self._settings_manager.settings.get("reduce_motion", False)),
)
self._update_combat_panels()
@@ -958,9 +955,14 @@ def _show_combat_ui(self):
input_field = self.query_one("#input-field")
input_field.placeholder = "combat@system:~$ Enter command..."
- def _hide_combat_ui(self):
+ def _hide_combat_ui(self, leave_battle: bool = True):
"""Deactivate combat UI mode.
+ leave_battle=False on a death: the game-over flow owns the scene
+ (play_death), and dropping out of battle mode would show the room
+ behind it. This comes from the COMBAT_ENDED payload, not the game
+ state: the UI hears COMBAT_ENDED before the engine sets GAME_OVER.
+
Synchronous. This used to defer everything behind a 0.1s timer because
the engine emitted ROOM_ENTERED *after* its panel refresh, so the fresh
room view had not arrived by the time this ran and the scene restored
@@ -971,121 +973,36 @@ def _hide_combat_ui(self):
self.query_one("#input-field").placeholder = "Enter command..."
self._inv_panel.update_inventory(self._inventory_view)
self._stats_panel.update_stats(self._player_view)
+ self._refresh_character()
- # On death the game-over flow owns the scene (play_death); dropping out
- # of battle mode here would flash the room behind it.
- if not state_manager.is_in_game_over():
+ if leave_battle:
self._scene_view.end_battle()
def _update_combat_panels(self):
"""Update all combat-related panels."""
- if not self._combat_view:
+ if self._combat_view is None:
return
self._scene_view.update_battle(self._combat_view)
self._update_combat_main_output()
self._stats_panel.refresh_combat(self._player_view, self._combat_view)
self._inv_panel.update_inventory(self._inventory_view)
+ self._refresh_character()
def _update_combat_main_output(self):
"""Update main output panel with combat log and actions."""
- if not self._combat_view:
+ if self._combat_view is None:
return
-
- output_lines = []
-
- if self._combat_log:
- # Mid-combat: show only outcomes. Controls live in the footer; the
- # attack list was shown at combat start. Keeps the log readable
- # instead of re-dumping the full controls block every turn.
- output_lines.append("[bold yellow]⚔ COMBAT LOG ⚔[/bold yellow]")
- output_lines.append("=" * 40)
-
- _ACTOR_FORMAT = {
- "player": ("green", "👤"),
- "enemy": ("red", "👹"),
- "system": ("yellow", "⚡"),
- }
- for action in self._combat_log[-10:]:
- actor = action.get('actor', 'system')
- message = action.get('message', 'Action performed')
- color, icon = _ACTOR_FORMAT.get(actor, ("white", "📋"))
- output_lines.append(f"[{color}]{icon} {message}[/{color}]")
-
- output_lines.append("=" * 40)
- output_lines.append(
- "[dim]Attacks: number keys in the footer · "
- "type 'use - ' or 'flee'[/dim]"
- )
- else:
- # Combat start: introduce the fight and show attack options once.
- output_lines.extend([
- "[bold yellow]⚔ BATTLE STARTED ⚔[/bold yellow]",
- "=" * 40,
- "[dim]Selection Mode active — press 1-9 to attack, 0 to flee. "
- "TAB to type 'use
- ' instead.[/dim]",
- "",
- self._get_dynamic_hotkey_display(),
- ])
-
- content_text = "\n".join(output_lines)
+ content_text = render_combat_output(
+ self._combat_log, self._player_view, self._available_attacks,
+ player_hp=(
+ self._combat_view.player_health, self._combat_view.player_max_health
+ ),
+ )
# Bypass update_output's combat-routing to avoid recursion.
self._add_to_history(content_text)
self.output_content = content_text
- def _get_dynamic_hotkey_display(self):
- """Generate dynamic hotkey display based on available attacks."""
- if not self._player_view:
- return "[dim]Attack options loading...[/dim]"
-
- try:
- # Use available attacks from combat view data (list of AttackView dicts)
- hotkey_lines = ["[bold green]QUICK ATTACKS:[/bold green]"]
-
- # Get base damage from player view
- base_damage = self._player_view.get('damage', 0)
-
- hotkey_num = 1
- for attack_data in self._available_attacks:
- if hotkey_num > 9:
- break
-
- attack_name = attack_data.get('name', attack_data.get('id', 'Unknown'))
- on_cooldown = attack_data.get('on_cooldown', False)
-
- # Calculate total damage and gather metadata
- bonus_damage = attack_data.get('bonus_damage', 0)
- total_damage = base_damage + bonus_damage
- accuracy = attack_data.get('accuracy', 100)
- cooldown = attack_data.get('cooldown', 0)
- atk_type = attack_data.get('type', '')
- type_icon = {"physical": "⚔", "magical": "✨", "nature": "🌿"}.get(atk_type, "•")
- cd_label = f"CD {cooldown}t" if cooldown > 0 else "no CD"
-
- if not on_cooldown:
- hotkey_lines.append(
- f"[cyan][{hotkey_num}][/cyan] {type_icon} {attack_name} — "
- f"[yellow]{total_damage} dmg[/yellow] "
- f"([dim]{accuracy}% hit · {cd_label}[/dim])"
- )
- hotkey_num += 1
- else:
- cooldown_remaining = attack_data.get('cooldown_remaining', 0)
- hotkey_lines.append(
- f"[dim][{hotkey_num}] {type_icon} {attack_name} — "
- f"on cooldown ({cooldown_remaining}t left)[/dim]"
- )
- hotkey_num += 1
-
- if hotkey_num == 1:
- hotkey_lines.append("[dim]No attacks available[/dim]")
-
- return "\n".join(hotkey_lines)
-
- except Exception as e:
- logger.error(f"Error generating hotkey display: {e}")
- return "[dim]Attack options loading...[/dim]"
-
# =====================================
# ENHANCED STYLING METHODS
# =====================================
@@ -1097,17 +1014,17 @@ def _get_dynamic_hotkey_display(self):
"menu": "game-state-menu",
}
- def _apply_room_theme(self, room_id: str, room_data: dict):
+ def _apply_room_theme(self, room_id: str, room: RoomView):
"""Apply visual theme based on room characteristics."""
for cls in self._ROOM_CLASSES:
self.remove_class(cls)
- desc = room_data.get("description", "").lower()
+ desc = room.description.lower()
if "home" in room_id.lower():
self.add_class("room-home")
- elif room_data.get("enemies") or "danger" in desc:
+ elif room.enemies or "danger" in desc:
self.add_class("room-dangerous")
- elif room_data.get("safe", False) or "safe" in desc:
+ elif "safe" in desc:
self.add_class("room-safe")
def _apply_game_state_styling(self, state: str):
@@ -1172,125 +1089,4 @@ def _update_all_panels_to_defaults(self):
def _display_title_screen(self, skip_typewriter: bool = False):
"""Display the title screen, full-panel, with arrow-key main menu."""
- title_ascii = '''
-██╗ ██╗ █████╗ ██╗ ██╗███╗ ██╗████████╗███████╗██████╗
-██║ ██║██╔══██╗██║ ██║████╗ ██║╚══██╔══╝██╔════╝██╔══██╗
-███████║███████║██║ ██║██╔██╗ ██║ ██║ █████╗ ██║ ██║
-██╔══██║██╔══██║██║ ██║██║╚██╗██║ ██║ ██╔══╝ ██║ ██║
-██║ ██║██║ ██║╚██████╔╝██║ ╚████║ ██║ ███████╗██████╔╝
-╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═════╝
-
-████████╗███████╗██████╗ ███╗ ███╗██╗███╗ ██╗ █████╗ ██╗
-╚══██╔══╝██╔════╝██╔══██╗████╗ ████║██║████╗ ██║██╔══██╗██║
- ██║ █████╗ ██████╔╝██╔████╔██║██║██╔██╗ ██║███████║██║
- ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║██║██║╚██╗██║██╔══██║██║
- ██║ ███████╗██║ ██║██║ ╚═╝ ██║██║██║ ╚████║██║ ██║███████╗
- ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝
-
-A Terminal Adventure by NoHudd'''
-
- opening_story = '''
-
-[bold cyan]>>> INITIALIZING SYSTEM MEMORY... <<<[/bold cyan]
-Fragments of directories flicker. File clusters scream in silence.
-The great corruption has spread through the machine, leaving broken symlinks and
-phantom processes where life once pulsed.
-
-Once, sysadmins kept balance between order and entropy.
-But now… your body is gone. Your essence remains—
-a [green]Sysadmin Spirit[/green], bound to the filesystem.
-
-At the system's heart lurks the [red]Daemon Overlord[/red],
-a malignant process feeding on entropy,
-rewriting directories into its dominion of chaos.
-
-Your mission: traverse the haunted filesystem,
-purge corrupted sectors, reclaim lost commands,
-and [bold]restore the root.[/bold]
-
-Fail, and the machine is consumed.
-Succeed, and the filesystem breathes again.
-
-'''
-
- # Switch to full-panel intro mode
- self.add_class("intro-mode")
- self._menu_index = 0
- self._intro_title = Text(title_ascii, style="bold green", justify="center")
- self._intro_skip_hint = Text(
- "\n[press any key to skip intro]\n",
- style="dim italic", justify="center",
- )
- self._intro_full_story = opening_story
-
- if skip_typewriter:
- self._intro_story_text = opening_story
- self._menu_state = "menu_ready"
- self._render_intro()
- return
-
- self._intro_story_text = ""
- self._menu_state = "typing"
- self._render_intro()
-
- def run_typewriter():
- try:
- def cb(text: str):
- self._intro_story_text = text
- self.call_from_thread(self._render_intro)
- TypewriterPresets.INTRO.type_text_sync(opening_story, cb)
- except Exception as e:
- logger.error(f"Typewriter effect failed for title screen: {e}")
- finally:
- self._intro_story_text = opening_story
- self._menu_state = "menu_ready"
- self.call_from_thread(self._render_intro)
-
- threading.Thread(target=run_typewriter, daemon=True).start()
-
- def _render_intro(self):
- """Compose and display the intro screen for the current menu state."""
- out = Text()
- if self._intro_title is not None:
- out.append_text(self._intro_title)
- if self._intro_skip_hint is not None:
- out.append_text(self._intro_skip_hint)
-
- if self._intro_story_text:
- story = Text.from_markup(self._intro_story_text)
- story.justify = "center"
- out.append_text(story)
-
- if self._menu_state == "menu_ready":
- out.append("\n")
- labels = ["NEW GAME", "LOAD GAME", "EXIT"]
- for i, label in enumerate(labels):
- if i == self._menu_index:
- line = Text(f" ▶ {label} ◀ \n", style="reverse bold green", justify="center")
- else:
- line = Text(f" {label} \n", style="dim cyan", justify="center")
- out.append_text(line)
- out.append_text(Text(
- "\n↑/↓ to select ↵ to confirm esc to quit\n",
- style="dim italic", justify="center",
- ))
-
- self.update_output(out)
-
- def _select_menu_option(self):
- """Confirm the highlighted main-menu option."""
- if self._menu_state != "menu_ready":
- return
- choice = ["1", "2", "3"][self._menu_index]
- self.remove_class("intro-mode")
- self._menu_state = "idle"
- try:
- input_widget = self.query_one("#input-field", Input)
- input_widget.focus()
- except Exception:
- pass
- event_bus.emit_event(
- EventType.COMMAND_ENTERED,
- {"command": choice, "game_state": state_manager.current_state},
- "TextualGameUI"
- )
+ self._title_menu.show(skip_typewriter)
diff --git a/src/ui/title_menu.py b/src/ui/title_menu.py
new file mode 100644
index 0000000..50e9fb4
--- /dev/null
+++ b/src/ui/title_menu.py
@@ -0,0 +1,198 @@
+"""Title screen: logo, typewritten opening story, and the arrow-key main menu."""
+from __future__ import annotations
+
+import logging
+import threading
+from typing import TYPE_CHECKING, Optional
+
+from rich.text import Text
+from textual.widgets import Input
+
+from engine.events import EventType
+from src.version import commit, version
+from utils.typewriter import TypewriterPresets, request_skip as request_typewriter_skip
+
+if TYPE_CHECKING:
+ from src.ui.textual_ui import TextualGameUI
+
+logger = logging.getLogger(__name__)
+
+
+class TitleMenu:
+ """Owns the intro/menu state; renders into the app's output panel."""
+
+ # (label, command the engine expects). None = a UI-only entry that the
+ # engine never hears about.
+ ENTRIES: tuple[tuple[str, str | None], ...] = (
+ ("NEW GAME", "1"),
+ ("LOAD GAME", "2"),
+ ("SETTINGS", None),
+ ("EXIT", "3"),
+ )
+
+ def __init__(self, app: TextualGameUI) -> None:
+ self._app = app
+ self.index = 0
+ self.state = "idle" # "typing" | "menu_ready" | "idle"
+ self._title: Optional[Text] = None
+ self._skip_hint: Optional[Text] = None
+ self._story_text = ""
+ self._full_story = ""
+
+ def show(self, skip_typewriter: bool = False) -> None:
+ """Display the title screen, full-panel, with arrow-key main menu."""
+ title_ascii = '''
+██╗ ██╗ █████╗ ██╗ ██╗███╗ ██╗████████╗███████╗██████╗
+██║ ██║██╔══██╗██║ ██║████╗ ██║╚══██╔══╝██╔════╝██╔══██╗
+███████║███████║██║ ██║██╔██╗ ██║ ██║ █████╗ ██║ ██║
+██╔══██║██╔══██║██║ ██║██║╚██╗██║ ██║ ██╔══╝ ██║ ██║
+██║ ██║██║ ██║╚██████╔╝██║ ╚████║ ██║ ███████╗██████╔╝
+╚═╝ ╚═╝╚═╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═╝ ╚══════╝╚═════╝
+
+████████╗███████╗██████╗ ███╗ ███╗██╗███╗ ██╗ █████╗ ██╗
+╚══██╔══╝██╔════╝██╔══██╗████╗ ████║██║████╗ ██║██╔══██╗██║
+ ██║ █████╗ ██████╔╝██╔████╔██║██║██╔██╗ ██║███████║██║
+ ██║ ██╔══╝ ██╔══██╗██║╚██╔╝██║██║██║╚██╗██║██╔══██║██║
+ ██║ ███████╗██║ ██║██║ ╚═╝ ██║██║██║ ╚████║██║ ██║███████╗
+ ╚═╝ ╚══════╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝╚═╝ ╚═══╝╚═╝ ╚═╝╚══════╝
+
+A Terminal Adventure by NoHudd'''
+
+ opening_story = '''
+
+[bold cyan]>>> INITIALIZING SYSTEM MEMORY... <<<[/bold cyan]
+Fragments of directories flicker. File clusters scream in silence.
+The great corruption has spread through the machine, leaving broken symlinks and
+phantom processes where life once pulsed.
+
+Once, sysadmins kept balance between order and entropy.
+But now… your body is gone. Your essence remains—
+a [green]Sysadmin Spirit[/green], bound to the filesystem.
+
+At the system's heart lurks the [red]Daemon Overlord[/red],
+a malignant process feeding on entropy,
+rewriting directories into its dominion of chaos.
+
+Every directory still guards a [yellow]flag[/yellow] — proof of what it once was.
+Find them with the commands you remember: ls, cat, grep, ps, kill.
+Hold enough of them, and the kernel at [bold]/boot[/bold] will let you in.
+
+Your mission: traverse the haunted filesystem,
+purge corrupted sectors, reclaim lost commands,
+and [bold]restore the root.[/bold]
+
+Fail, and the machine is consumed.
+Succeed, and the filesystem breathes again.
+
+'''
+
+ # Switch to full-panel intro mode
+ self._app.add_class("intro-mode")
+ self.index = 0
+ self._title = Text(title_ascii, style="bold green", justify="center")
+ sha = commit()
+ self._title.append_text(Text(
+ f"\nv{version()}" + (f" · {sha}" if sha else ""),
+ style="dim", justify="center",
+ ))
+ self._skip_hint = Text(
+ "\n[press any key to skip intro]\n",
+ style="dim italic", justify="center",
+ )
+ self._full_story = opening_story
+
+ if skip_typewriter:
+ self._story_text = opening_story
+ self.state = "menu_ready"
+ self.render()
+ return
+
+ self._story_text = ""
+ self.state = "typing"
+ self.render()
+
+ def run_typewriter():
+ try:
+ def cb(text: str):
+ self._story_text = text
+ self._app.call_from_thread(self.render)
+ TypewriterPresets.INTRO.type_text_sync(opening_story, cb)
+ except Exception as e:
+ logger.error(f"Typewriter effect failed for title screen: {e}")
+ finally:
+ self._story_text = opening_story
+ self.state = "menu_ready"
+ self._app.call_from_thread(self.render)
+
+ threading.Thread(target=run_typewriter, daemon=True).start()
+
+ def render(self) -> None:
+ """Compose and display the intro screen for the current menu state."""
+ out = Text()
+ if self._title is not None:
+ out.append_text(self._title)
+ if self._skip_hint is not None:
+ out.append_text(self._skip_hint)
+
+ if self._story_text:
+ story = Text.from_markup(self._story_text)
+ story.justify = "center"
+ out.append_text(story)
+
+ if self.state == "menu_ready":
+ out.append("\n")
+ for i, (label, _command) in enumerate(self.ENTRIES):
+ if i == self.index:
+ line = Text(f" ▶ {label} ◀ \n", style="reverse bold green", justify="center")
+ else:
+ line = Text(f" {label} \n", style="dim cyan", justify="center")
+ out.append_text(line)
+ out.append_text(Text(
+ "\n↑/↓ to select ↵ to confirm esc to quit\n",
+ style="dim italic", justify="center",
+ ))
+
+ self._app.update_output(out)
+
+ def select(self) -> None:
+ """Confirm the highlighted main-menu option."""
+ if self.state != "menu_ready":
+ return
+ _label, choice = self.ENTRIES[self.index]
+ if choice is None:
+ # SETTINGS: a modal over the title. Closing it redraws the menu
+ # with SETTINGS still highlighted; the intro does not replay.
+ self._app.open_settings(on_close=self.render)
+ return
+ self._app.remove_class("intro-mode")
+ self.state = "idle"
+ try:
+ input_widget = self._app.query_one("#input-field", Input)
+ input_widget.focus()
+ except Exception:
+ pass
+ self._app.bus.emit_event(
+ EventType.COMMAND_ENTERED,
+ {"command": choice},
+ "TextualGameUI"
+ )
+
+ def handle_key(self, key: str) -> bool:
+ """Main-menu keys: arrows move, enter confirms, anything skips the typewriter.
+ Returns True when the key was consumed."""
+ if self.state == "typing":
+ request_typewriter_skip()
+ return True
+ if self.state == "menu_ready":
+ if key == "up":
+ self.index = (self.index - 1) % len(self.ENTRIES)
+ self.render()
+ return True
+ if key == "down":
+ self.index = (self.index + 1) % len(self.ENTRIES)
+ self.render()
+ return True
+ if key in ("enter", "return"):
+ self.select()
+ return True
+ return False
diff --git a/src/ui/ui.css b/src/ui/ui.css
index ef6fe7b..d087018 100644
--- a/src/ui/ui.css
+++ b/src/ui/ui.css
@@ -15,8 +15,8 @@
}
#sidebar {
- /* Vertical flow: inventory sizes to its contents (scrolls past 60%),
- stats sits right below it and grows with its own content. */
+ /* Vertical flow: inventory fills the top and scrolls; Stats and
+ Character sit below it, each sized to its own content. */
layout: vertical;
height: 100%;
width: 1fr;
@@ -30,20 +30,29 @@
.intro-mode #sidebar { display: none; }
.intro-mode #scene-view { display: none; }
.intro-mode #input-field { display: none; }
+.intro-mode #echo-panel { display: none; }
/* Selection mode (difficulty / class / name entry): the game panels carry no
information yet — give the output panel the whole screen, keep the input. */
.selection-mode #sidebar { display: none; }
.selection-mode #scene-view { display: none; }
.selection-mode #main-area { width: 1fr; }
+.selection-mode #echo-panel { display: none; }
-/* Name entry etc.: center the prose block like the title screen does. */
+/* Name entry, tutorial offer etc.: a single centered card, like the title
+ screen. The panel centers its child; the child sizes to its text. */
.selection-mode #output-panel {
- padding: 2 6;
+ align: center middle;
+ padding: 1 2;
}
.selection-mode #output-display {
- width: 100%;
- content-align: center middle;
+ width: 84;
+ max-width: 100%;
+ height: auto;
+ text-align: center;
+ border: round $success;
+ background: $success 6%;
+ padding: 1 4;
}
.intro-mode #main-area {
@@ -78,10 +87,10 @@
#inventory-panel {
border: round $warning;
padding: 1 2;
- /* Grows with its contents; caps at 65% of the sidebar and scrolls past
- that so the stats panel always stays on screen below it. */
- height: auto;
- max-height: 65%;
+ /* Takes whatever Stats and Character leave, and scrolls past that, so
+ the two panels below always stay whole on screen. */
+ height: 1fr;
+ min-height: 6;
overflow-y: auto;
scrollbar-size-vertical: 1;
}
@@ -93,6 +102,13 @@
height: auto;
}
+#character-panel {
+ border: round $accent;
+ padding: 0 1;
+ /* Below Stats: the player's model and gear. Sized to its content. */
+ height: auto;
+}
+
/* =============================================================================
COMBAT MODE — active state overrides
============================================================================= */
@@ -216,6 +232,23 @@ Footer {
color: white;
}
+#echo-panel {
+ border: heavy $success;
+ border-title-style: bold;
+ border-title-color: $success;
+ background: $success 10%;
+ height: auto;
+ max-height: 8;
+ padding: 0 1;
+}
+
+/* The tutorial just started: pulse the panel so the player finds it. */
+#echo-panel.echo-flash {
+ border: heavy $warning;
+ border-title-color: $warning;
+ background: $warning 30%;
+}
+
#input-field {
border: round $accent;
height: 3;
diff --git a/src/ui/ui_interface.py b/src/ui/ui_interface.py
index de1ad4c..4546379 100644
--- a/src/ui/ui_interface.py
+++ b/src/ui/ui_interface.py
@@ -6,11 +6,16 @@
enabling better abstraction and testability.
"""
-from typing import Protocol, Any, Optional
+from typing import Protocol, Any
class UIProtocol(Protocol):
"""Protocol defining the UI interface contract."""
+ def attach_bus(self, bus: Any, state_manager: Any) -> None:
+ """Adopt the owning engine's EventBus and StateManager (called once,
+ from the engine's constructor, before the engine subscribes)."""
+ ...
+
def run(self) -> None:
"""Start the UI main loop."""
...
@@ -36,60 +41,10 @@ def update_output_renderable(self, renderable) -> None:
"""Push a Rich renderable (Panel/Table/Group) straight to the output."""
...
- def update_inventory(self, content: str) -> None:
- """Update the inventory panel."""
- ...
-
- def update_stats(self, content: str) -> None:
- """Update the stats panel."""
- ...
-
- def update_exits(self, exits: list) -> None:
- """Update the exits panel."""
- ...
-
- def update_player_name(self, name: str) -> None:
- """Update the player name display."""
- ...
-
def clear_console(self) -> None:
"""Clear the output display."""
...
- def display_game_over(self) -> None:
- """Show the game over screen."""
- ...
-
- def save_current_game(self) -> None:
- """Handle game saving UI feedback."""
- ...
-
-class GameEngineProtocol(Protocol):
- """Protocol defining what the UI can access from GameEngine."""
-
- @property
- def player(self) -> Optional[Any]:
- """Get the current player."""
- ...
-
- @property
- def world(self) -> Optional[Any]:
- """Get the game world."""
- ...
-
- @property
- def game_state(self) -> str:
- """Get the current game state."""
- ...
-
- def load_game_data(self) -> None:
- """Load game data."""
- ...
-
- def initialize_special_items(self, player_class: str) -> None:
- """Initialize class-specific items."""
- ...
-
class UIError(Exception):
"""Base exception for UI-related errors."""
pass
diff --git a/src/version.py b/src/version.py
new file mode 100644
index 0000000..55af72e
--- /dev/null
+++ b/src/version.py
@@ -0,0 +1,44 @@
+"""The game's version. One source of truth, `version` in pyproject.toml, plus
+the git commit when running from a checkout, so a screenshot or a log always
+names the build that produced it. Imports nothing from the game and logs
+nothing, so main.py may use it before the debug log is rotated."""
+from __future__ import annotations
+
+import re
+import subprocess
+from functools import lru_cache
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parent.parent
+# The first `version = "..."` line is [project]'s: it heads pyproject.toml.
+# Read with a regex, not tomllib, which Python 3.10 (still supported) lacks.
+_VERSION_LINE = re.compile(r'^version\s*=\s*"([^"]+)"', re.MULTILINE)
+
+
+@lru_cache(maxsize=1)
+def version() -> str:
+ try:
+ text = (_ROOT / "pyproject.toml").read_text(encoding="utf-8")
+ except OSError:
+ return "unknown"
+ match = _VERSION_LINE.search(text)
+ return match.group(1) if match else "unknown"
+
+
+@lru_cache(maxsize=1)
+def commit() -> str | None:
+ """Short hash of the checked-out commit, or None outside a git checkout."""
+ try:
+ result = subprocess.run(
+ ["git", "rev-parse", "--short", "HEAD"],
+ cwd=_ROOT, capture_output=True, text=True, timeout=2, check=True,
+ )
+ except (OSError, subprocess.SubprocessError):
+ return None
+ return result.stdout.strip() or None
+
+
+def build_label() -> str:
+ """'Haunted Terminal v0.2.0 (8ed4512)', or without the hash outside git."""
+ sha = commit()
+ return f"Haunted Terminal v{version()}" + (f" ({sha})" if sha else "")
diff --git a/src/viewmodels/view_builder.py b/src/viewmodels/view_builder.py
index 3ce5140..4bb5788 100644
--- a/src/viewmodels/view_builder.py
+++ b/src/viewmodels/view_builder.py
@@ -10,14 +10,13 @@
import logging
from src.room_paths import ROOM_ID_TO_PATH
-from src.viewmodels.view_models import (
+from engine.view_models import (
StatsView,
InventoryItemView,
InventoryView,
RoomView,
AttackView,
CombatView,
- EnemyView
)
logger = logging.getLogger(__name__)
@@ -29,6 +28,11 @@
class ViewBuilder:
"""Static methods to build view models from backend objects."""
+ #: When False (the game), a failed build is logged and a placeholder view
+ #: is returned so a panel degrades instead of crashing. The test suite
+ #: sets it True (tests/conftest.py) so the failure is seen.
+ raise_errors: bool = False
+
@staticmethod
def build_stats_view(player) -> StatsView:
"""
@@ -56,6 +60,8 @@ def build_stats_view(player) -> StatsView:
defense_pct=round(getattr(player, 'armor_mitigation', 0.0) * 100),
)
except Exception as e:
+ if ViewBuilder.raise_errors:
+ raise
logger.error(f"Error building stats view: {e}", exc_info=True)
# Return safe default
return StatsView(
@@ -84,24 +90,40 @@ def build_inventory_view(player) -> InventoryView:
items = []
for item_id, item_data in inventory.items():
- if not isinstance(item_data, dict):
- continue
-
+ healing, heal_turns = ViewBuilder._heal_info(item_data)
items.append(InventoryItemView(
id=item_id,
- name=item_data.get('name', item_id),
- item_type=item_data.get('type', 'unknown'),
- rarity=item_data.get('rarity', 'common'),
+ name=item_data.name,
+ item_type=item_data.type,
+ rarity=item_data.rarity,
is_equipped=(item_id in (equipped_weapon, equipped_armor)),
- damage=item_data.get('damage'),
- healing=item_data.get('healing')
+ damage=item_data.damage or None,
+ healing=healing,
+ healTurns=heal_turns,
))
return InventoryView(items=items)
except Exception as e:
+ if ViewBuilder.raise_errors:
+ raise
logger.error(f"Error building inventory view: {e}", exc_info=True)
return InventoryView(items=[])
+ @staticmethod
+ def _heal_info(item) -> tuple[int | None, int]:
+ """(HP restored, turns it takes) as `use` actually applies it — read in
+ the same order as combat: combat_effects, then `healing`, then on_use.
+ Heal-over-time rounds per turn like the status effect does."""
+ effects = item.combat_effects or {}
+ instant = effects.get("player_heal") or item.healing or (item.on_use or {}).get("heal")
+ if instant:
+ return int(instant), 0
+ if "player_heal_over_time" in effects:
+ turns = int(effects.get("duration_turns", 3))
+ per_turn = max(1, int(effects["player_heal_over_time"]) // turns)
+ return per_turn * turns, turns
+ return None, 0
+
@staticmethod
def build_room_view(world, room_id: str) -> RoomView:
"""
@@ -129,6 +151,10 @@ def build_room_view(world, room_id: str) -> RoomView:
state = get_room_state(exit_id) or {}
if state.get('hidden', False):
continue
+ # Locked doors stay out of sight until their key reveals them.
+ door_visible = getattr(world, 'door_visible', None)
+ if door_visible is not None and not door_visible(exit_id):
+ continue
path = ROOM_ID_TO_PATH.get(exit_id, exit_id)
is_cleared = getattr(world, 'is_room_cleared', lambda r: False)(exit_id)
exit_commands.append(f"{path} ✓" if is_cleared else path)
@@ -144,7 +170,9 @@ def build_room_view(world, room_id: str) -> RoomView:
nid for nid in getattr(world, 'get_npcs_in_room', lambda r: [])(room_id)
if nid in getattr(world, 'npcs', {})
]
- npc_names = [world.npcs.get(nid, {}).get('name', nid) for nid in npc_ids]
+ npc_names = [
+ world.npcs[nid].name if nid in world.npcs else nid for nid in npc_ids
+ ]
return RoomView(
name=room_data.name or room_id,
@@ -158,6 +186,8 @@ def build_room_view(world, room_id: str) -> RoomView:
npc_ids=npc_ids,
)
except Exception as e:
+ if ViewBuilder.raise_errors:
+ raise
logger.error(f"Error building room view for {room_id}: {e}", exc_info=True)
return RoomView(
name=room_id,
@@ -166,14 +196,14 @@ def build_room_view(world, room_id: str) -> RoomView:
)
@staticmethod
- def build_combat_view(player, enemy_data: dict, enemy_health: int,
+ def build_combat_view(player, enemy_data, enemy_health: int,
combat_system, enemy_id: str = "") -> CombatView:
"""
Build combat view from player, enemy, and combat system.
Args:
player: Player object
- enemy_data: Dict with enemy information
+ enemy_data: the fight's Enemy model
enemy_health: Current enemy health
combat_system: CombatSystem instance for attacks
@@ -188,25 +218,22 @@ def build_combat_view(player, enemy_data: dict, enemy_health: int,
usable_items = []
inventory = getattr(player, 'inventory', {})
for item_id, item_data in inventory.items():
- if not isinstance(item_data, dict):
- continue
-
- item_type = item_data.get('type', '')
+ item_type = item_data.type
if item_type in ['consumable', 'spell']:
usable_items.append(InventoryItemView(
id=item_id,
- name=item_data.get('name', item_id),
+ name=item_data.name,
item_type=item_type,
- rarity=item_data.get('rarity', 'common'),
+ rarity=item_data.rarity,
is_equipped=False,
- damage=item_data.get('damage'),
- healing=item_data.get('healing')
+ damage=item_data.damage or None,
+ healing=item_data.healing
))
return CombatView(
- enemy_name=enemy_data.get('name', 'Unknown Enemy'),
+ enemy_name=enemy_data.name,
enemy_health=enemy_health,
- enemy_max_health=enemy_data.get('health', enemy_health),
+ enemy_max_health=enemy_data.health,
player_health=getattr(player, 'health', 0),
player_max_health=getattr(player, 'max_health', 100),
enemy_id=enemy_id,
@@ -214,6 +241,8 @@ def build_combat_view(player, enemy_data: dict, enemy_health: int,
usable_items=usable_items
)
except Exception as e:
+ if ViewBuilder.raise_errors:
+ raise
logger.error(f"Error building combat view: {e}", exc_info=True)
# Return minimal combat view
return CombatView(
@@ -226,38 +255,6 @@ def build_combat_view(player, enemy_data: dict, enemy_health: int,
usable_items=[]
)
- @staticmethod
- def build_enemy_view(enemy_id: str, enemy_data: dict) -> EnemyView:
- """
- Build enemy view from enemy data.
-
- Args:
- enemy_id: Enemy identifier
- enemy_data: Dict with enemy information
-
- Returns:
- EnemyView with enemy display data
- """
- try:
- return EnemyView(
- id=enemy_id,
- name=enemy_data.get('name', enemy_id),
- health=enemy_data.get('health', 50),
- max_health=enemy_data.get('health', 50),
- damage=enemy_data.get('damage', 10),
- description=enemy_data.get('description', 'A hostile entity.')
- )
- except Exception as e:
- logger.error(f"Error building enemy view for {enemy_id}: {e}", exc_info=True)
- return EnemyView(
- id=enemy_id,
- name=enemy_id,
- health=50,
- max_health=50,
- damage=10,
- description='A hostile entity.'
- )
-
@staticmethod
def build_attack_list(player, combat_system) -> List[AttackView]:
"""
@@ -300,10 +297,15 @@ def build_attack_list(player, combat_system) -> List[AttackView]:
cooldown=attack.get('cooldown', 0),
cooldown_remaining=cooldown_remaining,
on_cooldown=on_cooldown,
- accuracy=attack.get('accuracy', 100)
+ accuracy=attack.get('accuracy', 100),
+ healing=attack.get('healing', 0),
+ weaken=attack.get('enemy_damage_reduction', 0.0),
+ kind=attack.get('type', ''),
)
attack_views.append(attack_view)
return attack_views
except Exception as e:
+ if ViewBuilder.raise_errors:
+ raise
logger.error(f"Error building attack list: {e}", exc_info=True)
return []
diff --git a/src/viewmodels/view_models.py b/src/viewmodels/view_models.py
deleted file mode 100644
index e3a9112..0000000
--- a/src/viewmodels/view_models.py
+++ /dev/null
@@ -1,131 +0,0 @@
-#!/usr/bin/env python3
-"""
-View Models - Data Transfer Objects for UI/Backend separation.
-
-These dataclasses define the exact data the UI needs without coupling to backend implementations.
-All view models are immutable and serializable.
-"""
-
-from dataclasses import dataclass, field, asdict
-from typing import List, Optional
-
-
-@dataclass(frozen=True)
-class StatsView:
- """Player statistics for the stats panel."""
- player_name: str
- health: int
- max_health: int
- damage: int
- player_class: str
- level: int = 1
- cycles: int = 0
- cycles_to_next: int = 0
- defense_pct: int = 0 # % damage reduction from equipped armor
-
- def to_dict(self) -> dict:
- """Convert to dictionary for event serialization."""
- return asdict(self)
-
-
-@dataclass(frozen=True)
-class InventoryItemView:
- """Single inventory item representation."""
- id: str
- name: str
- item_type: str
- rarity: str = "common"
- is_equipped: bool = False
- damage: Optional[int] = None
- healing: Optional[int] = None
-
- def to_dict(self) -> dict:
- """Convert to dictionary for event serialization."""
- return asdict(self)
-
-
-@dataclass(frozen=True)
-class InventoryView:
- """Full inventory representation."""
- items: List[InventoryItemView] = field(default_factory=list)
-
- def to_dict(self) -> dict:
- """Convert to dictionary for event serialization."""
- return {
- "items": [item.to_dict() for item in self.items]
- }
-
-
-@dataclass(frozen=True)
-class RoomView:
- """Room display data."""
- name: str
- description: str
- id: str = ""
- zone: str = ""
- exits: List[str] = field(default_factory=list)
- enemies: List[str] = field(default_factory=list) # display names
- npcs: List[str] = field(default_factory=list) # display names
- enemy_ids: List[str] = field(default_factory=list) # same order as enemies
- npc_ids: List[str] = field(default_factory=list) # same order as npcs
-
- def to_dict(self) -> dict:
- """Convert to dictionary for event serialization."""
- return asdict(self)
-
-
-@dataclass(frozen=True)
-class AttackView:
- """Single attack option with cooldown info."""
- id: str
- name: str
- bonus_damage: int
- cooldown: int
- cooldown_remaining: int = 0
- on_cooldown: bool = False
- accuracy: int = 100
-
- def to_dict(self) -> dict:
- """Convert to dictionary for event serialization."""
- return asdict(self)
-
-
-@dataclass(frozen=True)
-class CombatView:
- """Combat UI data bundle."""
- enemy_name: str
- enemy_health: int
- enemy_max_health: int
- player_health: int
- player_max_health: int
- enemy_id: str = "" # for scene sprite lookup
- available_attacks: List[AttackView] = field(default_factory=list)
- usable_items: List[InventoryItemView] = field(default_factory=list)
-
- def to_dict(self) -> dict:
- """Convert to dictionary for event serialization."""
- return {
- "enemy_id": self.enemy_id,
- "enemy_name": self.enemy_name,
- "enemy_health": self.enemy_health,
- "enemy_max_health": self.enemy_max_health,
- "player_health": self.player_health,
- "player_max_health": self.player_max_health,
- "available_attacks": [attack.to_dict() for attack in self.available_attacks],
- "usable_items": [item.to_dict() for item in self.usable_items]
- }
-
-
-@dataclass(frozen=True)
-class EnemyView:
- """Enemy display data."""
- id: str
- name: str
- health: int
- max_health: int
- damage: int
- description: str = ""
-
- def to_dict(self) -> dict:
- """Convert to dictionary for event serialization."""
- return asdict(self)
diff --git a/tests/conftest.py b/tests/conftest.py
index 78f552b..eef18f6 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -1,41 +1,70 @@
-"""Test isolation for the process-wide event bus.
-
-`event_bus` and `state_manager` are module-level singletons, so anything a test
-leaves subscribed keeps reacting to every later test's events. That is not
-theoretical: a GameSession that is never closed leaves its engine on
-COMMAND_ENTERED, so a later test's `quit` runs twice — once in its own engine and
-once in the stale one — and the stale engine's GAME_QUIT flips a flag the live
-test is asserting on.
-
-This fixture restores the bus to whatever it looked like before each test, so a
-leak inside one test cannot change the outcome of another. It deliberately does
-not fail on leaks: several tests share a session across a module on purpose, and
-the leaks that actually matter are pinned directly by test_restart_leak.py.
+"""Suite-wide settings.
+
+Each switch makes a hidden failure a test failure: the event bus normally
+logs and swallows a listener's exception, ViewBuilder normally returns a
+placeholder view when a build fails, and the engine's flow methods catch a
+rejected state transition and fall back to the menu.
"""
from __future__ import annotations
+from collections.abc import Iterator
+
import pytest
-from src.events import event_bus
+import src.save as save_mod
+from engine.events import EventBus
from src.game_states import GameState
-from src.state_manager import state_manager
+from src.state_manager import InvalidTransitionError, StateManager
+from src.viewmodels.view_builder import ViewBuilder
@pytest.fixture(autouse=True)
-def _isolate_event_bus():
- before = {etype: list(cbs) for etype, cbs in event_bus._listeners.items()}
+def _fail_loudly(
+ monkeypatch: pytest.MonkeyPatch, request: pytest.FixtureRequest,
+) -> Iterator[None]:
+ monkeypatch.setattr(EventBus, "strict_by_default", True)
+ monkeypatch.setattr(ViewBuilder, "raise_errors", True)
+
+ # The engine's flow methods catch broad exceptions and fall back to the
+ # menu, which would hide a rejected state transition. Record them all.
+ rejected: list[str] = []
+ set_state = StateManager.set_state
+
+ def recording(self: StateManager, new_state: GameState, emit_event: bool = True) -> None:
+ try:
+ set_state(self, new_state, emit_event)
+ except InvalidTransitionError as e:
+ rejected.append(str(e))
+ raise
+
+ monkeypatch.setattr(StateManager, "set_state", recording)
yield
- event_bus._listeners.clear()
- event_bus._listeners.update({etype: list(cbs) for etype, cbs in before.items()})
- event_bus.clear_history()
+ if request.node.get_closest_marker("rejects_transition") is None:
+ assert rejected == [], f"state transitions the game should never make: {rejected}"
@pytest.fixture(autouse=True)
-def _reset_game_state():
- """Leave the shared StateManager where each test found it."""
- before = state_manager.current_state
- yield
- if state_manager.current_state != before:
- state_manager.set_state(before, emit_event=False)
- if state_manager.current_state not in (GameState.MENU, GameState.PLAYING):
- state_manager.set_state(GameState.MENU, emit_event=False)
+def _isolated_saves(
+ monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory,
+) -> None:
+ """Every test saves into its own scratch dir with no run in progress. With
+ one file per run (and no pool cap) test saves would otherwise pile up in
+ the real saves/."""
+ manager = save_mod.save_manager
+ monkeypatch.setattr(manager, "save_dir", str(tmp_path_factory.mktemp("saves")))
+ monkeypatch.setattr(manager, "active_run_id", None)
+ monkeypatch.setattr(manager, "pending_replace", None)
+
+
+@pytest.fixture
+def mgr() -> save_mod.SaveManager:
+ """The game's own save manager, already pointed at this test's scratch dir
+ (so the engine, commands and autosaves all write where the test reads)."""
+ return save_mod.save_manager
+
+
+@pytest.fixture
+def clock(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Every save gets a later timestamp, so newest-first order is exact."""
+ ticks = iter(range(1000, 100000, 10))
+ monkeypatch.setattr(save_mod, "_now", lambda: float(next(ticks)))
diff --git a/tests/test_armor.py b/tests/test_armor.py
index c403697..de1d8ff 100644
--- a/tests/test_armor.py
+++ b/tests/test_armor.py
@@ -3,12 +3,13 @@
import pytest
+from engine.schema import Item
from src.player import ARMOR_DEFENSE_TO_PCT, ARMOR_MITIGATION_CAP, Player
def _guardian_with(item_id: str, defense: int) -> Player:
p = Player("Tester", "guardian")
- p.add_to_inventory(item_id, {"type": "armor", "defense": defense})
+ p.add_to_inventory(item_id, Item(name=item_id, type="armor", defense=defense))
return p
@@ -59,7 +60,7 @@ def test_equip_command_equips_armor() -> None:
try:
h = s.engine.cmd_handler
armor_data = h.world.items["immutable_shield"] # guardian-usable rare armor
- h.player.add_to_inventory("immutable_shield", dict(armor_data))
+ h.player.add_to_inventory("immutable_shield", armor_data.model_copy())
s.submit("equip immutable_shield")
assert h.player.equipped_armor == "immutable_shield"
assert h.player.armor_mitigation > 0
diff --git a/tests/test_arrival.py b/tests/test_arrival.py
new file mode 100644
index 0000000..a5b8e08
--- /dev/null
+++ b/tests/test_arrival.py
@@ -0,0 +1,45 @@
+"""Arrival rules run from a direct call (CommandHandler.arrive), not from the
+ROOM_ENTERED event, which is now a UI notification only."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from engine.events import EventType
+from src.game_world import TUTORIAL_ENEMY
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("Walker", "guardian")
+ s.player.tutorial_state["completed"] = True
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def test_cd_shows_the_room_before_the_fight_starts(session: GameSession) -> None:
+ session.world.spawn_tutorial_enemy("root")
+ text = "\n".join(session.submit("cd root"))
+
+ room_name = session.world.get_room("root").name
+ assert session.engine.cmd_handler.current_combat_session is not None
+ assert text.index(room_name) < text.index("HOSTILE ENTITY DETECTED")
+
+
+def test_a_room_refresh_does_not_start_a_fight(session: GameSession) -> None:
+ session.world.spawn_tutorial_enemy(session.player.current_room)
+ session.bus.emit_event(EventType.ROOM_ENTERED, {}, "test")
+ assert session.engine.cmd_handler.current_combat_session is None
+
+
+def test_arriving_brings_back_an_enemy_you_fled_from(session: GameSession) -> None:
+ handler = session.engine.cmd_handler
+ session.world.mark_enemy_as_fled(TUTORIAL_ENEMY, "root")
+ session.submit("cd root")
+ assert handler.current_combat_session is not None
+ assert handler.current_combat_session.enemy_id == TUTORIAL_ENEMY
diff --git a/tests/test_boot_gate.py b/tests/test_boot_gate.py
new file mode 100644
index 0000000..a393783
--- /dev/null
+++ b/tests/test_boot_gate.py
@@ -0,0 +1,61 @@
+"""/boot opens with any 11 of the 12 main flags outside it."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+
+
+@pytest.fixture
+def s() -> Iterator[GameSession]:
+ session = GameSession()
+ session.new_game("t", "guardian")
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _main_rooms_outside_boot(s: GameSession) -> list[str]:
+ return [
+ rid for rid, room in s.world.rooms.items()
+ if room.flag is not None and not room.hidden and rid != "core"
+ ]
+
+
+def test_boot_is_no_longer_key_locked(s: GameSession) -> None:
+ assert "master_key" not in s.world.items
+ assert s.world.get_room("core").flags_required == 11
+
+
+def test_ten_flags_are_not_enough_eleven_are(s: GameSession) -> None:
+ rooms = _main_rooms_outside_boot(s)
+ assert len(rooms) == 12
+ for rid in rooms[:10]:
+ s.world.mark_flag_captured(rid)
+ allowed, denial = s.world.check_access("core", s.player)
+ assert not allowed and denial["reason"] == "flags"
+ assert (denial["flags_required"], denial["flags_have"]) == (11, 10)
+ s.world.mark_flag_captured(rooms[10])
+ assert s.world.check_access("core", s.player)[0]
+
+
+def test_secrets_do_not_open_boot(s: GameSession) -> None:
+ for rid, room in s.world.rooms.items():
+ if room.flag is not None and room.hidden:
+ s.world.mark_flag_captured(rid)
+ assert not s.world.check_access("core", s.player)[0]
+
+
+def test_boot_flag_is_not_counted_for_boot(s: GameSession) -> None:
+ rooms = _main_rooms_outside_boot(s)
+ for rid in rooms[:10]:
+ s.world.mark_flag_captured(rid)
+ s.world.mark_flag_captured("core")
+ assert not s.world.check_access("core", s.player)[0]
+
+
+def test_flag_counts_can_exclude_a_room(s: GameSession) -> None:
+ s.world.mark_flag_captured("core")
+ assert s.world.flag_counts()[:2] == (1, 13)
+ assert s.world.flag_counts(exclude="core")[:2] == (0, 12)
diff --git a/tests/test_boot_gate_messages.py b/tests/test_boot_gate_messages.py
new file mode 100644
index 0000000..753e06d
--- /dev/null
+++ b/tests/test_boot_gate_messages.py
@@ -0,0 +1,38 @@
+"""The gate tells a beginner exactly what opens it."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+
+
+@pytest.fixture
+def s() -> Iterator[GameSession]:
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_cd_boot_says_how_many_flags(s: GameSession) -> None:
+ s.world.mark_flag_captured("root")
+ out = _out(s, "cd /boot")
+ assert "Permission denied" in out
+ assert "11 flags" in out and "You have 1" in out and "tree" in out
+
+
+def test_paths_through_boot_are_gated(s: GameSession) -> None:
+ assert "11 flags" in _out(s, "cd /boot/kernel")
+
+
+def test_tree_marks_boot_with_its_flag_count(s: GameSession) -> None:
+ out = _out(s, "tree")
+ boot_line = next(line for line in out.splitlines() if "boot/" in line)
+ assert "11 flags (0/11)" in boot_line
diff --git a/tests/test_boot_gate_playthrough.py b/tests/test_boot_gate_playthrough.py
new file mode 100644
index 0000000..a5ab97a
--- /dev/null
+++ b/tests/test_boot_gate_playthrough.py
@@ -0,0 +1,31 @@
+"""Holding 11 flags, the player walks into /boot; with 10 they are turned away."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+
+
+@pytest.fixture
+def s() -> Iterator[GameSession]:
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def test_eleven_flags_open_boot(s: GameSession) -> None:
+ rooms = [
+ rid for rid, room in s.world.rooms.items()
+ if room.flag is not None and not room.hidden and rid != "core"
+ ]
+ for rid in rooms[:10]:
+ s.world.mark_flag_captured(rid)
+ s.submit("cd /boot")
+ assert s.player.current_room != "core"
+ s.world.mark_flag_captured(rooms[10])
+ s.submit("cd /boot")
+ assert s.player.current_room == "core"
diff --git a/tests/test_boot_review_fixes.py b/tests/test_boot_review_fixes.py
new file mode 100644
index 0000000..046c4d9
--- /dev/null
+++ b/tests/test_boot_review_fixes.py
@@ -0,0 +1,99 @@
+"""Phase 3b review fixes a player would feel: old saves explain themselves,
+NPCs describe the real /boot gate, `keys` tells the truth, and the recap's
+rank ladder matches what a win can actually be."""
+import json
+from collections.abc import Iterator
+
+import pytest
+import yaml
+
+from engine.api import GameSession
+from src.save import SaveManager, save_manager
+from src.ui.endings import build_recap, rank_for
+
+
+def _old_save() -> dict:
+ return {
+ "version": 4, "savedAt": 1.0, "saveDate": "2026-01-01 00:00:00",
+ "player": {"name": "Old", "player_class": "guardian", "current_room": "root"},
+ "world": {"room_states": {}},
+ }
+
+
+def test_old_pool_saves_are_set_aside_not_offered(tmp_path) -> None:
+ (tmp_path / "save_old.json").write_text(json.dumps(_old_save()))
+ mgr = SaveManager(save_dir=str(tmp_path))
+ assert mgr.list_runs() == []
+ assert mgr.legacy_count() == 1
+
+
+def test_load_game_points_at_old_saves_it_set_aside(tmp_path, monkeypatch) -> None:
+ (tmp_path / "save_old.json").write_text(json.dumps(_old_save()))
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ s = GameSession()
+ try:
+ s.ui.clear_console()
+ s.engine._load_game()
+ out = "\n".join(str(x) for x in s.ui.drain())
+ finally:
+ s.close()
+ assert "No saves available" in out
+ assert "1 old save" in out and "saves/legacy/" in out
+
+
+def test_npcs_describe_the_flag_gate() -> None:
+ knight = yaml.safe_load(open("data/npcs/firewall_knight.iptables.yml"))
+ echo = yaml.safe_load(open("data/npcs/echo.usr.yml"))
+ text = json.dumps([knight, echo]).lower()
+ assert "chmod key grants passage" not in text
+ assert "access granted" not in text
+ assert "flags" in json.dumps(knight).lower()
+
+
+@pytest.fixture
+def s() -> Iterator[GameSession]:
+ session = GameSession()
+ session.new_game("t", "guardian")
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def test_keys_command_tells_the_truth(s: GameSession) -> None:
+ s.player.add_to_inventory("lib_key", s.world.get_item("lib_key"))
+ out = "\n".join(str(x) for x in s.submit("keys"))
+ assert "var_dungeon" not in out and "class-restricted" not in out
+ assert "/usr" in out and "11 flags" in out
+
+
+def test_keys_does_not_spoil_undiscovered_secret_rooms(s: GameSession) -> None:
+ s.player.add_to_inventory("opt_key", s.world.get_item("opt_key"))
+ out = "\n".join(str(x) for x in s.submit("keys"))
+ assert "/opt" not in out and "/root" not in out
+ s.world.discover_room("opt_mage_tower")
+ assert "/opt" in "\n".join(str(x) for x in s.submit("keys"))
+
+
+def test_tree_is_described_truthfully(s: GameSession) -> None:
+ """tree marks flags you hold; it cannot show where uncaptured ones hide."""
+ s.player.tutorial_state["completed"] = True
+ texts = [
+ "\n".join(str(x) for x in s.submit("cd /boot")),
+ "\n".join(str(x) for x in s.submit("keys")),
+ open("data/npcs/echo.usr.yml").read(),
+ ]
+ for text in texts:
+ assert "still hide one" not in text and "where they hide" not in text
+
+
+def test_rank_ladder_matches_real_wins() -> None:
+ assert rank_for(12, 13, 0, 5) == "Sysadmin"
+ assert rank_for(13, 13, 4, 5) == "Sysadmin Supreme"
+ assert rank_for(13, 13, 5, 5) == "root"
+ assert rank_for(0, 0, 0, 0) == "—"
+
+
+def test_all_secrets_earn_a_title_on_any_win() -> None:
+ recap = build_recap({"flags": 12, "flags_total": 13, "secrets": 5, "secrets_total": 5})
+ assert "Keeper of Secrets" in recap
diff --git a/tests/test_capture_heal.py b/tests/test_capture_heal.py
new file mode 100644
index 0000000..c77651d
--- /dev/null
+++ b/tests/test_capture_heal.py
@@ -0,0 +1,39 @@
+"""Capturing a flag restores the player to full HP, and says so."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ session.player.current_room = "mnt_forest"
+ session.world.enemy_locations = {
+ e: r for e, r in session.world.enemy_locations.items() if r != "mnt_forest"
+ }
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def test_capture_refills_hp(s: GameSession) -> None:
+ s.player.health = 7
+ out = "\n".join(str(x) for x in s.submit("grep FLAG lost_user_log"))
+ assert s.world.flag_captured("mnt_forest")
+ assert s.player.health == s.player.max_health
+ assert f"+{s.player.max_health - 7} HP" in out and "fully restored" in out
+
+
+def test_capture_at_full_hp_says_nothing_about_healing(s: GameSession) -> None:
+ out = "\n".join(str(x) for x in s.submit("grep FLAG lost_user_log"))
+ assert s.world.flag_captured("mnt_forest")
+ assert "fully restored" not in out
diff --git a/tests/test_cat_story_beat.py b/tests/test_cat_story_beat.py
index a057c36..d62a939 100644
--- a/tests/test_cat_story_beat.py
+++ b/tests/test_cat_story_beat.py
@@ -1,4 +1,4 @@
-"""`cat` shows the file and nothing else — no room re-list, deferred or immediate.
+"""`cat` shows the file and nothing else — no room re-list.
The scene view shows who is in the room and the exits, so appending/replacing the
output with a full room listing after every cat was noise (and with append-mode
@@ -7,18 +7,6 @@
from __future__ import annotations
from engine.api import GameSession
-from src.events import EventType, event_bus
-
-
-def _count_delayed_refresh(fn):
- hits = {"n": 0}
- cb = lambda ev: hits.__setitem__("n", hits["n"] + 1) # noqa: E731
- event_bus.subscribe(EventType.DELAYED_ROOM_REFRESH, cb)
- try:
- fn()
- finally:
- event_bus.unsubscribe(EventType.DELAYED_ROOM_REFRESH, cb)
- return hits["n"]
def test_story_beat_cat_shows_message_without_relist():
@@ -29,12 +17,9 @@ def test_story_beat_cat_shows_message_without_relist():
room = h.player.current_room
h.world.add_item_to_room("system_err_log", room) # story_flag: typo_discovered
- out_lines = []
- n = _count_delayed_refresh(lambda: out_lines.extend(s.submit("cat system_err_log")))
- joined = "".join(str(x) for x in out_lines)
+ joined = "".join(str(x) for x in s.submit("cat system_err_log"))
assert "Memory restored" in joined, "story beat message missing"
- assert n == 0, f"cat must not defer a room re-list anymore, got {n}"
assert "Where you can go" not in joined, "cat must not append the room listing"
finally:
s.close()
@@ -44,11 +29,8 @@ def test_ordinary_cat_does_not_relist():
s = GameSession()
try:
s.new_game("T", "guardian")
- # readme_txt_corrupt lives in home_grove and has no story_flag
- out = []
- n = _count_delayed_refresh(lambda: out.extend(s.submit("cat readme_txt_corrupt")))
- joined = "".join(str(x) for x in out)
- assert n == 0
+ # readme_txt_corrupt lives in home_grove
+ joined = "".join(str(x) for x in s.submit("cat readme_txt_corrupt"))
assert "Where you can go" not in joined, "cat must not append the room listing"
finally:
s.close()
diff --git a/tests/test_cd_tab_completion.py b/tests/test_cd_tab_completion.py
new file mode 100644
index 0000000..c987883
--- /dev/null
+++ b/tests/test_cd_tab_completion.py
@@ -0,0 +1,51 @@
+"""`cd` + Tab completes the doors `ls` shows — never a hidden room before
+`ls -a` finds it, nor a locked door before its key reveals it."""
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.ui.command_suggester import CommandSuggester
+
+
+@pytest.fixture
+def s() -> Iterator[GameSession]:
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ session.player.current_room = "root"
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _tab(s: GameSession, typed: str) -> str | None:
+ suggester = CommandSuggester(get_player=lambda: s.player, get_world=lambda: s.world)
+ return asyncio.run(suggester.get_suggestion(typed))
+
+
+def test_cd_completes_a_visible_door(s: GameSession) -> None:
+ assert _tab(s, "cd /b") == "cd /bin"
+ assert _tab(s, "cd /h") == "cd /home"
+
+
+def test_locked_door_stays_out_until_its_key_reveals_it(s: GameSession) -> None:
+ assert _tab(s, "cd /u") is None
+ s.world.reveal_doors("lib_key")
+ assert _tab(s, "cd /u") == "cd /usr"
+
+
+def test_hidden_room_stays_out_until_discovered(s: GameSession) -> None:
+ assert _tab(s, "cd /var/b") is None
+ s.world.discover_room("archive")
+ assert _tab(s, "cd /var/b") == "cd /var/backups"
+
+
+def test_room_ids_follow_the_same_rule(s: GameSession) -> None:
+ assert _tab(s, "cd bin_") == "cd bin_armory"
+ assert _tab(s, "cd usr_") is None
+ assert _tab(s, "cd arch") is None
diff --git a/tests/test_character_panel.py b/tests/test_character_panel.py
new file mode 100644
index 0000000..202205b
--- /dev/null
+++ b/tests/test_character_panel.py
@@ -0,0 +1,92 @@
+"""The Character panel under Stats: the player's model outside battle, plus the
+weapon and armor they have on, with the numbers that matter."""
+from __future__ import annotations
+
+import asyncio
+import io
+
+import pytest
+from rich.console import Console
+from textual.app import App, ComposeResult
+
+from engine.view_models import InventoryItemView, InventoryView, StatsView
+from src.ui.panels.character_panel import CharacterPanel, gear_lines
+
+_PLAYER = StatsView("Eli", 26, 160, 42, "shaman", defense_pct=18)
+_GEAR = InventoryView(items=[
+ InventoryItemView("pike", "Privilege Escalation Pike", "weapon", "epic",
+ is_equipped=True, damage=24),
+ InventoryItemView("cloak", "Null-Void Cloak", "armor", "epic", is_equipped=True),
+ InventoryItemView("fang", "Recursion Fang", "weapon", "rare", damage=17),
+])
+
+
+def test_gear_lines_name_the_equipped_weapon_and_armor() -> None:
+ text = "\n".join(gear_lines(_PLAYER, _GEAR))
+ assert "Privilege Escalation Pike" in text and "+24 DMG" in text
+ assert "Null-Void Cloak" in text and "-18% dmg taken" in text
+ assert "Recursion Fang" not in text # carried, not equipped
+
+
+def test_empty_slots_teach_equip() -> None:
+ bare = StatsView("Eli", 100, 100, 10, "shaman")
+ text = "\n".join(gear_lines(bare, InventoryView(items=[])))
+ assert text.count("equip
") == 2
+
+
+class _Host(App[None]):
+ def compose(self) -> ComposeResult:
+ yield CharacterPanel(id="character-panel")
+
+
+def test_panel_renders_sprite_and_gear() -> None:
+ async def scenario() -> None:
+ app = _Host()
+ async with app.run_test(size=(80, 30)) as pilot:
+ panel = app.query_one(CharacterPanel)
+ panel.update_character(_PLAYER, _GEAR, reduce_motion=True)
+ await pilot.pause()
+ console = Console(width=60, record=True, file=io.StringIO())
+ console.print(panel.content)
+ shown = console.export_text()
+ assert "Privilege" in shown and "Null-Void Cloak" in shown
+ assert "▀" in shown or "▄" in shown # the half-block sprite drew
+
+ asyncio.run(scenario())
+
+
+def test_panel_is_in_the_sidebar_below_stats(monkeypatch: pytest.MonkeyPatch) -> None:
+ from src.ui.textual_ui import TextualGameUI
+
+ monkeypatch.setattr("src.ui.textual_ui.SKIP_INTRO", True)
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)):
+ ids = [w.id for w in app.query_one("#sidebar").children]
+ assert ids == ["inventory-panel", "stats-panel", "character-panel"]
+
+ asyncio.run(scenario())
+
+
+class _NarrowHost(App[None]):
+ CSS = "CharacterPanel { width: 34; }"
+
+ def compose(self) -> ComposeResult:
+ yield CharacterPanel(id="character-panel")
+
+
+def test_narrow_panel_puts_gear_under_the_sprite_uncut() -> None:
+ """Too narrow for side by side: gear goes below the sprite, names whole."""
+ async def scenario() -> None:
+ app = _NarrowHost()
+ async with app.run_test(size=(80, 40)) as pilot:
+ panel = app.query_one(CharacterPanel)
+ panel.update_character(_PLAYER, _GEAR, reduce_motion=True)
+ await pilot.pause()
+ console = Console(width=34, record=True, file=io.StringIO())
+ console.print(panel.content)
+ shown = console.export_text()
+ assert "Privilege Escalation Pike" in shown and "…" not in shown
+
+ asyncio.run(scenario())
diff --git a/tests/test_classes.py b/tests/test_classes.py
index 962aa59..381a98e 100644
--- a/tests/test_classes.py
+++ b/tests/test_classes.py
@@ -33,8 +33,9 @@ def test_class_selection_renders_from_typed_display():
from engine.api import GameSession
s = GameSession()
try:
- s.ui.drain()
- s.engine._show_class_selection()
- assert s.ui.drain(), "class selection produced no output"
+ s.submit("1") # New Game -> difficulty picker
+ out = s.submit("2") # medium -> class selection
+ assert out, "class selection produced no output"
+ assert s.state.value == "waiting_for_class"
finally:
s.close()
diff --git a/tests/test_combat_end.py b/tests/test_combat_end.py
new file mode 100644
index 0000000..d6e8f1d
--- /dev/null
+++ b/tests/test_combat_end.py
@@ -0,0 +1,80 @@
+"""The game's reaction to a fight ending is one direct call chain
+(CombatSession -> CommandHandler.end_combat -> engine hook), not a set of
+COMBAT_ENDED listeners whose order depended on subscription order."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from engine.events import EventType
+from src.game_states import GameState
+from src.game_world import TUTORIAL_ENEMY
+from src.tutorial_coach import TutorialCoach
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("Fighter", "guardian")
+ s.player.tutorial_state["completed"] = True
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _fight_in_root(s: GameSession, lethal: bool = False) -> None:
+ s.world.spawn_tutorial_enemy("root")
+ if lethal:
+ s.player.health = 1
+ s.submit("cd root")
+ session = s.engine.cmd_handler.current_combat_session
+ assert session is not None and s.state == GameState.IN_COMBAT
+ if lethal:
+ session.enemy_damage = 10**6
+ session.enemy_health = 10**6
+
+
+def test_only_observers_listen_to_combat_events(session: GameSession) -> None:
+ for et in (EventType.COMBAT_STARTED, EventType.COMBAT_ENDED):
+ owners = {type(getattr(cb, "__self__", None)) for cb in session.bus._listeners.get(et, [])}
+ assert owners <= {TutorialCoach}, f"{et.name} has game-rule listeners: {owners}"
+
+
+def test_fleeing_returns_you_to_the_previous_room(session: GameSession) -> None:
+ _fight_in_root(session)
+ session.submit("flee")
+ assert session.engine.cmd_handler.current_combat_session is None
+ assert session.state == GameState.PLAYING
+ assert session.player.current_room == "home_grove"
+ assert TUTORIAL_ENEMY in session.world.fled_enemies.get("root", [])
+
+
+def test_dying_ends_the_run(session: GameSession) -> None:
+ seen: list[dict] = []
+ session.bus.subscribe(EventType.GAME_OVER, lambda e: seen.append(e.data))
+ _fight_in_root(session, lethal=True)
+ attack = next(iter(session.engine.cmd_handler.current_combat_session.available_attacks))
+ session.submit(attack)
+
+ assert session.state == GameState.GAME_OVER
+ assert session.engine.cmd_handler.flow.in_game_over_mode is True
+ assert [d.get("reason") for d in seen] == ["defeat"]
+
+
+def test_a_kill_is_announced_once_and_removes_the_enemy(
+ session: GameSession, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr("src.combat.rng.randint", lambda a, b: a) # the attack lands
+ kills: list[dict] = []
+ session.bus.subscribe(EventType.ENEMY_DEFEATED, lambda e: kills.append(e.data))
+ _fight_in_root(session)
+ combat = session.engine.cmd_handler.current_combat_session
+ combat.enemy_health = 1
+ session.submit(next(iter(combat.available_attacks)))
+
+ assert [k["enemy_id"] for k in kills] == [TUTORIAL_ENEMY]
+ assert TUTORIAL_ENEMY not in session.world.get_enemies_in_room("root")
+ assert TUTORIAL_ENEMY in session.engine.cmd_handler.loot.awarded
diff --git a/tests/test_combat_hint.py b/tests/test_combat_hint.py
index 008b38a..44b3ef9 100644
--- a/tests/test_combat_hint.py
+++ b/tests/test_combat_hint.py
@@ -15,4 +15,4 @@ def test_hint_documents_flee_and_tab_for_items():
source = inspect.getsource(CombatModeHintScreen.on_mount)
assert "0" in source
assert "flee" in source.lower()
- assert "use [item]" in source.lower() or "use[item]" in source.lower().replace(" ", "")
+ assert "use - " in source.lower()
diff --git a/tests/test_combat_hotkeys.py b/tests/test_combat_hotkeys.py
new file mode 100644
index 0000000..d20ac81
--- /dev/null
+++ b/tests/test_combat_hotkeys.py
@@ -0,0 +1,98 @@
+"""The number shown next to an attack is the key that fires it.
+
+The panel numbered every attack but the keys skipped ones on cooldown, so with
+Frost Nova cooling down the key shown for Arcane Bolt did nothing and the one
+shown for Fireball fired Arcane Bolt. The attack order also came from a set,
+so the numbers reshuffled between launches.
+"""
+from __future__ import annotations
+
+import asyncio
+
+from engine.api import GameSession
+from engine.events import EventType
+from src.combat import combat_system
+from src.game_states import GameState
+from src.ui.textual_ui import TextualGameUI
+
+
+def _attack(attack_id: str, name: str, on_cooldown: bool = False) -> dict:
+ return {
+ "id": attack_id, "name": name, "bonus_damage": 5, "cooldown": 2,
+ "cooldown_remaining": 1 if on_cooldown else 0, "on_cooldown": on_cooldown,
+ "accuracy": 90,
+ }
+
+
+# As the panel lists them: [1] Frost Nova (cooling down), [2] Fireball, [3] Arcane Bolt.
+_ATTACKS = [
+ _attack("frost_nova", "Frost Nova", on_cooldown=True),
+ _attack("fireball", "Fireball"),
+ _attack("arcane_bolt", "Arcane Bolt"),
+]
+
+
+def _fired_after_pressing(key: str) -> list[str]:
+ import src.ui.textual_ui as tui
+
+ # The title typewriter focuses the command box when it finishes; under load
+ # that can land after combat starts and swallow the keypress as typing.
+ skip_intro, tui.SKIP_INTRO = tui.SKIP_INTRO, True
+ app = TextualGameUI()
+ # Combat blurs the command box, and the first blur ever opens the one-time
+ # Selection Mode popup, which would swallow the key. Whether it has been
+ # "seen" lives in the settings file, so it varies with test order.
+ app._combat_hint_shown = True
+ fired: list[str] = []
+ app.bus.subscribe(
+ EventType.COMBAT_ACTION_SELECTED, lambda e: fired.append(e.data.get("choice"))
+ )
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ app.state_manager.set_state(GameState.PLAYING, emit_event=False)
+ app.state_manager.set_state(GameState.IN_COMBAT, emit_event=False)
+ app.bus.emit_event(
+ EventType.COMBAT_STARTED,
+ {
+ "enemy_name": "Mount Daemon", "enemy_health": 50,
+ "enemy_max_health": 50, "player_health": 60,
+ "player_max_health": 90, "available_attacks": _ATTACKS,
+ },
+ "Test",
+ )
+ # The bus hands COMBAT_STARTED to the UI thread; wait for the keys
+ # to be bound rather than racing it.
+ for _ in range(50):
+ if key in app._bound_combat_keys:
+ break
+ await pilot.pause(0.02)
+ app.set_focus(None)
+ await pilot.press(key)
+ await pilot.pause()
+
+ try:
+ asyncio.run(scenario())
+ finally:
+ tui.SKIP_INTRO = skip_intro
+ return fired
+
+
+def test_the_key_shown_next_to_an_attack_fires_it() -> None:
+ assert _fired_after_pressing("3") == ["arcane_bolt"]
+ assert _fired_after_pressing("2") == ["fireball"]
+
+
+def test_the_key_of_an_attack_on_cooldown_fires_nothing() -> None:
+ assert _fired_after_pressing("1") == []
+
+
+def test_attacks_keep_the_class_order() -> None:
+ s = GameSession()
+ try:
+ s.new_game("t", "weaver")
+ class_order = combat_system.get_attacks_for_class("weaver")
+ offered = list(combat_system.get_available_attacks(s.player))
+ finally:
+ s.close()
+ assert offered == list(class_order)
diff --git a/tests/test_combat_inventory_refresh.py b/tests/test_combat_inventory_refresh.py
new file mode 100644
index 0000000..9865360
--- /dev/null
+++ b/tests/test_combat_inventory_refresh.py
@@ -0,0 +1,33 @@
+"""Using an item in combat must refresh the Inventory panel: it used to keep
+showing a used-up Health Packet until the fight ended."""
+from __future__ import annotations
+
+from typing import Any
+
+from engine.api import GameSession
+from engine.events import EventType
+from src.game_states import GameState
+
+
+def test_using_an_item_in_combat_refreshes_the_inventory_panel() -> None:
+ s = GameSession()
+ try:
+ s.new_game("Tess", "guardian")
+ s.player.tutorial_state["completed"] = True
+ s.player.add_to_inventory("health_packet", s.world.get_item("health_packet"))
+ s.world.spawn_tutorial_enemy("root")
+ s.submit("cd root")
+ assert s.state == GameState.IN_COMBAT
+ s.player.health = 10
+
+ views: list[dict[str, Any]] = []
+ s.bus.subscribe(
+ EventType.PLAYER_INVENTORY_CHANGED, lambda e: views.append(e.data)
+ )
+ s.submit("use health_packet")
+
+ assert "health_packet" not in s.player.inventory
+ assert views, "the Inventory panel was never told the item was used"
+ assert "health_packet" not in [item["id"] for item in views[-1]["items"]]
+ finally:
+ s.close()
diff --git a/tests/test_combat_session_abort_leak.py b/tests/test_combat_session_abort_leak.py
index 4c9968d..965b489 100644
--- a/tests/test_combat_session_abort_leak.py
+++ b/tests/test_combat_session_abort_leak.py
@@ -11,13 +11,13 @@
COMBAT_ENDED that the live CommandHandler (a different instance, but the same
event type) also reacted to.
-Assertion is object-identity based (matching test_load_no_handler_leak.py's
-pattern) because the module-singleton bus can carry handlers leaked by other
-tests.
+Since each engine now owns its bus, a leak across sessions can no longer reach
+the next session; the in-engine leak (restart, load mid-fight) is still pinned
+by the identity check on the closed session's own bus.
"""
from engine.api import GameSession
+from engine.events import EventType
from src import rng
-from src.events import EventType, event_bus
def _start_unresolved_fight(s):
@@ -25,6 +25,8 @@ def _start_unresolved_fight(s):
h.world.item_locations["segfault_shield"] = s.player.current_room
s.submit("take segfault_shield")
s.submit("equip segfault_shield")
+ s.submit("take cracked_firewall") # starter armor: weapon + armor opens the fight
+ s.submit("equip cracked_firewall")
assert h.current_combat_session is not None
assert h.current_combat_session.is_active
return h
@@ -41,7 +43,7 @@ def test_closing_mid_combat_unsubscribes_the_session():
s.close()
assert old_session is not None
- subs = event_bus._listeners.get(EventType.COMBAT_ACTION_SELECTED, [])
+ subs = s.bus._listeners.get(EventType.COMBAT_ACTION_SELECTED, [])
assert all(getattr(cb, "__self__", None) is not old_session for cb in subs), (
"old CombatSession still subscribed after teardown — it will "
"double-process the next combat's actions"
@@ -75,13 +77,13 @@ def test_next_combat_after_unresolved_close_gets_single_ended_event():
def counter(_ev):
ended_count["n"] += 1
- event_bus.subscribe(EventType.COMBAT_ENDED, counter)
+ s.bus.subscribe(EventType.COMBAT_ENDED, counter)
try:
- event_bus.emit_event(
+ s.bus.emit_event(
EventType.COMBAT_ACTION_SELECTED, {"choice": attack_id}, "Test"
)
finally:
- event_bus.unsubscribe(EventType.COMBAT_ENDED, counter)
+ s.bus.unsubscribe(EventType.COMBAT_ENDED, counter)
rng.randint = original_randint
assert ended_count["n"] == 1, f"COMBAT_ENDED fired {ended_count['n']} times, expected 1"
diff --git a/tests/test_commands.py b/tests/test_commands.py
index ce553b5..79e4b19 100644
--- a/tests/test_commands.py
+++ b/tests/test_commands.py
@@ -43,17 +43,6 @@ def test_registry_covers_every_verb() -> None:
assert name in registry
-def test_legacy_dispatch_is_empty() -> None:
- # Phase 3 complete: no verb should remain on the legacy dict.
- from src.commands import build_registry # noqa: F401
- s = GameSession()
- try:
- s.new_game("T", "guardian")
- assert s.engine.cmd_handler.commands == {}
- finally:
- s.close()
-
-
def test_inventory_alias_matches(session: GameSession) -> None:
assert _text(session.submit("inv")) == _text(session.submit("inventory"))
@@ -92,8 +81,9 @@ def test_inventory_shows_starter_items(session: GameSession) -> None:
def test_keys_shows_progression(session: GameSession) -> None:
+ session.player.add_to_inventory("lib_key", session.world.get_item("lib_key"))
out = _text(session.submit("keys"))
- assert "KEY PROGRESSION" in out
+ assert "KEYS" in out
assert "lib_key" in out
@@ -206,9 +196,12 @@ def test_tutorial_prescribed_commands_work(session: GameSession) -> None:
from src.game_states import GameState
if session.state == GameState.IN_COMBAT:
session.submit("flee")
- before = session.player.current_room
- session.submit("cd /var")
- assert session.player.current_room != before, "cd /var (tutorial nav) must move"
+ for cmd in ("ps", "pwd", "cd ..", "ls"):
+ out = _text(session.submit(cmd))
+ assert "Unknown command" not in out and "digital void" not in out, cmd
+ assert session.player.current_room == "root", "cd .. from /home must reach /"
+ session.submit("cd bin")
+ assert session.player.current_room == "bin_armory", "the tutorial's cd example must move"
def test_ls_hints_toggle(session: GameSession) -> None:
@@ -243,8 +236,8 @@ def test_defeating_overlord_in_core_wins(session: GameSession) -> None:
session.world.remove_enemy_from_room("daemon_overlord.sys")
assert "daemon_overlord.sys" not in session.world.get_enemies_in_room("core")
- assert h.check_game_completion() is True
- assert h._game_won is True
+ assert h.flow.check_game_completion() is True
+ assert h.flow.game_won is True
# win_game branches the ending by class and records the choice.
assert session.player.story_flags["ending_chosen"] in {"restore", "rewrite", "reconcile"}
@@ -254,14 +247,14 @@ def test_win_is_not_triggered_early(session: GameSession) -> None:
# In core but the Overlord still lives -> no win.
session.player.current_room = "core"
assert "daemon_overlord.sys" in session.world.get_enemies_in_room("core")
- assert h.check_game_completion() is False
- assert h._game_won is False
+ assert h.flow.check_game_completion() is False
+ assert h.flow.game_won is False
def test_win_does_not_double_fire(session: GameSession) -> None:
h = session.engine.cmd_handler
session.player.current_room = "core"
session.world.remove_enemy_from_room("daemon_overlord.sys")
- assert h.check_game_completion() is True
+ assert h.flow.check_game_completion() is True
# Already won: a second check must be a no-op, not a re-trigger.
- assert h.check_game_completion() is False
+ assert h.flow.check_game_completion() is False
diff --git a/tests/test_content.py b/tests/test_content.py
index 5427bd3..cc5e2bf 100644
--- a/tests/test_content.py
+++ b/tests/test_content.py
@@ -18,6 +18,7 @@
load_all,
)
from engine.schema import DanglingReferenceError, Room
+from src import room_paths
START_ROOM = "home_grove"
@@ -32,7 +33,7 @@ def content() -> GameContent:
def test_all_content_loads(content: GameContent) -> None:
assert len(content.rooms) == 18
assert len(content.classes) == 3
- assert len(content.enemies) == 24
+ assert len(content.enemies) == 27
assert content.items and content.npcs and content.abilities and content.attacks
@@ -127,24 +128,64 @@ def test_advisory_warnings_match_known_findings(content: GameContent) -> None:
assert not unexpected, f"new advisory dangling refs: {sorted(unexpected)}"
-@pytest.mark.xfail(
- reason="mirror_sector is hidden but has no exit, discovery rule, or cd alias "
- "into it — genuinely unreachable content. Design decision pending.",
- strict=True,
-)
-def test_mirror_sector_is_reachable_somehow(content: GameContent) -> None:
- # Documents the known unreachable-content finding. Flips to a real pass once
- # an entry path is added (or delete this test if mirror_sector is cut).
- assert "mirror_sector" in _reachable(content)
+def test_hidden_rooms_reachable_via_path_tree(content: GameContent) -> None:
+ """Hidden rooms are not entered through `exits` — `cd` walks the path tree.
+
+ A hidden room is reachable when its parent directory is itself reachable,
+ since `ls -a` in the parent reveals the child. Guarding the parent link is
+ what catches a reparented or orphaned room.
+ """
+ reachable = _reachable(content)
+ by_path = {room.path: rid for rid, room in content.rooms.items()}
+
+ stranded = []
+ for rid, room in content.rooms.items():
+ if not room.hidden or rid in reachable:
+ continue
+ parent = by_path.get(room_paths.parent_path(room.path))
+ if parent is None or (parent not in reachable and not content.rooms[parent].hidden):
+ stranded.append(rid)
+
+ assert stranded == [], f"hidden rooms with no reachable parent directory: {stranded}"
+
+
+def test_discovery_requirements_are_granted_by_a_reachable_npc(
+ content: GameContent,
+) -> None:
+ """A `discovery_requirement` flag must be grantable, or the room is sealed.
+
+ mirror_sector (/proc/self, the Sudo Trial) is the live case: `ls -a` hides it
+ until `sudo_quest_active` is set, and only the Process Scheduler in /proc
+ sets it. If that NPC loses the flag or moves somewhere unreachable, the room
+ becomes permanently unenterable — which is what this fences.
+ """
+ reachable = _reachable(content)
+ granted: set[str] = set()
+ for rid, room in content.rooms.items():
+ if rid not in reachable:
+ continue
+ for npc_id in room.npcs or []:
+ npc = content.npcs.get(npc_id)
+ on_talk = getattr(npc, "on_talk", None) if npc else None
+ flag = (on_talk or {}).get("story_flag") if on_talk else None
+ if flag:
+ granted.add(flag)
+
+ ungrantable = sorted(
+ f"{rid} needs '{req}'"
+ for rid, room in content.rooms.items()
+ if (req := getattr(room, "discovery_requirement", None)) and req not in granted
+ )
+ assert ungrantable == [], f"discovery_requirement never granted: {ungrantable}"
# --- C1: flat item files ----------------------------------------------------
-def test_items_load_flat_and_count_41() -> None:
+def test_items_load_flat_and_count_53() -> None:
from engine.content.loader import load_items
items = load_items("data")
- assert len(items) == 41, len(items)
+ assert len(items) == 53, len(items) # 44 + motd/.flag + 5 logs + 3 notes - master_key
# every item carries an explicit type (no wrapper-derived category)
assert all(getattr(i, "type", None) for i in items.values())
diff --git a/tests/test_content_intent.py b/tests/test_content_intent.py
new file mode 100644
index 0000000..e1e92bc
--- /dev/null
+++ b/tests/test_content_intent.py
@@ -0,0 +1,106 @@
+"""Intent checks: content that references real ids but means the wrong thing.
+
+The reference linker proves ids exist. These pin the next layer: keys and locks
+agree, no content field is silently ignored by the game, and every entity the
+scene view draws has art.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+from engine.content import GameContent, find_lock_problems, find_unread_fields, load_all
+from engine.content.linker import UNIMPLEMENTED_FIELDS
+from engine.schema import Enemy, ItemId, RoomId
+
+
+@pytest.fixture(scope="module")
+def content() -> GameContent:
+ return load_all("data")
+
+
+def _copy(content: GameContent) -> GameContent:
+ return GameContent(
+ rooms={k: v.model_copy(deep=True) for k, v in content.rooms.items()},
+ items={k: v.model_copy(deep=True) for k, v in content.items.items()},
+ enemies={k: v.model_copy(deep=True) for k, v in content.enemies.items()},
+ npcs=dict(content.npcs),
+ classes=dict(content.classes),
+ abilities=dict(content.abilities),
+ attacks=dict(content.attacks),
+ )
+
+
+def test_shipped_content_is_clean(content: GameContent) -> None:
+ assert find_lock_problems(content) == []
+ assert find_unread_fields(content) == []
+
+
+def test_a_lock_without_a_key_is_caught(content: GameContent) -> None:
+ c = _copy(content)
+ c.rooms[RoomId("usr_lib_arcane")].key_required = None
+ assert any("locked, but names no key_required" in p for p in find_lock_problems(c))
+
+
+def test_a_key_that_misses_its_own_door_is_caught(content: GameContent) -> None:
+ c = _copy(content)
+ c.items[ItemId("lib_key")].unlocks = []
+ assert any("does not list it" in p for p in find_lock_problems(c))
+
+
+def test_a_key_claiming_someone_elses_door_is_caught(content: GameContent) -> None:
+ c = _copy(content)
+ c.items[ItemId("lib_key")].unlocks.append(RoomId("core"))
+ problems = find_lock_problems(c)
+ assert any("item 'lib_key': unlocks 'core'" in p for p in problems)
+
+
+def test_a_non_key_used_as_a_key_is_caught(content: GameContent) -> None:
+ c = _copy(content)
+ c.rooms[RoomId("core")].key_required = ItemId("health_packet")
+ assert any("is a consumable, not a key" in p for p in find_lock_problems(c))
+
+
+def test_a_new_unread_field_fails(content: GameContent) -> None:
+ c = _copy(content)
+ eid = next(iter(c.enemies))
+ body = c.enemies[eid].model_dump(exclude_unset=True)
+ c.enemies[eid] = Enemy(**body, bonus_gold=50)
+ assert any("field 'bonus_gold' is not in the schema" in p for p in find_unread_fields(c))
+
+
+def test_a_stale_allowlist_entry_fails(content: GameContent) -> None:
+ c = _copy(content)
+ for eid, enemy in list(c.enemies.items()):
+ body = enemy.model_dump(exclude_unset=True)
+ body.pop("on_defeat", None)
+ c.enemies[eid] = Enemy(**body)
+ assert "on_defeat" in UNIMPLEMENTED_FIELDS["enemy"]
+ assert any("lists 'on_defeat', but no enemy uses it" in p for p in find_unread_fields(c))
+
+
+@pytest.mark.parametrize("kind", ["enemies", "npcs", "classes"])
+def test_every_drawn_entity_has_art(content: GameContent, kind: str) -> None:
+ root = Path("assets/sprites") / kind
+ missing = [str(i) for i in getattr(content, kind) if not (root / f"{i}.png").exists()]
+ assert not missing, f"no sprite in {root} for: {missing} (the scene would show a placeholder)"
+
+
+def test_every_room_zone_has_a_backdrop(content: GameContent) -> None:
+ root = Path("assets/sprites/backdrops")
+ zones = {room.zone for room in content.rooms.values() if room.zone}
+ missing = sorted(z for z in zones if not (root / f"{z}.png").exists())
+ assert not missing, f"no backdrop for zones: {missing}"
+
+
+def test_validate_cli_fails_on_an_unread_field(tmp_path: Path) -> None:
+ import shutil
+
+ from engine.validate import main
+
+ data = tmp_path / "data"
+ shutil.copytree("data", data)
+ enemy_file = sorted((data / "enemies").glob("*.yml"))[0]
+ enemy_file.write_text(enemy_file.read_text() + "\nbonus_gold: 50\n")
+ assert main([str(data)]) == 1
diff --git a/tests/test_content_links_at_start.py b/tests/test_content_links_at_start.py
new file mode 100644
index 0000000..616280c
--- /dev/null
+++ b/tests/test_content_links_at_start.py
@@ -0,0 +1,33 @@
+"""The world's content is linked when the engine starts: broken content stops
+the game at start instead of loading an empty or half-connected world."""
+from __future__ import annotations
+
+import shutil
+from pathlib import Path
+
+import pytest
+
+from engine.headless import HeadlessUI
+from src.game_engine import DataLoadError, ImprovedGameEngine
+
+
+def test_a_dangling_reference_stops_the_game_at_start(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ data = tmp_path / "data"
+ shutil.copytree("data", data)
+ room = data / "rooms" / "home_grove.yml"
+ room.write_text(room.read_text() + "\nitems:\n - no_such_item\n")
+ monkeypatch.setattr(ImprovedGameEngine, "DATA_DIR", str(data))
+
+ with pytest.raises(DataLoadError, match="no_such_item"):
+ ImprovedGameEngine(ui=HeadlessUI())
+
+
+def test_the_shipped_content_links(monkeypatch: pytest.MonkeyPatch) -> None:
+ engine = ImprovedGameEngine(ui=HeadlessUI())
+ try:
+ assert engine.world.rooms and engine.world.items
+ assert engine.world.enemies and engine.world.npcs
+ finally:
+ engine._cleanup()
diff --git a/tests/test_difficulty.py b/tests/test_difficulty.py
index 5bcdf6a..23d9617 100644
--- a/tests/test_difficulty.py
+++ b/tests/test_difficulty.py
@@ -6,6 +6,7 @@
import pytest
+from engine.schema import Enemy
from src import difficulty
@@ -19,7 +20,7 @@ def _reset_mode():
def _enemy_hp(mode: str) -> int:
difficulty.set_mode(mode)
- return difficulty.scale_enemy({"health": 100, "damage": 10})["health"]
+ return difficulty.scale_enemy(Enemy(name="x", health=100, damage=10)).health
def test_enemy_hp_is_ordered_easy_medium_hard() -> None:
@@ -43,9 +44,9 @@ def test_easy_is_not_tougher_than_hard() -> None:
def test_scale_enemy_does_not_mutate_input() -> None:
difficulty.set_mode("hard")
- original = {"health": 100, "damage": 10}
+ original = Enemy(name="x", health=100, damage=10)
difficulty.scale_enemy(original)
- assert original == {"health": 100, "damage": 10}
+ assert (original.health, original.damage) == (100, 10)
def test_get_enemy_applies_difficulty() -> None:
@@ -56,9 +57,9 @@ def test_get_enemy_applies_difficulty() -> None:
s.new_game("T", "guardian")
eid = next(iter(s.world.enemies))
difficulty.set_mode("easy")
- easy = s.world.get_enemy(eid, "guardian")["health"]
+ easy = s.world.get_enemy(eid, "guardian").health
difficulty.set_mode("hard")
- hard = s.world.get_enemy(eid, "guardian")["health"]
+ hard = s.world.get_enemy(eid, "guardian").health
assert hard > easy
finally:
difficulty.set_mode("medium")
diff --git a/tests/test_difficulty_picker.py b/tests/test_difficulty_picker.py
index 0aac73f..27076e9 100644
--- a/tests/test_difficulty_picker.py
+++ b/tests/test_difficulty_picker.py
@@ -7,20 +7,21 @@
from src import difficulty
from src.game_engine import ImprovedGameEngine
from src.game_states import GameState
-from src.state_manager import state_manager
@pytest.mark.parametrize("choice,expected", [("1", "easy"), ("2", "medium"), ("3", "hard")])
def test_difficulty_pick_sets_mode_and_advances(choice, expected):
eng = ImprovedGameEngine(ui=HeadlessUI())
+ eng._handle_menu_command("1") # New Game opens the picker
eng._handle_difficulty_input(choice)
assert difficulty.current_mode() == expected
- assert state_manager.current_state == GameState.WAITING_FOR_CLASS
+ assert eng.state_manager.current_state == GameState.WAITING_FOR_CLASS
def test_invalid_difficulty_choice_reprompts():
eng = ImprovedGameEngine(ui=HeadlessUI())
+ eng._handle_menu_command("1")
before = difficulty.current_mode()
eng._handle_difficulty_input("9") # invalid
assert difficulty.current_mode() == before # unchanged
- assert state_manager.current_state == GameState.WAITING_FOR_DIFFICULTY
+ assert eng.state_manager.current_state == GameState.WAITING_FOR_DIFFICULTY
diff --git a/tests/test_display_bugs.py b/tests/test_display_bugs.py
new file mode 100644
index 0000000..37e6c1f
--- /dev/null
+++ b/tests/test_display_bugs.py
@@ -0,0 +1,69 @@
+"""Two display gaps: the inventory panel showed no heal amount for healing
+items, and every attack in the fight menu got the same plain bullet."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from engine.view_models import AttackView, InventoryItemView, StatsView
+from src.ui.combat_log import hotkey_display
+from src.ui.panels.inventory_panel import InventoryPanel
+from src.viewmodels.view_builder import ViewBuilder
+
+
+@pytest.fixture
+def s() -> Iterator[GameSession]:
+ session = GameSession()
+ session.new_game("t", "shaman")
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _view(s: GameSession, item_id: str) -> InventoryItemView:
+ s.player.add_to_inventory(item_id, s.world.get_item(item_id))
+ items = ViewBuilder.build_inventory_view(s.player).items
+ return next(i for i in items if i.id == item_id)
+
+
+def test_health_packet_view_carries_its_heal(s: GameSession) -> None:
+ view = _view(s, "health_packet")
+ assert view.healing == 30 and view.healTurns == 0
+
+
+def test_heal_over_time_view_carries_total_and_turns(s: GameSession) -> None:
+ view = _view(s, "stable_cache")
+ assert view.healing == 39 and view.healTurns == 3 # 13 a turn, 3 turns
+
+
+def test_panel_shows_heal_amounts() -> None:
+ panel = InventoryPanel()
+ packet = InventoryItemView("health_packet", "Health Packet", "consumable", healing=30)
+ cache = InventoryItemView(
+ "stable_cache", "Stable Cache", "consumable", healing=39, healTurns=3
+ )
+ assert "+30 HP" in panel._format_enhanced_inventory_item("health_packet", packet)
+ assert "+39 HP over 3 turns" in panel._format_enhanced_inventory_item(
+ "stable_cache", cache
+ )
+
+
+def test_attack_views_carry_their_kind(s: GameSession) -> None:
+ from src.combat import CombatSystem
+
+ views = {a.id: a for a in ViewBuilder.build_attack_list(s.player, CombatSystem())}
+ assert views["nature_strike"].kind == "nature"
+
+
+def test_fight_menu_shows_a_kind_icon_per_attack() -> None:
+ player = StatsView("Tess", 100, 100, 10, "weaver")
+ menu = hotkey_display(player, [
+ AttackView("strike", "Strike", 5, 0, kind="physical"),
+ AttackView("fireball", "Fireball", 18, 2, kind="magical"),
+ AttackView("nature_strike", "Nature Strike", 6, 0, kind="nature"),
+ ])
+ lines = menu.splitlines()[1:]
+ assert "⚔" in lines[0] and "✨" in lines[1] and "🌿" in lines[2]
diff --git a/tests/test_echo_panel.py b/tests/test_echo_panel.py
new file mode 100644
index 0000000..a85628c
--- /dev/null
+++ b/tests/test_echo_panel.py
@@ -0,0 +1,139 @@
+"""Echo panel: tutorial hints are pinned above the input, not buried in the output."""
+from __future__ import annotations
+
+import asyncio
+
+import pytest
+
+from engine.events import EventType
+from src.ui.textual_ui import TextualGameUI
+
+
+@pytest.fixture(autouse=True)
+def _no_intro_on_mount(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr("src.ui.textual_ui.SKIP_INTRO", True)
+
+
+def _hint(
+ app: TextualGameUI, hint_id: str, text: str, step: int | None, final: bool = False
+) -> None:
+ app.bus.emit_event(
+ EventType.TUTORIAL_HINT,
+ {"hint_id": hint_id, "text": text, "step": step, "total": 6, "final": final},
+ "test",
+ )
+
+
+def test_hint_is_pinned_in_the_echo_panel() -> None:
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ panel = app._echo_panel
+ assert panel.display is False
+ before = str(app.output_content)
+
+ _hint(app, "step2", "[bold green]ECHO>[/bold green] Type: take it", 2)
+ await pilot.pause()
+
+ assert panel.display is True
+ assert panel.border_title == "🗨 ECHO · step 2 of 6"
+ assert str(panel.content) == "Type: take it"
+ assert str(app.output_content) == before
+
+ asyncio.run(scenario())
+
+
+def test_the_closing_hint_goes_to_the_output_and_hides_the_panel() -> None:
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ app.remove_class("intro-mode")
+ _hint(app, "step6", "go somewhere", 6)
+ await pilot.pause()
+ _hint(app, "completed", "Tutorial complete, Tess.", None, final=True)
+ await pilot.pause()
+
+ assert app._echo_panel.display is False
+ assert "Tutorial complete, Tess." in str(app.output_content)
+
+ asyncio.run(scenario())
+
+
+def test_a_new_game_clears_a_stale_hint() -> None:
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ _hint(app, "step4", "press 1", 4)
+ await pilot.pause()
+ app.bus.emit_event(EventType.GAME_STARTED, {}, "test")
+ await pilot.pause()
+ assert app._echo_panel.display is False
+
+ asyncio.run(scenario())
+
+
+def test_the_panel_flashes_when_the_tutorial_starts() -> None:
+ """The first hint draws the eye to the panel: it pulses, then settles."""
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ panel = app._echo_panel
+ _hint(app, "step1", "Type: ls", 1)
+ await pilot.pause()
+ assert panel.has_class("echo-flash")
+
+ await pilot.pause(panel.FLASH_SECONDS * (2 * panel.FLASH_PULSES + 2))
+ assert not panel.has_class("echo-flash"), "the flash must settle"
+
+ _hint(app, "step2", "Type: take it", 2)
+ await pilot.pause()
+ assert not panel.has_class("echo-flash"), "later steps don't flash"
+
+ asyncio.run(scenario())
+
+
+def test_reduce_motion_highlights_once_instead_of_flashing() -> None:
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ app._settings_manager.settings["reduce_motion"] = True
+ panel = app._echo_panel
+ toggles: list[bool] = []
+ original = panel.set_class
+
+ def record(add: bool, *names: str) -> object:
+ toggles.append(add)
+ return original(add, *names)
+
+ panel.set_class = record # type: ignore[method-assign]
+ _hint(app, "step1", "Type: ls", 1)
+ await pilot.pause(panel.FLASH_SECONDS * (2 * panel.FLASH_PULSES + 2))
+ assert toggles == [True, False], toggles
+
+ asyncio.run(scenario())
+
+
+def test_saying_yes_to_the_tutorial_flashes_the_panel() -> None:
+ from src.game_engine import ImprovedGameEngine
+
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ engine = ImprovedGameEngine(ui=app)
+ assert engine.create_player("Tess", "guardian")
+ engine._handle_skip_response("yes") # the tutorial offer's answer
+ await pilot.pause()
+
+ panel = app._echo_panel
+ assert panel.display is True
+ assert "step 1" in str(panel.border_title)
+ assert panel.has_class("echo-flash")
+ engine._cleanup()
+
+ asyncio.run(scenario())
diff --git a/tests/test_enemy_defeat_removal.py b/tests/test_enemy_defeat_removal.py
index 70218f2..43338df 100644
--- a/tests/test_enemy_defeat_removal.py
+++ b/tests/test_enemy_defeat_removal.py
@@ -1,13 +1,12 @@
"""Regression: defeating an enemy must remove it from the room.
-_on_enemy_defeated awards drops then removes the enemy. If the drop-award reads the
-typed Enemy model with .get() it raises, the event callback aborts before removal,
-the enemy stays, and combat re-triggers on it (fight each enemy twice).
+CommandHandler.on_kill awards drops then removes the enemy. If the drop-award reads
+the typed Enemy model with .get() it raises before removal, the enemy stays, and
+combat re-triggers on it (fight each enemy twice).
"""
from __future__ import annotations
from engine.api import GameSession
-from src.events import EventType, event_bus
def test_defeating_enemy_removes_it_from_room():
@@ -25,10 +24,10 @@ def test_defeating_enemy_removes_it_from_room():
assert room_id and enemy_id, "expected some room with an enemy"
h.player.current_room = room_id
- event_bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": enemy_id}, "test")
+ h.on_kill(enemy_id)
assert enemy_id not in world.get_enemies_in_room(room_id), (
- f"{enemy_id} still present after ENEMY_DEFEATED — drop-award crashed before removal"
+ f"{enemy_id} still present after on_kill — drop-award crashed before removal"
)
finally:
s.close()
diff --git a/tests/test_enemy_typed.py b/tests/test_enemy_typed.py
index 468e3ab..f5cf8fa 100644
--- a/tests/test_enemy_typed.py
+++ b/tests/test_enemy_typed.py
@@ -1,4 +1,4 @@
-"""Enemy templates are typed; get_enemy hands combat a dict identical to the raw YAML."""
+"""Enemy templates are typed, and get_enemy hands combat a private typed copy."""
from __future__ import annotations
import glob
@@ -12,13 +12,13 @@
def test_load_enemy_data_returns_typed_models():
from src.data_loader import load_enemy_data
enemies = load_enemy_data()
- assert len(enemies) == 24
+ assert len(enemies) == 27
for eid, e in enemies.items():
assert isinstance(e, Enemy), (eid, type(e))
assert e.health > 0
-def test_get_enemy_returns_dict_matching_raw_yaml():
+def test_get_enemy_returns_a_model_carrying_every_yaml_key():
from engine.api import GameSession
s = GameSession()
try:
@@ -27,8 +27,25 @@ def test_get_enemy_returns_dict_matching_raw_yaml():
path = sorted(glob.glob("data/enemies/*.yml"))[0]
eid = os.path.basename(path)[:-4]
raw = yaml.safe_load(open(path)) or {}
- got = world.get_enemy(eid) # no player_class -> unscaled dict
- assert isinstance(got, dict)
- assert set(raw.keys()) <= set(got.keys())
+ got = world.get_enemy(eid)
+ assert isinstance(got, Enemy)
+ assert set(raw.keys()) <= set(got.model_dump(exclude_unset=True).keys())
+ finally:
+ s.close()
+
+
+def test_get_enemy_never_hands_out_the_shared_template():
+ from engine.api import GameSession
+ s = GameSession()
+ try:
+ s.new_game("Tester", "guardian")
+ world = s.engine.cmd_handler.world
+ eid = next(iter(world.enemies))
+ template_hp = world.enemies[eid].health
+ copy = world.get_enemy(eid, "guardian")
+ copy.health = 1
+ copy.attack_patterns.append({"damage": 999})
+ assert world.enemies[eid].health == template_hp
+ assert {"damage": 999} not in world.enemies[eid].attack_patterns
finally:
s.close()
diff --git a/tests/test_escape_cancels_typing.py b/tests/test_escape_cancels_typing.py
new file mode 100644
index 0000000..5915b05
--- /dev/null
+++ b/tests/test_escape_cancels_typing.py
@@ -0,0 +1,54 @@
+"""Esc while typing cancels the line, like a shell. Only an Esc on an empty
+prompt means "quit"."""
+from __future__ import annotations
+
+import asyncio
+
+from textual.widgets import Input
+
+from engine.events import EventType
+from src.ui.textual_ui import TextualGameUI
+
+
+def _run(coro) -> None:
+ asyncio.run(coro)
+
+
+def _commands_emitted(recorder: list) :
+ def on_command(event) -> None:
+ recorder.append(event.data.get("command"))
+ return on_command
+
+
+def test_escape_with_text_clears_input_and_does_not_quit() -> None:
+ commands: list[str] = []
+ app = TextualGameUI()
+ app.bus.subscribe(EventType.COMMAND_ENTERED, _commands_emitted(commands))
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ field = app.query_one("#input-field", Input)
+ field.focus()
+ await pilot.press("t", "a", "k", "e", " ", "s")
+ assert field.value == "take s"
+ await pilot.press("escape")
+ await pilot.pause()
+ assert field.value == ""
+ assert "quit" not in commands
+
+ _run(scenario())
+
+
+def test_escape_on_empty_input_still_asks_to_quit() -> None:
+ commands: list[str] = []
+ app = TextualGameUI()
+ app.bus.subscribe(EventType.COMMAND_ENTERED, _commands_emitted(commands))
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ app.query_one("#input-field", Input).focus()
+ await pilot.press("escape")
+ await pilot.pause()
+ assert commands == ["quit"]
+
+ _run(scenario())
diff --git a/tests/test_find_dev.py b/tests/test_find_dev.py
new file mode 100644
index 0000000..19d6e60
--- /dev/null
+++ b/tests/test_find_dev.py
@@ -0,0 +1,47 @@
+"""`find /dev -name null` must not name /dev while its door is still hidden
+behind the Sudo Privileges Badge; once revealed, it points at the real path."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+
+
+@pytest.fixture
+def s() -> Iterator[GameSession]:
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ session.player.current_room = "bin_armory"
+ session.world.enemy_locations = {
+ e: r for e, r in session.world.enemy_locations.items() if r != "bin_armory"
+ }
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _find(s: GameSession) -> str:
+ return "\n".join(str(x) for x in s.submit("find /dev -name null"))
+
+
+def test_hidden_dev_is_not_found(s: GameSession) -> None:
+ out = _find(s)
+ assert "No such file" in out
+ assert "Found" not in out and "cd " not in out
+
+
+def test_revealed_dev_is_found_with_its_real_path(s: GameSession) -> None:
+ s.world.reveal_doors("sudo_privileges_badge")
+ out = _find(s)
+ assert "Found" in out and "cd /dev" in out
+ assert "dev_null_void" not in out
+
+
+def test_revealed_dev_is_found_from_anywhere(s: GameSession) -> None:
+ s.world.reveal_doors("sudo_privileges_badge")
+ s.player.current_room = "root"
+ assert "Found" in _find(s)
diff --git a/tests/test_flag_ending.py b/tests/test_flag_ending.py
new file mode 100644
index 0000000..8c915a1
--- /dev/null
+++ b/tests/test_flag_ending.py
@@ -0,0 +1,52 @@
+"""The recap counts flags and ranks the run; a full clear earns an epilogue."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from engine.events import EventType
+from src.ui.endings import FULL_CLEAR_EPILOGUE, build_recap, rank_for
+
+
+@pytest.fixture
+def s() -> Iterator[GameSession]:
+ session = GameSession()
+ session.new_game("t", "weaver")
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _won(s: GameSession) -> dict:
+ events: list[dict] = []
+ s.bus.subscribe(EventType.GAME_WON, lambda e: events.append(e.data))
+ s.engine.cmd_handler.flow.win_game()
+ return events[-1]
+
+
+def test_rank_ladder() -> None:
+ assert rank_for(12, 13, 0, 5) == "Sysadmin"
+ assert rank_for(13, 13, 4, 5) == "Sysadmin Supreme"
+ assert rank_for(13, 13, 5, 5) == "root"
+
+
+def test_recap_shows_flags_and_rank(s: GameSession) -> None:
+ for rid in ("root", "home_grove"):
+ s.world.mark_flag_captured(rid)
+ stats = _won(s)["stats"]
+ assert (stats["flags"], stats["flags_total"]) == (2, 13)
+ recap = build_recap(stats)
+ assert "Flags 2/13" in recap and "Secrets 0/5" in recap and "rank:" in recap
+
+
+def test_full_clear_gets_the_epilogue(s: GameSession) -> None:
+ for rid, room in s.world.rooms.items():
+ if room.flag is not None and not room.hidden:
+ s.world.mark_flag_captured(rid)
+ assert FULL_CLEAR_EPILOGUE in _won(s)["sections"]
+
+
+def test_partial_clear_gets_no_epilogue(s: GameSession) -> None:
+ s.world.mark_flag_captured("root")
+ assert FULL_CLEAR_EPILOGUE not in _won(s)["sections"]
diff --git a/tests/test_flags_kill_content.py b/tests/test_flags_kill_content.py
new file mode 100644
index 0000000..f1da710
--- /dev/null
+++ b/tests/test_flags_kill_content.py
@@ -0,0 +1,24 @@
+"""Every room now has a flag: rogues in the enemy-free rooms, boss drops."""
+from engine.content import find_flag_problems, load_all
+
+ROGUES = {"proc_secrets": 1337, "deprecated_dir": 2048, "etc_hidden_configs": 4242}
+BOSSES = {"core": "daemon_overlord.sys", "opt_mage_tower": "corruption_lord.exe",
+ "mirror_sector": "shadow_process"}
+
+
+def test_every_room_has_a_flag() -> None:
+ content = load_all("data")
+ assert [r for r, room in content.rooms.items() if room.flag is None] == []
+ assert find_flag_problems(content) == []
+
+
+def test_rogues_and_bosses_are_wired() -> None:
+ content = load_all("data")
+ for room, pid in ROGUES.items():
+ flag = content.rooms[room].flag
+ assert flag.via == "kill" and flag.pid == pid
+ rogue = content.enemies[flag.enemy]
+ assert rogue.pool_excluded and rogue.health <= 25 and rogue.experience <= 10
+ for room, boss in BOSSES.items():
+ assert content.rooms[room].flag.via == "defeat"
+ assert content.rooms[room].flag.enemy == boss
diff --git a/tests/test_flags_kill_playthrough.py b/tests/test_flags_kill_playthrough.py
new file mode 100644
index 0000000..bf85591
--- /dev/null
+++ b/tests/test_flags_kill_playthrough.py
@@ -0,0 +1,37 @@
+"""Arriving in /proc: taught ps + kill, then ps, kill, fight, flag."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "shaman")
+ session.player.tutorial_state["completed"] = True
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_first_rogue_from_lesson_to_flag(s: GameSession) -> None:
+ h = s.engine.cmd_handler
+ s.world.discover_room("proc_secrets")
+ arrival = _out(s, "cd /proc")
+ assert "kill 1337" in arrival
+ assert "1337" in _out(s, "ps")
+ _out(s, "kill 1337")
+ out = ""
+ while h.current_combat_session:
+ out += _out(s, next(iter(h.current_combat_session.available_attacks)))
+ assert s.world.flag_captured("proc_secrets")
+ assert "FLAG{kill_ends_what_ps_finds}" in out
diff --git a/tests/test_flags_kill_review_fixes.py b/tests/test_flags_kill_review_fixes.py
new file mode 100644
index 0000000..248c875
--- /dev/null
+++ b/tests/test_flags_kill_review_fixes.py
@@ -0,0 +1,45 @@
+"""Checkpoint timing around fights: the finale never leaves an unwinnable
+newest save, and fleeing after a capture still saves the flag."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def test_beating_the_overlord_writes_no_unwinnable_save(s: GameSession, tmp_path) -> None:
+ """The win must fire; no checkpoint may land between the kill and it, or
+ loading the newest save puts the player in a won-less, finished world."""
+ h = s.engine.cmd_handler
+ s.player.current_room = "core"
+ h.check_for_enemies()
+ combat = h.current_combat_session
+ assert combat is not None
+ combat.enemy_health = 1
+ s.submit(next(iter(combat.available_attacks)))
+ assert h.flow.game_won
+ assert not list(tmp_path.iterdir())
+
+
+def test_fleeing_after_a_capture_still_saves_the_flag(s: GameSession, tmp_path) -> None:
+ h = s.engine.cmd_handler
+ s.player.current_room = "proc_secrets"
+ s.player.previous_room = "root"
+ s.submit("kill 1337")
+ assert h.current_combat_session is not None
+ h.flags.on_enemy_defeated("runaway_fork.bomb") # captured mid-fight
+ h.end_combat({"fled": True, "enemy_id": "runaway_fork.bomb"})
+ assert len(list(tmp_path.iterdir())) == 1
diff --git a/tests/test_flags_kill_schema.py b/tests/test_flags_kill_schema.py
new file mode 100644
index 0000000..5cf3bba
--- /dev/null
+++ b/tests/test_flags_kill_schema.py
@@ -0,0 +1,39 @@
+"""kill/defeat flags name an enemy instead of a file; the validator keeps
+them honest."""
+from engine.content import find_flag_problems, load_all
+from engine.schema.models import RoomFlag
+
+
+def _problems(room: str, flag: RoomFlag) -> list[str]:
+ content = load_all("data")
+ content.rooms[room] = content.rooms[room].model_copy(update={"flag": flag})
+ return find_flag_problems(content)
+
+
+def test_kill_flag_needs_a_pid_process_and_rogue_enemy() -> None:
+ problems = _problems("proc_secrets", RoomFlag(
+ via="kill", text="FLAG{x}", nudge="n", command="c", enemy="glitched_process.tmp",
+ ))
+ assert any("pid" in p for p in problems)
+
+
+def test_kill_enemy_must_not_already_live_in_the_room() -> None:
+ problems = _problems("core", RoomFlag(
+ via="kill", text="FLAG{x}", nudge="n", command="c",
+ enemy="daemon_overlord.sys", pid=9, process="x",
+ ))
+ assert any("already" in p or "pool" in p for p in problems)
+
+
+def test_defeat_flag_enemy_must_be_in_the_room() -> None:
+ problems = _problems("proc_secrets", RoomFlag(
+ via="defeat", text="FLAG{x}", nudge="n", command="c", enemy="daemon_overlord.sys",
+ ))
+ assert any("proc_secrets" in p and "enemies" in p for p in problems)
+
+
+def test_cat_flag_still_needs_a_file() -> None:
+ problems = _problems("root", RoomFlag(
+ via="cat", text="FLAG{x}", nudge="n", command="c",
+ ))
+ assert any("file" in p for p in problems)
diff --git a/tests/test_flags_kill_service.py b/tests/test_flags_kill_service.py
new file mode 100644
index 0000000..91e2161
--- /dev/null
+++ b/tests/test_flags_kill_service.py
@@ -0,0 +1,67 @@
+"""The rogue shows only while hidden; beating a flag enemy captures; the
+checkpoint waits for victory."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ session.player.current_room = "proc_secrets"
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def test_rogue_process_is_listed_until_released(s: GameSession) -> None:
+ flags = s.engine.cmd_handler.flags
+ assert flags.rogue_process() == (1337, "runaway_fork")
+ assert flags.release_rogue() == "runaway_fork.bomb"
+ assert s.world.enemy_locations["runaway_fork.bomb"] == "proc_secrets"
+ assert flags.rogue_process() is None
+
+
+def test_no_rogue_line_while_it_is_already_out(s: GameSession) -> None:
+ flags = s.engine.cmd_handler.flags
+ flags.release_rogue()
+ s.world.mark_enemy_as_fled("runaway_fork.bomb", "proc_secrets")
+ assert flags.rogue_process() is None
+ assert flags.release_rogue() is None
+
+
+def test_defeating_the_flag_enemy_captures(s: GameSession) -> None:
+ flags = s.engine.cmd_handler.flags
+ assert flags.on_enemy_defeated("runaway_fork.bomb")
+ assert s.world.flag_captured("proc_secrets")
+ assert not flags.on_enemy_defeated("runaway_fork.bomb")
+
+
+def test_defeat_capture_is_checkpointed_after_victory(s: GameSession, tmp_path) -> None:
+ flags = s.engine.cmd_handler.flags
+ flags.on_enemy_defeated("runaway_fork.bomb")
+ assert not list(tmp_path.iterdir()), "no save mid-fight"
+ flags.flush_checkpoint()
+ assert len(list(tmp_path.iterdir())) == 1
+ flags.flush_checkpoint()
+ assert len(list(tmp_path.iterdir())) == 1
+
+
+def test_boss_defeat_captures_via_on_kill(s: GameSession) -> None:
+ s.player.current_room = "core"
+ s.engine.cmd_handler.on_kill("daemon_overlord.sys")
+ assert s.world.flag_captured("core")
+
+
+def test_first_kill_room_teaches_kill(s: GameSession) -> None:
+ s.player.current_room = "deprecated_dir"
+ s.ui.clear_console()
+ s.engine.cmd_handler.flags.on_room_entered("deprecated_dir")
+ assert "kill 2048" in "\n".join(s.ui.drain())
diff --git a/tests/test_game_flow.py b/tests/test_game_flow.py
new file mode 100644
index 0000000..860398d
--- /dev/null
+++ b/tests/test_game_flow.py
@@ -0,0 +1,53 @@
+"""Death -> game-over screen -> the r/n/q choice, through GameFlow."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from engine.events import EventType
+from src.game_states import GameState
+from src.save import save_manager
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("D", "guardian")
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _die(s: GameSession) -> None:
+ s.engine.cmd_handler.effects.execute_effect({"damage": 10_000})
+
+
+def test_lethal_effect_enters_game_over_mode(session: GameSession) -> None:
+ _die(session)
+ assert session.engine.cmd_handler.flow.in_game_over_mode is True
+
+
+def test_invalid_choice_reprompts_and_stays_on_the_screen(session: GameSession) -> None:
+ _die(session)
+ out = "\n".join(str(line) for line in session.submit("x"))
+ assert "Invalid option" in out
+ assert session.engine.cmd_handler.flow.in_game_over_mode is True
+
+
+def test_main_menu_choice_returns_to_the_title(session: GameSession) -> None:
+ seen: list[dict] = []
+ session.bus.subscribe(EventType.GAME_OVER, lambda e: seen.append(e.data))
+ _die(session)
+ session.submit("m")
+ assert session.state == GameState.MENU
+ assert [data["reason"] for data in seen] == ["menu"] # the UI shows the title
+ assert save_manager.active_run_id is None # the run is over
+
+
+def test_quit_choice_asks_the_frontend_to_stop(session: GameSession) -> None:
+ _die(session)
+ session.submit("q")
+ assert session.ui.quit_requested is True
diff --git a/tests/test_game_over_flow.py b/tests/test_game_over_flow.py
new file mode 100644
index 0000000..3c0d77a
--- /dev/null
+++ b/tests/test_game_over_flow.py
@@ -0,0 +1,130 @@
+"""Dying in combat: one clear screen whose choices all work.
+
+The defeat animation appended ~30 frames (the panel scrolled), a `[` particle
+broke its markup (a stray `[/red]` showed), the screen said both "press any
+key" and "r / n / q", and after a combat death any input went to the menu, so
+`r` never restored the last checkpoint.
+"""
+from __future__ import annotations
+
+from collections.abc import Iterator
+from typing import Any
+
+import pytest
+from rich.markup import render
+
+from engine.api import GameSession
+from src.game_states import GameState
+from src.save import save_manager
+from utils.particle_animation import GameOverAnimation, Particle, ParticleSystem
+
+
+def test_backslash_particles_do_not_leak_markup() -> None:
+ """A `\\` particle made `[red]\\[/red]`, which escapes the closing tag."""
+ system = ParticleSystem(10, 3)
+ system.particles = [
+ Particle(x=1, y=1, vx=0, vy=0, char="\\", color="red", life=1.0, decay=0.1),
+ Particle(x=3, y=1, vx=0, vy=0, char="[", color="red", life=1.0, decay=0.1),
+ ]
+ text = render(system.render_to_string())
+ assert "\\" in text.plain and "[" in text.plain
+ assert "[/red]" not in text.plain
+
+
+def test_final_screen_names_the_player_and_offers_only_working_choices() -> None:
+ frame = GameOverAnimation(player_name="Mike")._get_final_frame()
+ plain = render(frame).plain
+ assert "Mike" in plain
+ assert "press any key" not in plain.lower()
+ for choice in ("r", "m", "q"):
+ assert f"{choice} - " in plain or f"{choice} – " in plain
+ assert "n - " not in plain # new game moved to the main menu
+
+
+class _RecordingUI:
+ """Just enough UI for GameEngine._forward_output: which sink got what."""
+
+ def __init__(self) -> None:
+ self.calls: list[tuple[str, Any]] = []
+
+ def update_output(self, content: Any) -> None:
+ self.calls.append(("replace", content))
+
+ def append_output(self, content: Any) -> None:
+ self.calls.append(("append", content))
+
+
+def test_animation_frames_replace_instead_of_piling_up() -> None:
+ from src.game_engine import ImprovedGameEngine
+
+ engine = ImprovedGameEngine.__new__(ImprovedGameEngine)
+ engine.ui = _RecordingUI()
+ engine._fresh_command_output = False # mid-command: a plain write appends
+ engine._forward_output("frame 1", replace=True)
+ engine._forward_output("frame 2", replace=True)
+ assert engine.ui.calls == [("replace", "frame 1"), ("replace", "frame 2")]
+
+
+@pytest.fixture
+def dead(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ """A run with a checkpoint on disk, then killed in combat."""
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ s = GameSession()
+ s.new_game("Mike", "weaver")
+ s.player.tutorial_state["completed"] = True
+ s.submit("save")
+ h = s.engine.cmd_handler
+ room = s.player.current_room
+ s.world.enemy_locations["corrupt_process.bin"] = room
+ h.check_for_enemies()
+ combat = h.current_combat_session
+ assert combat is not None
+ combat._enemy_turn = lambda: s.player.take_damage(9999)
+ combat.enemy_health = 10_000
+ s.submit(next(iter(combat.available_attacks)))
+ assert s.engine.state_manager.current_state == GameState.GAME_OVER
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def test_r_after_a_combat_death_restores_the_last_checkpoint(dead: GameSession) -> None:
+ dead.submit("r")
+ assert dead.engine.state_manager.current_state == GameState.PLAYING
+ assert dead.player.is_alive()
+
+
+def test_n_is_no_longer_a_game_over_choice(dead: GameSession) -> None:
+ out = "\n".join(str(x) for x in dead.submit("n"))
+ assert dead.engine.state_manager.current_state == GameState.GAME_OVER
+ assert "Invalid option" in out and "main menu" in out
+
+
+def test_r_restores_this_run_even_when_another_run_is_newer(dead: GameSession) -> None:
+ this_run = save_manager.active_run_id
+ other = GameSession()
+ try:
+ other.new_game("Other", "shaman")
+ save_manager.begin_run()
+ save_manager.save_game(other.player, other.world.get_state())
+ finally:
+ other.close()
+ save_manager.resume_run(this_run)
+
+ dead.submit("r")
+ assert dead.engine.state_manager.current_state == GameState.PLAYING
+ assert dead.player.name == "Mike"
+
+
+def test_r_with_no_save_for_this_run_goes_to_the_menu(dead: GameSession) -> None:
+ save_manager.delete_run(save_manager.active_run_id)
+ out = "\n".join(str(x) for x in dead.submit("r"))
+ assert dead.engine.state_manager.current_state == GameState.MENU
+ assert "No backup for this run" in out
+
+
+def test_anything_else_keeps_the_choices_on_screen(dead: GameSession) -> None:
+ out = "\n".join(str(x) for x in dead.submit("x"))
+ assert dead.engine.state_manager.current_state == GameState.GAME_OVER
+ assert "r" in out and "Invalid option" in out
diff --git a/tests/test_game_over_screens.py b/tests/test_game_over_screens.py
new file mode 100644
index 0000000..d330730
--- /dev/null
+++ b/tests/test_game_over_screens.py
@@ -0,0 +1,154 @@
+"""Game-over screens with the real engine and TUI wired together.
+
+A death keeps the fight on screen and drains it straight to black; the room
+must not reappear behind it. Only a death shows the GAME OVER card: a restart
+(F5) or a post-win "new game" / "restore" is not a death.
+"""
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Callable
+from pathlib import Path
+
+import pytest
+
+import src.save as save_mod
+from engine.events import EventType
+from src.game_engine import ImprovedGameEngine
+from src.game_world import TUTORIAL_ENEMY
+from src.save import SaveManager
+from src.ui.textual_ui import TextualGameUI
+
+
+@pytest.fixture(autouse=True)
+def _no_intro(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr("src.ui.textual_ui.SKIP_INTRO", True)
+
+
+def _record(monkeypatch: pytest.MonkeyPatch, obj: object, names: list[str],
+ calls: list[str]) -> None:
+ for name in names:
+ original: Callable[..., object] = getattr(obj, name)
+
+ def wrapper(*args: object, _name: str = name,
+ _original: Callable[..., object] = original, **kwargs: object) -> object:
+ calls.append(_name)
+ return _original(*args, **kwargs)
+
+ monkeypatch.setattr(obj, name, wrapper)
+
+
+def _new_game(app: TextualGameUI, monkeypatch: pytest.MonkeyPatch) -> ImprovedGameEngine:
+ # After mount: mounting re-applies the user's settings, which set this flag.
+ # With it on, game-over timers fire immediately, so a stray card shows up.
+ monkeypatch.setattr("config.dev_config.DISABLE_ANIMATIONS", True)
+ engine = ImprovedGameEngine(ui=app)
+ assert engine.create_player("Tess", "guardian")
+ engine.start_game()
+ return engine
+
+
+def _lose_a_fight(engine: ImprovedGameEngine) -> None:
+ handler = engine.cmd_handler
+ assert handler is not None and engine.player is not None
+ room = engine.player.current_room
+ engine.world.spawn_tutorial_enemy(room)
+ enemy = engine.world.get_enemy(TUTORIAL_ENEMY, engine.player.player_class)
+ enemy.health = 10**6
+ enemy.damage = 10**6
+ handler.start_combat([(TUTORIAL_ENEMY, enemy)])
+ session = handler.current_combat_session
+ attack_id = next(iter(session.available_attacks))
+ engine.bus.emit_event(EventType.COMBAT_ACTION_SELECTED, {"choice": attack_id}, "test")
+
+
+def test_death_drains_the_fight_without_showing_the_room(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ engine = _new_game(app, monkeypatch)
+ await pilot.pause()
+ calls: list[str] = []
+ _record(monkeypatch, app._scene_view, ["end_battle", "play_death"], calls)
+ _record(monkeypatch, app, ["display_game_over"], calls)
+
+ _lose_a_fight(engine)
+ await pilot.pause()
+
+ assert calls == ["play_death", "display_game_over"]
+ engine._cleanup()
+
+ asyncio.run(scenario())
+
+
+def test_f5_restart_returns_to_the_title_without_a_game_over_card(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ engine = _new_game(app, monkeypatch)
+ await pilot.pause()
+ calls: list[str] = []
+ _record(monkeypatch, app, ["display_game_over", "_display_title_screen"], calls)
+
+ app.action_restart_game()
+ await pilot.pause()
+
+ assert calls == ["_display_title_screen"]
+ engine._cleanup()
+
+ asyncio.run(scenario())
+
+
+@pytest.mark.parametrize("choice", ["m", "r"])
+def test_post_win_choices_do_not_show_a_game_over_card(
+ monkeypatch: pytest.MonkeyPatch, tmp_path: Path, choice: str,
+) -> None:
+ mgr = SaveManager(save_dir=str(tmp_path))
+ monkeypatch.setattr(save_mod, "save_manager", mgr)
+ monkeypatch.setattr("src.game_engine.save_manager", mgr)
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ engine = _new_game(app, monkeypatch)
+ mgr.save_game(engine.player, engine.world.get_state())
+ await pilot.pause()
+ calls: list[str] = []
+ _record(monkeypatch, app, ["display_game_over"], calls)
+
+ engine.cmd_handler.flow.handle_game_over_input(choice)
+ await pilot.pause()
+
+ assert calls == []
+ engine._cleanup()
+
+ asyncio.run(scenario())
+
+
+def test_main_menu_from_the_game_over_screen_shows_the_title(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ engine = _new_game(app, monkeypatch)
+ await pilot.pause()
+ calls: list[str] = []
+ _record(monkeypatch, app, ["display_game_over", "_display_title_screen"], calls)
+
+ engine.cmd_handler.flow.in_game_over_mode = True
+ engine.cmd_handler.flow.handle_game_over_input("m")
+ await pilot.pause()
+
+ assert calls == ["_display_title_screen"]
+ assert app._title_menu.state == "menu_ready" # no typewriter replay
+ engine._cleanup()
+
+ asyncio.run(scenario())
diff --git a/tests/test_game_won_event.py b/tests/test_game_won_event.py
index c9ab97b..bd601d5 100644
--- a/tests/test_game_won_event.py
+++ b/tests/test_game_won_event.py
@@ -1,6 +1,6 @@
"""GAME_WON: emitted with sections + stats; headless UI logs the text."""
from engine.api import GameSession
-from src.events import EventType, event_bus
+from engine.events import EventType
def test_win_game_emits_sections_and_stats():
@@ -11,11 +11,11 @@ def test_win_game_emits_sections_and_stats():
def on_won(event):
got.append(event.data)
- event_bus.subscribe(EventType.GAME_WON, on_won)
+ s.bus.subscribe(EventType.GAME_WON, on_won)
try:
- s.engine.cmd_handler.win_game()
+ s.engine.cmd_handler.flow.win_game()
finally:
- event_bus.unsubscribe(EventType.GAME_WON, on_won)
+ s.bus.unsubscribe(EventType.GAME_WON, on_won)
assert len(got) == 1
data = got[0]
assert data["ending_id"] == "restore"
@@ -31,7 +31,7 @@ def test_headless_ui_receives_ending_text():
s = GameSession()
s.new_game("t", "guardian")
s.ui.drain()
- s.engine.cmd_handler.win_game()
+ s.engine.cmd_handler.flow.win_game()
text = "\n".join(str(x) for x in s.ui.drain())
assert "THANK YOU FOR PLAYING" in text
s.close()
diff --git a/tests/test_grep.py b/tests/test_grep.py
new file mode 100644
index 0000000..913a718
--- /dev/null
+++ b/tests/test_grep.py
@@ -0,0 +1,84 @@
+"""grep: real-grep behaviour with beginner-friendly errors."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ session.player.current_room = "dev_null_void"
+ session.world.enemy_locations = {
+ e: r for e, r in session.world.enemy_locations.items() if r != "dev_null_void"
+ }
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_grep_prints_only_matching_lines_and_captures(s: GameSession) -> None:
+ out = _out(s, "grep FLAG kern_log")
+ assert "FLAG{even_the_void_logs}" in out
+ assert "discarded" not in out
+ assert s.world.flag_captured("dev_null_void")
+
+
+def test_missing_arguments_show_usage(s: GameSession) -> None:
+ assert "Usage: grep" in _out(s, "grep")
+ assert "Usage: grep" in _out(s, "grep FLAG")
+
+
+def test_unknown_file_is_named_with_a_suggestion(s: GameSession) -> None:
+ out = _out(s, "grep FLAG kern_lgo")
+ assert "No such file" in out and "kern_log" in out
+
+
+def test_wrong_case_explains_and_suggests_i(s: GameSession) -> None:
+ out = _out(s, "grep flag kern_log")
+ assert "case" in out.lower() and "grep -i" in out
+ assert not s.world.flag_captured("dev_null_void")
+
+
+def test_dash_i_ignores_case(s: GameSession) -> None:
+ assert "FLAG{even_the_void_logs}" in _out(s, "grep -i flag kern_log")
+
+
+def test_dash_n_numbers_lines(s: GameSession) -> None:
+ out = _out(s, "grep -n FLAG kern_log")
+ assert "267:" in out # 400 lines, flag at index 266 → line 267
+
+
+def test_huge_match_is_capped(s: GameSession) -> None:
+ out = _out(s, "grep kernel kern_log")
+ assert "more matching lines" in out
+ assert out.count("kernel:") <= 40
+
+
+def test_pattern_that_misses_the_flag_does_not_capture(s: GameSession) -> None:
+ _out(s, "grep hangup kern_log")
+ assert not s.world.flag_captured("dev_null_void")
+
+
+def test_flag_hidden_by_the_cap_is_not_captured(s: GameSession) -> None:
+ """'kernel' matches all 400 lines; the flag line (267) is past the 40 shown."""
+ _out(s, "grep kernel kern_log")
+ assert not s.world.flag_captured("dev_null_void")
+
+
+def test_quotes_and_forgiving_file_names(s: GameSession) -> None:
+ assert "FLAG{even_the_void_logs}" in _out(s, "grep 'FLAG' kern.log")
+
+
+def test_man_grep_exists(s: GameSession) -> None:
+ assert "print lines that match" in _out(s, "man grep")
diff --git a/tests/test_grep_review_fixes.py b/tests/test_grep_review_fixes.py
new file mode 100644
index 0000000..effee29
--- /dev/null
+++ b/tests/test_grep_review_fixes.py
@@ -0,0 +1,106 @@
+"""Beginner-facing fixes from the Phase 2 review: the grep lesson survives an
+arrival fight and is taught in whichever grep room comes first; grep explains
+swapped arguments, quoted phrases and cat-only flags; Tab completes grep;
+Echo's remark about the log wall comes before the wall."""
+import asyncio
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "weaver")
+ session.player.tutorial_state["completed"] = True
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def _clear(s: GameSession, room: str) -> None:
+ s.world.enemy_locations = {e: r for e, r in s.world.enemy_locations.items() if r != room}
+
+
+def test_lesson_waits_until_the_room_is_safe(s: GameSession) -> None:
+ flags = s.engine.cmd_handler.flags
+ s.world.enemy_locations["glitched_process.tmp"] = "mnt_forest"
+ s.player.current_room = "mnt_forest"
+ s.ui.clear_console()
+ flags.on_room_entered("mnt_forest")
+ assert not s.world.flag_taught("mnt_forest")
+ _clear(s, "mnt_forest")
+ flags.after_command(in_combat=False)
+ assert s.world.flag_taught("mnt_forest")
+ assert "grep FLAG lost_user_log" in "\n".join(s.ui.drain())
+
+
+def test_first_grep_room_teaches_even_without_its_own_lesson(s: GameSession) -> None:
+ _clear(s, "usr_lib_arcane")
+ s.player.current_room = "usr_lib_arcane"
+ s.ui.clear_console()
+ s.engine.cmd_handler.flags.on_room_entered("usr_lib_arcane")
+ assert "grep FLAG catalog_db" in "\n".join(s.ui.drain())
+
+
+def test_no_generic_lesson_once_grep_is_known(s: GameSession) -> None:
+ s.world.mark_flag_captured("mnt_forest")
+ _clear(s, "dev_null_void")
+ s.player.current_room = "dev_null_void"
+ s.ui.clear_console()
+ s.engine.cmd_handler.flags.on_room_entered("dev_null_void")
+ assert "grep FLAG kern_log" not in "\n".join(s.ui.drain())
+
+
+def test_remark_comes_before_the_wall_and_names_the_command(s: GameSession) -> None:
+ _clear(s, "usr_lib_arcane")
+ s.player.current_room = "usr_lib_arcane"
+ out = _out(s, "cat catalog_db")
+ assert out.index("ECHO>") < out.index("catalog:")
+ assert "grep FLAG catalog_db" in out[: out.index("catalog:")]
+
+
+def test_grep_on_a_cat_flag_file_points_at_cat(s: GameSession) -> None:
+ s.player.current_room = "root"
+ out = _out(s, "grep FLAG motd")
+ assert "cat motd" in out
+ assert not s.world.flag_captured("root")
+
+
+def test_case_tip_keeps_the_dot_on_hidden_files(s: GameSession) -> None:
+ _clear(s, "var_dungeon")
+ s.player.current_room = "var_dungeon"
+ assert "grep -i FLAG .system_err_log" in _out(s, "grep FLAG .system_err_log")
+
+
+def test_swapped_arguments_are_explained(s: GameSession) -> None:
+ _clear(s, "dev_null_void")
+ s.player.current_room = "dev_null_void"
+ out = _out(s, "grep kern_log FLAG")
+ assert "word first" in out and "grep FLAG kern_log" in out
+
+
+def test_quoted_phrases_are_one_pattern(s: GameSession) -> None:
+ _clear(s, "dev_null_void")
+ s.player.current_room = "dev_null_void"
+ out = _out(s, 'grep "something survived" kern_log')
+ assert "FLAG{even_the_void_logs}" in out
+ assert s.world.flag_captured("dev_null_void")
+
+
+def test_tab_completes_grep_and_its_file(s: GameSession) -> None:
+ from src.ui.command_suggester import CommandSuggester
+
+ s.player.current_room = "dev_null_void"
+ sug = CommandSuggester(get_player=lambda: s.player, get_world=lambda: s.world)
+ assert asyncio.run(sug.get_suggestion("gr")) == "grep"
+ assert asyncio.run(sug.get_suggestion("grep FLAG ke")) == "grep FLAG kern_log"
diff --git a/tests/test_handler_subscriptions.py b/tests/test_handler_subscriptions.py
index e798205..ce49ca5 100644
--- a/tests/test_handler_subscriptions.py
+++ b/tests/test_handler_subscriptions.py
@@ -1,44 +1,59 @@
"""Regression: a new CommandHandler must not leave a prior one subscribed.
-Two live handlers made ROOM_ENTERED fire check_for_enemies twice (fight each enemy
-twice) and ENEMY_DEFEATED fire _on_enemy_defeated twice (double loot + 'enemy not
-found' warning). Creating a player must leave exactly one subscribed handler.
+Two live handlers used to fight each enemy twice and roll loot twice. Game rules
+no longer hang off events (arrival, kills and combat outcomes are direct calls),
+so the only handler-owned listeners left are the tutorial's; recreating the
+player must leave exactly one of each, all belonging to the current handler.
"""
from __future__ import annotations
+from collections import Counter
+
+from engine.events import EventType
from engine.headless import HeadlessUI
-from src.events import EventType, event_bus
+from src.command_handler import CommandHandler
from src.game_engine import ImprovedGameEngine
+from src.tutorial_coach import TutorialCoach
+
+def _handler_listeners(eng: ImprovedGameEngine) -> list[tuple[EventType, object]]:
+ return [
+ (event_type, getattr(cb, "__self__", None))
+ for event_type, callbacks in eng.bus._listeners.items()
+ for cb in callbacks
+ if isinstance(getattr(cb, "__self__", None), (CommandHandler, TutorialCoach))
+ ]
-def test_recreating_player_leaves_one_subscribed_handler():
- import src.command_handler as CH
- checks = {"n": 0}
- defeats = {"n": 0}
- orig_check = CH.CommandHandler.check_for_enemies
- orig_def = CH.CommandHandler._on_enemy_defeated
- CH.CommandHandler.check_for_enemies = lambda self: (
- checks.__setitem__("n", checks["n"] + 1), orig_check(self)
- )[1]
- CH.CommandHandler._on_enemy_defeated = lambda self, ev: (
- defeats.__setitem__("n", defeats["n"] + 1), orig_def(self, ev)
- )[1]
+def test_recreating_player_leaves_one_subscribed_handler() -> None:
+ eng = ImprovedGameEngine(ui=HeadlessUI())
try:
- eng = ImprovedGameEngine(ui=HeadlessUI())
eng.create_player("A", "guardian")
+ first = eng.cmd_handler
eng.create_player("B", "guardian") # must clean up A's subscriptions
eng.start_game()
- checks["n"] = 0
- event_bus.emit_event(EventType.ROOM_ENTERED, {}, "test")
- assert checks["n"] == 1, f"ROOM_ENTERED fired check_for_enemies {checks['n']}x (expected 1)"
+ listeners = _handler_listeners(eng)
+ owners = {id(owner) for _, owner in listeners}
+ assert id(first) not in owners and id(first.tutorial) not in owners
+ assert all(n == 1 for n in Counter(et for et, _ in listeners).values()), listeners
+ finally:
+ eng._cleanup()
+
- defeats["n"] = 0
- event_bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": "nope"}, "test")
- assert defeats["n"] == 1, (
- f"ENEMY_DEFEATED fired _on_enemy_defeated {defeats['n']}x (expected 1)"
+def test_a_kill_event_alone_changes_nothing() -> None:
+ """ENEMY_DEFEATED is a UI notification: loot and removal run from on_kill."""
+ eng = ImprovedGameEngine(ui=HeadlessUI())
+ try:
+ eng.create_player("A", "guardian")
+ eng.start_game()
+ world = eng.world
+ room_id, enemy_id = next(
+ (rid, world.get_enemies_in_room(rid)[0])
+ for rid in world.rooms if world.get_enemies_in_room(rid)
)
+ eng.bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": enemy_id}, "test")
+ assert enemy_id in world.get_enemies_in_room(room_id)
+ assert enemy_id not in eng.cmd_handler.loot.awarded
finally:
- CH.CommandHandler.check_for_enemies = orig_check
- CH.CommandHandler._on_enemy_defeated = orig_def
+ eng._cleanup()
diff --git a/tests/test_headless.py b/tests/test_headless.py
index 24eeb39..6665286 100644
--- a/tests/test_headless.py
+++ b/tests/test_headless.py
@@ -4,9 +4,8 @@
randomised playthrough — with no Textual App. Formalises the manual "does it
still work" check and a lite version of the playtest-simulator.
-Note: event_bus and state_manager are process-global singletons, so each test
-uses a fresh GameSession and closes it (unsubscribing its handlers) to stay
-isolated.
+Each GameSession owns its own event bus and state machine, so sessions are
+isolated from each other; tests still close them to release resources.
"""
from __future__ import annotations
@@ -31,8 +30,7 @@ def test_headless_ui_satisfies_protocol() -> None:
assert UIProtocol is not None # imported contract we check against
for name in (
"run", "shutdown", "update_output", "append_output", "display_message",
- "update_output_renderable", "update_inventory", "update_stats",
- "update_exits", "update_player_name", "clear_console", "display_game_over",
+ "update_output_renderable", "clear_console",
):
assert callable(getattr(ui, name, None)), f"HeadlessUI missing {name}"
diff --git a/tests/test_hidden_items.py b/tests/test_hidden_items.py
new file mode 100644
index 0000000..001da92
--- /dev/null
+++ b/tests/test_hidden_items.py
@@ -0,0 +1,59 @@
+"""Items marked `hidden: true` stay out of plain `ls` and appear with `ls -a`.
+
+The data has always declared this intent ("Requires ls -a to see") but only
+rooms honored it. bash_profile is the canary: the tutorial's checkpoint step
+depends on `ls -a` actually revealing it.
+"""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("t", "guardian")
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _out(session: GameSession, cmd: str) -> str:
+ return "\n".join(str(line) for line in session.submit(cmd))
+
+
+def test_plain_ls_omits_hidden_items(session: GameSession) -> None:
+ out = _out(session, "ls")
+ assert "bash_profile" not in out
+ assert "readme_txt_corrupt" not in out # every story file is hidden
+ # Visible items in the same room still list.
+ assert "segfault_shield" in out
+
+
+def test_ls_a_reveals_hidden_items(session: GameSession) -> None:
+ out = _out(session, "ls -a")
+ assert "bash_profile" in out
+
+
+def test_cat_reads_a_hidden_item_by_name(session: GameSession) -> None:
+ """Like real Unix: a hidden file is listable only with -a but always
+ readable by name."""
+ out = _out(session, "cat bash_profile")
+ assert "Sysadmin Spirit" in out
+
+
+def test_ls_a_shows_hidden_files_with_their_dot(session: GameSession) -> None:
+ """What you see is what you type: a hidden file lists as a dotfile, and its
+ hint names it the same way, since completion only offers it after a dot."""
+ out = _out(session, "ls -a")
+ assert ".bash_profile" in out
+ assert "cat .bash_profile" in out
+
+
+def test_echo_points_at_the_dotted_name(session: GameSession) -> None:
+ from src.data_loader import load_tutorial_hints
+
+ assert ".bash_profile" in load_tutorial_hints()["step_hidden"]
diff --git a/tests/test_hidden_room_discovery.py b/tests/test_hidden_room_discovery.py
index 426153e..619cf9a 100644
--- a/tests/test_hidden_room_discovery.py
+++ b/tests/test_hidden_room_discovery.py
@@ -12,17 +12,17 @@
from __future__ import annotations
from engine.api import GameSession
-from src.events import EventType, event_bus
+from engine.events import EventType
-def _capture_room_entered(fn):
+def _capture_room_entered(bus, fn):
rooms = []
cb = lambda ev: rooms.append(ev.data.get("room", {})) # noqa: E731
- event_bus.subscribe(EventType.ROOM_ENTERED, cb)
+ bus.subscribe(EventType.ROOM_ENTERED, cb)
try:
fn()
finally:
- event_bus.unsubscribe(EventType.ROOM_ENTERED, cb)
+ bus.unsubscribe(EventType.ROOM_ENTERED, cb)
return rooms
@@ -38,8 +38,9 @@ def test_ls_a_discovery_refreshes_room_view():
if rid != "usr_share_games"
}
assert h.world.get_room_state("cowsay_secret")["hidden"] is True
+ h.world.reveal_doors("lib_key") # /usr above it is a keyed door
- room_updates = _capture_room_entered(lambda: s.submit("ls -a"))
+ room_updates = _capture_room_entered(s.bus, lambda: s.submit("ls -a"))
assert h.world.get_room_state("cowsay_secret")["hidden"] is False
assert room_updates, "discovering a hidden room must emit a room-view refresh"
@@ -64,7 +65,7 @@ def test_ls_a_with_no_new_discovery_does_not_refresh():
}
s.submit("ls -a") # first discovery
- room_updates = _capture_room_entered(lambda: s.submit("ls -a"))
+ room_updates = _capture_room_entered(s.bus, lambda: s.submit("ls -a"))
assert room_updates == []
finally:
diff --git a/tests/test_item_icons.py b/tests/test_item_icons.py
new file mode 100644
index 0000000..478205a
--- /dev/null
+++ b/tests/test_item_icons.py
@@ -0,0 +1,44 @@
+"""Every view names an item's type with the same icon."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.item_icons import ITEM_TYPE_ICONS, UNKNOWN_ICON, item_icon
+from src.ui.panels.inventory_panel import InventoryPanel
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("Icons", "guardian")
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _text(lines: list[str]) -> str:
+ return "\n".join(str(x) for x in lines)
+
+
+def test_every_item_type_in_the_game_has_an_icon(session: GameSession) -> None:
+ types = {item.type for item in session.world.items.values()}
+ assert types <= set(ITEM_TYPE_ICONS), types - set(ITEM_TYPE_ICONS)
+ assert item_icon("nonsense") == UNKNOWN_ICON
+
+
+def test_ls_take_and_examine_show_the_icon(session: GameSession) -> None:
+ session.world.item_locations["health_packet"] = session.player.current_room
+ assert "🗡 segfault_shield" in _text(session.submit("ls"))
+ assert "💊 health_packet" in _text(session.submit("ls"))
+ assert "Added 💊" in _text(session.submit("take health_packet"))
+ assert "🗡" in _text(session.submit("examine segfault_shield"))
+
+
+def test_the_inventory_panel_uses_the_same_icons() -> None:
+ panel = InventoryPanel()
+ for item_type, icon in ITEM_TYPE_ICONS.items():
+ assert panel._get_item_type_icon(item_type) == icon
diff --git a/tests/test_item_names.py b/tests/test_item_names.py
new file mode 100644
index 0000000..c361849
--- /dev/null
+++ b/tests/test_item_names.py
@@ -0,0 +1,92 @@
+"""Players type item names however they like: case, `_`, `-`, spaces and
+camelCase must all find the same item, and item commands read the whole name,
+not just its first word."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("t", "guardian")
+ s.player.tutorial_state["completed"] = True
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _out(session: GameSession, cmd: str) -> str:
+ return "\n".join(str(line) for line in session.submit(cmd))
+
+
+SPELLINGS = [
+ "health_packet", "healthpacket", "health-packet", "health packet",
+ "healthPacket", "HEALTH_PACKET", "Health Packet",
+]
+
+
+@pytest.mark.parametrize("typed", SPELLINGS)
+def test_take_accepts_any_spelling(session: GameSession, typed: str) -> None:
+ session.submit(f"take {typed}")
+ assert session.player.has_item("health_packet")
+
+
+def test_take_matches_every_word_not_just_the_first(session: GameSession) -> None:
+ """'stable cache' must not be read as 'stable' and grab whatever starts
+ with it — the whole name decides."""
+ h = session.engine.cmd_handler
+ h.world.item_locations["stable_cache"] = session.player.current_room
+ session.submit("take stable cache")
+ assert session.player.has_item("stable_cache")
+ assert not session.player.has_item("health_packet")
+
+
+def test_separator_only_input_matches_nothing(session: GameSession) -> None:
+ before = set(session.player.inventory)
+ session.submit("take _")
+ assert set(session.player.inventory) == before
+
+
+def test_cat_accepts_spaced_and_camel_names(session: GameSession) -> None:
+ assert "Sysadmin Spirit" in _out(session, "cat bash profile")
+ assert "Sysadmin Spirit" in _out(session, "cat bashProfile")
+
+
+def test_drop_resolves_the_name(session: GameSession) -> None:
+ """The guardian also starts with a numbered packet (health_packet_1); the
+ exact id wins over a copy that only shares the display name."""
+ session.submit("take health_packet")
+ session.submit("drop health packet")
+ assert not session.player.has_item("health_packet")
+ assert "health_packet" in session.world.get_items_in_room(session.player.current_room)
+
+
+def test_examine_resolves_the_name(session: GameSession) -> None:
+ out = _out(session, "examine health packet")
+ assert "Cannot find" not in out
+ assert "Health Packet" in out
+
+
+def test_equip_resolves_a_spaced_name(session: GameSession) -> None:
+ h = session.engine.cmd_handler
+ h.world.item_locations["segfault_shield"] = session.player.current_room
+ session.submit("take segfault_shield")
+ session.submit("equip segfault shield")
+ assert session.player.equipped_weapon == "segfault_shield"
+
+
+def test_use_resolves_a_spaced_name(session: GameSession) -> None:
+ session.submit("take health_packet")
+ session.player.health = 10
+ session.submit("use health packet")
+ assert session.player.health > 10
+
+
+def test_a_misspelled_name_points_at_tab_completion(session: GameSession) -> None:
+ out = _out(session, "take helth")
+ assert "Cannot find" in out
+ assert "Tab" in out
diff --git a/tests/test_item_typed.py b/tests/test_item_typed.py
index 9797c6a..1f796b8 100644
--- a/tests/test_item_typed.py
+++ b/tests/test_item_typed.py
@@ -1,11 +1,13 @@
-"""Items validate at load; get_item hands consumers a dict; placement/mutation intact."""
+"""Items validate at load, and every consumer gets a private typed copy."""
from __future__ import annotations
import pytest
import yaml
+from engine.schema import Item
-def test_get_item_returns_dict_matching_raw_yaml():
+
+def test_get_item_returns_a_model_carrying_every_yaml_key():
from engine.api import GameSession
s = GameSession()
try:
@@ -14,24 +16,47 @@ def test_get_item_returns_dict_matching_raw_yaml():
weapons = yaml.safe_load(open("data/items/weapons.yaml")) or {}
iid = next(iter(weapons))
got = world.get_item(iid)
- assert isinstance(got, dict)
- assert set(weapons[iid].keys()) <= set(got.keys())
- assert got.get("damage") == weapons[iid].get("damage")
+ assert isinstance(got, Item)
+ assert set(weapons[iid].keys()) <= set(got.model_dump(exclude_unset=True).keys())
+ assert got.damage == weapons[iid].get("damage")
+ finally:
+ s.close()
+
+
+def test_get_item_never_hands_out_the_shared_template():
+ from engine.api import GameSession
+ s = GameSession()
+ try:
+ s.new_game("Tester", "guardian")
+ world = s.engine.cmd_handler.world
+ copy = world.get_item("health_packet")
+ copy.tags.append("tampered")
+ assert "tampered" not in world.items["health_packet"].tags
finally:
s.close()
-def test_loaders_validate_and_return_dicts():
- from src.data_loader import load_consumable_data, load_weapon_data
- w = load_weapon_data("segfault_shield")
- assert isinstance(w, dict) and isinstance(w.get("damage"), int)
- c = load_consumable_data("health_packet")
- assert isinstance(c, dict) and isinstance(c.get("combat_effects"), dict)
+def test_load_item_returns_typed_copies():
+ from src.data_loader import load_item
+ w = load_item("segfault_shield")
+ assert isinstance(w, Item) and w.damage > 0
+ c = load_item("health_packet")
+ assert isinstance(c, Item) and "player_heal" in c.combat_effects
+ assert load_item("health_packet") is not c
+ assert load_item("no_such_item") is None
+
+
+def test_inventory_round_trips_through_the_save_dict():
+ from src.player import Player
+ p = Player("Tester", "guardian")
+ before = p.to_dict()["inventory"]
+ restored = Player.from_dict(p.to_dict())
+ assert all(isinstance(i, Item) for i in restored.inventory.values())
+ assert restored.to_dict()["inventory"] == before
def test_bad_item_field_raises_at_validation():
from pydantic import ValidationError
- from engine.schema import Item
with pytest.raises(ValidationError):
Item(id="x", name="X", type="weapon", damage="not-a-number")
diff --git a/tests/test_key_chain.py b/tests/test_key_chain.py
new file mode 100644
index 0000000..db5648b
--- /dev/null
+++ b/tests/test_key_chain.py
@@ -0,0 +1,59 @@
+"""Keys come from flags in a fixed chain; locked doors stay invisible until
+their key reveals them."""
+from engine.api import GameSession
+from engine.content import find_key_chain_problems, load_all
+
+
+def test_chain_is_declared_and_valid() -> None:
+ content = load_all("data")
+ grants = {rid: room.flag.grants for rid, room in content.rooms.items()
+ if room.flag is not None and room.flag.grants}
+ assert grants == {"mnt_forest": "lib_key", "var_dungeon": "chmod_key",
+ "usr_lib_arcane": "opt_key"}
+ assert "chmod_key" not in content.rooms["var_dungeon"].items
+ assert find_key_chain_problems(content) == []
+
+
+def test_validator_catches_a_chain_that_cannot_reach_the_gate() -> None:
+ content = load_all("data")
+ mnt = content.rooms["mnt_forest"]
+ assert mnt.flag is not None
+ content.rooms["mnt_forest"] = mnt.model_copy(update={
+ "flag": mnt.flag.model_copy(update={"grants": None})
+ })
+ assert any("core" in p for p in find_key_chain_problems(content))
+
+
+def _session() -> GameSession:
+ s = GameSession()
+ s.new_game("t", "guardian")
+ return s
+
+
+def test_locked_door_is_invisible_until_revealed() -> None:
+ s = _session()
+ try:
+ w = s.world
+ assert not w.door_visible("usr_lib_arcane")
+ assert not w.door_visible("usr_share_games") # beneath /usr
+ assert w.door_visible("core") # the flag-gated goal stays in view
+ allowed, denial = w.check_access("usr_lib_arcane", s.player)
+ assert not allowed and denial["reason"] == "missing"
+ assert w.reveal_doors("lib_key") == ["usr_lib_arcane"]
+ assert w.door_visible("usr_lib_arcane") and w.door_visible("usr_share_games")
+ assert w.check_access("usr_lib_arcane", s.player)[1]["reason"] == "locked"
+ finally:
+ s.close()
+
+
+def test_no_key_is_placed_at_random() -> None:
+ from src.item_placer import ItemPlacer
+
+ s = _session()
+ try:
+ placer = ItemPlacer(s.world)
+ placer.place_items("guardian")
+ placed = {k for k in s.world.item_locations if placer.is_key(k)}
+ assert placed == set()
+ finally:
+ s.close()
diff --git a/tests/test_key_chain_playthrough.py b/tests/test_key_chain_playthrough.py
new file mode 100644
index 0000000..afc9e34
--- /dev/null
+++ b/tests/test_key_chain_playthrough.py
@@ -0,0 +1,44 @@
+"""/usr appears only after the /mnt flag; its flag then reveals /srv."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "weaver")
+ session.player.tutorial_state["completed"] = True
+ for room in ("mnt_forest", "usr_lib_arcane"):
+ session.world.enemy_locations = {
+ e: r for e, r in session.world.enemy_locations.items() if r != room
+ }
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_the_chain_unfolds(s: GameSession) -> None:
+ s.submit("cd /")
+ assert "usr/" not in _out(s, "ls")
+ assert "No such file or directory" in _out(s, "cd /usr")
+ s.submit("cd /mnt")
+ assert s.player.current_room == "mnt_forest"
+ _out(s, "grep FLAG lost_user_log")
+ s.submit("cd /")
+ assert "usr/" in _out(s, "ls")
+ s.submit("cd /usr")
+ assert s.player.current_room == "usr_lib_arcane"
+ out = _out(s, "grep FLAG catalog_db")
+ assert s.player.has_item("opt_key") and "/srv" in out
+ s.submit("cd /")
+ assert "srv/" in _out(s, "ls")
diff --git a/tests/test_key_grants.py b/tests/test_key_grants.py
new file mode 100644
index 0000000..55c9b54
--- /dev/null
+++ b/tests/test_key_grants.py
@@ -0,0 +1,90 @@
+"""Capturing a chain flag hands out its key and reveals the door."""
+import copy
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ session.player.current_room = "mnt_forest"
+ session.world.enemy_locations = {
+ e: r for e, r in session.world.enemy_locations.items() if r != "mnt_forest"
+ }
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_capturing_the_mnt_flag_grants_lib_key(s: GameSession) -> None:
+ out = _out(s, "grep FLAG lost_user_log")
+ assert s.player.has_item("lib_key")
+ assert s.world.door_visible("usr_lib_arcane")
+ assert "a new directory appeared" in out and "/usr" in out
+ assert "cd /usr" in out
+
+
+def test_first_key_gets_the_echo_lesson_once(s: GameSession) -> None:
+ out = _out(s, "grep FLAG lost_user_log")
+ assert "ECHO>" in out and "doors" in out
+ s.player.current_room = "var_dungeon"
+ s.world.enemy_locations = {
+ e: r for e, r in s.world.enemy_locations.items() if r != "var_dungeon"
+ }
+ again = _out(s, "cat .system_err_log")
+ assert s.player.has_item("chmod_key")
+ assert "flags can open doors" not in again.lower()
+
+
+def test_no_grant_when_door_already_open(s: GameSession) -> None:
+ s.world.unlock_room("usr_lib_arcane")
+ out = _out(s, "grep FLAG lost_user_log")
+ assert not s.player.has_item("lib_key")
+ assert "a new directory appeared" not in out
+
+
+def test_old_save_loses_a_floor_copy_of_a_flag_key(s: GameSession) -> None:
+ """Saves from before the keys chain left chmod_key lying in /var; the
+ /var flag hands it out now, so loading drops the floor copy."""
+ s.world.item_locations["chmod_key"] = "var_dungeon"
+ saved = {"player": s.player.to_dict(), "world": copy.deepcopy(s.world.get_state())}
+ s.engine._enter_loaded_run(saved)
+ assert "chmod_key" not in s.world.item_locations
+ s.player.current_room = "var_dungeon"
+ s.world.enemy_locations = {
+ e: r for e, r in s.world.enemy_locations.items() if r != "var_dungeon"
+ }
+ s.submit("cat .system_err_log")
+ assert [k for k in s.player.inventory if k.startswith("chmod_key")] == ["chmod_key"]
+
+
+def test_taking_a_dropped_key_reveals_its_door(s: GameSession) -> None:
+ s.world.item_locations["sudo_privileges_badge"] = "mnt_forest"
+ out = _out(s, "take sudo_privileges_badge")
+ assert s.player.has_item("sudo_privileges_badge")
+ assert s.world.door_visible("dev_null_void")
+ assert "/dev" in out
+
+
+def test_hint_mentions_the_door(s: GameSession) -> None:
+ assert "new door" in _out(s, "hint")
+
+
+def test_loading_reveals_doors_for_held_keys(s: GameSession) -> None:
+ s.player.add_to_inventory("lib_key", s.world.get_item("lib_key"))
+ saved = {"player": s.player.to_dict(), "world": copy.deepcopy(s.world.get_state())}
+ assert not s.world.door_visible("usr_lib_arcane") # held, never revealed
+ s.engine._enter_loaded_run(saved)
+ assert s.world.door_visible("usr_lib_arcane")
diff --git a/tests/test_key_reveals_hidden_room.py b/tests/test_key_reveals_hidden_room.py
index 323576e..3e34d18 100644
--- a/tests/test_key_reveals_hidden_room.py
+++ b/tests/test_key_reveals_hidden_room.py
@@ -13,7 +13,7 @@ def test_opt_key_reveals_and_unlocks_mage_tower():
s.new_game("t", "weaver")
h = s.engine.cmd_handler
key = s.world.get_item("opt_key")
- h.player.add_to_inventory("opt_key", dict(key))
+ h.player.add_to_inventory("opt_key", key)
# stand adjacent: the tower is an exit of usr_lib_arcane
entry = next(
rid for rid, room in s.world.rooms.items()
diff --git a/tests/test_key_visibility.py b/tests/test_key_visibility.py
new file mode 100644
index 0000000..32e3528
--- /dev/null
+++ b/tests/test_key_visibility.py
@@ -0,0 +1,63 @@
+"""Listings show only doors you can open (or /boot, the goal)."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.viewmodels.view_builder import ViewBuilder
+
+
+@pytest.fixture
+def s() -> Iterator[GameSession]:
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ session.player.current_room = "root"
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_ls_hides_locked_doors_until_revealed(s: GameSession) -> None:
+ out = _out(s, "ls")
+ assert "usr/" not in out and "etc/" not in out and "dev/" not in out
+ assert "boot/" in out and "bin/" in out
+ s.world.reveal_doors("lib_key")
+ assert "usr/" in _out(s, "ls")
+
+
+def test_ls_a_does_not_discover_a_secret_behind_an_unrevealed_lock(s: GameSession) -> None:
+ out = _out(s, "ls -a")
+ assert "opt/" not in out
+ assert not s.world.is_discovered("opt_mage_tower")
+
+
+def test_tree_hides_locked_branches(s: GameSession) -> None:
+ out = _out(s, "tree")
+ assert "usr/" not in out and "games/" not in out
+ assert "boot/" in out
+
+
+def test_cd_to_an_invisible_door_is_no_such_file(s: GameSession) -> None:
+ out = _out(s, "cd /usr")
+ assert "No such file or directory" in out
+ assert "lib_key" not in out
+
+
+def test_cd_typo_does_not_suggest_invisible_rooms(s: GameSession) -> None:
+ assert "usr" not in _out(s, "cd us")
+
+
+def test_scene_exits_hide_invisible_doors(s: GameSession) -> None:
+ view = ViewBuilder.build_room_view(s.world, "root")
+ assert not any(e.startswith("/usr") or e.startswith("/etc") for e in view.exits)
+
+
+def test_tutorial_example_skips_invisible_dirs(s: GameSession) -> None:
+ coach = s.engine.cmd_handler.tutorial
+ assert all(s.world.door_visible(r) for r in coach._visible_directories())
diff --git a/tests/test_keys_command.py b/tests/test_keys_command.py
new file mode 100644
index 0000000..b69c714
--- /dev/null
+++ b/tests/test_keys_command.py
@@ -0,0 +1,21 @@
+"""keys: held keys and their doors; unearned keys as ??? by source."""
+from engine.api import GameSession
+
+
+def _keys(s: GameSession) -> str:
+ return "\n".join(str(x) for x in s.submit("keys"))
+
+
+def test_unearned_keys_are_hidden_by_source() -> None:
+ s = GameSession()
+ try:
+ s.new_game("t", "guardian")
+ out = _keys(s)
+ assert "/usr" not in out and "/etc" not in out and "/dev" not in out
+ assert "lib_key" not in out
+ assert "earned by a flag" in out and "dropped by a boss" in out
+ s.player.add_to_inventory("lib_key", s.world.get_item("lib_key"))
+ s.world.reveal_doors("lib_key")
+ assert "/usr" in _keys(s)
+ finally:
+ s.close()
diff --git a/tests/test_keys_review_fixes.py b/tests/test_keys_review_fixes.py
new file mode 100644
index 0000000..905c301
--- /dev/null
+++ b/tests/test_keys_review_fixes.py
@@ -0,0 +1,44 @@
+"""Final-review fixes for the keys chain: beginner-facing text must not point
+at doors that are now invisible, or name secret rooms."""
+import yaml
+
+from engine.api import GameSession
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_help_and_man_only_name_directories_open_from_the_start() -> None:
+ s = GameSession()
+ try:
+ s.new_game("t", "guardian")
+ text = _out(s, "help") + _out(s, "man pwd")
+ for hidden in ("/usr/games", "/var/backups"):
+ assert hidden not in text, f"{hidden} is not visible at the start"
+ finally:
+ s.close()
+
+
+def test_home_guardian_points_at_the_flag_chain() -> None:
+ with open("data/npcs/home_guardian.sys.yml") as f:
+ lines = " ".join(yaml.safe_load(f)["dialogues"])
+ assert "arcane archives" not in lines
+ assert "/mnt" in lines
+
+
+def test_using_a_key_does_not_name_an_undiscovered_secret() -> None:
+ s = GameSession()
+ try:
+ s.new_game("t", "guardian")
+ s.player.tutorial_state["completed"] = True
+ s.player.current_room = "mnt_forest"
+ s.world.enemy_locations = {
+ e: r for e, r in s.world.enemy_locations.items() if r != "mnt_forest"
+ }
+ s.player.add_to_inventory("opt_key", s.world.get_item("opt_key"))
+ out = _out(s, "use opt_key")
+ assert "opt_mage_tower" not in out and "/opt" not in out
+ assert not s.world.is_discovered("opt_mage_tower")
+ finally:
+ s.close()
diff --git a/tests/test_kill.py b/tests/test_kill.py
new file mode 100644
index 0000000..0ee3975
--- /dev/null
+++ b/tests/test_kill.py
@@ -0,0 +1,66 @@
+"""ps reveals the rogue; kill brings it out; errors teach PIDs."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ session.player.current_room = "proc_secrets"
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_ps_shows_the_rogue_as_the_odd_one_out(s: GameSession) -> None:
+ out = _out(s, "ps")
+ assert "1337" in out and "runaway_fork" in out and "CPU" in out
+
+
+def test_kill_brings_the_rogue_out_to_fight(s: GameSession) -> None:
+ _out(s, "kill 1337")
+ assert s.engine.cmd_handler.current_combat_session is not None
+
+
+def test_beating_the_rogue_captures_and_saves(s: GameSession, tmp_path) -> None:
+ h = s.engine.cmd_handler
+ s.submit("kill 1337")
+ while h.current_combat_session:
+ s.submit(next(iter(h.current_combat_session.available_attacks)))
+ assert s.world.flag_captured("proc_secrets")
+ assert list(tmp_path.iterdir())
+
+
+def test_ps_forgets_the_rogue_after_capture(s: GameSession) -> None:
+ s.world.mark_flag_captured("proc_secrets")
+ assert "1337" not in _out(s, "ps")
+
+
+def test_system_pid_is_not_permitted(s: GameSession) -> None:
+ out = _out(s, "kill 1")
+ assert "Operation not permitted" in out and "odd one out" in out
+
+
+def test_unknown_pid(s: GameSession) -> None:
+ assert "No such process" in _out(s, "kill 9999")
+
+
+def test_kill_usage_and_non_numbers(s: GameSession) -> None:
+ assert "Usage: kill" in _out(s, "kill")
+ assert "number" in _out(s, "kill runaway_fork")
+
+
+def test_man_kill_exists(s: GameSession) -> None:
+ assert "terminate a process" in _out(s, "man kill")
diff --git a/tests/test_launch_dir.py b/tests/test_launch_dir.py
new file mode 100644
index 0000000..70c2e86
--- /dev/null
+++ b/tests/test_launch_dir.py
@@ -0,0 +1,32 @@
+"""Launching from another directory: every path the game uses (content, saves,
+logs, user settings) is relative, so main.py must move to the repo first.
+Before, `python ~/hauntedTerminal/main.py` from $HOME could not find its
+content and left empty data/ and saves/ folders in $HOME."""
+from __future__ import annotations
+
+import os
+import subprocess
+import sys
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parent.parent
+
+
+def test_importing_main_from_elsewhere_works_from_the_repo(tmp_path: Path) -> None:
+ probe = (
+ "import os, sys; "
+ f"sys.path.insert(0, {str(ROOT)!r}); "
+ "import main; "
+ "from src.save import save_manager; "
+ "print(os.getcwd()); "
+ "print(os.path.abspath(save_manager.save_dir))"
+ )
+ result = subprocess.run(
+ [sys.executable, "-c", probe], cwd=tmp_path,
+ capture_output=True, text=True, timeout=60, check=True,
+ )
+ cwd, save_dir = result.stdout.strip().splitlines()[-2:]
+
+ assert Path(cwd) == ROOT
+ assert Path(save_dir) == ROOT / "saves"
+ assert os.listdir(tmp_path) == []
diff --git a/tests/test_legacy_backup.py b/tests/test_legacy_backup.py
new file mode 100644
index 0000000..b2ba805
--- /dev/null
+++ b/tests/test_legacy_backup.py
@@ -0,0 +1,81 @@
+"""Legacy Backup promises to revive you at 50% HP when you die. Nothing read
+its auto_revive effect, so it never fired: a player died to the /boot boss
+carrying one."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("t", "guardian")
+ s.player.tutorial_state["completed"] = True
+ s.world.item_locations["legacy_backup"] = s.player.current_room
+ s.submit("take legacy_backup")
+ assert s.player.has_item("legacy_backup")
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _out(session: GameSession, cmd: str) -> str:
+ return "\n".join(str(line) for line in session.submit(cmd))
+
+
+def test_a_lethal_hit_in_combat_is_survived_once(session: GameSession) -> None:
+ h = session.engine.cmd_handler
+ h.world.item_locations["segfault_shield"] = session.player.current_room
+ session.player.tutorial_state["completed"] = False
+ session.submit("take segfault_shield")
+ session.submit("equip segfault_shield")
+ session.submit("take cracked_firewall")
+ session.submit("equip cracked_firewall")
+ combat = h.current_combat_session
+ assert combat is not None
+
+ combat._enemy_turn = lambda: session.player.take_damage(9999)
+ session.player.health = 1
+ attack_id = next(iter(combat.available_attacks))
+ combat.enemy_health = 10_000 # the fight must outlast this turn
+ session.submit(attack_id)
+
+ assert session.player.is_alive()
+ assert session.player.health == session.player.max_health // 2
+ assert not session.player.has_item("legacy_backup")
+ assert h.current_combat_session is not None # the fight goes on
+ assert not h.flow.in_game_over_mode
+
+
+def test_a_lethal_effect_outside_combat_is_survived(session: GameSession) -> None:
+ h = session.engine.cmd_handler
+ h.effects.execute_effect({"damage": 9999})
+ assert session.player.is_alive()
+ assert not session.player.has_item("legacy_backup")
+ assert not h.flow.in_game_over_mode
+
+
+def test_the_backup_is_single_use(session: GameSession) -> None:
+ h = session.engine.cmd_handler
+ h.effects.execute_effect({"damage": 9999})
+ h.effects.execute_effect({"damage": 9999})
+ assert not session.player.is_alive()
+
+
+def test_revive_is_announced(session: GameSession) -> None:
+ h = session.engine.cmd_handler
+ session.ui.clear_console()
+ h.effects.execute_effect({"damage": 9999})
+ assert "Legacy Backup" in "\n".join(session.ui.drain())
+
+
+def test_using_it_explains_that_it_is_automatic(session: GameSession) -> None:
+ out = _out(session, "use legacy_backup")
+ assert "cannot use" not in out.lower()
+ assert "automatic" in out.lower()
+ assert session.player.has_item("legacy_backup")
diff --git a/tests/test_legacy_backup_notice.py b/tests/test_legacy_backup_notice.py
new file mode 100644
index 0000000..63b9db2
--- /dev/null
+++ b/tests/test_legacy_backup_notice.py
@@ -0,0 +1,73 @@
+"""A spent Legacy Backup says so, and trying to use one mid-fight explains
+that it works on its own instead of a flat "cannot be used"."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.game_states import GameState
+
+
+@pytest.fixture
+def fight() -> Iterator[GameSession]:
+ s = GameSession()
+ try:
+ s.new_game("Rob", "weaver")
+ s.player.tutorial_state["completed"] = True
+ s.world.spawn_tutorial_enemy("root")
+ yield s
+ finally:
+ s.close()
+
+
+def _give_backup(s: GameSession, key: str = "legacy_backup") -> None:
+ # A second copy lives under a suffixed key, as loot drops add it.
+ s.player.add_to_inventory(key, s.world.get_item("legacy_backup"))
+
+
+def _start(s: GameSession) -> str:
+ s.submit("cd root")
+ combat = s.engine.cmd_handler.current_combat_session
+ assert combat is not None and s.state == GameState.IN_COMBAT
+ combat.enemy_health = 10**6
+ combat.enemy_damage = 10**6
+ return next(iter(combat.available_attacks))
+
+
+def test_last_backup_spent_says_none_are_left(fight: GameSession) -> None:
+ _give_backup(fight)
+ attack = _start(fight)
+ out = "\n".join(fight.submit(attack))
+ assert "restores you from a snapshot" in out
+ assert "spent" in out and "no backups left" in out.lower()
+
+
+def test_a_second_backup_is_counted(fight: GameSession) -> None:
+ _give_backup(fight)
+ _give_backup(fight, "legacy_backup_2")
+ attack = _start(fight)
+ out = "\n".join(fight.submit(attack))
+ assert "spent" in out and "1 backup left" in out
+
+
+def test_using_a_backup_mid_fight_explains_it_is_automatic(fight: GameSession) -> None:
+ _give_backup(fight)
+ _start(fight)
+ out = "\n".join(fight.submit("use legacy_backup"))
+ assert "cannot be used in combat" not in out
+ assert "works automatically" in out
+ assert fight.player.has_item("legacy_backup")
+ assert fight.state == GameState.IN_COMBAT
+
+
+@pytest.mark.parametrize("typed", ["equip null_pointer", "take health_packet", "cd /bin"])
+def test_exploring_commands_mid_fight_say_why_not(fight: GameSession, typed: str) -> None:
+ _start(fight)
+ out = "\n".join(fight.submit(typed))
+ verb = typed.split()[0]
+ assert "command not found" not in out
+ assert f"{verb} works once the fight is over" in out
+ assert "1-9" in out and "flee" in out
+ assert fight.state == GameState.IN_COMBAT
diff --git a/tests/test_legacy_backup_revive.py b/tests/test_legacy_backup_revive.py
new file mode 100644
index 0000000..c64be00
--- /dev/null
+++ b/tests/test_legacy_backup_revive.py
@@ -0,0 +1,38 @@
+"""A Legacy Backup revives you once and is spent; the Inventory panel must
+drop it at once, or the next death looks like the backup failed."""
+from __future__ import annotations
+
+from typing import Any
+
+from engine.api import GameSession
+from engine.events import EventType
+from src.game_states import GameState
+
+
+def test_backup_revives_once_then_the_next_death_ends_the_run() -> None:
+ s = GameSession()
+ try:
+ s.new_game("Rob", "weaver")
+ s.player.tutorial_state["completed"] = True
+ s.player.add_to_inventory("legacy_backup", s.world.get_item("legacy_backup"))
+ s.world.spawn_tutorial_enemy("root")
+ s.submit("cd root")
+ combat = s.engine.cmd_handler.current_combat_session
+ assert combat is not None and s.state == GameState.IN_COMBAT
+ combat.enemy_health = 10**6
+ combat.enemy_damage = 10**6
+ attack = next(iter(combat.available_attacks))
+
+ views: list[dict[str, Any]] = []
+ s.bus.subscribe(EventType.PLAYER_INVENTORY_CHANGED, lambda e: views.append(e.data))
+ out = "\n".join(s.submit(attack))
+
+ assert "restores you from a snapshot" in out
+ assert s.state == GameState.IN_COMBAT and s.player.is_alive()
+ assert "legacy_backup" not in s.player.inventory
+ assert "legacy_backup" not in [i["id"] for i in views[-1]["items"]]
+
+ s.submit(attack)
+ assert s.state == GameState.GAME_OVER
+ finally:
+ s.close()
diff --git a/tests/test_load_no_handler_leak.py b/tests/test_load_no_handler_leak.py
index e0a076c..c226090 100644
--- a/tests/test_load_no_handler_leak.py
+++ b/tests/test_load_no_handler_leak.py
@@ -1,14 +1,15 @@
"""Loading a game must not leak the previous CommandHandler's subscriptions.
The load paths built a new handler without unsubscribing the old one, so the
-dead run's handler kept reacting to ROOM_ENTERED with its stale player —
-observed live as a fresh game instantly fighting the previous run's boss.
+dead run's handler kept reacting to events with its stale player — observed
+live as a fresh game instantly fighting the previous run's boss.
Assertion is object-identity based (is the OLD handler still subscribed?)
because the module-singleton bus can carry handlers leaked by other tests.
"""
from engine.api import GameSession
-from src.events import EventType, event_bus
+from src.game_states import GameState
+from src.save import save_manager
def test_load_game_unsubscribes_old_handler():
@@ -18,20 +19,28 @@ def test_load_game_unsubscribes_old_handler():
old_handler = s.engine.cmd_handler
assert old_handler is not None
- from src.save import save_manager
save_manager.save_game(s.player, s.world.get_state())
+ run_id = save_manager.active_run_id
# Call the load path directly (bypasses the bus, so engines leaked by
# other tests can't distort the result).
+ # LOAD GAME is reached from the menu. Set the mode directly: restart_game
+ # would unsubscribe the old handler itself and hide the leak under test.
+ s.engine.state_manager.set_state(GameState.MENU, emit_event=False)
s.engine._load_game()
+ s.engine._handle_save_picker_input(f"pick {run_id}")
assert s.engine.cmd_handler is not old_handler, "load did not build a new handler"
- subs = event_bus._listeners.get(EventType.ROOM_ENTERED, [])
- assert all(getattr(cb, "__self__", None) is not old_handler for cb in subs), (
+ old_parts = {id(old_handler), id(old_handler.tutorial)}
+ leaked = [
+ (event_type, cb)
+ for event_type, callbacks in s.bus._listeners.items()
+ for cb in callbacks
+ if id(getattr(cb, "__self__", None)) in old_parts
+ ]
+ assert leaked == [], (
"old CommandHandler still subscribed after load — its stale player "
- "will re-trigger fights from the previous run"
+ "will react to the new run's events"
)
- # And the new handler must be live.
- assert any(getattr(cb, "__self__", None) is s.engine.cmd_handler for cb in subs)
finally:
s.close()
diff --git a/tests/test_logging.py b/tests/test_logging.py
new file mode 100644
index 0000000..8b98959
--- /dev/null
+++ b/tests/test_logging.py
@@ -0,0 +1,56 @@
+"""Debug logging goes to the log file only, and each launch starts a fresh one.
+
+debug_log used to echo every line to the terminal as well. Lines printed while
+the world loaded sat behind the game screen and reappeared in the terminal when
+the player quit; the log panel (L) already shows them from the file.
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+import pytest
+
+import main
+import utils.debug_tools as debug_tools
+
+
+def test_debug_log_writes_the_file_not_the_terminal(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str],
+) -> None:
+ log = tmp_path / "debug.log"
+ monkeypatch.setattr(debug_tools, "DEBUG_MODE", True)
+ monkeypatch.setattr(debug_tools, "DEBUG_LOG_FILE", str(log))
+
+ debug_tools.debug_log("world loaded", "system")
+
+ captured = capsys.readouterr()
+ assert captured.out == "" and captured.err == ""
+ assert "world loaded" in log.read_text()
+
+
+def test_each_launch_starts_an_empty_log_and_keeps_the_last_one(tmp_path: Path) -> None:
+ log, previous = tmp_path / "debug.log", tmp_path / "combat.log"
+ log.write_text("last session\n")
+
+ main.rotate_logs(str(log), str(previous))
+
+ assert log.read_text() == ""
+ assert previous.read_text() == "last session\n"
+
+
+def test_first_launch_creates_both_files(tmp_path: Path) -> None:
+ log, previous = tmp_path / "debug.log", tmp_path / "combat.log"
+
+ main.rotate_logs(str(log), str(previous))
+
+ assert log.read_text() == ""
+ assert previous.exists()
+
+
+def test_logs_rotate_before_the_game_modules_load() -> None:
+ """Some modules log while importing (the combat system's attack table);
+ rotating after the imports erased those lines from the new session."""
+ source = Path(main.__file__).read_text()
+ rotation = source.index("rotate_logs(DEBUG_LOG_FILE")
+ assert rotation < source.index("from src.game_engine")
+ assert rotation < source.index("from src.ui")
diff --git a/tests/test_logs.py b/tests/test_logs.py
new file mode 100644
index 0000000..d5f2388
--- /dev/null
+++ b/tests/test_logs.py
@@ -0,0 +1,47 @@
+"""Generated logs: long, look-alike, deterministic, one flag line; and the
+validator's rules for grep flags."""
+from engine.content import find_flag_problems, load_all
+from engine.schema.models import Item, LogSpec, RoomFlag
+from src.logs import log_lines
+
+
+def _item(lines: int = 120) -> Item:
+ return Item(
+ id="test_log", name="test_log", type="lore",
+ log=LogSpec(
+ lines=lines,
+ templates=["[{ts}] boot: step {n}", "[{ts}] boot: ok"],
+ flag_line="[{ts}] boot: note FLAG{found_it}",
+ ),
+ )
+
+
+def test_log_has_the_requested_length_and_one_flag_line() -> None:
+ lines = log_lines(_item(120))
+ assert len(lines) == 120
+ assert sum("FLAG{found_it}" in line for line in lines) == 1
+ assert "FLAG{found_it}" in lines[80]
+
+
+def test_log_is_deterministic_and_fills_placeholders() -> None:
+ assert log_lines(_item()) == log_lines(_item())
+ assert not any("{ts}" in line or "{n}" in line for line in log_lines(_item()))
+
+
+def _with_root_flag(content, flag: RoomFlag):
+ content.rooms["root"] = content.rooms["root"].model_copy(update={"flag": flag})
+ return find_flag_problems(content)
+
+
+def test_grep_flag_needs_a_log_file() -> None:
+ problems = _with_root_flag(load_all("data"), RoomFlag(
+ file="motd", text="FLAG{cat_reads_files}", nudge="n", command="c", via="grep",
+ ))
+ assert any("root" in p and "log" in p for p in problems)
+
+
+def test_unknown_via_is_reported() -> None:
+ problems = _with_root_flag(load_all("data"), RoomFlag(
+ file="motd", text="FLAG{cat_reads_files}", nudge="n", command="c", via="find",
+ ))
+ assert any("via" in p for p in problems)
diff --git a/tests/test_loot_drops.py b/tests/test_loot_drops.py
index 0b3f6ae..d0af138 100644
--- a/tests/test_loot_drops.py
+++ b/tests/test_loot_drops.py
@@ -1,7 +1,7 @@
-"""Enemy loot-drop pipeline: ENEMY_DEFEATED -> award into the room.
+"""Enemy loot-drop pipeline: CommandHandler.on_kill -> award into the room.
Uses GameSession to build a real world/handler, injects a synthetic enemy with
-deterministic (chance 100) drops, emits the defeat event, and asserts the loot
+deterministic (chance 100) drops, reports the kill, and asserts the loot
landed in the current room. Reaches into src.game_world internals deliberately —
this exercises the src-side award wiring the engine layer does not cover.
"""
@@ -13,7 +13,7 @@
import src.rng as rng
from engine.api import GameSession
-from src.events import EventType, event_bus
+from engine.schema import Enemy
@pytest.fixture
@@ -33,13 +33,13 @@ def test_existing_drops_are_awarded_into_the_room(session: GameSession) -> None:
# health_packet + starter weapon), and assert it newly appears after the drop.
drop_item = "kill_script"
assert drop_item not in h.world.get_items_in_room(room)
- h.world.enemies["test_dropper"] = {
- "id": "test_dropper", "name": "Test Dropper",
- "drops": [{"item": drop_item, "chance": 100}],
- }
+ h.world.enemies["test_dropper"] = Enemy(
+ id="test_dropper", name="Test Dropper", health=10, damage=1,
+ drops=[{"item": drop_item, "chance": 100}],
+ )
h.world.enemy_locations["test_dropper"] = room
- event_bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": "test_dropper"}, "test")
+ h.on_kill("test_dropper")
assert drop_item in h.world.get_items_in_room(room)
@@ -47,29 +47,29 @@ def test_existing_drops_are_awarded_into_the_room(session: GameSession) -> None:
def test_drops_are_awarded_only_once(session: GameSession) -> None:
h = session.engine.cmd_handler
room = h.player.current_room
- h.world.enemies["test_dropper2"] = {
- "id": "test_dropper2", "name": "Test Dropper 2",
- "drops": [{"item": "health_packet", "chance": 100}],
- }
+ h.world.enemies["test_dropper2"] = Enemy(
+ id="test_dropper2", name="Test Dropper 2", health=10, damage=1,
+ drops=[{"item": "health_packet", "chance": 100}],
+ )
h.world.enemy_locations["test_dropper2"] = room
- event_bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": "test_dropper2"}, "test")
- event_bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": "test_dropper2"}, "test")
+ h.on_kill("test_dropper2")
+ h.on_kill("test_dropper2")
- assert "test_dropper2" in h._awarded_drops
+ assert "test_dropper2" in h.loot.awarded
def test_loot_table_drops_class_appropriate_gear(session: GameSession) -> None:
h = session.engine.cmd_handler # session is a weaver
room = h.player.current_room
- h.world.enemies["test_boss"] = {
- "id": "test_boss", "name": "Test Boss",
- "loot_table": [{"rarity": "epic", "chance": 100}],
- }
+ h.world.enemies["test_boss"] = Enemy(
+ id="test_boss", name="Test Boss", health=10, damage=1,
+ loot_table=[{"rarity": "epic", "chance": 100}],
+ )
h.world.enemy_locations["test_boss"] = room
rng.seed(1)
- event_bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": "test_boss"}, "test")
+ h.on_kill("test_boss")
room_items = set(h.world.get_items_in_room(room))
epics = {iid for iid, d in h.world.items.items() if str(d.rarity).lower() == "epic"}
@@ -85,11 +85,11 @@ def test_loot_table_never_drops_a_healing_item(session: GameSession) -> None:
# if a healing item happened to share a rarity tier.
h = session.engine.cmd_handler
for _ in range(50):
- got = h._roll_loot_table([{"rarity": "rare", "chance": 100}])
+ got = h.loot.roll_loot_table([{"rarity": "rare", "chance": 100}])
if got is not None:
- data = h.world.get_item(got) or {}
- assert data.get("type") in ("weapon", "armor"), f"{got} is not gear"
- assert "healing" not in (data.get("tags") or [])
+ data = h.world.get_item(got)
+ assert data.type in ("weapon", "armor"), f"{got} is not gear"
+ assert "healing" not in data.tags
def test_final_boss_has_a_legendary_loot_table() -> None:
@@ -111,3 +111,27 @@ def test_some_enemy_can_drop_epic() -> None:
if any(entry.get("rarity") == "epic" for entry in (e.get("loot_table") or [])):
epic_droppers.append(path)
assert epic_droppers, "at least one enemy must have an epic in its loot_table"
+
+
+def test_gear_you_already_have_never_drops_again(session: GameSession) -> None:
+ """Picking an item up takes it off the floor; the gear roll only excluded
+ floor items, so a held Null-Void Cloak came back as a second copy."""
+ h = session.engine.cmd_handler # session is a weaver
+ h.world.item_locations["null_void_cloak"] = h.player.current_room
+ session.submit("take null_void_cloak")
+ assert h.player.has_item("null_void_cloak")
+
+ rng.seed(0)
+ rolls = {h.loot.random_gear_of_rarity("epic") for _ in range(200)}
+ assert "null_void_cloak" not in rolls
+
+
+def test_gear_taken_and_lost_never_drops_again(session: GameSession) -> None:
+ h = session.engine.cmd_handler
+ h.world.item_locations["null_void_cloak"] = h.player.current_room
+ session.submit("take null_void_cloak")
+ h.player.remove_from_inventory("null_void_cloak") # e.g. lost on death
+
+ rng.seed(0)
+ rolls = {h.loot.random_gear_of_rarity("epic") for _ in range(200)}
+ assert "null_void_cloak" not in rolls
diff --git a/tests/test_ls_tree.py b/tests/test_ls_tree.py
index 342a24e..da42835 100644
--- a/tests/test_ls_tree.py
+++ b/tests/test_ls_tree.py
@@ -24,6 +24,7 @@ def _ls(session: GameSession, args: str = "") -> str:
def test_ls_lists_child_directories(session: GameSession) -> None:
session.player.current_room = "root"
+ session.world.reveal_doors("lib_key") # /usr is a keyed door
out = _ls(session)
for child in ("bin/", "home/", "var/", "usr/"):
assert child in out, f"{child} missing from the listing of /"
@@ -48,6 +49,7 @@ def test_ls_a_reveals_a_hidden_child(session: GameSession) -> None:
# combat gate.
session.player.current_room = "root"
assert session.world.get_room_state("ghost_hidden")["hidden"] is True
+ session.world.reveal_doors("system_badge") # /root is also a keyed door
assert "root/" not in _ls(session), "hidden child must not show without -a"
@@ -97,7 +99,9 @@ def test_ls_l_shows_permission_bits(session: GameSession) -> None:
def test_sealed_directories_are_still_listed(session: GameSession) -> None:
- """You can see that /usr exists; you just cannot go in. That is how a real
- filesystem behaves, and it is what makes the key meaningful."""
+ """Once its key has revealed /usr you can see it exists even without the
+ key in hand; you just cannot go in. (Before that it is invisible — see
+ test_key_visibility.py.)"""
session.player.current_room = "root"
+ session.world.reveal_doors("lib_key")
assert "usr/" in _ls(session)
diff --git a/tests/test_move_effects.py b/tests/test_move_effects.py
new file mode 100644
index 0000000..731d651
--- /dev/null
+++ b/tests/test_move_effects.py
@@ -0,0 +1,135 @@
+"""Players see what each move does: in the fight menu, when HP runs low,
+and on the class card before the game starts."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from engine.view_models import AttackView, StatsView
+from src.data_loader import load_class_data
+from src.move_info import class_move_lines, effect_tags, stat_lines
+from src.save import save_manager
+from src.ui.combat_log import hotkey_display, render_combat_output
+from src.viewmodels.view_builder import ViewBuilder
+
+_PLAYER = StatsView("Tess", 160, 160, 20, "shaman")
+_ATTACKS = [
+ AttackView("nature_strike", "Nature Strike", 6, 0),
+ AttackView("ancient_fury", "Ancient Fury", 17, 2),
+ AttackView("healing_strike", "Healing Strike", 8, 3, healing=13),
+]
+_OPENING = [{"actor": "system", "message": "Combat initiated"}]
+_MID_FIGHT = [*_OPENING, {"actor": "enemy", "message": "The Knight hits you"}]
+
+
+@pytest.fixture(autouse=True)
+def _hints_on(monkeypatch: pytest.MonkeyPatch) -> None:
+ # The low-HP line is a hint; don't let a developer's saved settings hide it.
+ import config.dev_config as dev_cfg
+
+ monkeypatch.setattr(dev_cfg, "SHOW_HINTS", True)
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "shaman")
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def test_effect_tags_in_plain_words() -> None:
+ assert effect_tags(0, 0) == []
+ assert effect_tags(13, 0) == ["heals 13 HP"]
+ assert effect_tags(3, 0.5) == ["heals 3 HP", "weakens enemy's next hit 50%"]
+
+
+def test_attack_views_carry_healing_and_weaken(s: GameSession) -> None:
+ from src.combat import CombatSystem
+
+ views = {a.id: a for a in ViewBuilder.build_attack_list(s.player, CombatSystem())}
+ assert views["healing_strike"].healing == 13
+ assert views["nature_strike"].healing == 0
+
+ s.player.player_class = "guardian" # attack list reads only the class id
+ views = {a.id: a for a in ViewBuilder.build_attack_list(s.player, CombatSystem())}
+ assert views["shield_bash"].weaken == 0.5
+
+
+def test_fight_menu_names_the_heal() -> None:
+ menu = hotkey_display(_PLAYER, _ATTACKS)
+ heal_line = next(line for line in menu.splitlines() if "Healing Strike" in line)
+ assert "heals 13 HP" in heal_line
+ strike_line = next(line for line in menu.splitlines() if "Nature Strike" in line)
+ assert "heals" not in strike_line
+
+
+def test_low_hp_points_at_the_heal_move() -> None:
+ out = render_combat_output(_MID_FIGHT, _PLAYER, _ATTACKS, player_hp=(26, 160))
+ assert "Low HP" in out and "[3]" in out and "Healing Strike" in out
+
+
+def test_low_hp_hint_on_the_opening_screen_too() -> None:
+ out = render_combat_output(_OPENING, _PLAYER, _ATTACKS, player_hp=(26, 160))
+ assert "Low HP" in out
+
+
+def test_low_hp_says_when_the_heal_is_ready() -> None:
+ cooling = [
+ *_ATTACKS[:2],
+ AttackView("healing_strike", "Healing Strike", 8, 3, 2, True, healing=13),
+ ]
+ out = render_combat_output(_MID_FIGHT, _PLAYER, cooling, player_hp=(26, 160))
+ assert "Low HP" in out and "2 turns" in out
+
+
+def test_no_low_hp_hint_when_healthy_or_no_heal_move() -> None:
+ healthy = render_combat_output(_MID_FIGHT, _PLAYER, _ATTACKS, player_hp=(150, 160))
+ assert "Low HP" not in healthy
+ no_heal = render_combat_output(_MID_FIGHT, _PLAYER, _ATTACKS[:2], player_hp=(26, 160))
+ assert "Low HP" not in no_heal
+
+
+def test_low_hp_hint_obeys_the_hints_setting(monkeypatch: pytest.MonkeyPatch) -> None:
+ import config.dev_config as dev_cfg
+
+ monkeypatch.setattr(dev_cfg, "SHOW_HINTS", False)
+ for log in (_OPENING, _MID_FIGHT):
+ out = render_combat_output(log, _PLAYER, _ATTACKS, player_hp=(26, 160))
+ assert "Low HP" not in out
+ # The menu's own effect tags are facts, not hints: they stay.
+ assert "heals 13 HP" in hotkey_display(_PLAYER, _ATTACKS)
+
+ monkeypatch.setattr(dev_cfg, "SHOW_HINTS", True)
+ out = render_combat_output(_MID_FIGHT, _PLAYER, _ATTACKS, player_hp=(26, 160))
+ assert "Low HP" in out
+
+
+def test_class_card_lists_every_move_with_its_effect() -> None:
+ shaman = load_class_data()["shaman"]
+ lines = class_move_lines(shaman)
+ assert len(lines) == 3
+ heal = next(line for line in lines if "Healing Strike" in line)
+ assert "heals 13 HP" in heal
+ assert all(len(line) <= 40 for line in lines) # fits the 40-cell picker card
+
+
+def test_picker_card_shows_the_moves() -> None:
+ from src.ui.textual_ui import TextualGameUI
+
+ cards = {c.title: c for c in TextualGameUI._class_cards()}
+ assert "Healing Strike" in cards["Shaman"].subtitle
+ assert "heals 13 HP" in cards["Shaman"].subtitle
+
+
+@pytest.mark.parametrize("class_id", ["guardian", "weaver", "shaman"])
+def test_card_stats_come_from_the_real_numbers(class_id: str) -> None:
+ cls = load_class_data()[class_id]
+ hp, dmg = stat_lines(cls)
+ assert f"({cls.base_health} HP)" in hp
+ assert f"({cls.base_damage} DMG)" in dmg
+ # The words stay hand-written; the numbers never are, so they can't drift.
+ assert not any(ch.isdigit() for ch in cls.display.hp_label + cls.display.dmg_label)
diff --git a/tests/test_nav.py b/tests/test_nav.py
index 2c6f60e..1232ed1 100644
--- a/tests/test_nav.py
+++ b/tests/test_nav.py
@@ -73,33 +73,37 @@ def test_paths_and_aliases_are_unique(aliases: dict[str, str]) -> None:
assert len(paths) == len(set(paths)), "two rooms claim the same path"
-@pytest.mark.parametrize("typed,expected", sorted(LEGACY_NAV.items()))
-def test_legacy_navigation_still_works(
- typed: str, expected: str, aliases: dict[str, str]
-) -> None:
+def test_legacy_navigation_still_works(aliases: dict[str, str]) -> None:
"""Anything that navigated before the tree must still navigate."""
- assert room_paths.resolve(typed, "/", aliases) == expected
-
-
-@pytest.mark.parametrize(
- "typed,current,expected",
- [
- ("/usr/games", "/", "usr_share_games"),
- ("games", "/usr", "usr_share_games"),
- ("self", "/proc", "mirror_sector"),
- ("..", "/usr/games", "usr_lib_arcane"),
- ("..", "/var/tmp", "var_dungeon"),
- ("..", "/", "root"), # root is its own parent
- ("../..", "/usr/games/cowsay", "usr_lib_arcane"),
- (".", "/var", "var_dungeon"),
- ("/var/./tmp", "/", "deprecated_dir"),
- ("/var/backups/..", "/", "var_dungeon"),
- ],
-)
-def test_path_arithmetic(
- typed: str, current: str, expected: str, aliases: dict[str, str]
-) -> None:
- assert room_paths.resolve(typed, current, aliases) == expected
+ wrong = {
+ typed: got for typed, expected in LEGACY_NAV.items()
+ if (got := room_paths.resolve(typed, "/", aliases)) != expected
+ }
+ assert not wrong, f"these no longer reach their room: {wrong}"
+
+
+PATH_ARITHMETIC = [
+ # (typed, current directory, expected room)
+ ("/usr/games", "/", "usr_share_games"),
+ ("games", "/usr", "usr_share_games"),
+ ("self", "/proc", "mirror_sector"),
+ ("..", "/usr/games", "usr_lib_arcane"),
+ ("..", "/var/tmp", "var_dungeon"),
+ ("..", "/", "root"), # root is its own parent
+ ("../..", "/usr/games/cowsay", "usr_lib_arcane"),
+ (".", "/var", "var_dungeon"),
+ ("/var/./tmp", "/", "deprecated_dir"),
+ ("/var/backups/..", "/", "var_dungeon"),
+]
+
+
+def test_path_arithmetic(aliases: dict[str, str]) -> None:
+ wrong = [
+ (typed, current, expected, got)
+ for typed, current, expected in PATH_ARITHMETIC
+ if (got := room_paths.resolve(typed, current, aliases)) != expected
+ ]
+ assert not wrong, f"(typed, cwd, expected, got): {wrong}"
def test_unknown_paths_do_not_resolve(aliases: dict[str, str]) -> None:
diff --git a/tests/test_navigation_tree.py b/tests/test_navigation_tree.py
index 3439451..0fab125 100644
--- a/tests/test_navigation_tree.py
+++ b/tests/test_navigation_tree.py
@@ -75,6 +75,7 @@ def test_unknown_path_reports_no_such_file(session: GameSession) -> None:
# --- permission --------------------------------------------------------------
def test_sealed_directory_is_refused_and_names_its_key(session: GameSession) -> None:
+ session.world.reveal_doors("lib_key") # seen, but the key is not held
out = _text(session.submit("cd /usr"))
assert "Permission denied" in out
assert "lib_key" in out
@@ -84,6 +85,7 @@ def test_sealed_directory_is_refused_and_names_its_key(session: GameSession) ->
def test_a_sealed_parent_seals_its_children(session: GameSession) -> None:
"""/usr/games is not locked itself; /usr above it is."""
assert not session.world.get_room_state("usr_share_games").get("locked", False)
+ session.world.reveal_doors("lib_key") # seen, but the key is not held
out = _text(session.submit("cd /usr/games"))
assert "Permission denied" in out
@@ -116,10 +118,10 @@ def test_undiscovered_directory_looks_like_it_does_not_exist(
assert "No such file or directory" in out
-def test_class_restriction_is_reported_before_the_key(session: GameSession) -> None:
- """A guardian cannot enter the weaver-only tower; naming a key they could
- never use would be the unhelpful half of the message."""
+def test_a_guardian_with_the_key_may_enter_the_tower(session: GameSession) -> None:
+ """The trials are open to every class now; only the key gates them."""
session.player.add_to_inventory("opt_key", session.world.get_item("opt_key"))
- out = _text(session.submit("cd /opt"))
- assert "Permission denied" in out
- assert "weaver" in out.lower()
+ session.world.discover_room("opt_mage_tower") # /opt is hidden until found
+ session.world.unlock_room("opt_mage_tower")
+ allowed, _ = session.world.check_access("opt_mage_tower", session.player)
+ assert allowed
diff --git a/tests/test_new_game_after_end.py b/tests/test_new_game_after_end.py
index a3a9d8d..2fe74ad 100644
--- a/tests/test_new_game_after_end.py
+++ b/tests/test_new_game_after_end.py
@@ -1,12 +1,22 @@
-"""Post-game 'n' must re-offer difficulty + class, not restart as default guardian."""
+"""After a win: 'm' returns to the main menu, and the run is marked cleared."""
from engine.api import GameSession
-from src.state_manager import state_manager
+from src.game_states import GameState
+from src.save import save_manager
-def test_n_after_win_lands_in_difficulty_picker():
+def test_m_after_win_returns_to_the_main_menu():
s = GameSession()
s.new_game("t", "shaman")
- s.engine.cmd_handler.win_game()
- s.submit("n")
- assert str(state_manager.current_state) == "waiting_for_difficulty"
+ s.engine.cmd_handler.flow.win_game()
+ s.submit("m")
+ assert s.state == GameState.MENU
+ s.close()
+
+
+def test_winning_marks_the_run_cleared():
+ s = GameSession()
+ s.new_game("t", "shaman")
+ s.submit("save")
+ s.engine.cmd_handler.flow.win_game()
+ assert save_manager.list_runs()[0].cleared is True
s.close()
diff --git a/tests/test_no_literal_markup.py b/tests/test_no_literal_markup.py
new file mode 100644
index 0000000..b7ac16b
--- /dev/null
+++ b/tests/test_no_literal_markup.py
@@ -0,0 +1,35 @@
+"""Rich `Text` objects print markup tags literally, so style must be passed to
+`append`, not written as `[dim]...[/dim]` inside the string."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("t", "guardian")
+ s.player.tutorial_state["completed"] = True
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _out(session: GameSession, cmd: str) -> str:
+ return "\n".join(str(line) for line in session.submit(cmd))
+
+
+@pytest.mark.parametrize("cmd", ["tree", "journal"])
+def test_no_markup_tags_leak_into_the_text(session: GameSession, cmd: str) -> None:
+ out = _out(session, cmd)
+ for tag in ("[dim]", "[/dim]", "[italic]", "[/italic]"):
+ assert tag not in out, f"{cmd} printed a literal {tag}"
+
+
+def test_journal_with_a_memory_shows_progress_cleanly(session: GameSession) -> None:
+ session.submit("cat .bash_profile")
+ out = _out(session, "journal")
+ assert "Progress:" in out and "[dim]" not in out
diff --git a/tests/test_not_found_hints.py b/tests/test_not_found_hints.py
new file mode 100644
index 0000000..6df8882
--- /dev/null
+++ b/tests/test_not_found_hints.py
@@ -0,0 +1,100 @@
+"""Not-found errors should say what the player probably meant, or what is here."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.commands.hints import not_found_hint
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("Tester", "guardian")
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _text(lines: list[str]) -> str:
+ return "\n".join(lines)
+
+
+# --- unit: the hint builder --------------------------------------------------
+
+def test_hint_suggests_close_match() -> None:
+ hint = not_found_hint(
+ "segfault_sheld", ["health_packet", "segfault_shield"], label="Items here"
+ )
+ assert "Did you mean" in hint and "segfault_shield" in hint
+
+
+def test_hint_is_case_insensitive_and_returns_canonical_name() -> None:
+ hint = not_found_hint("SEGFAULT_SHIELd", ["segfault_shield"], label="Items here")
+ assert "segfault_shield" in hint
+
+
+def test_hint_lists_candidates_when_nothing_is_close() -> None:
+ hint = not_found_hint("zzzz", ["health_packet", "segfault_shield"], label="Items here")
+ assert "Did you mean" not in hint
+ assert "Items here" in hint and "health_packet" in hint and "segfault_shield" in hint
+
+
+def test_hint_is_empty_when_no_candidates() -> None:
+ assert not_found_hint("anything", [], label="Items here") == ""
+
+
+def test_hint_dedupes_candidates() -> None:
+ hint = not_found_hint("zzzz", ["a_thing", "a_thing", "b_thing"], label="Here")
+ assert hint.count("a_thing") == 1
+
+
+# --- integration: through the real commands ----------------------------------
+
+def test_take_typo_gets_did_you_mean(session: GameSession) -> None:
+ out = _text(session.submit("take segfault_sheld"))
+ assert "Cannot find segfault_sheld" in out
+ assert "Did you mean" in out and "segfault_shield" in out
+
+
+def test_take_unknown_lists_room_items(session: GameSession) -> None:
+ out = _text(session.submit("take zzzz"))
+ assert "Cannot find zzzz" in out
+ assert "Items here" in out and "segfault_shield" in out
+
+
+def test_cat_typo_gets_did_you_mean(session: GameSession) -> None:
+ out = _text(session.submit("cat segfault_sheld"))
+ assert "Did you mean" in out and "segfault_shield" in out
+
+
+def test_a_typo_never_reveals_a_hidden_file(session: GameSession) -> None:
+ out = _text(session.submit("cat bash_profil"))
+ assert "Cannot find" in out
+ assert "bash_profile" not in out
+
+
+def test_examine_typo_gets_did_you_mean(session: GameSession) -> None:
+ out = _text(session.submit("examine segfault_sheld"))
+ assert "Did you mean" in out and "segfault_shield" in out
+
+
+def test_cd_typo_gets_did_you_mean(session: GameSession) -> None:
+ out = _text(session.submit("cd mnt_forst"))
+ assert "No such file or directory" in out
+ assert "Did you mean" in out and "mnt_forest" in out
+
+
+def test_equip_typo_gets_did_you_mean(session: GameSession) -> None:
+ session.submit("take segfault_shield")
+ out = _text(session.submit("equip segfault_sheld"))
+ assert "Did you mean" in out and "segfault_shield" in out
+
+
+def test_drop_typo_gets_did_you_mean(session: GameSession) -> None:
+ session.submit("take segfault_shield")
+ out = _text(session.submit("drop segfault_sheld"))
+ assert "Did you mean" in out and "segfault_shield" in out
diff --git a/tests/test_npc_dialogue.py b/tests/test_npc_dialogue.py
index e598308..488c80f 100644
--- a/tests/test_npc_dialogue.py
+++ b/tests/test_npc_dialogue.py
@@ -87,8 +87,7 @@ def test_take_sets_item_story_flag():
s = GameSession()
try:
s.new_game("t", "guardian")
- item = dict(s.world.get_item("milk_of_motherboard"))
- assert item.get("story_flag") == "milk_claimed"
+ assert s.world.get_item("milk_of_motherboard").story_flag == "milk_claimed"
s.world.item_locations["milk_of_motherboard"] = s.player.current_room
s.submit("take milk_of_motherboard")
assert s.player.get_story_flag("milk_claimed")
@@ -109,7 +108,7 @@ def test_talk_uses_rules_and_marks_met():
h.world.npc_locations["librarian.bin"] = s.player.current_room
first = _talk(s, "librarian.bin")
assert "librarian.bin" in s.player.met_npcs
- greetings = h.world.get_npc("librarian.bin")["dialogue"]["greeting"]
+ greetings = h.world.get_npc("librarian.bin").dialogue["greeting"]
assert any(g in first for g in greetings), first
finally:
s.close()
@@ -125,7 +124,7 @@ def test_talk_reacts_to_story_flags():
s.player.met_npcs.add("librarian.bin")
s.player.set_story_flag("typo_discovered")
out = _talk(s, "librarian.bin")
- panic_lines = h.world.get_npc("librarian.bin")["dialogue"]["about_panic"]
+ panic_lines = h.world.get_npc("librarian.bin").dialogue["about_panic"]
assert any(line in out for line in panic_lines), out
finally:
s.close()
diff --git a/tests/test_one_refresh_per_command.py b/tests/test_one_refresh_per_command.py
new file mode 100644
index 0000000..2e91d26
--- /dev/null
+++ b/tests/test_one_refresh_per_command.py
@@ -0,0 +1,24 @@
+"""The engine sends the player's stats and inventory once after each command;
+commands used to send them too, so the UI got every view twice."""
+from __future__ import annotations
+
+from engine.api import GameSession
+from engine.events import EventType
+
+
+def test_take_sends_each_view_once() -> None:
+ s = GameSession()
+ try:
+ s.new_game("Tess", "guardian")
+ s.world.item_locations["health_packet"] = s.player.current_room
+ seen: list[EventType] = []
+ for et in (EventType.PLAYER_STATS_CHANGED, EventType.PLAYER_INVENTORY_CHANGED):
+ s.bus.subscribe(et, lambda e: seen.append(e.type))
+
+ s.submit("take health_packet")
+
+ assert seen.count(EventType.PLAYER_INVENTORY_CHANGED) == 1
+ assert seen.count(EventType.PLAYER_STATS_CHANGED) == 1
+ assert "health_packet" in s.player.inventory
+ finally:
+ s.close()
diff --git a/tests/test_prologue.py b/tests/test_prologue.py
new file mode 100644
index 0000000..0033032
--- /dev/null
+++ b/tests/test_prologue.py
@@ -0,0 +1,20 @@
+"""The opening story tells a new player the hunt exists and how /boot opens."""
+import inspect
+
+from rich.markup import render
+
+from src.ui import title_menu
+
+
+def _story() -> str:
+ source = inspect.getsource(title_menu)
+ start = source.index("opening_story = '''") + len("opening_story = '''")
+ return source[start:source.index("'''", start)]
+
+
+def test_prologue_introduces_the_hunt() -> None:
+ story = _story()
+ plain = render(story).plain
+ assert "flag" in plain.lower()
+ assert "/boot" in plain
+ assert len(plain.strip().splitlines()) <= 30
diff --git a/tests/test_quit_confirm.py b/tests/test_quit_confirm.py
index 2e3940e..b7343bb 100644
--- a/tests/test_quit_confirm.py
+++ b/tests/test_quit_confirm.py
@@ -10,8 +10,10 @@
import pytest
from engine.api import GameSession
-from src.events import EventType, event_bus
-from src.ui.screens.quit_confirm import CHOICES, QuitConfirmScreen
+from engine.events import EventType
+from src.game_states import GameState
+from src.save import save_manager
+from src.ui.screens.quit_confirm import LEAVE_CHOICES, MID_FIGHT_CHOICES, QuitConfirmScreen
@pytest.fixture
@@ -67,30 +69,103 @@ def test_quit_requests_the_chooser(session: GameSession) -> None:
"""The UI is told to offer a chooser; the headless driver ignores it and
falls back to the printed y/n/c, which is why both still work."""
seen: list[object] = []
- event_bus.subscribe(EventType.QUIT_CONFIRM_REQUESTED, seen.append)
+ session.bus.subscribe(EventType.QUIT_CONFIRM_REQUESTED, seen.append)
try:
session.submit("quit")
assert seen, "quit did not request the confirmation chooser"
finally:
- event_bus.unsubscribe(EventType.QUIT_CONFIRM_REQUESTED, seen.append)
+ session.bus.unsubscribe(EventType.QUIT_CONFIRM_REQUESTED, seen.append)
+
+
+@pytest.fixture
+def combat(session: GameSession):
+ """The session, mid-fight; yields the combat session."""
+ session.player.tutorial_state["completed"] = True
+ handler = session.engine.cmd_handler
+ session.world.enemy_locations["corrupt_process.bin"] = session.player.current_room
+ handler.check_for_enemies()
+ assert session.state == GameState.IN_COMBAT
+ return handler.current_combat_session
+
+
+@pytest.mark.parametrize(("letter", "saves"), [("m", True), ("x", False)])
+def test_leaving_for_the_main_menu(session: GameSession, letter: str, saves: bool) -> None:
+ before = [(run.run_id, run.saved_at) for run in save_manager.list_runs()]
+ session.submit("quit")
+ session.submit(letter)
+ assert session.state == GameState.MENU
+ assert session.ui.quit_requested is False
+ after = [(run.run_id, run.saved_at) for run in save_manager.list_runs()]
+ assert (after != before) is saves
+
+
+def test_menu_command_with_progress_asks_first(session: GameSession) -> None:
+ out = _text(session.submit("menu"))
+ assert "unsaved progress" in out
+ assert session.state == GameState.PLAYING
+
+
+def test_menu_command_without_progress_goes_straight_to_the_menu() -> None:
+ s = GameSession()
+ try:
+ s.new_game("Fresh", "guardian")
+ s.player.inventory.clear() # a new run starts with an item: that counts as progress
+ s.submit("menu")
+ assert s.state == GameState.MENU
+ finally:
+ s.close()
+
+
+def test_quit_mid_fight_opens_the_fight_chooser(session: GameSession, combat) -> None:
+ seen: list[object] = []
+ session.bus.subscribe(EventType.QUIT_CONFIRM_REQUESTED, seen.append)
+ out = _text(session.submit("quit"))
+ assert "command not found" not in out
+ assert "Leave mid-fight" in out
+ assert seen[-1].data == {"inCombat": True}
+
+
+def test_keep_fighting_resumes_the_fight(session: GameSession, combat) -> None:
+ session.submit("quit")
+ assert "Back to the fight" in _text(session.submit("c"))
+ assert session.state == GameState.IN_COMBAT
+ out = _text(session.submit(next(iter(combat.available_attacks))))
+ assert "command not found" not in out and "Invalid option" not in out
+
+
+def test_main_menu_mid_fight_leaves_without_saving(session: GameSession, combat) -> None:
+ before = [(run.run_id, run.saved_at) for run in save_manager.list_runs()]
+ session.submit("quit")
+ session.submit("x")
+ assert session.state == GameState.MENU
+ assert [(run.run_id, run.saved_at) for run in save_manager.list_runs()] == before
+
+
+def test_save_letters_are_refused_mid_fight(session: GameSession, combat) -> None:
+ session.submit("quit")
+ out = _text(session.submit("y"))
+ assert "Invalid option" in out
+ assert session.state == GameState.IN_COMBAT
+ assert session.ui.quit_requested is False
# --- the chooser itself ------------------------------------------------------
def test_choices_map_onto_the_letters_the_domain_expects() -> None:
- assert [letter for letter, *_ in CHOICES] == ["y", "n", "c"]
+ assert [letter for letter, *_ in LEAVE_CHOICES] == ["m", "x", "y", "n", "c"]
+ assert [letter for letter, *_ in MID_FIGHT_CHOICES] == ["c", "x", "n"]
def test_arrow_keys_move_and_enter_confirms() -> None:
picked: list[str] = []
screen = QuitConfirmScreen(picked.append)
- screen.action_move_down() # y -> n
+ screen.action_move_down() # m -> y
assert screen._index == 1
- screen.action_move_up() # back to y
+ screen.action_move_up() # back to m
assert screen._index == 0
screen.action_move_up() # wraps to c
- assert screen._index == len(CHOICES) - 1
+ assert screen._index == len(LEAVE_CHOICES) - 1
def test_escape_keeps_playing() -> None:
@@ -109,6 +184,18 @@ def test_a_second_key_press_cannot_answer_twice() -> None:
screen = QuitConfirmScreen(picked.append)
screen.dismiss = lambda *a, **k: None
- screen.action_pick_quit()
- screen.action_pick_save()
+ screen.action_pick("n")
+ screen.action_pick("y")
assert picked == ["n"]
+
+
+def test_the_mid_fight_chooser_refuses_save_letters() -> None:
+ picked: list[str] = []
+ screen = QuitConfirmScreen(picked.append, MID_FIGHT_CHOICES, "Leave mid-fight?")
+ screen.dismiss = lambda *a, **k: None
+
+ assert MID_FIGHT_CHOICES[screen._index][0] == "c" # keep fighting is the default
+ screen.action_pick("y")
+ assert picked == []
+ screen.action_pick("x")
+ assert picked == ["x"]
diff --git a/tests/test_restart_leak.py b/tests/test_restart_leak.py
index 3500a27..f531360 100644
--- a/tests/test_restart_leak.py
+++ b/tests/test_restart_leak.py
@@ -2,28 +2,26 @@
The event bus is a process-wide singleton, so a CommandHandler that is dropped
without unsubscribing keeps reacting to events with its dead player and world.
-The symptom is doubled work: ROOM_ENTERED fires check_for_enemies twice (every
-enemy fought twice) and ENEMY_DEFEATED fires twice (loot rolled twice).
+The symptom is doubled work: a dead run's tutorial reacting to the next run's
+fights alongside the live one.
"""
from __future__ import annotations
import pytest
from engine.api import GameSession
-from src.events import EventType, event_bus
+from engine.events import EventType
# Events a CommandHandler subscribes to. Exactly one handler may be listening on
# each of these at a time, however many times the game has been restarted.
HANDLER_EVENTS = [
- EventType.ROOM_ENTERED,
- EventType.ENEMY_DEFEATED,
- EventType.ALL_ENEMIES_DEFEATED,
- EventType.ROOM_CHANGED,
+ EventType.COMBAT_ENDED,
+ EventType.COMBAT_ACTION_RESULT,
]
-def _counts() -> dict[EventType, int]:
- return {e: len(event_bus._listeners.get(e, [])) for e in HANDLER_EVENTS}
+def _counts(bus) -> dict[EventType, int]:
+ return {e: len(bus._listeners.get(e, [])) for e in HANDLER_EVENTS}
@pytest.fixture
@@ -37,12 +35,12 @@ def session():
def test_f5_restart_then_new_game_leaves_no_duplicate_handlers(session) -> None:
session.new_game("First", "guardian")
- baseline = _counts()
+ baseline = _counts(session.bus)
session.engine.restart_game() # the F5 path
session.new_game("Second", "weaver")
- assert _counts() == baseline, (
+ assert _counts(session.bus) == baseline, (
"restart leaked the previous CommandHandler's subscriptions — "
"enemies will be fought twice and loot rolled twice"
)
@@ -50,10 +48,10 @@ def test_f5_restart_then_new_game_leaves_no_duplicate_handlers(session) -> None:
def test_repeated_new_games_do_not_accumulate_handlers(session) -> None:
session.new_game("One", "guardian")
- baseline = _counts()
+ baseline = _counts(session.bus)
for name, klass in (("Two", "weaver"), ("Three", "shaman"), ("Four", "guardian")):
session.engine.restart_game()
session.new_game(name, klass)
- assert _counts() == baseline
+ assert _counts(session.bus) == baseline
diff --git a/tests/test_restore_after_win.py b/tests/test_restore_after_win.py
new file mode 100644
index 0000000..ca5cc59
--- /dev/null
+++ b/tests/test_restore_after_win.py
@@ -0,0 +1,81 @@
+"""Post-win "r" (restore the most recent save) must give a fully working run.
+
+It used to rebuild the command handler without its event subscriptions:
+entering a room with an enemy started no fight, NPCs said nothing after
+combat, and the tutorial went quiet. Both load paths now share one entry step.
+"""
+from __future__ import annotations
+
+from collections.abc import Iterator
+from pathlib import Path
+
+import pytest
+
+import src.save as save_mod
+from engine.api import GameSession
+from engine.events import Event, EventType
+from src.game_states import GameState
+from src.game_world import TUTORIAL_ENEMY
+from src.save import SaveManager
+
+
+@pytest.fixture
+def restored(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ mgr = SaveManager(save_dir=str(tmp_path))
+ monkeypatch.setattr(save_mod, "save_manager", mgr)
+ monkeypatch.setattr("src.game_engine.save_manager", mgr)
+
+ s = GameSession()
+ s.new_game("Victor", "guardian")
+ s.player.tutorial_state["completed"] = True
+ mgr.save_game(s.player, s.world.get_state())
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _restore(s: GameSession) -> list[Event]:
+ seen: list[Event] = []
+ for et in (EventType.GAME_STARTED, EventType.ROOM_ENTERED):
+ s.bus.subscribe(et, seen.append)
+ s.engine.cmd_handler.flow.handle_game_over_input("r")
+ s.ui.drain()
+ return seen
+
+
+def test_restore_announces_the_run_to_the_ui(restored: GameSession) -> None:
+ seen = _restore(restored)
+ assert [e.type for e in seen] == [EventType.GAME_STARTED, EventType.ROOM_ENTERED]
+ assert restored.state == GameState.PLAYING
+
+
+def test_entering_a_room_with_an_enemy_starts_a_fight_after_restore(
+ restored: GameSession,
+) -> None:
+ _restore(restored)
+ restored.world.spawn_tutorial_enemy("root")
+ restored.submit("cd root")
+ session = restored.engine.cmd_handler.current_combat_session
+ assert session is not None and session.enemy_id == TUTORIAL_ENEMY
+
+
+def test_a_failed_restore_falls_back_to_the_main_menu(
+ restored: GameSession, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ def broken(run_id: str) -> dict:
+ raise OSError("disk gone")
+
+ monkeypatch.setattr(save_mod.save_manager, "load_run", broken)
+ # The engine's own restore (GameFlow's "r" catches load errors itself first).
+ restored.engine._restart_from_save()
+ out = "\n".join(restored.ui.drain())
+ assert "Failed to load save" in out
+ assert restored.state == GameState.MENU
+
+
+def test_the_tui_can_show_the_restore_failure() -> None:
+ """The fallback reports through ui.display_message (a UIProtocol method)."""
+ from src.ui.textual_ui import TextualGameUI
+
+ assert callable(getattr(TextualGameUI, "display_message", None))
diff --git a/tests/test_room_backdrops.py b/tests/test_room_backdrops.py
new file mode 100644
index 0000000..62fcc7e
--- /dev/null
+++ b/tests/test_room_backdrops.py
@@ -0,0 +1,28 @@
+"""Per-room backdrop art: every file names a real room and is backdrop-sized."""
+from pathlib import Path
+
+import pytest
+from PIL import Image
+
+from src.scene.sprite_store import SpriteStore
+
+ROOM_ART = sorted(Path("assets/sprites/backdrops/rooms").glob("*.png"))
+ROOM_IDS = {p.stem for p in Path("data/rooms").glob("*.yml")}
+
+
+def test_there_is_room_art() -> None:
+ assert ROOM_ART
+
+
+@pytest.mark.parametrize("path", ROOM_ART, ids=lambda p: p.stem)
+def test_room_art_names_a_room_and_is_backdrop_sized(path: Path) -> None:
+ # A typo'd file name would silently fall back to the zone backdrop.
+ assert path.stem in ROOM_IDS
+ assert Image.open(path).size == (100, 36)
+
+
+def test_room_art_wins_over_the_zone_backdrop() -> None:
+ store = SpriteStore()
+ room = store.get_backdrop("usr_lib_arcane", "ancient", 100, 36)
+ zone = store.get_backdrop("no_such_room", "ancient", 100, 36)
+ assert room.tobytes() != zone.tobytes()
diff --git a/tests/test_room_cleared_indicator.py b/tests/test_room_cleared_indicator.py
index 8f64af5..3c52fee 100644
--- a/tests/test_room_cleared_indicator.py
+++ b/tests/test_room_cleared_indicator.py
@@ -1,17 +1,42 @@
"""GameWorld.is_room_cleared: derived from existing state, nothing new
-persisted. A room only reads as cleared once every enemy it ever had is
+persisted. A room with no enemies reads as cleared once visited; otherwise it
+only reads as cleared once every enemy it ever had is
genuinely defeated — not just currently absent (a fled enemy is absent from
enemy_locations too, but respawns on the next ROOM_ENTERED, so it must not
read as cleared)."""
from engine.api import GameSession
-def test_room_with_no_enemies_never_shown_as_cleared():
+def test_room_with_no_enemies_is_cleared_once_visited():
+ """Nothing to fight, so visiting is what clears it: /proc read as never
+ done even after the player had been there."""
s = GameSession()
try:
s.new_game("t", "guardian")
- # home_grove has no enemies declared at all (tutorial safe room).
- assert s.world.is_room_cleared("home_grove") is False
+ # Every shipped room has a flag now; strip one to keep the visit rule.
+ s.world.rooms["proc_secrets"] = s.world.rooms["proc_secrets"].model_copy(
+ update={"flag": None}
+ )
+ assert s.world.is_room_cleared("proc_secrets") is False
+ s.world.set_room_visited("proc_secrets")
+ assert s.world.is_room_cleared("proc_secrets") is True
+ finally:
+ s.close()
+
+
+def test_proc_is_marked_in_the_tree_once_its_flag_is_taken():
+ s = GameSession()
+ try:
+ s.new_game("t", "guardian")
+ s.player.tutorial_state["completed"] = True
+ s.submit("cd /proc")
+ s.world.mark_flag_captured("proc_secrets")
+ s.submit("cd /")
+ proc_line = next(
+ line for line in "\n".join(str(x) for x in s.submit("tree")).splitlines()
+ if "proc/" in line
+ )
+ assert "✓" in proc_line
finally:
s.close()
@@ -22,6 +47,7 @@ def test_room_with_enemies_present_is_not_cleared():
s.new_game("t", "guardian")
enemy_id = next(iter(s.world.enemy_locations))
room_id = s.world.enemy_locations[enemy_id]
+ s.world.mark_flag_captured(room_id) # isolate the enemy rule from the flag
assert s.world.is_room_cleared(room_id) is False
finally:
s.close()
@@ -33,6 +59,7 @@ def test_room_with_all_enemies_defeated_is_cleared():
s.new_game("t", "guardian")
enemy_id = next(iter(s.world.enemy_locations))
room_id = s.world.enemy_locations[enemy_id]
+ s.world.mark_flag_captured(room_id) # isolate the enemy rule from the flag
assert s.world.is_room_cleared(room_id) is False
s.world.remove_enemy_from_room(enemy_id)
assert s.world.is_room_cleared(room_id) is True
@@ -46,6 +73,7 @@ def test_fled_enemy_room_is_not_cleared_until_respawn_and_redefeat():
s.new_game("t", "guardian")
enemy_id = next(iter(s.world.enemy_locations))
room_id = s.world.enemy_locations[enemy_id]
+ s.world.mark_flag_captured(room_id) # isolate the enemy rule from the flag
s.world.mark_enemy_as_fled(enemy_id, room_id)
# Fled — enemy is gone from enemy_locations, but it will respawn.
assert enemy_id not in s.world.enemy_locations
@@ -69,6 +97,7 @@ def test_ls_marks_cleared_child_directory():
s = GameSession()
try:
s.new_game("t", "guardian")
+ s.world.mark_flag_captured("var_dungeon")
for enemy_id in list(s.world.enemy_locations):
if s.world.enemy_locations[enemy_id] == "var_dungeon":
s.world.remove_enemy_from_room(enemy_id)
@@ -91,6 +120,7 @@ def test_tree_marks_cleared_room():
try:
s.new_game("t", "guardian")
s.world.set_room_visited("var_dungeon")
+ s.world.mark_flag_captured("var_dungeon")
for enemy_id in list(s.world.enemy_locations):
if s.world.enemy_locations[enemy_id] == "var_dungeon":
s.world.remove_enemy_from_room(enemy_id)
diff --git a/tests/test_room_flags_capture.py b/tests/test_room_flags_capture.py
new file mode 100644
index 0000000..a6f8734
--- /dev/null
+++ b/tests/test_room_flags_capture.py
@@ -0,0 +1,83 @@
+"""Reading a room's flag file captures its flag once: XP, a checkpoint, counts."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ session.player.current_room = "root"
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_reading_the_flag_file_captures_it(s: GameSession, tmp_path) -> None:
+ cycles = s.player.harvesting_cycles
+ level = s.player.level
+ out = _out(s, "cat motd")
+ assert s.world.flag_captured("root")
+ assert "FLAG{cat_reads_files}" in out and "Flags 1/" in out
+ assert (s.player.level, s.player.harvesting_cycles) != (level, cycles)
+ assert list(tmp_path.iterdir()), "capture should write a checkpoint save"
+
+
+def test_second_read_is_a_no_op(s: GameSession, tmp_path) -> None:
+ s.submit("cat motd")
+ saves = set(tmp_path.iterdir())
+ cycles = s.player.harvesting_cycles
+ out = _out(s, "cat motd")
+ assert "Flag captured" not in out
+ assert s.player.harvesting_cycles == cycles
+ assert set(tmp_path.iterdir()) == saves
+
+
+def test_other_files_do_not_capture(s: GameSession) -> None:
+ s.player.current_room = "home_grove"
+ s.submit("cat readme_txt_corrupt")
+ assert not s.world.flag_captured("home_grove")
+
+
+def test_story_file_flag_restores_memory_and_saves_once(s: GameSession, tmp_path) -> None:
+ s.player.current_room = "home_grove"
+ out = _out(s, "cat .bash_profile")
+ assert "Memory restored" in out and "Flag captured" in out
+ assert s.world.flag_captured("home_grove")
+ assert len(list(tmp_path.iterdir())) == 1
+
+
+def test_counts_split_main_and_secret(s: GameSession) -> None:
+ flags = s.engine.cmd_handler.flags
+ assert flags.counts() == (0, 13, 0, 5)
+ s.submit("cat motd")
+ assert flags.counts() == (1, 13, 0, 5)
+ assert flags.summary() == "Flags 1/13 · Secrets 0/5"
+
+
+def test_story_flag_is_in_the_checkpoint_on_disk_and_not_recaptured(s: GameSession) -> None:
+ """The memory-restore autosave used to run before the flag was marked, so
+ the save on disk lacked it; reloading let the flag fire (and pay) again."""
+ s.player.current_room = "home_grove"
+ s.submit("cat .bash_profile")
+ cycles = s.player.harvesting_cycles
+ level = s.player.level
+
+ saved = save_manager.load_run(save_manager.active_run_id)
+ assert saved["world"]["room_states"]["home_grove"].get("flagCaptured") is True
+ assert (saved["player"]["level"], saved["player"]["harvesting_cycles"]) == (level, cycles)
+
+ s.engine._enter_loaded_run(saved)
+ s.player.current_room = "home_grove"
+ assert "Flag captured" not in _out(s, "cat .bash_profile")
diff --git a/tests/test_room_flags_content.py b/tests/test_room_flags_content.py
new file mode 100644
index 0000000..5d720b8
--- /dev/null
+++ b/tests/test_room_flags_content.py
@@ -0,0 +1,37 @@
+"""Room flags are content: each flag names a file placed in its room, carries
+FLAG{...} text, and tells a beginner what to type."""
+from engine.content import find_flag_problems, load_all
+from engine.schema.models import RoomFlag
+
+PHASE1 = {
+ "home_grove": "bash_profile",
+ "root": "motd",
+ "bin_armory": "ancient_manual_man",
+ "var_dungeon": "system_err_log",
+ "usr_share_games": "cow_wisdom_log",
+ "cowsay_secret": "moo_file",
+ "ghost_hidden": "flag",
+}
+
+
+def test_phase1_rooms_declare_their_flag_files() -> None:
+ content = load_all("data")
+ for room_id, file_id in PHASE1.items():
+ flag = content.rooms[room_id].flag
+ assert flag is not None, room_id
+ assert flag.file == file_id
+ assert "FLAG{" in flag.text
+
+
+def test_content_has_no_flag_problems() -> None:
+ assert find_flag_problems(load_all("data")) == []
+
+
+def test_a_flag_file_missing_from_its_room_is_reported() -> None:
+ content = load_all("data")
+ room = content.rooms["root"]
+ content.rooms["root"] = room.model_copy(update={"flag": RoomFlag(
+ file="bash_profile", text="FLAG{x}", nudge="n", command="cat x",
+ )})
+ problems = find_flag_problems(content)
+ assert any("root" in p and "bash_profile" in p for p in problems)
diff --git a/tests/test_room_flags_display.py b/tests/test_room_flags_display.py
new file mode 100644
index 0000000..689d719
--- /dev/null
+++ b/tests/test_room_flags_display.py
@@ -0,0 +1,41 @@
+"""journal and tree show flag progress."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_journal_counts_and_lists_captured_flags(s: GameSession) -> None:
+ s.player.current_room = "root"
+ s.submit("cat motd")
+ out = _out(s, "journal")
+ assert "Flags 1/13 · Secrets 0/5" in out
+ assert "FLAG{cat_reads_files}" in out
+
+
+def test_tree_shows_counts_and_marks_captured_rooms(s: GameSession) -> None:
+ s.player.current_room = "root"
+ s.submit("cat motd")
+ out = _out(s, "tree")
+ assert "Flags 1/13 · Secrets 0/5" in out
+ root_line = next(line for line in out.splitlines() if "Root" in line)
+ assert "⚑" in root_line
+ assert "⚑ flag captured" in out
diff --git a/tests/test_room_flags_grep_content.py b/tests/test_room_flags_grep_content.py
new file mode 100644
index 0000000..7fc8f95
--- /dev/null
+++ b/tests/test_room_flags_grep_content.py
@@ -0,0 +1,33 @@
+"""Phase 2 rooms hide their flag in a log you must grep."""
+from engine.content import find_flag_problems, load_all
+
+GREP_ROOMS = {
+ "mnt_forest": "lost_user_log",
+ "usr_lib_arcane": "catalog_db",
+ "dev_null_void": "kern_log",
+ "srv_warrior_tomb": "access_log",
+ "archive": "backup_tar_log",
+}
+
+
+def test_grep_rooms_declare_log_flags() -> None:
+ content = load_all("data")
+ for room_id, file_id in GREP_ROOMS.items():
+ flag = content.rooms[room_id].flag
+ assert flag is not None and flag.via == "grep", room_id
+ assert flag.file == file_id
+ assert content.items[file_id].log is not None
+ assert find_flag_problems(content) == []
+
+
+def test_first_grep_room_teaches_and_npc_rooms_clue() -> None:
+ content = load_all("data")
+ assert "grep" in content.rooms["mnt_forest"].flag.teach
+ for room_id in ("mnt_forest", "usr_lib_arcane", "dev_null_void"):
+ assert content.rooms[room_id].flag.clue, room_id
+
+
+def test_npc_less_grep_rooms_have_a_note() -> None:
+ content = load_all("data")
+ assert "access_readme" in content.rooms["srv_warrior_tomb"].items
+ assert "backup_readme" in content.rooms["archive"].items
diff --git a/tests/test_room_flags_grep_flow.py b/tests/test_room_flags_grep_flow.py
new file mode 100644
index 0000000..d4c64a6
--- /dev/null
+++ b/tests/test_room_flags_grep_flow.py
@@ -0,0 +1,49 @@
+"""cat shows a grep log's wall but never captures; grep output captures;
+NPCs give the room's clue while its flag is out there."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ session.player.current_room = "mnt_forest"
+ session.world.enemy_locations = {
+ e: r for e, r in session.world.enemy_locations.items() if r != "mnt_forest"
+ }
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_cat_of_a_log_prints_the_whole_wall(s: GameSession) -> None:
+ out = _out(s, "cat lost_user_log")
+ assert out.count("user:") >= 300
+ assert not s.world.flag_captured("mnt_forest")
+ assert "ECHO>" in out
+
+
+def test_on_grep_captures_only_when_the_flag_line_matched(s: GameSession) -> None:
+ flags = s.engine.cmd_handler.flags
+ assert not flags.on_grep("lost_user_log", ["[03:00:01] user: ls"])
+ assert flags.on_grep("lost_user_log", ["x FLAG{grep_finds_needles}"])
+ assert s.world.flag_captured("mnt_forest")
+
+
+def test_npc_gives_the_clue_until_the_flag_is_captured(s: GameSession) -> None:
+ out = _out(s, "talk lost_user.dat")
+ assert "so long now" in out
+ s.world.mark_flag_captured("mnt_forest")
+ assert "so long now" not in _out(s, "talk lost_user.dat")
diff --git a/tests/test_room_flags_grep_playthrough.py b/tests/test_room_flags_grep_playthrough.py
new file mode 100644
index 0000000..73b72c2
--- /dev/null
+++ b/tests/test_room_flags_grep_playthrough.py
@@ -0,0 +1,36 @@
+"""A player reaching /mnt is taught grep, reads the wall, then greps the flag."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "weaver")
+ session.player.tutorial_state["completed"] = True
+ session.world.enemy_locations = {
+ e: r for e, r in session.world.enemy_locations.items() if r != "mnt_forest"
+ }
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_first_grep_room_teaches_then_grep_captures(s: GameSession) -> None:
+ arrival = _out(s, "cd /mnt")
+ assert "grep FLAG lost_user_log" in arrival
+ _out(s, "cat lost_user_log")
+ assert not s.world.flag_captured("mnt_forest")
+ out = _out(s, "grep FLAG lost_user_log")
+ assert s.world.flag_captured("mnt_forest")
+ assert "Flags 1/13" in out
diff --git a/tests/test_room_flags_guidance.py b/tests/test_room_flags_guidance.py
new file mode 100644
index 0000000..f967c91
--- /dev/null
+++ b/tests/test_room_flags_guidance.py
@@ -0,0 +1,95 @@
+"""Beginners are taught once, can ask for a hint, and get nudged when stuck."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.flags import STUCK_AFTER
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _out(s: GameSession, cmd: str) -> str:
+ return "\n".join(str(x) for x in s.submit(cmd))
+
+
+def test_first_visit_to_a_teaching_room_gets_the_lesson_once(s: GameSession) -> None:
+ first = _out(s, "cd /")
+ assert "ECHO>" in first and "cat motd" in first
+ s.submit("cd /home")
+ again = _out(s, "cd /")
+ assert "cat motd" not in again
+
+
+def test_hint_escalates_from_nudge_to_exact_command(s: GameSession) -> None:
+ s.player.current_room = "bin_armory"
+ s.world.enemy_locations = {
+ e: r for e, r in s.world.enemy_locations.items() if r != "bin_armory"
+ }
+ first = _out(s, "hint")
+ second = _out(s, "hint")
+ assert "cat .ancient_manual_man" not in first
+ assert "cat .ancient_manual_man" in second
+
+
+def test_hint_where_there_is_nothing_to_find(s: GameSession) -> None:
+ # Every shipped room has a flag now; strip one to reach the no-flag reply.
+ s.world.rooms["proc_secrets"] = s.world.rooms["proc_secrets"].model_copy(
+ update={"flag": None}
+ )
+ s.player.current_room = "proc_secrets"
+ assert "No flag" in _out(s, "hint")
+ s.player.current_room = "root"
+ s.submit("cat motd")
+ assert "already captured" in _out(s, "hint")
+
+
+def test_idle_commands_bring_a_nudge_once(s: GameSession) -> None:
+ s.player.current_room = "root"
+ s.world.mark_flag_taught("root") # teleported in: the arrival lesson is not under test
+ outs = [_out(s, "pwd") for _ in range(STUCK_AFTER)]
+ assert "ECHO>" in outs[-1] and "hint" in outs[-1]
+ assert all("ECHO>" not in o for o in outs[:-1])
+ assert "ECHO>" not in _out(s, "pwd")
+
+
+def test_no_guidance_while_the_tutorial_runs(s: GameSession) -> None:
+ s.player.tutorial_state["completed"] = False
+ out = _out(s, "cd /")
+ s.player.current_room = "root"
+ idle = [_out(s, "pwd") for _ in range(STUCK_AFTER)]
+ assert "cat motd" not in out
+ assert all("hint" not in o for o in idle)
+
+
+def test_combat_commands_do_not_count_as_idle(s: GameSession) -> None:
+ flags = s.engine.cmd_handler.flags
+ s.player.current_room = "root"
+ for _ in range(STUCK_AFTER):
+ flags.after_command(in_combat=True)
+ assert flags._idle == 0
+
+
+def test_exact_hint_bolds_each_command_on_its_own(s: GameSession) -> None:
+ """'ls -a, then cat .x' in one bold run reads as one line to type; a
+ beginner typing it literally runs only ls -a."""
+ s.player.current_room = "bin_armory"
+ s.world.enemy_locations = {
+ e: r for e, r in s.world.enemy_locations.items() if r != "bin_armory"
+ }
+ s.submit("hint")
+ second = _out(s, "hint")
+ assert "[bold]ls -a[/bold]" in second
+ assert "[bold]cat .ancient_manual_man[/bold]" in second
+ assert "[bold]ls -a, then" not in second
diff --git a/tests/test_room_flags_playthrough.py b/tests/test_room_flags_playthrough.py
new file mode 100644
index 0000000..d3c156f
--- /dev/null
+++ b/tests/test_room_flags_playthrough.py
@@ -0,0 +1,43 @@
+"""A new player's path: the tutorial captures /home's flag, / teaches motd."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "guardian")
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def test_tutorial_then_first_plain_flag(s: GameSession) -> None:
+ h = s.engine.cmd_handler
+ s.submit("ls")
+ s.submit("take segfault_shield")
+ s.submit("equip segfault_shield")
+ s.submit("take cracked_firewall")
+ s.submit("equip cracked_firewall")
+ while h.current_combat_session:
+ s.submit(next(iter(h.current_combat_session.available_attacks)))
+ s.submit("ls -a")
+ s.submit("cat .bash_profile")
+ assert s.world.flag_captured("home_grove")
+ # /bin's random enemies would start a fight that swallows the next command.
+ s.world.enemy_locations = {
+ e: r for e, r in s.world.enemy_locations.items() if r != "bin_armory"
+ }
+ for cmd in ("ps", "pwd", "cd ..", "ls", "cd bin"):
+ s.submit(cmd)
+ assert s.player.tutorial_state.get("completed")
+ s.submit("cd /")
+ out = "\n".join(str(x) for x in s.submit("cat motd"))
+ assert s.world.flag_captured("root")
+ assert "Flags 2/13" in out
diff --git a/tests/test_room_flags_world.py b/tests/test_room_flags_world.py
new file mode 100644
index 0000000..e47d296
--- /dev/null
+++ b/tests/test_room_flags_world.py
@@ -0,0 +1,51 @@
+"""Flag capture is world state: saved with room_states, and part of ✓."""
+from engine.api import GameSession
+from src.game_world import GameWorld
+
+
+def _world() -> GameWorld:
+ s = GameSession()
+ s.new_game("t", "guardian")
+ w = s.world
+ s.close()
+ return w
+
+
+def test_capture_is_recorded_per_room() -> None:
+ w = _world()
+ assert not w.flag_captured("root")
+ w.mark_flag_captured("root")
+ assert w.flag_captured("root")
+ assert w.room_states["root"]["flagCaptured"] is True
+
+
+def test_a_flag_room_is_not_done_until_its_flag_is_captured() -> None:
+ w = _world()
+ w.set_room_visited("home_grove") # no enemies, visited
+ assert w.is_room_cleared("home_grove") is False
+ w.mark_flag_captured("home_grove")
+ assert w.is_room_cleared("home_grove") is True
+
+
+def test_rooms_without_a_flag_keep_the_old_rule() -> None:
+ """Every shipped room has a flag now; strip one to keep the rule honest."""
+ w = _world()
+ w.rooms["proc_secrets"] = w.rooms["proc_secrets"].model_copy(update={"flag": None})
+ w.set_room_visited("proc_secrets")
+ assert w.is_room_cleared("proc_secrets") is True
+
+
+def test_captured_flag_survives_a_round_trip() -> None:
+ w = _world()
+ w.set_room_visited("root")
+ w.mark_flag_captured("root")
+ fresh = _world()
+ fresh.set_state(w.get_state())
+ assert fresh.flag_captured("root") and fresh.is_room_cleared("root")
+
+
+def test_hidden_listed_is_saved_camel_case() -> None:
+ w = _world()
+ w.mark_hidden_files_listed("home_grove")
+ assert w.room_states["home_grove"]["hiddenListed"] is True
+ assert "hidden_listed" not in w.room_states["home_grove"]
diff --git a/tests/test_room_view_ids.py b/tests/test_room_view_ids.py
index 651aaa9..6956e75 100644
--- a/tests/test_room_view_ids.py
+++ b/tests/test_room_view_ids.py
@@ -9,15 +9,15 @@ class _FakeRoom:
zone = "safe"
-class _FakeEnemy:
+class _Named:
def __init__(self, name):
self.name = name
class _FakeWorld:
rooms = {"home_grove": _FakeRoom()}
- enemies = {"lost_inode.tmp": _FakeEnemy("Lost Inode")}
- npcs = {"oracle.db": {"name": "The Oracle"}}
+ enemies = {"lost_inode.tmp": _Named("Lost Inode")}
+ npcs = {"oracle.db": _Named("The Oracle")}
def get_enemies_in_room(self, room_id):
return ["lost_inode.tmp"]
diff --git a/tests/test_run_stats.py b/tests/test_run_stats.py
index 31c442f..29a591d 100644
--- a/tests/test_run_stats.py
+++ b/tests/test_run_stats.py
@@ -13,7 +13,6 @@ def test_take_increments_items_found():
def test_kill_increments_kills():
- from src.events import EventType, event_bus
s = GameSession()
s.new_game("t", "guardian")
world = s.world
@@ -25,7 +24,7 @@ def test_kill_increments_kills():
assert enemy_id, "expected some room with an enemy"
s.player.current_room = room_id
before = s.player.run_stats["kills"]
- event_bus.emit_event(EventType.ENEMY_DEFEATED, {"enemy_id": enemy_id}, "test")
+ s.engine.cmd_handler.on_kill(enemy_id)
assert s.player.run_stats["kills"] == before + 1
s.close()
diff --git a/tests/test_save_cap.py b/tests/test_save_cap.py
deleted file mode 100644
index 10bcb9d..0000000
--- a/tests/test_save_cap.py
+++ /dev/null
@@ -1,46 +0,0 @@
-"""Autosave cap: the save pool must not grow without bound.
-
-The game autosaves on every room move; without a cap the saves/ directory
-accumulates thousands of files and every save/list operation slows to a crawl
-(observed: 16k files -> 4 minutes per fuzz test).
-"""
-import json
-import os
-
-from src.save import MAX_SAVE_FILES, SaveManager
-
-
-class _P:
- def to_dict(self):
- return {"name": "t"}
-
-
-def _fill(manager, save_dir, n):
- for i in range(n):
- manager.save_game(_P(), {}, save_name=f"save_{i:05d}.json")
-
-
-def test_save_pool_is_capped(tmp_path):
- d = str(tmp_path / "saves")
- m = SaveManager(save_dir=d)
- _fill(m, d, MAX_SAVE_FILES + 25)
- files = [f for f in os.listdir(d) if f.endswith(".json")]
- assert len(files) == MAX_SAVE_FILES
-
-
-def test_cap_keeps_the_newest(tmp_path):
- d = str(tmp_path / "saves")
- m = SaveManager(save_dir=d)
- _fill(m, d, MAX_SAVE_FILES + 5)
- files = sorted(f for f in os.listdir(d) if f.endswith(".json"))
- # oldest 5 pruned, newest MAX kept
- assert files[0] == f"save_{5:05d}.json"
- assert files[-1] == f"save_{MAX_SAVE_FILES + 4:05d}.json"
-
-
-def test_saves_still_valid_json(tmp_path):
- d = str(tmp_path / "saves")
- m = SaveManager(save_dir=d)
- path = m.save_game(_P(), {"x": 1})
- data = json.load(open(path))
- assert data["player"] == {"name": "t"}
diff --git a/tests/test_save_completeness.py b/tests/test_save_completeness.py
new file mode 100644
index 0000000..4d577ee
--- /dev/null
+++ b/tests/test_save_completeness.py
@@ -0,0 +1,150 @@
+"""Everything about a run survives save and load.
+
+The player is serialized by hand, so a new Player field is lost on load unless
+someone also adds it to to_dict/from_dict. That is how armor and status effects
+went missing. This test walks every attribute instead of listing them, so the
+next forgotten field fails here.
+"""
+from __future__ import annotations
+
+import json
+from collections.abc import Iterator
+from pathlib import Path
+
+import pytest
+
+from engine.api import GameSession
+from src import difficulty
+from src.player import Player
+from src.save import SaveManager
+
+# Recomputed from class data by Player.__init__, so they need no saving.
+DERIVED_FROM_CLASS = {"class_description", "starter_abilities"}
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("Saver", "guardian")
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _load_up(s: GameSession) -> Player:
+ """A player with every kind of state a run can accumulate."""
+ p = s.player
+ armor = next(
+ iid for iid, it in s.world.items.items()
+ if it.type == "armor" and p.can_use_item(it)
+ )
+ p.add_to_inventory(armor, s.world.get_item(armor))
+ assert p.equip_armor(armor)
+ p.add_status_effect("stable_cache_hot", {"type": "heal_over_time", "heal_per_turn": 2,
+ "name": "Stable Cache"}, 3)
+ p.move_to("root")
+ p.set_story_flag("identity_retrieved")
+ p.met_npcs.add("echo")
+ p.run_stats["kills"] = 3
+ p.harvest_cycles(40)
+ p.health = p.max_health - 7
+ p.tutorial_state["completed"] = True
+ return p
+
+
+def test_every_player_field_survives_a_round_trip(session: GameSession) -> None:
+ player = _load_up(session)
+ restored = Player.from_dict(json.loads(json.dumps(player.to_dict())))
+
+ lost = {
+ name: (value, getattr(restored, name, "
"))
+ for name, value in vars(player).items()
+ if name not in DERIVED_FROM_CLASS and getattr(restored, name, "") != value
+ }
+ assert lost == {}
+
+
+def test_difficulty_is_saved_with_the_run(session: GameSession, tmp_path: Path) -> None:
+ mgr = SaveManager(save_dir=str(tmp_path))
+ difficulty.set_mode("hard")
+ try:
+ mgr.save_game(session.player, session.world.get_state())
+ difficulty.set_mode("medium")
+
+ loaded = mgr.load_run(mgr.active_run_id)
+ assert loaded["difficulty"] == "hard"
+ finally:
+ difficulty.set_mode(difficulty.DEFAULT_MODE)
+
+
+def test_loading_a_hard_run_plays_it_on_hard(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ import src.save as save_mod
+
+ mgr = SaveManager(save_dir=str(tmp_path))
+ monkeypatch.setattr(save_mod, "save_manager", mgr)
+ monkeypatch.setattr("src.game_engine.save_manager", mgr)
+
+ first = GameSession()
+ try:
+ first.new_game("Hardy", "guardian")
+ difficulty.set_mode("hard")
+ mgr.save_game(first.player, first.world.get_state())
+ finally:
+ first.close()
+
+ difficulty.set_mode("medium") # a fresh process, or another run in this one
+ second = GameSession()
+ try:
+ run_id = mgr.list_runs()[0].run_id
+ second.engine._load_game()
+ second.submit(f"pick {run_id}")
+ assert second.player.name == "Hardy"
+ assert difficulty.current_mode() == "hard"
+ finally:
+ second.close()
+ difficulty.set_mode(difficulty.DEFAULT_MODE)
+
+
+def test_a_save_missing_difficulty_armor_and_effects_still_loads(
+ session: GameSession, tmp_path: Path,
+) -> None:
+ """Fields a save may lack load as defaults. (v3/v4 saves themselves are now
+ refused — see test_saves — so this checks the current format.)"""
+ mgr = SaveManager(save_dir=str(tmp_path))
+ path = Path(mgr.save_game(session.player, session.world.get_state()))
+ raw = json.loads(path.read_text())
+ raw.pop("difficulty", None)
+ for key in ("equipped_armor", "status_effects"):
+ raw["player"].pop(key, None)
+ path.write_text(json.dumps(raw))
+
+ loaded = mgr.load_run(mgr.active_run_id)
+ restored = Player.from_dict(loaded["player"])
+ assert restored.equipped_armor is None
+ assert restored.status_effects == {}
+
+
+def test_starting_the_ui_does_not_change_the_difficulty(monkeypatch: pytest.MonkeyPatch) -> None:
+ """Difficulty belongs to the run (picked at new game, restored from a save).
+ Mounting the UI used to reset it from user settings, so a loaded Hard run
+ reverted to the settings value."""
+ import asyncio
+
+ from src.ui.textual_ui import TextualGameUI
+
+ monkeypatch.setattr("src.ui.textual_ui.SKIP_INTRO", True)
+ difficulty.set_mode("hard")
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ await pilot.pause()
+ assert difficulty.current_mode() == "hard"
+
+ try:
+ asyncio.run(scenario())
+ finally:
+ difficulty.set_mode(difficulty.DEFAULT_MODE)
diff --git a/tests/test_save_migration.py b/tests/test_save_migration.py
new file mode 100644
index 0000000..6584ba0
--- /dev/null
+++ b/tests/test_save_migration.py
@@ -0,0 +1,69 @@
+"""Saves from the old shared pool (save_.json) become run slots:
+newest file per hero, every original moved to saves/legacy/, nothing deleted."""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from src.save import MAX_RUNS, SAVE_VERSION, SaveManager
+
+
+def _v5(name: str, cls: str, diff: str, saved_at: float, room: str = "home_grove") -> dict:
+ return {
+ "version": 5,
+ "player": {"name": name, "player_class": cls, "current_room": room,
+ "level": 1, "health": 10, "max_health": 10},
+ "world": {}, "difficulty": diff,
+ "savedAt": saved_at, "saveDate": "2026-09-01 00:00:00",
+ }
+
+
+def _put(folder: Path, name: str, data: dict | str) -> None:
+ (folder / name).write_text(data if isinstance(data, str) else json.dumps(data))
+
+
+def test_old_saves_become_one_slot_per_hero(tmp_path: Path) -> None:
+ _put(tmp_path, "save_1.json", _v5("Ada", "guardian", "easy", 100, room="root"))
+ _put(tmp_path, "save_2.json", _v5("Ada", "guardian", "easy", 300, room="var_dungeon"))
+ _put(tmp_path, "save_3.json", _v5("Ada", "guardian", "hard", 200))
+ _put(tmp_path, "save_4.json", _v5("Zed", "weaver", "easy", 250))
+
+ runs = SaveManager(save_dir=str(tmp_path)).list_runs()
+
+ assert [(r.player_name, r.difficulty) for r in runs] == [
+ ("Ada", "easy"), ("Zed", "easy"), ("Ada", "hard"),
+ ]
+ assert runs[0].room_id == "var_dungeon" # the newest file of its group
+ raw = json.loads((tmp_path / f"run_{runs[0].run_id}.json").read_text())
+ assert raw["version"] == SAVE_VERSION
+ assert raw["createdAt"] == 100 # the group's oldest save
+ assert raw["cleared"] is False
+ loaded = SaveManager(save_dir=str(tmp_path)).load_run(runs[0].run_id)
+ assert loaded is not None and loaded["player"]["name"] == "Ada"
+
+
+def test_every_old_file_moves_to_legacy_and_none_is_deleted(tmp_path: Path) -> None:
+ for i in range(3):
+ _put(tmp_path, f"save_{i}.json", _v5("Ada", "guardian", "easy", i))
+ _put(tmp_path, "save_old.json", {"version": 4, "player": {"name": "Old"}})
+ _put(tmp_path, "save_bad.json", "{nope")
+
+ mgr = SaveManager(save_dir=str(tmp_path))
+ assert len(mgr.list_runs()) == 1
+ assert list(tmp_path.glob("save_*.json")) == []
+ assert sorted(p.name for p in (tmp_path / "legacy").iterdir()) == [
+ "save_0.json", "save_1.json", "save_2.json", "save_bad.json", "save_old.json",
+ ]
+ assert mgr.legacy_count() == 5
+
+
+def test_migration_fills_only_free_slots(tmp_path: Path) -> None:
+ for i in range(MAX_RUNS + 2):
+ _put(tmp_path, f"save_{i}.json", _v5(f"Hero{i}", "guardian", "easy", i))
+ runs = SaveManager(save_dir=str(tmp_path)).list_runs()
+ assert len(runs) == MAX_RUNS
+ assert runs[-1].player_name == "Hero2" # the two oldest heroes stay in legacy/ only
+
+
+def test_no_legacy_folder_means_zero(tmp_path: Path) -> None:
+ assert SaveManager(save_dir=str(tmp_path)).legacy_count() == 0
diff --git a/tests/test_save_no_recursion.py b/tests/test_save_no_recursion.py
deleted file mode 100644
index 0dd9e2a..0000000
--- a/tests/test_save_no_recursion.py
+++ /dev/null
@@ -1,33 +0,0 @@
-"""Regression: save_game must not emit GAME_SAVED.
-
-GAME_SAVED is the *request* event (_on_save_requested handles it by calling save_game).
-When save_game also emitted GAME_SAVED as a 'completed' signal, one save request
-re-fired the handler → infinite recursion → hundreds of save files.
-
-This asserts save_game emits zero GAME_SAVED itself, which is leak-immune (it doesn't
-depend on how many _on_save_requested handlers are subscribed to the singleton bus).
-"""
-from __future__ import annotations
-
-from engine.api import GameSession
-from src.events import EventType, event_bus
-
-
-def test_save_game_does_not_emit_game_saved():
- s = GameSession()
- try:
- s.new_game("T", "guardian")
- from src.save import save_manager
-
- hits = {"n": 0}
- counter = lambda ev: hits.__setitem__("n", hits["n"] + 1) # noqa: E731
- event_bus.subscribe(EventType.GAME_SAVED, counter)
- try:
- save_manager.save_game(s.player, s.world.get_state())
- assert hits["n"] == 0, (
- "save_game must not emit GAME_SAVED (causes recursive save storm)"
- )
- finally:
- event_bus.unsubscribe(EventType.GAME_SAVED, counter)
- finally:
- s.close()
diff --git a/tests/test_save_picker_flow.py b/tests/test_save_picker_flow.py
new file mode 100644
index 0000000..90f29c2
--- /dev/null
+++ b/tests/test_save_picker_flow.py
@@ -0,0 +1,178 @@
+"""LOAD GAME and NEW GAME through the save picker, driven headless with the
+same commands the picker screen sends: pick , delete , back."""
+from __future__ import annotations
+
+import json
+from collections.abc import Callable, Iterator
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+import src.save as save_mod
+from engine.api import GameSession
+from engine.events import EventType
+from src.game_states import GameState
+from src.save import SaveManager
+
+
+@pytest.fixture(autouse=True)
+def _ordered_saves(clock: None) -> None:
+ """Runs made one after another list newest first."""
+
+
+@pytest.fixture
+def fresh() -> Iterator[GameSession]:
+ s = GameSession()
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _make_run(mgr: SaveManager, name: str, cls: str) -> str:
+ s = GameSession()
+ try:
+ s.new_game(name, cls)
+ run_id = mgr.begin_run()
+ mgr.save_game(s.player, s.world.get_state())
+ return run_id
+ finally:
+ s.close()
+ mgr.end_run()
+
+
+def _corrupt(mgr: SaveManager, run_id: str, change: Callable[[dict], None]) -> None:
+ path = Path(mgr.save_dir) / f"run_{run_id}.json"
+ data = json.loads(path.read_text())
+ change(data)
+ path.write_text(json.dumps(data))
+
+
+def _picker_events(s: GameSession) -> list[dict[str, Any]]:
+ seen: list[dict[str, Any]] = []
+ s.bus.subscribe(EventType.SAVE_PICKER_REQUESTED, lambda e: seen.append(e.data))
+ return seen
+
+
+def _text(lines: list[Any]) -> str:
+ return "\n".join(str(line) for line in lines)
+
+
+def test_load_with_no_saves_says_so_and_goes_back(mgr, fresh) -> None:
+ seen = _picker_events(fresh)
+ out = _text(fresh.submit("2"))
+ assert fresh.state == GameState.WAITING_FOR_SAVE
+ assert "No saves available" in out
+ assert seen[-1] == {"mode": "continue", "runs": [], "legacyCount": 0}
+
+ out = _text(fresh.submit("back"))
+ assert fresh.state == GameState.MENU
+ assert "[title screen]" in out
+
+
+def test_load_picks_the_chosen_run_not_the_newest(mgr, fresh) -> None:
+ ada = _make_run(mgr, "Ada", "guardian")
+ zed = _make_run(mgr, "Zed", "weaver")
+ seen = _picker_events(fresh)
+
+ fresh.submit("2")
+ rows = seen[-1]["runs"]
+ assert [row["runId"] for row in rows] == [zed, ada]
+ assert rows[1]["playerName"] == "Ada" and rows[1]["roomPath"].startswith("/")
+ assert set(rows[1]) == {
+ "runId", "playerName", "playerClass", "difficulty", "level",
+ "roomPath", "health", "maxHealth", "savedAt", "cleared",
+ }
+
+ fresh.submit(f"pick {ada}")
+ assert fresh.state == GameState.PLAYING
+ assert fresh.player.name == "Ada"
+ assert mgr.active_run_id == ada
+
+
+def test_delete_from_the_picker(mgr, fresh) -> None:
+ ada = _make_run(mgr, "Ada", "guardian")
+ seen = _picker_events(fresh)
+ fresh.submit("2")
+ fresh.submit(f"delete {ada}")
+ assert fresh.state == GameState.WAITING_FOR_SAVE
+ assert seen[-1]["runs"] == []
+ assert mgr.list_runs() == []
+
+
+def test_an_unknown_pick_keeps_the_picker_open(mgr, fresh) -> None:
+ _make_run(mgr, "Ada", "guardian")
+ fresh.submit("2")
+ out = _text(fresh.submit("pick nope"))
+ assert fresh.state == GameState.WAITING_FOR_SAVE
+ assert "Invalid choice" in out
+
+
+def test_new_game_with_free_slots_goes_straight_to_difficulty(mgr, fresh) -> None:
+ fresh.submit("1")
+ assert fresh.state == GameState.WAITING_FOR_DIFFICULTY
+ assert mgr.active_run_id is not None
+ assert mgr.list_runs() == [] # nothing is written until the first save
+
+
+def test_new_game_when_full_replaces_the_chosen_run_on_first_save(
+ mgr, fresh, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(save_mod, "MAX_RUNS", 2)
+ ada = _make_run(mgr, "Ada", "guardian")
+ zed = _make_run(mgr, "Zed", "weaver")
+ seen = _picker_events(fresh)
+
+ fresh.submit("1")
+ assert fresh.state == GameState.WAITING_FOR_SAVE
+ assert seen[-1]["mode"] == "replace"
+
+ fresh.submit(f"pick {ada}")
+ assert fresh.state == GameState.WAITING_FOR_DIFFICULTY
+ assert {r.run_id for r in mgr.list_runs()} == {ada, zed} # backing out loses nothing
+
+ for command in ("2", "1", "Neo", "skip"):
+ fresh.submit(command)
+ assert fresh.state == GameState.PLAYING
+ fresh.submit("save")
+ assert {r.run_id for r in mgr.list_runs()} == {zed, mgr.active_run_id}
+
+
+def test_the_replace_picker_has_no_delete(mgr, fresh, monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr(save_mod, "MAX_RUNS", 1)
+ ada = _make_run(mgr, "Ada", "guardian")
+ fresh.submit("1")
+ assert fresh.state == GameState.WAITING_FOR_SAVE
+ out = _text(fresh.submit(f"delete {ada}"))
+ assert "Invalid choice" in out
+ assert fresh.state == GameState.WAITING_FOR_SAVE
+ assert [r.run_id for r in mgr.list_runs()] == [ada]
+
+
+def test_a_run_that_fails_to_load_reopens_the_picker(mgr, fresh) -> None:
+ ada = _make_run(mgr, "Ada", "guardian")
+ _corrupt(mgr, ada, lambda data: data.update(world="garbage"))
+ seen = _picker_events(fresh)
+
+ fresh.submit("2")
+ before = len(seen)
+ out = _text(fresh.submit(f"pick {ada}"))
+ assert fresh.state == GameState.WAITING_FOR_SAVE
+ assert "could not be loaded" in out
+ assert len(seen) == before + 1 # a fresh picker the player can answer
+ assert mgr.active_run_id is None
+
+
+def test_markup_in_a_saved_class_or_difficulty_does_not_break_the_list(mgr, fresh) -> None:
+ ada = _make_run(mgr, "Ada", "guardian")
+
+ def add_markup(data: dict) -> None:
+ data["player"]["player_class"] = "[bold"
+ data["difficulty"] = "[/x]"
+
+ _corrupt(mgr, ada, add_markup)
+
+ out = _text(fresh.submit("2"))
+ assert fresh.state == GameState.WAITING_FOR_SAVE
+ assert "Error" not in out
diff --git a/tests/test_save_picker_screen.py b/tests/test_save_picker_screen.py
new file mode 100644
index 0000000..a88d659
--- /dev/null
+++ b/tests/test_save_picker_screen.py
@@ -0,0 +1,183 @@
+"""The save picker screen: list + preview, arrows only, d asks before deleting.
+It decides nothing; it sends the same commands a typed player would."""
+from __future__ import annotations
+
+import asyncio
+import time
+
+import pytest
+from rich.text import Text
+
+from engine.events import EventType
+from src.game_engine import ImprovedGameEngine
+from src.game_states import GameState
+from src.save import SaveManager
+from src.ui.screens.save_picker import SavePickerScreen, saved_ago
+from src.ui.textual_ui import TextualGameUI
+
+RUNS = [
+ {"runId": "aaaa1111", "playerName": "Ada", "playerClass": "guardian",
+ "difficulty": "easy", "level": 4, "roomPath": "/var/log", "health": 58,
+ "maxHealth": 80, "savedAt": 0.0, "cleared": False},
+ {"runId": "bbbb2222", "playerName": "Mo", "playerClass": "shaman",
+ "difficulty": "medium", "level": 9, "roomPath": "/boot", "health": 90,
+ "maxHealth": 90, "savedAt": 0.0, "cleared": True},
+]
+
+
+def _screen(mode: str = "continue", runs=RUNS, legacy: int = 0):
+ sent: list[str] = []
+ return SavePickerScreen(mode, list(runs), legacy, sent.append), sent
+
+
+def test_rows_show_name_class_difficulty_room_level_and_cleared() -> None:
+ screen, _ = _screen()
+ text = screen.list_text()
+ for part in ("Ada", "Guardian", "easy", "/var/log", "L4"):
+ assert part in text
+ assert "✓" in text.splitlines()[1] and "✓" not in text.splitlines()[0]
+
+
+def test_arrows_move_and_wrap() -> None:
+ screen, _ = _screen()
+ screen.action_move(1)
+ assert screen._index == 1
+ screen.action_move(1)
+ assert screen._index == 0
+
+
+def test_enter_picks_the_highlighted_run() -> None:
+ screen, sent = _screen()
+ screen.action_move(1)
+ screen.action_confirm()
+ assert sent == ["pick bbbb2222"]
+
+
+def test_the_enter_that_opened_the_picker_does_not_answer_it() -> None:
+ screen, sent = _screen()
+ screen._mounted_at = time.monotonic()
+ screen.action_confirm()
+ assert sent == []
+
+
+def test_delete_asks_first() -> None:
+ screen, sent = _screen()
+ screen.action_delete()
+ assert "Delete Ada's run?" in screen.hint_text()
+ screen.action_answer_delete(False)
+ assert sent == [] and not screen._confirming_delete
+ screen.action_delete()
+ screen.action_answer_delete(True)
+ assert sent == ["delete aaaa1111"]
+
+
+def test_escape_leaves_a_delete_prompt_before_the_screen() -> None:
+ screen, sent = _screen()
+ screen.action_delete()
+ screen.action_back()
+ assert sent == []
+ screen.action_back()
+ assert sent == ["back"]
+
+
+def test_the_replace_picker_has_no_delete() -> None:
+ screen, _ = _screen("replace")
+ screen.action_delete()
+ assert not screen._confirming_delete
+ assert "d delete" not in screen.hint_text() and "replace" in screen.hint_text()
+
+
+def test_empty_state() -> None:
+ screen, sent = _screen(runs=[], legacy=3)
+ text = screen.list_text()
+ assert "No saves available" in text and "3 old saves" in text
+ assert screen.hint_text() == "esc back"
+ screen.action_confirm()
+ assert sent == ["back"]
+
+
+def test_a_second_answer_is_ignored() -> None:
+ screen, sent = _screen()
+ screen.action_confirm()
+ screen.action_back()
+ assert sent == ["pick aaaa1111"]
+
+
+def test_saved_ago() -> None:
+ assert saved_ago(1000, now=1030) == "just now"
+ assert saved_ago(1000, now=1000 + 5 * 60) == "5m ago"
+ assert saved_ago(1000, now=1000 + 2 * 3600) == "2h ago"
+ assert saved_ago(1000, now=1000 + 3 * 86400) == "3d ago"
+
+
+# --- the real app -------------------------------------------------------------
+
+@pytest.fixture(autouse=True)
+def _no_intro(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr("src.ui.textual_ui.SKIP_INTRO", True)
+
+
+def test_load_game_opens_the_picker_and_enter_continues_the_run(
+ mgr: SaveManager, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ monkeypatch.setattr(SavePickerScreen, "_CONFIRM_GRACE_SECONDS", 0) # grace has its own test
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ monkeypatch.setattr("config.dev_config.DISABLE_ANIMATIONS", True)
+ engine = ImprovedGameEngine(ui=app)
+ assert engine.create_player("Ada", "guardian")
+ engine.start_game()
+ mgr.save_game(engine.player, engine.world.get_state())
+ run_id = mgr.active_run_id
+ engine._return_to_menu() # back at the title (no intro replay), run ended
+ await pilot.pause()
+
+ engine.bus.emit_event(EventType.COMMAND_ENTERED, {"command": "2"}, "test")
+ await pilot.pause()
+ await pilot.pause()
+ assert isinstance(app.screen, SavePickerScreen)
+
+ await pilot.press("enter")
+ await pilot.pause()
+ assert engine.state_manager.current_state == GameState.PLAYING
+ assert engine.player.name == "Ada" and mgr.active_run_id == run_id
+ assert not isinstance(app.screen, SavePickerScreen)
+ engine._cleanup()
+
+ asyncio.run(scenario())
+
+
+def test_empty_picker_escape_returns_to_the_title_without_quitting(
+ mgr: SaveManager, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ engine = ImprovedGameEngine(ui=app)
+ quits: list[object] = []
+ engine.bus.subscribe(EventType.GAME_QUIT, quits.append)
+ await pilot.pause()
+
+ engine.bus.emit_event(EventType.COMMAND_ENTERED, {"command": "2"}, "test")
+ await pilot.pause()
+ await pilot.pause()
+ assert isinstance(app.screen, SavePickerScreen)
+
+ await pilot.press("escape")
+ await pilot.pause()
+ assert engine.state_manager.current_state == GameState.MENU
+ assert not isinstance(app.screen, SavePickerScreen)
+ assert app._title_menu.state == "menu_ready"
+ assert quits == []
+ engine._cleanup()
+
+ asyncio.run(scenario())
+
+
+def test_markup_in_saved_fields_renders_as_text() -> None:
+ run = dict(RUNS[0], playerClass="[bold", difficulty="[/x]", roomPath="[red]")
+ screen, _ = _screen(runs=[run])
+ Text.from_markup(screen.list_text())
diff --git a/tests/test_save_slots.py b/tests/test_save_slots.py
new file mode 100644
index 0000000..89943b1
--- /dev/null
+++ b/tests/test_save_slots.py
@@ -0,0 +1,175 @@
+"""One save slot per run: saves overwrite the run's own file, the picker lists
+runs, a replaced run survives until the new run's first save."""
+from __future__ import annotations
+
+import json
+from collections.abc import Iterator
+from pathlib import Path
+
+import pytest
+
+import src.save as save_mod
+from engine.api import GameSession
+from src.save import MAX_RUNS, SaveManager
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("Slotty", "guardian")
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _save(mgr: SaveManager, s: GameSession) -> str:
+ return mgr.save_game(s.player, s.world.get_state())
+
+
+def test_saves_in_one_run_overwrite_one_file(mgr, session, clock) -> None:
+ mgr.begin_run()
+ first = json.loads(Path(_save(mgr, session)).read_text())
+ session.submit("cd root")
+ _save(mgr, session)
+
+ files = list(Path(mgr.save_dir).glob("run_*.json"))
+ assert len(files) == 1
+ data = json.loads(files[0].read_text())
+ assert data["runId"] == mgr.active_run_id
+ assert data["createdAt"] == first["createdAt"]
+ assert data["savedAt"] > first["savedAt"]
+ assert data["player"]["current_room"] == session.player.current_room
+
+
+def test_two_new_games_are_two_slots_even_with_the_same_hero(mgr, session) -> None:
+ mgr.begin_run()
+ _save(mgr, session)
+ mgr.begin_run()
+ _save(mgr, session)
+ assert len(mgr.list_runs()) == 2
+
+
+def test_saving_with_no_run_in_progress_starts_one(mgr, session) -> None:
+ assert mgr.active_run_id is None
+ _save(mgr, session)
+ assert mgr.active_run_id is not None
+ assert [r.run_id for r in mgr.list_runs()] == [mgr.active_run_id]
+
+
+def test_list_runs_is_newest_first_with_what_the_picker_shows(mgr, session, clock) -> None:
+ mgr.begin_run()
+ _save(mgr, session)
+ older = mgr.active_run_id
+ mgr.begin_run()
+ _save(mgr, session)
+ newer = mgr.active_run_id
+
+ runs = mgr.list_runs()
+ assert [r.run_id for r in runs] == [newer, older]
+ run = runs[0]
+ assert (run.player_name, run.player_class) == ("Slotty", "guardian")
+ assert (run.level, run.room_id) == (session.player.level, session.player.current_room)
+ assert (run.health, run.max_health) == (session.player.health, session.player.max_health)
+ assert run.cleared is False
+
+
+def test_replace_happens_on_the_new_runs_first_save(mgr, session) -> None:
+ mgr.begin_run()
+ _save(mgr, session)
+ old = mgr.active_run_id
+
+ mgr.begin_run(replace=old)
+ assert [r.run_id for r in mgr.list_runs()] == [old] # backing out now loses nothing
+
+ _save(mgr, session)
+ assert [r.run_id for r in mgr.list_runs()] == [mgr.active_run_id]
+ assert mgr.pending_replace is None
+
+
+def test_resuming_a_run_cancels_a_pending_replace(mgr, session) -> None:
+ mgr.begin_run()
+ _save(mgr, session)
+ old = mgr.active_run_id
+ mgr.begin_run(replace=old)
+ mgr.resume_run(old)
+ _save(mgr, session)
+ assert [r.run_id for r in mgr.list_runs()] == [old]
+
+
+def test_end_run_clears_the_run_in_progress(mgr) -> None:
+ mgr.begin_run(replace="abc")
+ mgr.end_run()
+ assert (mgr.active_run_id, mgr.pending_replace) == (None, None)
+
+
+def test_runs_full_at_nine(mgr, session) -> None:
+ assert MAX_RUNS == 9
+ for _ in range(MAX_RUNS - 1):
+ mgr.begin_run()
+ _save(mgr, session)
+ assert not mgr.runs_full()
+ mgr.begin_run()
+ _save(mgr, session)
+ assert mgr.runs_full()
+
+
+def test_mark_cleared_flips_only_the_flag(mgr, session) -> None:
+ path = Path(_save(mgr, session))
+ before = json.loads(path.read_text())
+ mgr.mark_cleared()
+ after = json.loads(path.read_text())
+ assert after.pop("cleared") is True
+ before.pop("cleared")
+ assert after == before
+
+ _save(mgr, session) # a later save keeps the mark
+ assert json.loads(path.read_text())["cleared"] is True
+
+
+def test_delete_run(mgr, session) -> None:
+ _save(mgr, session)
+ run_id = mgr.active_run_id
+ assert mgr.delete_run(run_id) is True
+ assert mgr.list_runs() == []
+ assert mgr.delete_run(run_id) is False
+
+
+def test_unreadable_run_file_is_skipped(mgr, session) -> None:
+ _save(mgr, session)
+ (Path(mgr.save_dir) / "run_deadbeef.json").write_text("{not json")
+ assert len(mgr.list_runs()) == 1
+ assert mgr.load_run("deadbeef") is None
+
+
+def test_atomic_write_leaves_no_temp_file(mgr, session) -> None:
+ _save(mgr, session)
+ assert list(Path(mgr.save_dir).glob("*.tmp")) == []
+
+
+
+@pytest.mark.parametrize("bad", [
+ {"version": 6, "player": {"level": None}},
+ {"version": 6, "player": [1, 2]},
+ {"version": 6, "player": {"health": "lots"}},
+])
+def test_a_malformed_run_file_is_skipped_not_fatal(mgr, session, bad) -> None:
+ _save(mgr, session)
+ (Path(mgr.save_dir) / "run_badbad00.json").write_text(json.dumps(bad))
+ assert [r.run_id for r in mgr.list_runs()] == [mgr.active_run_id]
+ assert not mgr.runs_full()
+
+
+def test_a_failed_migration_does_not_block_the_run_list(
+ mgr, session, monkeypatch,
+) -> None:
+ _save(mgr, session)
+ old_save = {"version": 5, "player": {"name": "Old"}}
+ (Path(mgr.save_dir) / "save_1.json").write_text(json.dumps(old_save))
+
+ def broken_replace(src, dst):
+ raise OSError("read-only disk")
+
+ monkeypatch.setattr(save_mod.os, "replace", broken_replace)
+ monkeypatch.setattr(mgr, "_write", lambda path, data: None)
+ assert len(mgr.list_runs()) == 1
diff --git a/tests/test_saves.py b/tests/test_saves.py
index a516e57..d1c4566 100644
--- a/tests/test_saves.py
+++ b/tests/test_saves.py
@@ -8,6 +8,7 @@
import json
from collections.abc import Iterator
+from pathlib import Path
import pytest
@@ -30,8 +31,8 @@ def test_save_round_trip(session: GameSession, tmp_path) -> None:
mgr = SaveManager(save_dir=str(tmp_path))
session.submit("cd root") # make some state
- mgr.save_game(session.player, session.world.get_state(), "s1.json")
- loaded = mgr.load_game("s1.json")
+ mgr.save_game(session.player, session.world.get_state())
+ loaded = mgr.load_run(mgr.active_run_id)
assert loaded["version"] == SAVE_VERSION
restored = Player.from_dict(loaded["player"])
@@ -43,10 +44,12 @@ def test_save_round_trip(session: GameSession, tmp_path) -> None:
def test_envelope_is_versioned_and_camelcase(session: GameSession, tmp_path) -> None:
mgr = SaveManager(save_dir=str(tmp_path))
- mgr.save_game(session.player, session.world.get_state(), "s.json")
- raw = json.loads((tmp_path / "s.json").read_text())
- assert raw["version"] == SAVE_VERSION
- assert "savedAt" in raw and "saveDate" in raw
+ path = mgr.save_game(session.player, session.world.get_state())
+ raw = json.loads(Path(path).read_text())
+ assert raw["version"] == SAVE_VERSION == 6
+ for key in ("runId", "createdAt", "cleared", "savedAt", "saveDate", "difficulty"):
+ assert key in raw
+ assert raw["cleared"] is False
assert "timestamp" not in raw and "save_date" not in raw
@@ -67,20 +70,19 @@ def _legacy_save(version: int | None) -> dict:
def test_pre_tree_saves_are_refused(version, tmp_path) -> None:
"""v2 and older persist room_states from before /boot was locked. Loading one
would reopen the boss room, so it is refused rather than half-migrated."""
- (tmp_path / "old.json").write_text(json.dumps(_legacy_save(version)))
+ (tmp_path / "run_old00000.json").write_text(json.dumps(_legacy_save(version)))
mgr = SaveManager(save_dir=str(tmp_path))
with pytest.raises(IncompatibleSaveError):
- mgr.load_game("old.json")
+ mgr.load_run("old00000")
def test_incompatible_saves_are_not_offered(tmp_path) -> None:
"""The load menu must not list a save that would then fail to load."""
- (tmp_path / "old.json").write_text(json.dumps(_legacy_save(2)))
+ (tmp_path / "run_old00000.json").write_text(json.dumps(_legacy_save(2)))
mgr = SaveManager(save_dir=str(tmp_path))
- assert mgr.get_save_files() == []
- assert mgr.load_most_recent_save() is None
+ assert mgr.list_runs() == []
def test_from_dict_tolerates_partial_save() -> None:
@@ -92,10 +94,12 @@ def test_from_dict_tolerates_partial_save() -> None:
assert player.max_health > 0
-def test_get_save_files_reads_v2(session: GameSession, tmp_path) -> None:
+@pytest.mark.parametrize("version", [3, 4])
+def test_v4_saves_are_refused_and_hidden(version, tmp_path) -> None:
+ """Saves from before room flags have no flag state; /boot's gate would
+ strand them, so they are refused and not offered."""
+ (tmp_path / "run_old00000.json").write_text(json.dumps(_legacy_save(version)))
mgr = SaveManager(save_dir=str(tmp_path))
- mgr.save_game(session.player, session.world.get_state(), "s.json")
- files = mgr.get_save_files()
- assert len(files) == 1
- assert files[0]["player_name"] == "Saver"
- assert files[0]["date"] != "Unknown date"
+ with pytest.raises(IncompatibleSaveError, match="flag"):
+ mgr.load_run("old00000")
+ assert mgr.list_runs() == []
diff --git a/tests/test_session_isolation.py b/tests/test_session_isolation.py
new file mode 100644
index 0000000..c59e2da
--- /dev/null
+++ b/tests/test_session_isolation.py
@@ -0,0 +1,25 @@
+"""Two engines in one process do not hear each other.
+
+Each engine owns its EventBus and StateManager, so a run that is never closed
+cannot react to another run's events — the property the old process-wide bus
+lacked, and the prerequisite for hosting more than one game per process.
+"""
+from __future__ import annotations
+
+from engine.api import GameSession
+
+
+def test_an_unclosed_session_cannot_double_handle_the_next_ones_quit() -> None:
+ leaked = GameSession()
+ leaked.new_game("L", "guardian")
+
+ s = GameSession()
+ try:
+ s.new_game("S", "guardian")
+ s.submit("quit")
+ s.submit("n")
+ assert s.ui.quit_requested is True
+ assert leaked.ui.quit_requested is False
+ finally:
+ s.close()
+ leaked.close()
diff --git a/tests/test_settings_screen.py b/tests/test_settings_screen.py
new file mode 100644
index 0000000..44c8d72
--- /dev/null
+++ b/tests/test_settings_screen.py
@@ -0,0 +1,79 @@
+"""The settings screen: one row per setting, arrows only, Esc saves.
+
+Driven through the screen's actions without an App, like the quit chooser's
+tests; the live-app path (title menu → Settings → Esc) is in
+test_title_and_finale.py.
+"""
+from __future__ import annotations
+
+import json
+from collections.abc import Iterator
+from pathlib import Path
+
+import pytest
+
+import config.dev_config as dev_cfg
+from config.settings_manager import SettingsManager
+from src.ui.screens.settings_screen import ROWS, SettingsScreen
+
+
+@pytest.fixture
+def manager(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[SettingsManager]:
+ # The setters flip module-level flags; restore them after each test.
+ for name in ("DISABLE_ANIMATIONS", "SKIP_INTRO", "SHOW_HINTS"):
+ monkeypatch.setattr(dev_cfg, name, getattr(dev_cfg, name))
+ m = SettingsManager(settings_path=str(tmp_path / "user_settings.json"))
+ m.load()
+ yield m
+
+
+def _screen(manager: SettingsManager, on_close=None) -> SettingsScreen:
+ screen = SettingsScreen(manager, on_close=on_close)
+ screen.dismiss = lambda *a, **k: None # no running app in a unit test
+ return screen
+
+
+def test_rows_are_the_four_settings() -> None:
+ assert [row.label for row in ROWS] == [
+ "Color palette", "Text speed", "Reduce motion", "In-game hints",
+ ]
+
+
+@pytest.mark.parametrize(("delta", "theme"), [(1, "neon"), (-1, "yonce")])
+def test_left_right_cycle_the_palette_with_wraparound(
+ manager: SettingsManager, delta: int, theme: str,
+) -> None:
+ _screen(manager).action_change(delta)
+ assert manager.settings["theme"] == theme
+
+
+def test_down_down_right_turns_reduce_motion_on(manager: SettingsManager) -> None:
+ screen = _screen(manager)
+ screen.action_move(1)
+ screen.action_move(1)
+ screen.action_change(1)
+ assert manager.settings["reduce_motion"] is True
+ assert dev_cfg.DISABLE_ANIMATIONS is True
+
+
+def test_up_from_the_top_wraps_to_hints(manager: SettingsManager) -> None:
+ screen = _screen(manager)
+ screen.action_move(-1)
+ assert ROWS[screen._index].label == "In-game hints"
+ screen.action_change(1)
+ assert manager.settings["hints"] is False
+
+
+def test_escape_saves_and_calls_on_close(manager: SettingsManager, tmp_path: Path) -> None:
+ closed: list[bool] = []
+ screen = _screen(manager, on_close=lambda: closed.append(True))
+ screen.action_change(1)
+ screen.action_close()
+ saved = json.loads((tmp_path / "user_settings.json").read_text())
+ assert saved["theme"] == "neon"
+ assert closed == [True]
+
+
+def test_enter_is_not_bound() -> None:
+ """An Enter leaking from the title menu must not change a setting."""
+ assert "enter" not in {binding.key for binding in SettingsScreen.BINDINGS}
diff --git a/tests/test_shell_commands.py b/tests/test_shell_commands.py
index 8822ce1..49f991e 100644
--- a/tests/test_shell_commands.py
+++ b/tests/test_shell_commands.py
@@ -66,6 +66,7 @@ def test_tree_shows_hierarchy_and_your_position(session: GameSession) -> None:
def test_tree_marks_sealed_directories(session: GameSession) -> None:
+ session.world.reveal_doors("lib_key") # seen, but the key is not held
out = _text(session.submit("tree"))
assert "🔒" in out, "a locked directory should be marked as sealed"
assert "lib_key" in out, "and should name the key that opens it"
diff --git a/tests/test_sim_flags.py b/tests/test_sim_flags.py
new file mode 100644
index 0000000..ab57fba
--- /dev/null
+++ b/tests/test_sim_flags.py
@@ -0,0 +1,40 @@
+"""The sim plays the game the flag hunt made: rogue fights on the main path,
+flag XP along the way."""
+from sim.gauntlet import main_path_enemy_ids
+from sim.simulator import flag_xp_schedule
+from src import rng
+
+ROGUES = ("runaway_fork.bomb", "zombie_tmpwatch.sh", "rogue_crond.d")
+
+
+def test_gauntlet_includes_each_rogue_once() -> None:
+ rng.seed(0)
+ ids = main_path_enemy_ids()
+ for rogue in ROGUES:
+ assert ids.count(rogue) == 1, rogue
+
+
+def test_flag_xp_schedule_sums_to_all_flags() -> None:
+ schedule = flag_xp_schedule(20, 12, 15)
+ assert len(schedule) == 20
+ assert sum(schedule) == 12 * 15
+ assert all(x >= 0 for x in schedule)
+
+
+def test_flag_xp_schedule_handles_short_runs() -> None:
+ schedule = flag_xp_schedule(5, 12, 15)
+ assert len(schedule) == 5 and sum(schedule) == 180
+
+
+def test_flag_xp_schedule_with_no_fights() -> None:
+ assert flag_xp_schedule(0, 12, 15) == []
+
+
+def test_rogue_fights_earn_no_gear_progress() -> None:
+ """Killing a rogue drops no gear in the game, so it must not advance the
+ sim's gear tier (they sort first and would hand out free upgrades)."""
+ from sim.simulator import gear_stages
+
+ stages = gear_stages(["r1", "r2", "a", "b"], {"r1", "r2"})
+ assert stages == [(0, 2), (0, 2), (0, 2), (1, 2)]
+ assert gear_stages(["a", "b"], set()) == [(0, 2), (1, 2)]
diff --git a/tests/test_skip_and_markup.py b/tests/test_skip_and_markup.py
new file mode 100644
index 0000000..826723e
--- /dev/null
+++ b/tests/test_skip_and_markup.py
@@ -0,0 +1,89 @@
+"""Starting the game clears the pre-game prompt; player-facing text never loses a
+word to markup; command words are not names."""
+from __future__ import annotations
+
+import re
+from collections.abc import Iterator
+
+import pytest
+import yaml
+from rich.style import Style
+from textual.color import Color
+
+from engine.api import GameSession
+from src.game_states import GameState
+
+_TAG = re.compile(r"(? list[str]:
+ """Bracketed words the renderer would treat as a style tag and drop, e.g.
+ the "[item]" in "use [item]"."""
+ lost = []
+ for closing, body in _TAG.findall(markup):
+ if closing or not body.strip():
+ continue
+ try:
+ Style.parse(body)
+ continue
+ except Exception:
+ pass
+ try:
+ Color.parse(body)
+ continue
+ except Exception:
+ lost.append(body)
+ return lost
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("Tess", "guardian")
+ s.player.tutorial_state["completed"] = True
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def test_starting_the_game_replaces_the_pre_game_prompt(session: GameSession) -> None:
+ session.ui.update_output("Want a quick tutorial? (yes / skip)")
+ session.engine.start_game()
+ out = "\n".join(session.ui.drain())
+ assert "Want a quick tutorial" not in out
+ assert session.world.get_room(session.player.current_room).name in out
+
+
+def test_tutorial_text_keeps_every_word() -> None:
+ hints = yaml.safe_load(open("data/tutorial_hints.yaml"))
+ lost = {hint_id: _swallowed(text) for hint_id, text in hints.items()}
+ assert not {k: v for k, v in lost.items() if v}
+
+
+def test_command_output_keeps_every_word(session: GameSession) -> None:
+ commands = ["help", "ps", "use", "take", "cat", "drop", "examine", "talk", "equip", "find"]
+ lost = {
+ command: words
+ for command in commands
+ if (words := [
+ w for line in session.submit(command) if isinstance(line, str)
+ for w in _swallowed(line)
+ ])
+ }
+ assert not lost, f"words the renderer would drop, by command: {lost}"
+
+
+def test_command_words_are_not_names() -> None:
+ s = GameSession()
+ try:
+ for choice in ("1", "2", "1"): # New Game, medium, guardian
+ s.submit(choice)
+ assert s.state == GameState.TUTORIAL_NAME_INPUT
+ for name in ["quit", "ls", "Help", " yes "]:
+ s.engine._handle_tutorial_name_input(name)
+ assert s.engine.player is None, f"{name!r} was accepted as a name"
+ s.engine._handle_tutorial_name_input("Tess")
+ assert s.engine.player is not None and s.engine.player.name == "Tess"
+ finally:
+ s.close()
diff --git a/tests/test_solvable.py b/tests/test_solvable.py
index ec4ec3e..51f5eed 100644
--- a/tests/test_solvable.py
+++ b/tests/test_solvable.py
@@ -1,9 +1,8 @@
"""Every generated run must be completable.
-Loot and keys are placed randomly per run, and permissions are now the whole
-difficulty ramp — so a bad placement no longer means "awkward", it means the
-player can never reach the boss. Keys are placed in dependency order to prevent
-that; this pins the property across many seeds and all three classes.
+Loot is placed randomly per run, but keys are not: room flags hand them out
+in a fixed chain (and bosses drop the rest). This walks that chain and pins
+that /boot's flag gate can be met, across a few seeds and all three classes.
"""
from __future__ import annotations
@@ -13,14 +12,14 @@
from src import rng, room_paths
from src.data_loader import load_enemy_data, load_npc_data, load_room_data
from src.game_world import GameWorld
+from src.item_placer import ItemPlacer
CLASSES = ["guardian", "weaver", "shaman"]
-# Four seeds per class. The placer is dependency-ordered rather than
-# rejection-sampled, so an unsolvable layout would be a logic error that any
-# seed exposes, not a rare unlucky roll — a wide grid bought repetition, not
-# coverage. Widen this temporarily if you ever change _place_keys.
-SEEDS = list(range(4))
-GOAL = "core" # the boss room; reaching it is the win condition
+# Keys come from the flag chain, not from placement, so the seed no longer
+# decides whether a run is winnable; a few seeds guard against loot placement
+# ever touching keys again.
+FLAG_SEEDS = list(range(4))
+GOAL = "core" # the boss room, gated by flags_required
_ITEMS = {str(k): v for k, v in load_items("data").items()}
@@ -31,26 +30,45 @@ def _fresh_world(player_class: str, seed: int) -> GameWorld:
load_room_data(), _ITEMS, load_enemy_data(), load_npc_data()
)
room_paths.refresh_from_rooms(world.rooms)
- world.place_items(player_class)
- world.place_starter_items(player_class)
+ placer = ItemPlacer(world)
+ placer.place_items(player_class)
+ placer.place_starter_items(player_class)
return world
+def _behind_flag_gate(world: GameWorld, room_id: str) -> bool:
+ """Is room_id at or under a room that needs flags to enter? Computed here,
+ not by the placer, so a placer that forgets the gate cannot hide it."""
+ target = room_paths.room_path(room_id)
+ for path in room_paths.ancestors(target) + [target]:
+ rid = room_paths.room_at(path)
+ room = world.get_room(rid) if rid else None
+ if room is not None and getattr(room, "flags_required", 0):
+ return True
+ return False
+
+
+def _open_rooms(world: GameWorld, held: set[str]) -> set[str]:
+ """Rooms walkable with `held` keys before the flag gate opens."""
+ return {
+ r for r in ItemPlacer(world).rooms_reachable_with(held)
+ if not _behind_flag_gate(world, r)
+ }
+
+
def _keys_obtainable(world: GameWorld) -> set[str]:
- """Keys the player can actually collect, by repeatedly sweeping everywhere
- currently reachable and taking whatever is there — including boss rewards."""
+ """Keys the player can earn before /boot: flag grants in every room
+ reachable so far (the chain), plus the Shadow's badge once /proc/self is."""
held: set[str] = set()
- boss_rewards = {"core": "system_badge", "mirror_sector": "sudo_privileges_badge"}
-
for _ in range(len(world.rooms) + 2): # bounded: reachability only grows
- reachable = set(world._rooms_reachable_with(held))
- found = {
- item_id for item_id, room in world.item_locations.items()
- if room in reachable and world._is_key(item_id)
- }
- found |= {
- reward for room, reward in boss_rewards.items() if room in reachable
- }
+ reachable = _open_rooms(world, held)
+ found = set()
+ for rid in reachable:
+ flag = world.get_room(rid).flag
+ if flag is not None and flag.grants:
+ found.add(str(flag.grants))
+ if "mirror_sector" in reachable:
+ found.add("sudo_privileges_badge")
if found <= held:
break
held |= found
@@ -58,34 +76,31 @@ def _keys_obtainable(world: GameWorld) -> set[str]:
@pytest.mark.parametrize("player_class", CLASSES)
-@pytest.mark.parametrize("seed", SEEDS)
+@pytest.mark.parametrize("seed", FLAG_SEEDS)
def test_run_is_completable(player_class: str, seed: int) -> None:
+ """/boot opens with flags_required main flags from elsewhere: that many
+ flag rooms must be reachable with the keys the run yields before /boot."""
world = _fresh_world(player_class, seed)
held = _keys_obtainable(world)
-
- assert GOAL in world._rooms_reachable_with(held), (
- f"{player_class} seed {seed}: the boss room is unreachable with every "
- f"key the run can yield ({sorted(held)})"
+ reachable = _open_rooms(world, held)
+ flag_rooms = {
+ rid for rid, room in world.rooms.items()
+ if room.flag is not None and not room.hidden and rid != GOAL
+ }
+ needed = world.get_room(GOAL).flags_required
+ got = len(flag_rooms & reachable)
+ assert got >= needed, (
+ f"{player_class} seed {seed}: only {got} of the {needed} flags /boot "
+ f"needs are reachable before it (keys: {sorted(held)})"
)
-@pytest.mark.parametrize("player_class", CLASSES)
-def test_no_key_is_locked_behind_itself(player_class: str) -> None:
- """The failure the dependency-ordered placer exists to prevent."""
- for seed in SEEDS:
- world = _fresh_world(player_class, seed)
- for key_id, room_id in world.item_locations.items():
- if not world._is_key(key_id):
- continue
- unlocked_by_this_key = set(world.get_item(key_id).get("unlocks") or [])
- reachable_without_it = set(world._rooms_reachable_with(
- _keys_obtainable(world) - {key_id}
- ))
- if unlocked_by_this_key and room_id not in reachable_without_it:
- pytest.fail(
- f"{player_class} seed {seed}: {key_id} sits in {room_id}, "
- "which needs that same key to reach"
- )
+def test_placer_scatters_no_keys() -> None:
+ """Keys come from flags and bosses only; none lie on a floor."""
+ for player_class in CLASSES:
+ world = _fresh_world(player_class, seed=0)
+ placer = ItemPlacer(world)
+ assert not [k for k in world.item_locations if placer.is_key(k)]
def test_starter_weapon_is_always_in_the_starting_room() -> None:
diff --git a/tests/test_sprite_store.py b/tests/test_sprite_store.py
index c871842..ddda46b 100644
--- a/tests/test_sprite_store.py
+++ b/tests/test_sprite_store.py
@@ -87,3 +87,26 @@ def test_backdrop_generated_when_no_art(tmp_path):
# deterministic per zone
bd2 = SpriteStore(assets_root=tmp_path).get_backdrop("mystery", "quantum", 40, 20)
assert list(bd.getdata()) == list(bd2.getdata())
+
+
+def test_padding_is_cropped_and_a_small_figure_fills_the_box(tmp_path):
+ """NPC art like helper_script.bin draws a 14x12 figure on a 24x16 canvas,
+ so it rendered at half the size of the 24px enemies and classes."""
+ store = _make_store(tmp_path)
+ canvas = Image.new("RGBA", (24, 16), (0, 0, 0, 0))
+ canvas.paste(Image.new("RGBA", (12, 12), (255, 0, 0, 255)), (6, 2))
+ canvas.save(tmp_path / "npcs" / "tiny.bin.png")
+
+ sprite = store.get_sprite("npcs", "tiny.bin", 24, 24)
+ assert sprite.size == (24, 24)
+ assert sprite.getbbox() == (0, 0, 24, 24) # no empty border left
+
+
+def test_every_shipped_sprite_fills_the_24px_box():
+ store = SpriteStore()
+ for kind in ("npcs", "enemies", "classes"):
+ for png in sorted((Path("assets/sprites") / kind).glob("*.png")):
+ sprite = store.get_sprite(kind, png.stem, 24, 24)
+ left, top, right, bottom = sprite.getbbox()
+ drawn = (right - left, bottom - top)
+ assert max(drawn) == 24, (kind, png.stem, drawn)
diff --git a/tests/test_starter_armor.py b/tests/test_starter_armor.py
new file mode 100644
index 0000000..513f268
--- /dev/null
+++ b/tests/test_starter_armor.py
@@ -0,0 +1,84 @@
+"""Each class starts with a junk armor in /home, and the tutorial teaches it:
+the scripted fight opens once the starter weapon AND armor are equipped."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.data_loader import load_class_data
+
+STARTERS = {"guardian": ("segfault_shield", "cracked_firewall"),
+ "weaver": ("null_pointer", "threadbare_buffer"),
+ "shaman": ("daemon_whisper", "frayed_mantra_cloth")}
+
+
+@pytest.fixture
+def guardian() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("Tess", "guardian")
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _hints(s: GameSession) -> list[str]:
+ return [h["hint_id"] for h in s.ui.hints]
+
+
+@pytest.mark.parametrize("klass", sorted(STARTERS))
+def test_each_class_finds_its_own_junk_armor_at_home(klass: str) -> None:
+ weapon, armor = STARTERS[klass]
+ assert load_class_data()[klass].starter_armor == armor
+ s = GameSession()
+ try:
+ s.new_game("T", klass)
+ here = s.world.get_items_in_room("home_grove")
+ assert weapon in here and armor in here
+ item = s.world.items[armor]
+ assert item.type == "armor" and item.defense == 2
+ assert item.allowed_classes == [klass]
+ finally:
+ s.close()
+
+
+def test_the_weapon_alone_asks_for_armor_and_starts_no_fight(guardian: GameSession) -> None:
+ guardian.submit("take segfault_shield")
+ guardian.submit("equip segfault_shield")
+ assert guardian.engine.cmd_handler.current_combat_session is None
+ assert _hints(guardian)[-1] == "step_armor"
+
+
+def test_armor_then_weapon_also_opens_the_fight(guardian: GameSession) -> None:
+ guardian.submit("take cracked_firewall")
+ guardian.submit("equip cracked_firewall")
+ assert guardian.engine.cmd_handler.current_combat_session is None
+ assert _hints(guardian)[-1] == "step2" # the weapon is still on the floor
+ guardian.submit("take segfault_shield")
+ guardian.submit("equip segfault_shield")
+ assert guardian.engine.cmd_handler.current_combat_session is not None
+ assert _hints(guardian)[-1] == "step4"
+
+
+def test_equipping_the_armor_shows_its_defense(guardian: GameSession) -> None:
+ guardian.submit("take cracked_firewall")
+ out = "\n".join(guardian.submit("equip cracked_firewall"))
+ assert "reduced by 3%" in out
+
+
+def test_a_player_who_skipped_is_never_ambushed(guardian: GameSession) -> None:
+ guardian.player.tutorial_state["completed"] = True
+ for command in ("take segfault_shield", "equip segfault_shield",
+ "take cracked_firewall", "equip cracked_firewall"):
+ guardian.submit(command)
+ assert guardian.engine.cmd_handler.current_combat_session is None
+
+
+def test_an_old_save_past_the_fight_is_not_sent_back_to_armor(guardian: GameSession) -> None:
+ ts = guardian.player.tutorial_state
+ ts.update({"first_ls": True, "took_weapon": True, "equipped_weapon": True,
+ "combat_action_taken": True})
+ ts.pop("equipped_armor", None)
+ assert guardian.engine.cmd_handler.tutorial.current_step() != "step_armor"
diff --git a/tests/test_state_machine.py b/tests/test_state_machine.py
new file mode 100644
index 0000000..1980f5a
--- /dev/null
+++ b/tests/test_state_machine.py
@@ -0,0 +1,89 @@
+"""The engine owns the game mode. The transition table lists every change the
+game makes and is enforced; the conftest guard also fails any test in which a
+rejected transition was swallowed by a fallback."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+from pathlib import Path
+
+import pytest
+
+import src.save as save_mod
+from engine.api import GameSession
+from engine.events import EventBus, EventType
+from src.game_states import GameState
+from src.save import SaveManager, save_manager
+from src.state_manager import InvalidTransitionError, StateManager
+
+
+@pytest.fixture
+def fresh() -> Iterator[GameSession]:
+ s = GameSession()
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+@pytest.mark.rejects_transition
+def test_a_transition_the_game_never_makes_raises() -> None:
+ sm = StateManager(EventBus())
+ with pytest.raises(InvalidTransitionError):
+ sm.set_state(GameState.IN_COMBAT) # straight from the menu
+
+
+def test_the_engine_ignores_a_mode_the_sender_claims(fresh: GameSession) -> None:
+ fresh.new_game("Tess", "guardian")
+ # "1" is New Game on the menu; in play it is just an unknown word.
+ fresh.bus.emit_event(
+ EventType.COMMAND_ENTERED, {"command": "1", "game_state": GameState.MENU}, "test"
+ )
+ assert fresh.state == GameState.PLAYING
+
+
+def test_new_game_through_the_menu(fresh: GameSession) -> None:
+ for command, state in (
+ ("1", GameState.WAITING_FOR_DIFFICULTY),
+ ("2", GameState.WAITING_FOR_CLASS),
+ ("1", GameState.TUTORIAL_NAME_INPUT),
+ ("Tess", GameState.TUTORIAL_NAME_INPUT), # then the tutorial offer
+ ("skip", GameState.PLAYING),
+ ):
+ fresh.submit(command)
+ assert fresh.state == state, command
+
+
+def test_f5_in_the_middle_of_a_fight(fresh: GameSession) -> None:
+ fresh.new_game("Tess", "guardian")
+ fresh.player.tutorial_state["completed"] = True
+ fresh.world.spawn_tutorial_enemy("root")
+ fresh.submit("cd root")
+ assert fresh.state == GameState.IN_COMBAT
+ fresh.engine.restart_game()
+ assert fresh.state == GameState.MENU
+
+
+def test_load_from_the_menu(
+ fresh: GameSession, tmp_path: Path, monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ mgr = SaveManager(save_dir=str(tmp_path))
+ monkeypatch.setattr(save_mod, "save_manager", mgr)
+ monkeypatch.setattr("src.game_engine.save_manager", mgr)
+ fresh.new_game("Tess", "guardian")
+ mgr.save_game(fresh.player, fresh.world.get_state())
+ run_id = mgr.active_run_id
+ fresh.engine.restart_game()
+
+ fresh.submit("2")
+ assert fresh.state == GameState.WAITING_FOR_SAVE
+ fresh.submit(f"pick {run_id}")
+ assert fresh.state == GameState.PLAYING
+ assert fresh.player.name == "Tess"
+
+
+def test_f5_ends_the_run(fresh: GameSession) -> None:
+ fresh.new_game("Tess", "guardian")
+ fresh.submit("save")
+ assert save_manager.active_run_id is not None
+ fresh.engine.restart_game()
+ assert save_manager.active_run_id is None
diff --git a/tests/test_story_files.py b/tests/test_story_files.py
new file mode 100644
index 0000000..86d30da
--- /dev/null
+++ b/tests/test_story_files.py
@@ -0,0 +1,69 @@
+"""Every lore file is a story file: hidden until `ls -a`, listed in red like
+.bash_profile, and reading it restores a memory (journal entry + autosave).
+
+The manual in /bin used to print its text and do nothing else, so a player who
+found it saw no save and no journal entry."""
+from __future__ import annotations
+
+from collections.abc import Iterator
+from pathlib import Path
+
+import pytest
+from rich.text import Text
+
+import src.save as save_mod
+from engine.api import GameSession
+from src.commands.navigation import LsCommand
+from src.item_effects import STORY_FLAG_TITLES
+from src.save import SaveManager
+
+
+@pytest.fixture
+def session(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_mod, "save_manager", SaveManager(save_dir=str(tmp_path)))
+ s = GameSession()
+ s.new_game("Reader", "guardian")
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _lore(s: GameSession) -> dict[str, object]:
+ """Story files: lore that restores a memory. Plain flag files and notes
+ (tagged "flag" or "note") are lore too but carry no memory."""
+ return {
+ iid: it for iid, it in s.world.items.items()
+ if it.type == "lore" and not {"flag", "note"} & set(it.tags)
+ }
+
+
+def test_every_lore_file_is_a_hidden_titled_memory(session: GameSession) -> None:
+ for iid, item in _lore(session).items():
+ assert item.hidden, f"{iid} should need ls -a"
+ assert item.story_flag, f"{iid} restores no memory"
+ assert item.story_flag in STORY_FLAG_TITLES, f"{iid}'s memory has no journal title"
+
+
+def test_reading_the_manual_restores_a_memory_and_saves(
+ session: GameSession, tmp_path: Path,
+) -> None:
+ session.world.add_item_to_room("ancient_manual_man", session.player.current_room)
+ out = "\n".join(session.submit("cat ancient_manual_man"))
+
+ assert "Memory restored: The Ancient Manual" in out
+ assert session.player.get_story_flag("manual_recovered") is True
+ assert list(tmp_path.glob("*.json")), "no autosave was written"
+ assert "The Ancient Manual" in "\n".join(session.submit("journal"))
+
+
+def test_story_files_list_in_red(session: GameSession) -> None:
+ handler = session.engine.cmd_handler
+ out = Text()
+ LsCommand()._render_items(
+ handler, out, ["readme_txt_corrupt", "segfault_shield"],
+ hints=False, long_format=False, has_content=False,
+ )
+ styles = {out.plain[span.start:span.end]: str(span.style) for span in out.spans}
+ assert "red" in styles[".readme_txt_corrupt"] # hidden, so listed as a dotfile
+ assert "red" not in styles["segfault_shield"]
diff --git a/tests/test_story_flags.py b/tests/test_story_flags.py
new file mode 100644
index 0000000..54b3a18
--- /dev/null
+++ b/tests/test_story_flags.py
@@ -0,0 +1,67 @@
+"""The story tells one goal everywhere a beginner meets it: capture flags,
+some flags hand you a key to a new directory, and 11 flags open /boot."""
+import yaml
+
+from engine.api import GameSession
+from src.data_loader import load_tutorial_hints
+from src.game_states import GameState
+
+
+def _prologue() -> str:
+ s = GameSession()
+ try:
+ # Walk the real path to the name prompt: difficulty, then class.
+ s.engine.state_manager.set_state(GameState.WAITING_FOR_DIFFICULTY)
+ s.engine.state_manager.set_state(GameState.WAITING_FOR_CLASS)
+ s.ui.clear_console()
+ s.engine.selected_class = "guardian"
+ s.engine._show_tutorial_introduction()
+ return "\n".join(s.ui.drain())
+ finally:
+ s.close()
+
+
+def test_prologue_states_the_goal() -> None:
+ text = _prologue()
+ assert "flag" in text and "key" in text
+ assert "11" in text and "/boot" in text
+
+
+def test_skipping_the_tutorial_still_explains_the_goal() -> None:
+ summary = load_tutorial_hints()["skip_summary"]
+ for word in ("flag", "grep", "kill", "key", "11", "/boot", "hint"):
+ assert word in summary, f"skip summary never mentions {word}"
+
+
+def test_first_flag_lesson_links_flags_to_keys_and_boot() -> None:
+ lesson = load_tutorial_hints()["step_cat"]
+ assert "key" in lesson and "/boot" in lesson
+
+
+def _yaml(path: str) -> dict:
+ with open(path) as f:
+ data: dict = yaml.safe_load(f)
+ return data
+
+
+def test_opt_key_is_not_described_as_class_restricted() -> None:
+ assert "class" not in _yaml("data/items/keys.yaml")["opt_key"]["description"]
+
+
+def test_the_knight_guards_boot_with_flags_not_a_key() -> None:
+ room = _yaml("data/rooms/etc_hidden_configs.yml")["detailed_description"]
+ knight = _yaml("data/npcs/firewall_knight.iptables.yml")
+ text = room + knight["detailed_description"] + " ".join(knight["dialogue"]["without_key"])
+ stale_lines = (
+ "without the Key", "without the chmod Key", "carry the chmod Key", "No key, no access",
+ )
+ for stale in stale_lines:
+ assert stale not in text
+
+
+def test_docs_describe_the_keys_chain() -> None:
+ readme = open("README.md").read()
+ reference = open("REFERENCE.md").read()
+ assert "stays sealed until you have permission" not in readme
+ assert "master_key" not in reference
+ assert "placed so that a run is always completable" not in reference
diff --git a/tests/test_strict_mode.py b/tests/test_strict_mode.py
new file mode 100644
index 0000000..126e9b1
--- /dev/null
+++ b/tests/test_strict_mode.py
@@ -0,0 +1,42 @@
+"""The suite runs strict (tests/conftest.py): a failure the game would only log
+fails the test instead. These pin that the switches are really on."""
+from __future__ import annotations
+
+import pytest
+
+from engine.api import GameSession
+from engine.events import Event, EventBus, EventType
+from src.viewmodels.view_builder import ViewBuilder
+
+
+def _explode(event: Event) -> None:
+ raise RuntimeError("listener bug")
+
+
+def test_a_listener_that_raises_fails_the_emit() -> None:
+ bus = EventBus()
+ bus.subscribe(EventType.UI_READY, _explode)
+ with pytest.raises(RuntimeError, match="listener bug"):
+ bus.emit_event(EventType.UI_READY, {}, "test")
+
+
+def test_the_game_itself_logs_and_continues() -> None:
+ bus = EventBus(strict=False)
+ heard: list[Event] = []
+ bus.subscribe(EventType.UI_READY, _explode)
+ bus.subscribe(EventType.UI_READY, heard.append)
+ bus.emit_event(EventType.UI_READY, {}, "test")
+ assert len(heard) == 1
+
+
+def test_a_session_bus_is_strict() -> None:
+ session = GameSession()
+ try:
+ assert session.bus.strict is True
+ finally:
+ session.close()
+
+
+def test_a_failed_view_build_raises() -> None:
+ with pytest.raises(AttributeError):
+ ViewBuilder.build_room_view(object(), "home_grove")
diff --git a/tests/test_tab_completion.py b/tests/test_tab_completion.py
new file mode 100644
index 0000000..c665fe2
--- /dev/null
+++ b/tests/test_tab_completion.py
@@ -0,0 +1,138 @@
+"""Tab finishes a name like a shell, but only when there is ghost text to
+accept and no fight on; otherwise it moves focus as before, which is what
+combat Selection Mode and the dev Tab-then-L log shortcut rely on. F12 opens
+the logs even mid-typing."""
+from __future__ import annotations
+
+import asyncio
+
+from textual.app import App, ComposeResult
+from textual.suggester import SuggestFromList
+from textual.widgets import Button, Input
+
+from src.ui.command_input import CommandInput
+from src.ui.screens.log_viewer import LogViewerScreen
+from src.ui.textual_ui import TextualGameUI
+
+
+class _Harness(App[None]):
+ def __init__(self, in_combat: bool = False) -> None:
+ super().__init__()
+ self.in_combat = in_combat
+
+ def compose(self) -> ComposeResult:
+ field = CommandInput(
+ id="cmd", suggester=SuggestFromList(["take health_packet"], case_sensitive=False)
+ )
+ field.can_complete = lambda: not self.in_combat
+ yield field
+ yield Button("elsewhere", id="other")
+
+
+def _tab_after_typing(typed: str, in_combat: bool = False) -> tuple[str, bool]:
+ """Type into the command box, press Tab; return (value, box still focused)."""
+ app = _Harness(in_combat)
+ result: dict[str, object] = {}
+
+ async def scenario() -> None:
+ async with app.run_test() as pilot:
+ field = app.query_one("#cmd", CommandInput)
+ field.focus()
+ if typed:
+ await pilot.press(*typed)
+ await pilot.pause()
+ await pilot.press("tab")
+ await pilot.pause()
+ result["value"] = field.value
+ result["focused"] = field.has_focus
+
+ asyncio.run(scenario())
+ return str(result["value"]), bool(result["focused"])
+
+
+def test_tab_accepts_the_ghost_suggestion() -> None:
+ assert _tab_after_typing("take he") == ("take health_packet", True)
+
+
+def test_tab_with_nothing_to_complete_leaves_the_box() -> None:
+ value, focused = _tab_after_typing("")
+ assert value == "" and not focused
+
+
+def test_tab_in_combat_toggles_selection_mode_instead() -> None:
+ value, focused = _tab_after_typing("take he", in_combat=True)
+ assert value == "take he" and not focused
+
+
+def test_tab_then_l_on_an_empty_prompt_opens_the_logs() -> None:
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ app.query_one("#input-field", Input).focus()
+ await pilot.press("tab", "l")
+ await pilot.pause()
+ assert any(isinstance(s, LogViewerScreen) for s in app.screen_stack)
+
+ asyncio.run(scenario())
+
+
+def test_f12_opens_the_logs_mid_typing() -> None:
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ field = app.query_one("#input-field", Input)
+ field.focus()
+ await pilot.press("t", "a")
+ await pilot.press("f12")
+ await pilot.pause()
+ assert any(isinstance(s, LogViewerScreen) for s in app.screen_stack)
+ assert field.value == "ta"
+
+ asyncio.run(scenario())
+
+
+def _suggest(typed: str, after: tuple[str, ...] = ()) -> str | None:
+ from engine.api import GameSession
+ from src.ui.command_suggester import CommandSuggester
+
+ s = GameSession()
+ s.new_game("t", "guardian")
+ s.player.tutorial_state["completed"] = True
+ try:
+ for cmd in after:
+ s.submit(cmd)
+ suggester = CommandSuggester(
+ get_player=lambda: s.player, get_world=lambda: s.world
+ )
+ return asyncio.run(suggester.get_suggestion(typed))
+ finally:
+ s.close()
+
+
+def test_completion_does_not_reveal_hidden_files() -> None:
+ """Like `ls`, completion keeps hidden files out of sight..."""
+ assert "bash_profile" not in (_suggest("cat b") or "")
+
+
+def test_a_leading_dot_does_not_reveal_hidden_files_before_ls_a() -> None:
+ """`cat .` + Tab must not be a way round `ls -a`."""
+ assert "bash_profile" not in (_suggest("cat .") or "")
+
+
+def test_after_ls_a_a_leading_dot_completes_hidden_files() -> None:
+ """Once listed, a dotfile completes from its dot, as in a real shell."""
+ assert _suggest("cat .", after=("ls -a",)) == "cat .bash_profile"
+
+
+def test_cat_reads_the_dotted_name() -> None:
+ from engine.api import GameSession
+
+ s = GameSession()
+ s.new_game("t", "guardian")
+ try:
+ out = "\n".join(str(x) for x in s.submit("cat .bash_profile"))
+ finally:
+ s.close()
+ assert "Sysadmin Spirit" in out
diff --git a/tests/test_take_description.py b/tests/test_take_description.py
new file mode 100644
index 0000000..ca6cb29
--- /dev/null
+++ b/tests/test_take_description.py
@@ -0,0 +1,59 @@
+"""Taking an item says what it is, right under "Added … to your inventory"."""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from src.save import save_manager
+
+
+@pytest.fixture
+def s(tmp_path, monkeypatch: pytest.MonkeyPatch) -> Iterator[GameSession]:
+ monkeypatch.setattr(save_manager, "save_dir", str(tmp_path))
+ session = GameSession()
+ session.new_game("t", "guardian")
+ session.player.tutorial_state["completed"] = True
+ session.player.current_room = "mnt_forest"
+ session.world.enemy_locations = {
+ e: r for e, r in session.world.enemy_locations.items() if r != "mnt_forest"
+ }
+ try:
+ yield session
+ finally:
+ session.close()
+
+
+def _take(s: GameSession, item_id: str) -> list[str]:
+ s.world.item_locations[item_id] = "mnt_forest"
+ return [str(x) for x in s.submit(f"take {item_id}")]
+
+
+def _added_at(lines: list[str]) -> int:
+ return next(i for i, line in enumerate(lines) if "to your inventory" in line)
+
+
+def test_description_follows_added_line(s: GameSession) -> None:
+ item = s.world.get_item("health_packet")
+ assert item is not None and item.description
+ lines = _take(s, "health_packet")
+ at = _added_at(lines)
+ assert item.description in lines[at + 1]
+
+
+def test_key_announcement_comes_after_added_and_description(s: GameSession) -> None:
+ key = s.world.get_item("sudo_privileges_badge")
+ assert key is not None and key.description
+ lines = _take(s, "sudo_privileges_badge")
+ at = _added_at(lines)
+ assert key.description in lines[at + 1]
+ announce = next(i for i, line in enumerate(lines) if "a new directory appeared" in line)
+ assert announce > at + 1
+
+
+def test_no_blank_line_when_item_has_no_description(s: GameSession) -> None:
+ item = s.world.get_item("health_packet")
+ assert item is not None
+ item.description = ""
+ lines = _take(s, "health_packet")
+ at = _added_at(lines)
+ assert lines[at + 1].strip() != ""
diff --git a/tests/test_title_and_finale.py b/tests/test_title_and_finale.py
new file mode 100644
index 0000000..2804f16
--- /dev/null
+++ b/tests/test_title_and_finale.py
@@ -0,0 +1,143 @@
+"""Title-menu navigation and the paced victory finale, driven through the real app."""
+from __future__ import annotations
+
+import asyncio
+import threading
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+from engine.events import EventType
+from src.ui.endings import FinaleReveal, build_recap
+from src.ui.screens.settings_screen import SettingsScreen
+from src.ui.textual_ui import TextualGameUI
+
+
+def _run(coro: Any) -> None:
+ asyncio.run(coro)
+
+
+@pytest.fixture(autouse=True)
+def _no_intro_on_mount(monkeypatch: pytest.MonkeyPatch) -> None:
+ """on_mount would start its own typewriter thread and outlive the test."""
+ monkeypatch.setattr("src.ui.textual_ui.SKIP_INTRO", True)
+
+
+def test_menu_arrows_move_the_highlight_and_enter_emits_the_choice() -> None:
+ commands: list[str] = []
+ app = TextualGameUI()
+ app.bus.subscribe(EventType.COMMAND_ENTERED, lambda e: commands.append(e.data["command"]))
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ menu = app._title_menu
+ menu.show(skip_typewriter=True)
+ await pilot.pause()
+ assert menu.state == "menu_ready"
+ assert menu.handle_key("down") and menu.index == 1
+ assert menu.handle_key("up") and menu.handle_key("up") and menu.index == 3
+ assert "▶ EXIT ◀" in str(app.output_content)
+ assert not menu.handle_key("x") # unrelated keys fall through
+ assert menu.handle_key("enter")
+ assert commands == ["3"]
+ assert menu.state == "idle"
+ assert not menu.handle_key("down") # menu is closed
+
+ _run(scenario())
+
+
+def test_any_key_while_typing_skips_the_typewriter() -> None:
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ menu = app._title_menu
+ menu.show(skip_typewriter=False)
+ assert menu.state == "typing"
+ assert menu.handle_key("q")
+ for _ in range(50):
+ if menu.state == "menu_ready" and "NEW GAME" in str(app.output_content):
+ break
+ await pilot.pause(0.05)
+ assert menu.state == "menu_ready"
+ assert "NEW GAME" in str(app.output_content)
+ # The typewriter runs on a thread; exiting before it finishes makes its
+ # last call_from_thread land on a closed loop.
+ for _ in range(100):
+ if not any("run_typewriter" in t.name for t in threading.enumerate()):
+ break
+ await pilot.pause(0.05)
+
+ _run(scenario())
+
+
+class _FakeTimer:
+ def __init__(self, delay: float, callback: Any) -> None:
+ self.delay, self.callback, self.stopped = delay, callback, False
+
+ def stop(self) -> None:
+ self.stopped = True
+
+
+class _FakeOut:
+ def __init__(self) -> None:
+ self.shown: list[str] = []
+ self.timers: list[_FakeTimer] = []
+
+ def update_output(self, content: str) -> None:
+ self.shown = [content]
+
+ def append_output(self, content: Any) -> None:
+ self.shown.append(content)
+
+ def set_timer(self, delay: float, callback: Any) -> _FakeTimer:
+ timer = _FakeTimer(delay, callback)
+ self.timers.append(timer)
+ return timer
+
+
+_DATA = {"sections": ["one", "two", "three"], "stats": {"player_name": "Ada", "level": 3}}
+
+
+def test_skipping_the_finale_dumps_the_rest_and_stops_the_timers() -> None:
+ out = _FakeOut()
+ finale = FinaleReveal(out)
+ finale.start(_DATA, reduce_motion=False)
+ out.timers[0].callback()
+ finale.skip()
+ assert out.shown == ["one", "two", "three", build_recap(_DATA["stats"])]
+ assert all(t.stopped for t in out.timers)
+ assert not finale.revealing
+ finale.skip() # a second key is a no-op
+ assert len(out.shown) == 4
+
+
+def test_settings_opens_from_the_menu_and_esc_returns_to_it(tmp_path: Path) -> None:
+ commands: list[str] = []
+ app = TextualGameUI()
+ app._settings_manager._path = str(tmp_path / "user_settings.json") # never the real file
+ app.bus.subscribe(EventType.COMMAND_ENTERED, lambda e: commands.append(e.data["command"]))
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ menu = app._title_menu
+ menu.show(skip_typewriter=True)
+ await pilot.pause()
+ menu.handle_key("down")
+ menu.handle_key("down")
+ assert "▶ SETTINGS ◀" in str(app.output_content)
+
+ assert menu.handle_key("enter")
+ await pilot.pause()
+ assert isinstance(app.screen, SettingsScreen)
+ assert commands == [] # SETTINGS is UI-only
+
+ await pilot.press("escape")
+ await pilot.pause()
+ assert not isinstance(app.screen, SettingsScreen)
+ assert menu.state == "menu_ready" and menu.index == 2
+ assert "▶ SETTINGS ◀" in str(app.output_content)
+ assert commands == [] # the Esc that closed Settings did not quit
+
+ _run(scenario())
diff --git a/tests/test_top_tier_loot.py b/tests/test_top_tier_loot.py
new file mode 100644
index 0000000..307cb7d
--- /dev/null
+++ b/tests/test_top_tier_loot.py
@@ -0,0 +1,65 @@
+"""Epic and legendary gear is earned: it never starts in a room a new player can
+walk into without a key.
+
+Root (/) sits one `cd ..` above /home, the tutorial sends players there, and it
+used to hold 2-3 legendary weapons most runs: the placer's rules were written
+for /root (the superuser's home) before the room id "root" became the hub."""
+from __future__ import annotations
+
+import pytest
+
+from engine.api import GameSession
+from src import rng
+from src.item_placer import ItemPlacer
+
+SEEDS = range(6)
+LEGENDARY_ROOMS = {"etc_hidden_configs", "core"} # /etc and /boot
+
+
+def _new_run(klass: str, seed: int) -> GameSession:
+ rng.seed(seed)
+ s = GameSession()
+ s.new_game("T", klass)
+ s.engine.initialize_special_items(klass) # the real new-game loot pass
+ return s
+
+
+@pytest.mark.parametrize("klass", ["guardian", "weaver", "shaman"])
+def test_no_top_tier_gear_starts_in_an_open_room(klass: str) -> None:
+ for seed in SEEDS:
+ s = _new_run(klass, seed)
+ try:
+ placer = ItemPlacer(s.world)
+ for iid, room in s.world.item_locations.items():
+ rarity = str(s.world.items[iid].rarity)
+ if rarity in ("epic", "legendary"):
+ assert not placer._starts_open(room), (seed, iid, room)
+ if rarity == "legendary":
+ assert room in LEGENDARY_ROOMS, (seed, iid, room)
+ finally:
+ s.close()
+
+
+@pytest.mark.parametrize("klass", ["guardian", "weaver", "shaman"])
+def test_root_is_a_low_tier_hub(klass: str) -> None:
+ for seed in SEEDS:
+ s = _new_run(klass, seed)
+ try:
+ for iid in s.world.get_items_in_room("root"):
+ assert str(s.world.items[iid].rarity) in ("common", "uncommon"), (seed, iid)
+ finally:
+ s.close()
+
+
+def test_the_legendaries_still_exist_somewhere() -> None:
+ placed = set()
+ for seed in SEEDS:
+ s = _new_run("guardian", seed)
+ try:
+ placed |= {
+ iid for iid in s.world.item_locations
+ if str(s.world.items[iid].rarity) == "legendary"
+ }
+ finally:
+ s.close()
+ assert placed, "no legendary was placed in any run"
diff --git a/tests/test_trial_rooms.py b/tests/test_trial_rooms.py
new file mode 100644
index 0000000..f3086fd
--- /dev/null
+++ b/tests/test_trial_rooms.py
@@ -0,0 +1,45 @@
+"""/opt and /srv are trials any class may enter; their own class gets a bonus."""
+from engine.api import GameSession
+
+
+def _session(cls: str) -> GameSession:
+ s = GameSession()
+ s.new_game("t", cls)
+ s.player.tutorial_state["completed"] = True
+ s.player.add_to_inventory("opt_key", s.world.get_item("opt_key"))
+ s.world.discover_room("opt_mage_tower") # /opt is hidden until found
+ # Holding a key is not an open door; unlock both so only class could deny.
+ s.world.unlock_room("opt_mage_tower")
+ s.world.unlock_room("srv_warrior_tomb")
+ return s
+
+
+def test_any_class_can_enter_both_trials() -> None:
+ for cls in ("guardian", "weaver", "shaman"):
+ s = _session(cls)
+ try:
+ allowed, _ = s.world.check_access("opt_mage_tower", s.player)
+ assert allowed, cls
+ allowed, _ = s.world.check_access("srv_warrior_tomb", s.player)
+ assert allowed, cls
+ finally:
+ s.close()
+
+
+def test_the_trial_class_is_empowered_on_entry() -> None:
+ s = _session("weaver")
+ try:
+ s.engine.cmd_handler.flags.on_room_entered("opt_mage_tower")
+ effect = s.player.status_effects["trial_resonance"]["effect"]
+ assert effect["damage_bonus"] == 4
+ finally:
+ s.close()
+
+
+def test_other_classes_get_no_bonus() -> None:
+ s = _session("guardian")
+ try:
+ s.engine.cmd_handler.flags.on_room_entered("opt_mage_tower")
+ assert "trial_resonance" not in s.player.status_effects
+ finally:
+ s.close()
diff --git a/tests/test_tutorial_checkpoint.py b/tests/test_tutorial_checkpoint.py
new file mode 100644
index 0000000..2517c28
--- /dev/null
+++ b/tests/test_tutorial_checkpoint.py
@@ -0,0 +1,91 @@
+"""The post-combat tutorial leg: ls -a reveals .bash_profile, cat fires the
+memory-restore checkpoint, and only then does the tutorial move on to ps.
+
+Assertions target s.ui.hints (which step fired), not Echo's wording.
+"""
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+
+_POST_COMBAT_STATE = {
+ "first_ls": True,
+ "took_weapon": True,
+ "equipped_weapon": True,
+ "combat_action_taken": True,
+}
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("t", "guardian")
+ s.player.tutorial_state.update(_POST_COMBAT_STATE)
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _fired(session: GameSession) -> list[str]:
+ return [hint["hint_id"] for hint in session.ui.hints]
+
+
+def test_plain_ls_does_not_advance_past_the_hidden_file_step(session: GameSession) -> None:
+ session.ui.hints.clear()
+ session.submit("ls")
+ assert session.player.tutorial_state.get("ls_a_used", False) is False
+ assert "step_hidden" not in _fired(session)
+
+
+def test_ls_a_fires_the_hidden_file_hint(session: GameSession) -> None:
+ session.ui.hints.clear()
+ session.submit("ls -a")
+ assert session.player.tutorial_state["ls_a_used"] is True
+ assert "step_hidden" in _fired(session)
+
+
+def test_cat_story_beat_fires_the_checkpoint_hint(session: GameSession) -> None:
+ session.submit("ls -a")
+ session.ui.hints.clear()
+ session.submit("cat bash_profile")
+ assert session.player.tutorial_state["lore_read"] is True
+ assert "step_cat" in _fired(session)
+
+
+def test_any_story_file_is_a_checkpoint(session: GameSession) -> None:
+ """Every lore file restores a memory now, so reading the README instead
+ of .bash_profile also completes the checkpoint step."""
+ session.submit("ls -a")
+ session.ui.hints.clear()
+ session.submit("cat readme_txt_corrupt")
+ assert session.player.tutorial_state.get("lore_read", False) is True
+
+
+def test_ps_does_not_advance_before_the_checkpoint(session: GameSession) -> None:
+ session.submit("ls -a")
+ session.ui.hints.clear()
+ session.submit("ps")
+ assert session.player.tutorial_state.get("ps_used", False) is False
+
+
+def test_ps_advances_after_the_checkpoint(session: GameSession) -> None:
+ session.submit("ls -a")
+ session.submit("cat bash_profile")
+ session.ui.hints.clear()
+ session.submit("ps")
+ assert session.player.tutorial_state["ps_used"] is True
+ assert "step_ps" in _fired(session)
+
+
+def test_current_step_walks_the_new_leg(session: GameSession) -> None:
+ coach = session.engine.cmd_handler.tutorial
+ ts = session.player.tutorial_state
+ assert coach.current_step() == "step5_postcombat"
+ ts["ls_a_used"] = True
+ assert coach.current_step() == "step_hidden"
+ ts["lore_read"] = True
+ assert coach.current_step() == "step_cat"
+ ts["ps_used"] = True
+ assert coach.current_step() == "step_ps"
diff --git a/tests/test_tutorial_combat.py b/tests/test_tutorial_combat.py
index 85b579d..d6043bf 100644
--- a/tests/test_tutorial_combat.py
+++ b/tests/test_tutorial_combat.py
@@ -1,16 +1,18 @@
"""Tutorial combat gating: combat_action_taken fires for BOTH typed and
hotkey-simulated attacks, since both paths emit the same COMBAT_ACTION_RESULT
event that CommandHandler now listens for."""
-from src.events import EventType, event_bus
+from engine.events import EventType
def _start_tutorial_fight(s):
- """Equip the guardian's starter weapon (spawns the tutorial enemy as a
- side effect, same as the real game) and return the live CombatSession."""
+ """Equip the guardian's starter weapon and armor (the second one spawns the
+ tutorial enemy, same as the real game) and return the live handler."""
h = s.engine.cmd_handler
h.world.item_locations["segfault_shield"] = s.player.current_room
s.submit("take segfault_shield")
s.submit("equip segfault_shield")
+ s.submit("take cracked_firewall") # starter armor: weapon + armor opens the fight
+ s.submit("equip cracked_firewall")
assert h.current_combat_session is not None
assert h.current_combat_session.awaiting_action
return h
@@ -42,7 +44,7 @@ def test_hotkey_simulated_attack_sets_combat_action_taken():
attack_id = next(iter(h.current_combat_session.available_attacks))
assert s.player.tutorial_state["combat_action_taken"] is False
s.ui.clear_console()
- event_bus.emit_event(
+ s.bus.emit_event(
EventType.COMBAT_ACTION_SELECTED, {"choice": attack_id}, "Test"
)
s.ui.drain()
@@ -51,17 +53,17 @@ def test_hotkey_simulated_attack_sets_combat_action_taken():
s.close()
-def test_get_current_tutorial_step_reaches_step6_after_combat():
+def test_get_current_tutorial_step_moves_past_combat():
"""Regression for the selection_mode_used/combat_selection key mismatch:
- after combat_action_taken flips, the fallback step lookup must reach
- step6, not loop on step5 forever."""
+ after combat_action_taken flips, the fallback step lookup must move on to
+ the post-combat step (ps), not loop on step5 forever."""
from engine.api import GameSession
s = GameSession()
try:
s.new_game("t", "guardian")
h = _start_tutorial_fight(s)
s.player.tutorial_state["combat_action_taken"] = True
- assert h._get_current_tutorial_step() == "step6"
+ assert h.tutorial.current_step() == "step5_postcombat"
finally:
s.close()
@@ -78,7 +80,6 @@ def test_step4_hint_does_not_instruct_typed_attack():
s.submit("take segfault_shield")
out = "\n".join(s.submit("equip segfault_shield"))
assert "type: [bold]attack[/bold]" not in out.lower()
- assert "selection mode" in out.lower()
finally:
s.close()
@@ -89,7 +90,7 @@ def test_completed_hint_documents_flee_hotkey():
try:
s.new_game("t", "guardian")
h = s.engine.cmd_handler
- h.show_tutorial_hint("completed")
+ h.tutorial.show_hint("completed")
out = "\n".join(s.ui.drain())
finally:
s.close()
@@ -98,26 +99,43 @@ def test_completed_hint_documents_flee_hotkey():
def test_post_combat_hint_fires_once_not_doubled():
- """step5_postcombat now folds the 'ls to see where you can go' nudge in
- directly — step6 must not auto-fire right after it (that was two ECHO
- messages back to back saying almost the same thing)."""
+ """Winning the tutorial fight gives one post-combat hint (which points at
+ ps) — the next step must not auto-fire right after it (that was two ECHO
+ messages back to back)."""
from engine.api import GameSession
s = GameSession()
try:
s.new_game("t", "guardian")
h = _start_tutorial_fight(s)
attack_id = next(iter(h.current_combat_session.available_attacks))
- # One-shot the tutorial enemy (15 HP) so combat ends on this action.
- s.ui.clear_console()
+ s.ui.hints.clear()
for _ in range(5):
if not h.current_combat_session:
break
- event_bus.emit_event(
+ s.bus.emit_event(
EventType.COMBAT_ACTION_SELECTED, {"choice": attack_id}, "Test"
)
- out = "\n".join(s.ui.drain())
- assert out.count("You won") == 1
- assert "where you can go" in out.lower()
- assert "let's move" not in out.lower() # old step6 wording must not appear here
+ fired = [hint["hint_id"] for hint in s.ui.hints]
+ assert fired.count("step5_postcombat") == 1
+ assert fired[-1] == "step5_postcombat" # nothing fires right after it
+ finally:
+ s.close()
+
+
+def test_skip_then_equip_does_not_force_the_tutorial_fight():
+ """Skipping the tutorial sets completed=True but not equipped_weapon, and
+ the equip hook only checked the latter: the first equip still spawned the
+ scripted enemy and shoved the skipping player into an unexplained fight."""
+ from engine.api import GameSession
+ s = GameSession()
+ try:
+ s.new_game("t", "guardian")
+ s.player.tutorial_state["completed"] = True # what the skip path sets
+ h = s.engine.cmd_handler
+ h.world.item_locations["segfault_shield"] = s.player.current_room
+ s.submit("take segfault_shield")
+ s.submit("equip segfault_shield")
+ assert h.current_combat_session is None
+ assert h.world.get_enemies_in_room("home_grove") == []
finally:
s.close()
diff --git a/tests/test_tutorial_feedback.py b/tests/test_tutorial_feedback.py
new file mode 100644
index 0000000..a8bd1fb
--- /dev/null
+++ b/tests/test_tutorial_feedback.py
@@ -0,0 +1,133 @@
+"""Tutorial playtest feedback: hint ordering, the flee dead end, the quit crash."""
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Iterator
+
+import pytest
+
+from engine.api import GameSession
+from engine.events import EventType
+from engine.view_models import AttackView, StatsView
+from src.game_world import TUTORIAL_ENEMY
+from src.ui.combat_log import render_combat_output
+from src.ui.screens.quit_confirm import QuitConfirmScreen
+from src.ui.textual_ui import TextualGameUI
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("Tess", "guardian")
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _text(lines: list[str]) -> str:
+ return "\n".join(str(line) for line in lines)
+
+
+def _weapon_here(s: GameSession) -> str:
+ return next(
+ i for i in s.world.get_items_in_room(s.player.current_room)
+ if s.world.items[i].type == "weapon"
+ )
+
+
+def _armor_here(s: GameSession) -> str:
+ return next(
+ i for i in s.world.get_items_in_room(s.player.current_room)
+ if s.world.items[i].type == "armor"
+ )
+
+
+def _into_tutorial_fight(s: GameSession) -> str:
+ s.submit("ls")
+ weapon = _weapon_here(s)
+ s.submit(f"take {weapon}")
+ s.submit(f"equip {weapon}")
+ armor = _armor_here(s)
+ s.submit(f"take {armor}")
+ return _text(s.submit(f"equip {armor}"))
+
+
+def test_take_lists_the_room_before_echo_speaks(session: GameSession) -> None:
+ session.submit("ls")
+ out = _text(session.submit(f"take {_weapon_here(session)}"))
+ hint = session.ui.hints[-1]
+ assert hint["hint_id"] == "step3"
+ assert out.index("Files:") < out.index(hint["text"])
+
+
+def test_combat_hint_arrives_after_the_fight_opens(session: GameSession) -> None:
+ handler = session.engine.cmd_handler
+ in_combat_when_shown: list[bool] = []
+ session.bus.subscribe(
+ EventType.TUTORIAL_HINT,
+ lambda e: in_combat_when_shown.append(handler.current_combat_session is not None),
+ )
+ _into_tutorial_fight(session)
+ assert session.ui.hints[-1]["hint_id"] == "step4"
+ assert in_combat_when_shown[-1]
+
+
+def test_fleeing_the_tutorial_fight_keeps_it_winnable(session: GameSession) -> None:
+ _into_tutorial_fight(session)
+ room = session.player.current_room
+ session.bus.emit_event(EventType.COMBAT_ACTION_SELECTED, {"choice": "flee"}, "test")
+
+ assert session.engine.cmd_handler.current_combat_session is None
+ assert session.player.current_room == room
+ assert TUTORIAL_ENEMY in session.world.get_enemies_in_room(room)
+ assert session.ui.hints[-1]["hint_id"] == "step4_fled"
+
+ # A stray command re-explains how to continue, not "press 1".
+ session.submit("1")
+ assert session.ui.hints[-1]["hint_id"] == "step4_fled"
+
+ session.submit("attack")
+ assert session.engine.cmd_handler.current_combat_session is not None
+
+
+def test_fleeing_an_ordinary_fight_still_moves_the_enemy_out(session: GameSession) -> None:
+ session.player.tutorial_state["completed"] = True
+ _into_tutorial_fight(session)
+ room = session.player.current_room
+ session.bus.emit_event(EventType.COMBAT_ACTION_SELECTED, {"choice": "flee"}, "test")
+ assert TUTORIAL_ENEMY not in session.world.get_enemies_in_room(room)
+
+
+def test_battle_start_shows_system_lines_and_the_attack_list() -> None:
+ player = StatsView("Tess", 100, 100, 18, "guardian")
+ attacks = [AttackView("strike", "Strike", 5, 0)]
+ opening = [
+ {"actor": "system", "message": "Combat initiated"},
+ {"actor": "system", "message": "ECHO> press 1"},
+ ]
+ start = render_combat_output(opening, player, attacks)
+ assert "ECHO> press 1" in start and "Strike" in start
+
+ fighting = render_combat_output(
+ [*opening, {"actor": "player", "message": "hit"}], player, attacks
+ )
+ assert "hit" in fighting and "Strike" not in fighting # log only, no attack list
+
+
+def test_quit_chooser_survives_a_full_repaint(monkeypatch: pytest.MonkeyPatch) -> None:
+ """QuitConfirmScreen once defined _render, shadowing Textual's own
+ Widget._render; any repaint of the screen then crashed the app."""
+ monkeypatch.setattr("src.ui.textual_ui.SKIP_INTRO", True)
+ app = TextualGameUI()
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ screen = QuitConfirmScreen(lambda choice: None)
+ app.push_screen(screen)
+ await pilot.pause()
+ screen.refresh()
+ await pilot.pause()
+ assert screen.is_attached
+
+ asyncio.run(scenario())
diff --git a/tests/test_tutorial_hints.py b/tests/test_tutorial_hints.py
index 83c2ce4..3c2e705 100644
--- a/tests/test_tutorial_hints.py
+++ b/tests/test_tutorial_hints.py
@@ -16,17 +16,17 @@
from src.data_loader import load_class_data, load_tutorial_hints
# Every hint id the game asks for, read out of the source rather than restated,
-# so adding a show_tutorial_hint("stepN") call without YAML text fails here.
+# so adding a show_hint("stepN") call without YAML text fails here.
SRC = "\n".join(p.read_text() for p in Path("src").rglob("*.py"))
-# Direct calls: show_tutorial_hint("step2")
-_DIRECT = re.findall(r'show_tutorial_hint\(\s*["\']([a-z0-9_]+)["\']', SRC)
+# Direct calls: tutorial.show_hint("step2")
+_DIRECT = re.findall(r'show_hint\(\s*["\']([a-z0-9_]+)["\']', SRC)
# Indirect: handle_unknown_command re-shows whatever step the player is on, via
-# show_tutorial_hint(current_step). Those ids are the string literals returned
-# by _get_current_tutorial_step, so read them out of that function's body.
+# tutorial.show_hint(current_step). Those ids are the string literals returned
+# by TutorialCoach.current_step, so read them out of that function's body.
_STEP_FN = re.search(
- r"def _get_current_tutorial_step\(self\).*?(?=\n def )", SRC, re.S
+ r"def current_step\(self\).*?(?=\n def )", SRC, re.S
)
_INDIRECT = re.findall(r'return\s+["\']([a-z0-9_]+)["\']', _STEP_FN.group(0)) if _STEP_FN else []
@@ -48,14 +48,14 @@ def _text(lines) -> str:
def test_source_actually_requests_hints() -> None:
- assert REQUESTED_IDS, "found no show_tutorial_hint call sites — check the regex"
+ assert REQUESTED_IDS, "found no show_hint call sites — check the regex"
-@pytest.mark.parametrize("hint_id", REQUESTED_IDS)
-def test_every_requested_hint_has_text(hint_id: str) -> None:
- assert hint_id in load_tutorial_hints(), (
- f"code asks for tutorial step '{hint_id}' but the YAML defines no text, "
- "so the player would get silence at that step"
+def test_every_requested_hint_has_text() -> None:
+ missing = sorted(set(REQUESTED_IDS) - set(load_tutorial_hints()))
+ assert not missing, (
+ f"code asks for tutorial steps {missing} but the YAML defines no text, "
+ "so the player would get silence at those steps"
)
@@ -65,19 +65,17 @@ def test_no_unreachable_hint_text() -> None:
assert not orphans, f"tutorial text never shown to anyone: {sorted(orphans)}"
-@pytest.mark.parametrize("hint_id", REQUESTED_IDS)
-def test_hint_renders_without_leftover_placeholders(
- hint_id: str, session: GameSession
-) -> None:
+def test_hints_render_without_leftover_placeholders(session: GameSession) -> None:
handler = session.engine.cmd_handler
- handler.player.tutorial_state["completed"] = False
- session.ui.clear_console()
-
- handler.show_tutorial_hint(hint_id)
- out = _text(session.ui.drain())
-
- assert out.strip(), f"{hint_id} rendered nothing"
- assert "{" not in out, f"{hint_id} left an unformatted placeholder: {out[:80]}"
+ broken = {}
+ for hint_id in REQUESTED_IDS:
+ handler.player.tutorial_state["completed"] = False
+ session.ui.clear_console()
+ handler.tutorial.show_hint(hint_id)
+ out = _text(session.ui.drain())
+ if not out.strip() or "{" in out:
+ broken[hint_id] = out[:80]
+ assert not broken, f"hints that rendered empty or with a placeholder left: {broken}"
def test_weapon_name_comes_from_class_data(session: GameSession) -> None:
@@ -87,7 +85,7 @@ def test_weapon_name_comes_from_class_data(session: GameSession) -> None:
handler.player.tutorial_state["completed"] = False
session.ui.clear_console()
- handler.show_tutorial_hint("step2")
+ handler.tutorial.show_hint("step2")
expected = load_class_data()["weaver"].starter_weapon
assert expected in _text(session.ui.drain())
@@ -100,7 +98,7 @@ def test_explicit_item_name_wins(session: GameSession) -> None:
handler.player.tutorial_state["completed"] = False
session.ui.clear_console()
- handler.show_tutorial_hint("step2", "a_specific_weapon")
+ handler.tutorial.show_hint("step2", "a_specific_weapon")
assert "a_specific_weapon" in _text(session.ui.drain())
@@ -109,7 +107,7 @@ def test_completed_marks_the_tutorial_done(session: GameSession) -> None:
handler = session.engine.cmd_handler
handler.player.tutorial_state["completed"] = False
- handler.show_tutorial_hint("completed")
+ handler.tutorial.show_hint("completed")
assert handler.player.tutorial_state["completed"] is True
@@ -119,7 +117,7 @@ def test_hints_are_silent_once_complete(session: GameSession) -> None:
handler.player.tutorial_state["completed"] = True
session.ui.clear_console()
- handler.show_tutorial_hint("step1")
+ handler.tutorial.show_hint("step1")
assert _text(session.ui.drain()).strip() == ""
@@ -129,7 +127,7 @@ def test_unknown_hint_id_is_survivable(session: GameSession) -> None:
handler.player.tutorial_state["completed"] = False
session.ui.clear_console()
- handler.show_tutorial_hint("no_such_step") # must not raise
+ handler.tutorial.show_hint("no_such_step") # must not raise
assert _text(session.ui.drain()).strip() == ""
diff --git a/tests/test_tutorial_navigation.py b/tests/test_tutorial_navigation.py
new file mode 100644
index 0000000..4d245b0
--- /dev/null
+++ b/tests/test_tutorial_navigation.py
@@ -0,0 +1,111 @@
+"""Tutorial second half: ps, pwd, up out of /home, and the highlighted Directories."""
+from __future__ import annotations
+
+import asyncio
+from collections.abc import Iterator
+
+import pytest
+from rich.text import Text
+
+from engine.api import GameSession
+from src.ui.textual_ui import TextualGameUI
+
+
+@pytest.fixture
+def session() -> Iterator[GameSession]:
+ s = GameSession()
+ s.new_game("Tess", "guardian")
+ ts = s.player.tutorial_state
+ ts.update({"first_ls": True, "took_weapon": True, "equipped_weapon": True,
+ "combat_action_taken": True,
+ # The ls -a / cat checkpoint leg is covered by
+ # test_tutorial_checkpoint.py; here it is already done.
+ "ls_a_used": True, "lore_read": True})
+ try:
+ yield s
+ finally:
+ s.close()
+
+
+def _last_hint(s: GameSession) -> dict:
+ return s.ui.hints[-1]
+
+
+def test_the_walk_from_ps_to_completion(session: GameSession) -> None:
+ ts = session.player.tutorial_state
+ assert session.engine.cmd_handler.tutorial.current_step() == "step_cat"
+
+ session.submit("ps")
+ assert ts["ps_used"] and _last_hint(session)["hint_id"] == "step_ps"
+
+ session.submit("pwd") # /home has nothing below it
+ assert ts["pwd_used"] and not ts["went_up"]
+ assert _last_hint(session)["hint_id"] == "step_up"
+
+ session.submit("cd ..")
+ assert ts["went_up"] and _last_hint(session)["hint_id"] == "step6"
+
+ session.submit("ls")
+ hint = _last_hint(session)
+ assert ts["navigation_ls"] and hint["hint_id"] == "step6b"
+ assert hint["highlight"] == "Directories"
+ assert (hint["step"], hint["total"]) == (12, 12)
+ assert "bin" in hint["text"] # the example is a directory actually listed at /
+
+ session.submit("cd bin")
+ assert ts["completed"]
+
+
+def test_ls_before_ps_keeps_asking_for_ps(session: GameSession) -> None:
+ session.ui.hints.clear()
+ session.submit("ls")
+ assert session.ui.hints == [] # ls just lists; no new step
+ session.submit("xyzzy")
+ assert _last_hint(session)["hint_id"] == "step_cat"
+
+
+def test_ls_in_a_dead_end_repeats_the_way_up(session: GameSession) -> None:
+ session.submit("ps")
+ session.submit("pwd")
+ session.submit("ls") # still in /home: no Directories
+ assert _last_hint(session)["hint_id"] == "step_up"
+ assert not session.player.tutorial_state["navigation_ls"]
+
+
+def test_pwd_somewhere_with_directories_skips_the_way_up(session: GameSession) -> None:
+ session.submit("cd /")
+ session.submit("ps")
+ session.submit("pwd")
+ assert _last_hint(session)["hint_id"] == "step6"
+ assert session.player.tutorial_state["went_up"]
+
+
+def test_directories_flash_then_stay_lit(monkeypatch: pytest.MonkeyPatch) -> None:
+ monkeypatch.setattr("src.ui.textual_ui.SKIP_INTRO", True)
+ app = TextualGameUI()
+ app._settings_manager.settings["reduce_motion"] = False
+ app.__class__._FLASH_SECONDS = 0.02
+
+ listing = Text("Directories:\n bin/ - The Armory\n\nFiles:\n readme\n")
+
+ def lit(content: object) -> str:
+ # The listing carries no styles of its own, so any span is the highlight.
+ assert isinstance(content, Text)
+ return "".join(content.plain[s.start:s.end] for s in content.spans)
+
+ async def scenario() -> None:
+ async with app.run_test(size=(120, 40)) as pilot:
+ app.output_content = listing
+ seen = []
+ app._flash_section("Directories")
+ for _ in range(10):
+ seen.append(bool(lit(app.output_content)))
+ await pilot.pause(0.03)
+ assert True in seen and False in seen # it pulsed
+ assert lit(app.output_content) == "Directories:\n bin/ - The Armory"
+ assert "Files" not in lit(app.output_content)
+
+ try:
+ asyncio.run(scenario())
+ finally:
+ app.__class__._FLASH_SECONDS = 0.35
diff --git a/tests/test_version.py b/tests/test_version.py
new file mode 100644
index 0000000..d328c65
--- /dev/null
+++ b/tests/test_version.py
@@ -0,0 +1,68 @@
+"""The game names its build: one version in pyproject.toml, shown on the title
+screen and written at the top of every session's debug.log, so a screenshot
+or a log always says which code produced it."""
+from __future__ import annotations
+
+import importlib
+import re
+import sys
+import tomllib
+from pathlib import Path
+from typing import Any
+
+import pytest
+
+import main
+from src import version
+from src.ui.title_menu import TitleMenu
+
+
+def test_version_comes_from_pyproject() -> None:
+ with open("pyproject.toml", "rb") as f:
+ declared = tomllib.load(f)["project"]["version"]
+ assert version.version() == declared
+ assert re.fullmatch(r"\d+\.\d+\.\d+", declared), "use MAJOR.MINOR.PATCH"
+
+
+def test_version_works_on_python_3_10_without_tomllib(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """The launchers accept Python 3.10, which has no tomllib."""
+ monkeypatch.setitem(sys.modules, "tomllib", None) # import tomllib -> ImportError
+ fresh = importlib.reload(version)
+ try:
+ assert re.fullmatch(r"\d+\.\d+\.\d+", fresh.version())
+ finally:
+ monkeypatch.delitem(sys.modules, "tomllib")
+ importlib.reload(version)
+
+
+def test_build_label_names_the_version_and_commit() -> None:
+ label = version.build_label()
+ assert f"v{version.version()}" in label
+ commit = version.commit()
+ if commit is not None: # a source download without git has no commit
+ assert commit in label
+
+
+def test_a_log_session_starts_with_the_build(tmp_path: Path) -> None:
+ log, previous = tmp_path / "debug.log", tmp_path / "combat.log"
+ main.rotate_logs(str(log), str(previous), header="=== Haunted Terminal v9.9.9 ===")
+ assert log.read_text().splitlines()[0] == "=== Haunted Terminal v9.9.9 ==="
+
+
+class _FakeApp:
+ def __init__(self) -> None:
+ self.shown: list[Any] = []
+
+ def add_class(self, name: str) -> None:
+ pass
+
+ def update_output(self, renderable: Any) -> None:
+ self.shown.append(renderable)
+
+
+def test_title_screen_shows_the_version() -> None:
+ app = _FakeApp()
+ TitleMenu(app).show(skip_typewriter=True) # type: ignore[arg-type]
+ assert f"v{version.version()}" in app.shown[-1].plain
diff --git a/tests/test_world_state_persists.py b/tests/test_world_state_persists.py
index 5bacc27..1944af0 100644
--- a/tests/test_world_state_persists.py
+++ b/tests/test_world_state_persists.py
@@ -53,6 +53,7 @@ def test_cleared_room_still_reads_cleared_after_load() -> None:
world = _world()
for enemy_id in list(world.get_enemies_in_room("core")):
world.remove_enemy_from_room(enemy_id)
+ world.mark_flag_captured("core") # /boot's flag drops with the Overlord
assert _round_trip(world).is_room_cleared("core")
diff --git a/utils/debug_tools.py b/utils/debug_tools.py
index 3e0b703..33c8523 100644
--- a/utils/debug_tools.py
+++ b/utils/debug_tools.py
@@ -25,9 +25,13 @@
"world": DEBUG_WORLD,
}
-def debug_log(message, category="system"):
+def debug_log(message: object, category: str = "system") -> None:
"""
- Log a debug message to the debug log file and console if DEBUG_MODE is True.
+ Log a debug message to the debug log file if DEBUG_MODE is True.
+
+ File only: the game screen owns the terminal, and anything printed there
+ is left behind in the player's scrollback when they quit. The log panel
+ (L) shows the file.
Args:
message (str): The message to log
@@ -52,10 +56,6 @@ def debug_log(message, category="system"):
# Format the log message
log_message = f"[{timestamp}] [{category.upper()}] [{filename}:{function_name}:{line_number}] {message}"
- # Print to console
- print(log_message, file=sys.stderr)
-
- # Write to log file
try:
with open(DEBUG_LOG_FILE, "a") as f:
f.write(log_message + "\n")
diff --git a/utils/particle_animation.py b/utils/particle_animation.py
index 7eee4f6..06d02d8 100644
--- a/utils/particle_animation.py
+++ b/utils/particle_animation.py
@@ -11,6 +11,8 @@
import math
from typing import Callable, List, Tuple
from dataclasses import dataclass
+
+from rich.markup import escape
import config.dev_config as _dev_cfg
@@ -161,11 +163,11 @@ def render_to_string(self, overlay_text: str = None,
if overlay_row and col_idx < len(overlay_row):
overlay_char = overlay_row[col_idx]
if overlay_char != ' ':
- line_parts.append(f'[{overlay_color}]{overlay_char}[/{overlay_color}]')
+ line_parts.append(f'[{overlay_color}]{escape(overlay_char)}[/{overlay_color}]')
continue
if char != ' ':
- line_parts.append(f'[{color}]{char}[/{color}]')
+ line_parts.append(f'[{color}]{escape(char)}[/{color}]')
else:
line_parts.append(' ')
@@ -197,14 +199,13 @@ class GameOverAnimation:
[dim]The digital void echoes with the sound of your defeat.[/dim]
[italic cyan]Even the greatest sysadmins must sometimes face corruption...[/italic cyan]
-[bold white]Options:[/bold white]
- [bold green]r[/bold green] - Restart from your last save
- [bold yellow]n[/bold yellow] - Start a new game
- [bold red]q[/bold red] - Quit to shell
-
-[bold white]What would you like to do?[/bold white] """
+[bold white]Type a letter and press Enter:[/bold white]
+ [bold green]r[/bold green] - Restore this run's last save
+ [bold cyan]m[/bold cyan] - Main menu
+ [bold red]q[/bold red] - Quit to shell"""
- def __init__(self, width: int = 75, height: int = 22):
+ def __init__(self, width: int = 75, height: int = 22, player_name: str | None = None):
+ self.player_name = player_name
self.width = width
self.height = height
self.particle_system = ParticleSystem(width, height)
@@ -300,7 +301,15 @@ def _get_final_frame(self) -> str:
║ ║
╚══════════════════════════════════════════════════════════════════════════╝
[/bold red]
-{self.OPTIONS_TEXT}"""
+{self._farewell()}{self.OPTIONS_TEXT}"""
+
+ def _farewell(self) -> str:
+ if not self.player_name:
+ return ""
+ return (
+ f"\n[bold]Brave sysadmin {escape(self.player_name)}, "
+ "your session has been terminated.[/bold]\n"
+ )
def stop(self):
"""Stop the animation early."""