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](./assets/screenshot-explore.svg) -*Exploring the Graveyard — pixel scene view, live panels, ECHO guiding your first `ls`* +--- -| Pokemon-style battles | Pick your difficulty | -|---|---| -| ![Battle](./assets/screenshot-battle.svg) | ![Difficulty](./assets/screenshot-difficulty.svg) | +Exploring the Graveyard + +_Exploring the Graveyard — pixel scene view, live panels, ECHO's quick reference_ + + + + + + + + + + + + + + + + + + +
Pokemon-style battlesPick your difficulty
BattleDifficulty
Continue any run — one save slot per runSettings — right from the main menu
Save slotsSettings
--- ## 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/120FULL│ -│▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄││██████████│ -│🛡 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'│ -││ -││ -││ -││ -││ -╰───────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│combat@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.│ -││ -││ -││ -││ -││ -││ -││ -││ -││ -││ -││ -││ -││ -││ -││ -││ -╰───────────────────────────────────────────────────────────────────────────────────────────────────╯ -╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│Enter 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/120FULL│ +│││██████████│ +││││ +│││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>│ +│││▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄│ +│││▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄│ +│││▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄│ +│││▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄│ +│││▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄│ +│││▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄│ +│││▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄│ +│││▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄│ +│││▄▄▄▄▄▄▄▄▄│ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯╰──────────────────────────────────────────────────────────╯ +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│Enter 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 -> "