[
  {
    "path": ".editorconfig",
    "content": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.{js,json}]\nindent_size = 2\nindent_style = space\n\n[*.md]\ntrim_trailing_whitespace = false\n"
  },
  {
    "path": ".gitattributes",
    "content": "* text=auto\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE.md",
    "content": "<!--\n⚠️ PLEASE READ BEFORE DELETING THIS TEMPLATE! ⚠️\n\nThanks for your contribution. Please follow this guide before submitting an\nissue:\n\n1. Do you have a setup/usage question?\n================================\n\n- Look for prior or closed issues (but please avoid replying to them if they're\n  too old).\n- Check the docs: https://warrior.js.org/docs\n- Start a thread on our Spectrum Help channel:\n  https://spectrum.chat/warriorjs/help\n\n2. Do you think you found a bug?\n================================\n\n- Make sure you're on the latest version of WarriorJS (`npm i -g @warriorjs/cli`)\n- Consider submitting a PR with a failing test instead.\n- Use the \"BUG TEMPLATE\" below to report a bug.\n- Don't forget to provide reproduction steps.\n- If you can't provide a reproduction, snippets of code can help, but are\n  incomplete reports.\n\n3. Do you have a feature request?\n================================\n\n- Look for old & closed issues (replying might be ok if they're not too old or\n  have no conclusion).\n- Otherwise: Remove this entire template and provide thoughtful commentary *and\n  code samples* on what this feature means for you. Example:\n  - What will it allow you to do that you can't do today?\n  - How will it make current work-arounds straightforward?\n  - What potential bugs and edge cases does it help to avoid?\n  - Please keep it product-centric.\n-->\n\n<!-- BUG TEMPLATE -->\n\n# Environment\n\n<!--\nPlease run this command from the directory under which you're running warriorjs\nand paste its contents here:\n\nnpx envinfo --system --binaries --npmPackages @warriorjs/* --npmGlobalPackages @warriorjs/* --markdown\n-->\n\n# Steps to reproduce\n\n# Expected Behavior\n\n# Actual Behavior\n"
  },
  {
    "path": ".github/workflows/ci.yml",
    "content": "name: CI\n\non:\n  push:\n    branches: [master]\n  pull_request:\n    branches: [master]\n\njobs:\n  lint:\n    name: Lint\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version: 24\n          cache: pnpm\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm lint\n\n  test:\n    name: Test\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version: 24\n          cache: pnpm\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm build\n      - run: pnpm test:coverage\n      - uses: codecov/codecov-action@v5\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n\n  build:\n    name: Build\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v6\n      - uses: pnpm/action-setup@v4\n      - uses: actions/setup-node@v6\n        with:\n          node-version: 24\n          cache: pnpm\n      - run: pnpm install --frozen-lockfile\n      - run: pnpm build\n"
  },
  {
    "path": ".gitignore",
    "content": "# Logs\nlogs\n*.log\nturbo-debug.log*\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pid.lock\n\n# Directory for instrumented libs generated by jscoverage/JSCover\nlib-cov\n\n# Coverage directory used by tools like istanbul\ncoverage\n\n# nyc test coverage\n.nyc_output\n\n# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files)\n.grunt\n\n# Bower dependency directory (https://bower.io/)\nbower_components\n\n# node-waf configuration\n.lock-wscript\n\n# Compiled binary addons (http://nodejs.org/api/addons.html)\nbuild/Release\n\n# Dependency directories\nnode_modules/\njspm_packages/\n\n# Typescript v1 declaration files\ntypings/\n\n# Optional npm cache directory\n.npm\n\n# Optional eslint cache\n.eslintcache\n\n# Optional REPL history\n.node_repl_history\n\n# Output of 'npm pack'\n*.tgz\n\n# Yarn Integrity file\n.yarn-integrity\n\n# dotenv environment variables file\n.env\n\n# Turborepo cache\n.turbo\n\n# Build directories\napps/*/dist/\napps/website/build/\nlibs/*/dist/\ntowers/*/dist/\n\n# Website\napps/website/translated_docs\napps/website/i18n/*\n!apps/website/i18n/en.json\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)\nand this project adheres to\n[Semantic Versioning](https://semver.org/spec/v2.0.0.html).\n\n## [Unreleased]\n\n## [0.14.0] - 2018-10-17\n\n### Changed\n\n- Simplify `@warriorjs/helper-get-level-score` ([@olistic][] in [#247][])\n- Rename `@warriorjs/helper-get-play-score` package to\n  `@warriorjs/helper-get-level-score` ([@olistic][] in [#246][])\n\n## [0.13.0] - 2018-10-09\n\n### Added\n\n- Add `maxHealth` ability ([@jseed][] in [#238][])\n\n### Changed\n\n- Improve abilities descriptions ([@olistic][] in [#240][])\n\n## [0.12.3] - 2018-10-04\n\n### Fixed\n\n- Fix `getLevelConfig` (make it pure for real) ([@olistic][] in [#236][])\n\n## [0.12.2] - 2018-10-04\n\n### Fixed\n\n- Make `getLevelConfig` a pure function ([@olistic][] in [#234][])\n\n## [0.12.1] - 2018-07-31\n\n### Fixed\n\n- Fix `tick-tick-boom` tower description\n\n## [0.12.0] - 2018-07-30\n\n### Changed\n\n- Official towers names and descriptions ([@olistic][] in [#221][])\n\n### Fixed\n\n- Think ability with complex thoughts ([@olistic][] in [#220][])\n\n## [0.11.3] - 2018-07-24\n\n### Added\n\n- Warrior status to level JSON ([@olistic][] in [#219][])\n\n## [0.11.2] - 2018-07-17\n\n### Changed\n\n- Update `superheroes` dependency to version that doesn't include a CLI\n  ([@wtgtybhertgeghgtwtg][] in [#217][])\n\n## [0.11.1] - 2018-07-14\n\nForce publish of helper packages.\n\n## [0.11.0] - 2018-07-14 [YANKED]\n\n### Changed\n\n- Calling `.default` after `require('@warriorjs/helper-**')` is no longer needed\n  (nor supported) ([@olistic][] in [#216][]).\n\n## [0.10.0] - 2018-07-14\n\nThis release modularizes the codebase even more, adding new helper packages\nwhose logic can be reused by different flavors of the game.\n\n### Changed\n\n- Remove play score from play result ([@olistic][] in [#215][])\n\n## [0.9.0] - 2018-07-09\n\n### Changed\n\n- Use less fragile method to check for player code errors ([@olistic][] in\n  [#213][])\n\n## [0.8.0] - 2018-07-06\n\n### Added\n\n- RGB colors to units ([@olistic][] in [#165][])\n\n### Changed\n\n- Optimize level and events payload ([@olistic][] in [#210][])\n\n## [0.7.0] - 2018-07-06\n\n### Changed\n\n- Reduce `unit.log()` calls making play reproduction ~50% faster ([@olistic][]\n  in [#208][])\n- Improve play log ([@olistic][] in [#209][])\n\n## [0.6.0] - 2018-06-17\n\n### Changed\n\n- Enhance CLI welcome message ([@olistic][] in [#191][])\n- Sort actions and senses alphabetically in README ([@djohalo2][] in [#194][])\n- Reference tower by ID in levels ([@olistic][] in [#202][])\n\n## [0.5.1] - 2018-06-01\n\n### Fixed\n\n- External towers discovery ([@olistic][] in [#188][])\n\n## [0.5.0] - 2018-06-01\n\n### Added\n\n- Load towers dynamically (support for external towers) ([@olistic][] in\n  [#169][])\n- Add tower description to README if available ([@olistic][] in [#185][])\n\n### Changed\n\n- Prevent seeing through walls with \"look\" ability ([@pigalot][] in [#162][])\n- Optimize profile file ([@olistic][] in [#170][] and [#180][])\n- No longer ask for confirmation before creating game directory ([@olistic][] in\n  [#177][])\n\n## [0.4.2] - 2018-05-23\n\n### Added\n\n- Warrior name suggestions ([@olistic][] in [#152][])\n\n## [0.4.1] - 2018-05-22\n\n### Added\n\n- `--silent` flag to CLI ([@xaviserrag][] in [#82][])\n\n### Changed\n\n- Print level independently of play log ([@olistic][] in [#145][])\n- UI tweaks ([@olistic][] in [#147][])\n- Improve think ability description ([@olistic][] in [#149][])\n- Improve level tips ([@olistic][] in [#150][])\n\n## [0.4.0] - 2018-05-19\n\n### Added\n\n- Make warrior upset when losing points ([@skywalker212][] in [#107][])\n- `getRelativeOffset` function (`@warriorjs/geography`) ([@olistic][] in\n  [#138][])\n- `unit.release()` method ([@olistic][] in [#140][])\n\n### Changed\n\n- Rename `unit.say()` to `unit.log()` ([@olistic][] in [#131][])\n- Improve profile directory detection ([@glneto][] in [#133][])\n- Rename `unit.isHostile()` to `unit.isEnemy()` ([@olistic][] in [#129][])\n- `space.getLocation()` returns the relative location of the space ([@olistic][]\n  in [#129][])\n- `unit.isEnemy()` returns whether the unit is considered an enemy by the unit\n  that sensed it ([@olistic][] in [#129][])\n\n### Removed\n\n- `unit.isFriendly()` ([@olistic][] in [#129][])\n- `unit.isWarrior()` and `unit.isPlayer()` ([@olistic][] in [#129][])\n\n### Fixed\n\n- Line breaks on CLI output on Windows ([@olistic][] in [#120][])\n- Diamond symbol on Windows ([@glneto][] in [#121][])\n\n## [0.3.0] - 2018-05-16\n\n### Added\n\n- Subtract reward points when killing a friendly unit ([@Terseus][] in [#87][])\n- Think ability (`console.log` replacement) ([@olistic][] in [#102][])\n\n### Changed\n\n- Distinguish between hostile and friendly units ([@xFloki][] in [#101][])\n- Move unit methods out of the Space API and to the Unit API ([@olistic][] in\n  [#113][])\n\n### Fixed\n\n- Enforce [Player API](https://warrior.js.org/docs/player/space-api)\n  ([@olistic][] in [#114][])\n\n## [0.2.0] - 2018-05-14\n\n### Added\n\n- Reward property to Unit class ([@RascalTwo][] in [#67][])\n- Warrior score next to health in play log ([@RascalTwo][] in [#70][])\n- Support for Node 9 and 10 ([@olistic][] in [#81][])\n- `--yes` flag to CLI ([@olistic][] in [#93][] and [#98][])\n\n### Changed\n\n- Exclude abilities from play log except warrior's ([@olistic][] in [#83][])\n- Rescue ability awards Unit's reward ([@RascalTwo][] in [#86][], [@olistic][]\n  in [#90][])\n- CLI default answers ([@olistic][] in [#97][])\n\n### Removed\n\n- `--skip` flag from CLI ([@olistic][] in [#93][])\n\n### Fixed\n\n- Path normalization in tests ([@jakehamilton][] in [#77][])\n\n## [0.1.1] - 2018-05-03\n\n### Fixed\n\n- Missing `bin` directory in `@warriorjs/cli` package files\n\n## 0.1.0 - 2018-05-03 [YANKED]\n\nInitial version.\n\n[unreleased]: https://github.com/olistic/warriorjs/compare/v0.14.0...HEAD\n[0.14.0]: https://github.com/olistic/warriorjs/compare/v0.13.0...v0.14.0\n[0.13.0]: https://github.com/olistic/warriorjs/compare/v0.12.3...v0.13.0\n[0.12.3]: https://github.com/olistic/warriorjs/compare/v0.12.2...v0.12.3\n[0.12.2]: https://github.com/olistic/warriorjs/compare/v0.12.1...v0.12.2\n[0.12.1]: https://github.com/olistic/warriorjs/compare/v0.12.0...v0.12.1\n[0.12.0]: https://github.com/olistic/warriorjs/compare/v0.11.3...v0.12.0\n[0.11.3]: https://github.com/olistic/warriorjs/compare/v0.11.2...v0.11.3\n[0.11.2]: https://github.com/olistic/warriorjs/compare/v0.11.1...v0.11.2\n[0.11.1]: https://github.com/olistic/warriorjs/compare/v0.11.0...v0.11.1\n[0.11.0]: https://github.com/olistic/warriorjs/compare/v0.10.0...v0.11.0\n[0.10.0]: https://github.com/olistic/warriorjs/compare/v0.9.0...v0.10.0\n[0.9.0]: https://github.com/olistic/warriorjs/compare/v0.8.0...v0.9.0\n[0.8.0]: https://github.com/olistic/warriorjs/compare/v0.7.0...v0.8.0\n[0.7.0]: https://github.com/olistic/warriorjs/compare/v0.6.0...v0.7.0\n[0.6.0]: https://github.com/olistic/warriorjs/compare/v0.5.1...v0.6.0\n[0.5.1]: https://github.com/olistic/warriorjs/compare/v0.5.0...v0.5.1\n[0.5.0]: https://github.com/olistic/warriorjs/compare/v0.4.2...v0.5.0\n[0.4.2]: https://github.com/olistic/warriorjs/compare/v0.4.1...v0.4.2\n[0.4.1]: https://github.com/olistic/warriorjs/compare/v0.4.0...v0.4.1\n[0.4.0]: https://github.com/olistic/warriorjs/compare/v0.3.0...v0.4.0\n[0.3.0]: https://github.com/olistic/warriorjs/compare/v0.2.0...v0.3.0\n[0.2.0]: https://github.com/olistic/warriorjs/compare/v0.1.1...v0.2.0\n[0.1.1]: https://github.com/olistic/warriorjs/compare/v0.1.0...v0.1.1\n[@olistic]: https://github.com/olistic\n[@rascaltwo]: https://github.com/RascalTwo\n[@jakehamilton]: https://github.com/jakehamilton\n[@terseus]: https://github.com/Terseus\n[@xfloki]: https://github.com/xFloki\n[@skywalker212]: https://github.com/skywalker212\n[@glneto]: https://github.com/glneto\n[@xaviserrag]: https://github.com/xaviserrag\n[@pigalot]: https://github.com/pigalot\n[@djohalo2]: https://github.com/djohalo2\n[@wtgtybhertgeghgtwtg]: https://github.com/wtgtybhertgeghgtwtg\n[@jseed]: https://github.com/JSeed\n[#67]: https://github.com/olistic/warriorjs/pull/67\n[#70]: https://github.com/olistic/warriorjs/pull/70\n[#77]: https://github.com/olistic/warriorjs/pull/77\n[#81]: https://github.com/olistic/warriorjs/pull/81\n[#82]: https://github.com/olistic/warriorjs/pull/82\n[#83]: https://github.com/olistic/warriorjs/pull/83\n[#86]: https://github.com/olistic/warriorjs/pull/86\n[#87]: https://github.com/olistic/warriorjs/pull/87\n[#90]: https://github.com/olistic/warriorjs/pull/90\n[#93]: https://github.com/olistic/warriorjs/pull/93\n[#97]: https://github.com/olistic/warriorjs/pull/97\n[#98]: https://github.com/olistic/warriorjs/pull/98\n[#101]: https://github.com/olistic/warriorjs/pull/101\n[#102]: https://github.com/olistic/warriorjs/pull/102\n[#107]: https://github.com/olistic/warriorjs/pull/107\n[#113]: https://github.com/olistic/warriorjs/pull/113\n[#114]: https://github.com/olistic/warriorjs/pull/114\n[#120]: https://github.com/olistic/warriorjs/pull/120\n[#121]: https://github.com/olistic/warriorjs/pull/121\n[#129]: https://github.com/olistic/warriorjs/pull/129\n[#131]: https://github.com/olistic/warriorjs/pull/131\n[#133]: https://github.com/olistic/warriorjs/pull/133\n[#138]: https://github.com/olistic/warriorjs/pull/138\n[#140]: https://github.com/olistic/warriorjs/pull/140\n[#145]: https://github.com/olistic/warriorjs/pull/145\n[#147]: https://github.com/olistic/warriorjs/pull/147\n[#149]: https://github.com/olistic/warriorjs/pull/149\n[#150]: https://github.com/olistic/warriorjs/pull/150\n[#152]: https://github.com/olistic/warriorjs/pull/152\n[#162]: https://github.com/olistic/warriorjs/pull/162\n[#165]: https://github.com/olistic/warriorjs/pull/165\n[#169]: https://github.com/olistic/warriorjs/pull/169\n[#170]: https://github.com/olistic/warriorjs/pull/170\n[#177]: https://github.com/olistic/warriorjs/pull/177\n[#180]: https://github.com/olistic/warriorjs/pull/180\n[#185]: https://github.com/olistic/warriorjs/pull/185\n[#188]: https://github.com/olistic/warriorjs/pull/188\n[#191]: https://github.com/olistic/warriorjs/pull/191\n[#194]: https://github.com/olistic/warriorjs/pull/194\n[#202]: https://github.com/olistic/warriorjs/pull/202\n[#208]: https://github.com/olistic/warriorjs/pull/208\n[#209]: https://github.com/olistic/warriorjs/pull/209\n[#210]: https://github.com/olistic/warriorjs/pull/210\n[#213]: https://github.com/olistic/warriorjs/pull/213\n[#215]: https://github.com/olistic/warriorjs/pull/215\n[#216]: https://github.com/olistic/warriorjs/pull/216\n[#217]: https://github.com/olistic/warriorjs/pull/217\n[#219]: https://github.com/olistic/warriorjs/pull/219\n[#220]: https://github.com/olistic/warriorjs/pull/220\n[#221]: https://github.com/olistic/warriorjs/pull/221\n[#234]: https://github.com/olistic/warriorjs/pull/234\n[#236]: https://github.com/olistic/warriorjs/pull/236\n[#238]: https://github.com/olistic/warriorjs/pull/238\n[#240]: https://github.com/olistic/warriorjs/pull/240\n[#246]: https://github.com/olistic/warriorjs/pull/246\n[#247]: https://github.com/olistic/warriorjs/pull/247\n"
  },
  {
    "path": "CLAUDE.md",
    "content": "# WarriorJS\n\nA game that teaches JavaScript and TypeScript through interactive coding challenges. Players write code to control a warrior navigating through towers full of enemies.\n\n## Commands\n\n```bash\npnpm install        # Install dependencies\npnpm build          # Build all packages (Turborepo handles ordering)\npnpm test           # Run all tests\npnpm lint           # Check linting/formatting (Biome)\npnpm lint:fix       # Auto-fix linting/formatting\n```\n\nRun a single package's tests: `npx vitest run apps/cli/`\n\n## Architecture\n\npnpm monorepo with Turborepo. Code is organized into three top-level directories:\n\n- **`apps/`** — End-user applications\n  - **@warriorjs/cli** — CLI for offline play\n- **`libs/`** — Shared libraries\n  - **@warriorjs/core** — Game engine, level runner, player code loader\n  - **@warriorjs/abilities** — Warrior abilities (walk, attack, feel, etc.)\n  - **@warriorjs/units** — Game units/enemies\n  - **@warriorjs/effects** — Status effects system\n  - **@warriorjs/spatial** — Spatial/direction utilities (foundational, no deps)\n  - **@warriorjs/scoring** — Score calculation and grade letters\n- **`towers/`** — Built-in tower definitions\n  - **@warriorjs/tower-the-narrow-path** / **tower-the-powder-keep** — The Narrow Path and The Powder Keep\n\nDependency flow: spatial → abilities → units → towers, spatial → core → scoring → cli.\n\nEach package compiles with `tsc` to `dist/`.\n\n## Conventions\n\n- **Biome** enforces formatting and linting — don't manually fix style, run `pnpm lint:fix`\n- **Lefthook** pre-commit hook auto-formats staged files\n- All imports use `.js` extensions (ES modules with NodeNext resolution)\n- Tests live next to source: `src/Foo.test.ts`\n- Coverage thresholds: 80% (lines, functions, branches, statements)\n- Conventional Commits with scope: `feat(cli): add language choice`, `fix(core): handle edge case`\n"
  },
  {
    "path": "CODE_OF_CONDUCT.md",
    "content": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participation in our\ncommunity a harassment-free experience for everyone, regardless of age, body\nsize, visible or invisible disability, ethnicity, sex characteristics, gender\nidentity and expression, level of experience, education, socio-economic status,\nnationality, personal appearance, race, caste, color, religion, or sexual\nidentity and orientation.\n\nWe pledge to act and interact in ways that contribute to an open, welcoming,\ndiverse, inclusive, and healthy community.\n\n## Our Standards\n\nExamples of behavior that contributes to a positive environment for our\ncommunity include:\n\n- Demonstrating empathy and kindness toward other people\n- Being respectful of differing opinions, viewpoints, and experiences\n- Giving and gracefully accepting constructive feedback\n- Accepting responsibility and apologizing to those affected by our mistakes,\n  and learning from the experience\n- Focusing on what is best not just for us as individuals, but for the overall\n  community\n\nExamples of unacceptable behavior include:\n\n- The use of sexualized language or imagery, and sexual attention or advances of\n  any kind\n- Trolling, insulting or derogatory comments, and personal or political attacks\n- Public or private harassment\n- Publishing others' private information, such as a physical or email address,\n  without their explicit permission\n- Other conduct which could reasonably be considered inappropriate in a\n  professional setting\n\n## Enforcement Responsibilities\n\nCommunity leaders are responsible for clarifying and enforcing our standards of\nacceptable behavior and will take appropriate and fair corrective action in\nresponse to any behavior that they deem inappropriate, threatening, offensive,\nor harmful.\n\nCommunity leaders have the right and responsibility to remove, edit, or reject\ncomments, commits, code, wiki edits, issues, and other contributions that are\nnot aligned to this Code of Conduct, and will communicate reasons for moderation\ndecisions when appropriate.\n\n## Scope\n\nThis Code of Conduct applies within all community spaces, and also applies when\nan individual is officially representing the community in public spaces.\nExamples of representing our community include using an official e-mail address,\nposting via an official social media account, or acting as an appointed\nrepresentative at an online or offline event.\n\n## Enforcement\n\nInstances of abusive, harassing, or otherwise unacceptable behavior may be\nreported to the community leaders responsible for enforcement at\nhi@matiasolivera.com. All complaints will be reviewed and investigated promptly\nand fairly.\n\nAll community leaders are obligated to respect the privacy and security of the\nreporter of any incident.\n\n## Enforcement Guidelines\n\nCommunity leaders will follow these Community Impact Guidelines in determining\nthe consequences for any action they deem in violation of this Code of Conduct:\n\n### 1. Correction\n\n**Community Impact**: Use of inappropriate language or other behavior deemed\nunprofessional or unwelcome in the community.\n\n**Consequence**: A private, written warning from community leaders, providing\nclarity around the nature of the violation and an explanation of why the\nbehavior was inappropriate. A public apology may be requested.\n\n### 2. Warning\n\n**Community Impact**: A violation through a single incident or series of\nactions.\n\n**Consequence**: A warning with consequences for continued behavior. No\ninteraction with the people involved, including unsolicited interaction with\nthose enforcing the Code of Conduct, for a specified period of time. This\nincludes avoiding interactions in community spaces as well as external channels\nlike social media. Violating these terms may lead to a temporary or permanent\nban.\n\n### 3. Temporary Ban\n\n**Community Impact**: A serious violation of community standards, including\nsustained inappropriate behavior.\n\n**Consequence**: A temporary ban from any sort of interaction or public\ncommunication with the community for a specified period of time. No public or\nprivate interaction with the people involved, including unsolicited interaction\nwith those enforcing the Code of Conduct, is allowed during this period.\nViolating these terms may lead to a permanent ban.\n\n### 4. Permanent Ban\n\n**Community Impact**: Demonstrating a pattern of violation of community\nstandards, including sustained inappropriate behavior, harassment of an\nindividual, or aggression toward or disparagement of classes of individuals.\n\n**Consequence**: A permanent ban from any sort of public interaction within the\ncommunity.\n\n## Attribution\n\nThis Code of Conduct is adapted from the [Contributor Covenant][homepage],\nversion 2.1, available at\n[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].\n\nCommunity Impact Guidelines were inspired by\n[Mozilla's code of conduct enforcement ladder][Mozilla CoC].\n\nFor answers to common questions about this code of conduct, see the FAQ at\n[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at\n[https://www.contributor-covenant.org/translations][translations].\n\n[homepage]: https://www.contributor-covenant.org\n[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html\n[Mozilla CoC]: https://github.com/mozilla/diversity\n[FAQ]: https://www.contributor-covenant.org/faq\n[translations]: https://www.contributor-covenant.org/translations\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\n## Code of Conduct\n\nThis project follows a [Code of Conduct](CODE_OF_CONDUCT.md). Please read it.\n\n## Getting Started\n\n1. Fork and clone the repo.\n2. Install dependencies: `pnpm install`\n3. Build: `pnpm build`\n4. Run tests: `pnpm test`\n5. Lint: `pnpm lint`\n\nEdit code in `libs/*/src/`. Tests live next to source files\n(`Foo.test.ts`).\n\n## Making Changes\n\n- **Bug fix or feature?** Open an issue first to discuss scope.\n- **Building a tower?** See the\n  [tower guide](https://warrior.js.org/docs/player/towers).\n- Create a branch, make your changes, and open a PR.\n- Use [Conventional Commits](https://www.conventionalcommits.org) for commit\n  messages: `feat(cli): add language choice`, `fix(core): handle edge case`.\n\nNew to open source? GitHub's\n[guide to contributing](https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project)\nis a good starting point.\n"
  },
  {
    "path": "LICENSE",
    "content": "The MIT License (MIT)\n\nCopyright (c) 2015-present Matías Olivera\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in all\ncopies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE\nSOFTWARE.\n"
  },
  {
    "path": "README.md",
    "content": "<div align=\"center\">\n  <a href=\"https://warrior.js.org\">\n    <img alt=\"WarriorJS Banner\" title=\"WarriorJS\" src=\"logo/warriorjs-banner-dark.png?raw=true\">\n  </a>\n</div>\n\n<br />\n\n<div align=\"center\">\n  <strong>Learn JavaScript and TypeScript by writing code that fights</strong>\n</div>\n\n<br />\n\n<div align=\"center\">\n  <a href=\"https://github.com/olistic/warriorjs/actions/workflows/ci.yml\">\n    <img alt=\"CI\" src=\"https://img.shields.io/github/actions/workflow/status/olistic/warriorjs/ci.yml?branch=master&style=flat-square\">\n  </a>\n  <a href=\"https://codecov.io/gh/olistic/warriorjs\">\n    <img alt=\"Codecov\" src=\"https://img.shields.io/codecov/c/github/olistic/warriorjs.svg?style=flat-square\">\n  </a>\n</div>\n\n<br />\n\nIn WarriorJS, you write JavaScript or TypeScript to guide a warrior through\ntowers full of enemies. Each floor is a puzzle: battle sludge, dodge archers,\nrescue captives, and reach the stairs alive. The code you write _is_ the\nstrategy — there's no clicking, no dragging, just logic and sharp thinking.\n\n**Whether you're writing your first `if` statement or refactoring for a perfect\nscore, every floor will test you.**\n\n## Quick Start\n\n1. Install [Node.js](https://nodejs.org) 22 or later.\n\n2. Install the CLI:\n\n```sh\nnpm install --global @warriorjs/cli\n```\n\n3. Launch the game:\n\n```sh\nwarriorjs\n```\n\nThe game walks you through creating a warrior and choosing a tower. Open the\ngenerated `README.md` for your first level's instructions, write your solution\nin `Player.js`, then run `warriorjs` again to see how your warrior fares.\n\nYou can also play from your browser at\n[warriorjs.com](https://warriorjs.com/?ref=gh).\n\n## Documentation\n\nThe [official docs](https://warrior.js.org) cover everything from first steps\nto building your own towers:\n\n- [Gameplay](https://warrior.js.org/docs/player/gameplay)\n- [Towers](https://warrior.js.org/docs/player/towers)\n- [Player API](https://warrior.js.org/docs/player/space-api)\n\n## Contributing\n\nThe best way to contribute is to build a\n[tower](https://warrior.js.org/docs/player/towers) — a set of levels that\nother players can install and play.\n\nYou can also fix bugs, improve the docs, or add new abilities and units.\nSee the [contribution guide](CONTRIBUTING.md) and\n[Code of Conduct](CODE_OF_CONDUCT.md).\n\n## Acknowledgments\n\nThis project was born as a port of\n[ruby-warrior](https://github.com/ryanb/ruby-warrior). Credits for the original\nidea go to [Ryan Bates](https://github.com/ryanb).\n\nSpecial thanks to [Guillermo Cura](https://github.com/guillecura) for designing\na wonderful [logo](logo).\n\n## License\n\nWarriorJS is licensed under a [MIT License](LICENSE).\n"
  },
  {
    "path": "apps/README.md",
    "content": "# Apps\n\nEnd-user applications built on the WarriorJS packages.\n\n| Package                                           | Version                                                          |\n| ------------------------------------------------- | ---------------------------------------------------------------- |\n| [`@warriorjs/cli`][warriorjs-cli]                 | [![npm][warriorjs-cli-badge]][warriorjs-cli-npm]                 |\n\n- [`@warriorjs/cli`][warriorjs-cli] is the original version of WarriorJS,\n  playable from the terminal.\n\n[warriorjs-cli]: /apps/cli\n[warriorjs-cli-badge]:\n  https://img.shields.io/npm/v/@warriorjs/cli.svg?style=flat-square\n[warriorjs-cli-npm]: https://www.npmjs.com/package/@warriorjs/cli\n"
  },
  {
    "path": "apps/cli/README.md",
    "content": "# @warriorjs/cli\n\n> WarriorJS command line.\n\n## Install\n\n```sh\nnpm install --global @warriorjs/cli\n```\n\n## Usage\n\n```sh\nwarriorjs\n```\n\nFor more in depth documentation see: https://warrior.js.org/docs/player/options.\n"
  },
  {
    "path": "apps/cli/bin/warriorjs.js",
    "content": "#!/usr/bin/env node\n\nimport { hideBin } from 'yargs/helpers';\n\nimport('../dist/cli.js').then(({ run }) => run(hideBin(process.argv)));\n"
  },
  {
    "path": "apps/cli/declarations.d.ts",
    "content": "declare module 'yargs' {\n  function yargs(args?: string[]): any;\n  export default yargs;\n}\n\ndeclare module 'yargs/helpers' {\n  function hideBin(args: string[]): string[];\n  export type { hideBin };\n}\n\ndeclare module 'mock-fs' {\n  function mock(config: Record<string, unknown>): void;\n  namespace mock {\n    function restore(): void;\n  }\n  export default mock;\n}\n\ndeclare module 'array-shuffle' {\n  function arrayShuffle<T>(array: T[]): T[];\n  export default arrayShuffle;\n}\n"
  },
  {
    "path": "apps/cli/package.json",
    "content": "{\n  \"name\": \"@warriorjs/cli\",\n  \"version\": \"0.14.0\",\n  \"description\": \"WarriorJS command line\",\n  \"author\": \"Matias Olivera <hi@matiasolivera.com>\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://warrior.js.org\",\n  \"repository\": \"https://github.com/olistic/warriorjs/tree/master/apps/cli\",\n  \"type\": \"module\",\n  \"keywords\": [\n    \"warriorjs\",\n    \"warriorjs-cli\",\n    \"warrior\",\n    \"epic\",\n    \"battle\",\n    \"game\",\n    \"learn\",\n    \"polish\",\n    \"refine\",\n    \"test\",\n    \"js\",\n    \"javascript\",\n    \"nodejs\",\n    \"ai\",\n    \"artificial-intelligence\",\n    \"skills\"\n  ],\n  \"bin\": {\n    \"warriorjs\": \"./bin/warriorjs.js\"\n  },\n  \"engines\": {\n    \"node\": \">=24\"\n  },\n  \"main\": \"dist/cli.js\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/cli.js\",\n      \"types\": \"./dist/cli.d.ts\"\n    }\n  },\n  \"files\": [\n    \"bin\",\n    \"dist\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc -p tsconfig.build.json\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"devDependencies\": {\n    \"@types/react\": \"^19.2.14\",\n    \"ink-testing-library\": \"^4.0.0\",\n    \"mock-fs\": \"^5.5.0\"\n  },\n  \"dependencies\": {\n    \"@warriorjs/abilities\": \"workspace:^\",\n    \"@warriorjs/core\": \"workspace:^\",\n    \"@warriorjs/scoring\": \"workspace:^\",\n    \"@warriorjs/tower-the-narrow-path\": \"workspace:^\",\n    \"array-shuffle\": \"^3.0.0\",\n    \"find-up\": \"^7.0.0\",\n    \"globby\": \"^14.0.0\",\n    \"ink\": \"^6.8.0\",\n    \"ink-link\": \"^5.0.0\",\n    \"react\": \"^19.2.4\",\n    \"yargs\": \"^17.0.0\"\n  }\n}\n"
  },
  {
    "path": "apps/cli/src/Game.test.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\nimport { getLevelConfig } from '@warriorjs/core';\nimport mock from 'mock-fs';\nimport { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Game from './Game.js';\nimport GameError from './GameError.js';\nimport loadTowers from './loadTowers.js';\nimport Profile from './Profile.js';\nimport ProfileGenerator from './ProfileGenerator.js';\n\nvi.mock('@warriorjs/core');\nvi.mock('./ProfileGenerator.js', () => {\n  const MockProfileGenerator = vi.fn(function (this: any) {});\n  return { default: MockProfileGenerator, __esModule: true };\n});\nvi.mock('./loadTowers.js', () => ({\n  default: vi.fn(() => [{ id: 'tower1', name: 'Tower 1' }]),\n}));\n\ndescribe('Game', () => {\n  let game: any;\n\n  beforeEach(() => {\n    game = new Game('/path/to/game', undefined, false);\n  });\n\n  test('has a run directory path', () => {\n    expect(game.runDirectoryPath).toBe('/path/to/game');\n  });\n\n  test('has a game directory path', () => {\n    expect(game.gameDirectoryPath).toBe(path.normalize('/path/to/game/warriorjs'));\n  });\n\n  describe('buildContext', () => {\n    beforeEach(() => {\n      vi.spyOn(Profile, 'load').mockReturnValue(null);\n      game.getProfiles = vi.fn().mockReturnValue([]);\n    });\n\n    afterEach(() => {\n      vi.restoreAllMocks();\n    });\n\n    test('loads towers', () => {\n      const context = game.buildContext();\n      expect(context.towers).toEqual([{ id: 'tower1', name: 'Tower 1' }]);\n    });\n\n    test('sets needsProfileSetup when no profile found', () => {\n      const context = game.buildContext();\n      expect(context.needsProfileSetup).toBe(true);\n      expect(context.profile).toBeNull();\n    });\n\n    test('sets profile when found', () => {\n      const mockProfile = { isEpic: () => false };\n      vi.spyOn(Profile, 'load').mockReturnValue(mockProfile as any);\n      const context = game.buildContext();\n      expect(context.needsProfileSetup).toBe(false);\n      expect(context.profile).toBe(mockProfile);\n    });\n\n    test('sets error when tower loading fails', () => {\n      vi.mocked(loadTowers).mockImplementationOnce(() => {\n        throw new GameError('Tower load failed');\n      });\n      const context = game.buildContext();\n      expect(context.error).toBe('Tower load failed');\n    });\n\n    test('provides callbacks', () => {\n      const context = game.buildContext();\n      expect(typeof context.onCreateProfile).toBe('function');\n      expect(typeof context.onIsExistingProfile).toBe('function');\n      expect(typeof context.onPrepareNextLevel).toBe('function');\n      expect(typeof context.onPrepareEpicMode).toBe('function');\n      expect(typeof context.onGenerateProfileFiles).toBe('function');\n      expect(typeof context.onProfileSelected).toBe('function');\n    });\n  });\n\n  describe('createProfile', () => {\n    test('creates a profile with the correct directory path', () => {\n      const tower = { id: 'the-narrow-path' };\n      const profile = game.createProfile('Olric', 'typescript', tower);\n      expect(profile).toBeInstanceOf(Profile);\n      expect(profile.warriorName).toBe('Olric');\n      expect(profile.language).toBe('typescript');\n      expect(profile.directoryPath).toBe(\n        path.normalize('/path/to/game/warriorjs/olric-the-narrow-path'),\n      );\n    });\n  });\n\n  test('returns profiles', () => {\n    const originalLoad = Profile.load;\n    game.towers = ['tower1', 'tower2'];\n    game.getProfileDirectoriesPaths = () => [\n      '/path/to/game/warriorjs/profile1',\n      '/path/to/game/warriorjs/profile2',\n    ];\n    Profile.load = vi.fn() as any;\n    game.getProfiles();\n    expect(Profile.load).toHaveBeenCalledWith('/path/to/game/warriorjs/profile1', [\n      'tower1',\n      'tower2',\n    ]);\n    expect(Profile.load).toHaveBeenCalledWith('/path/to/game/warriorjs/profile2', [\n      'tower1',\n      'tower2',\n    ]);\n    Profile.load = originalLoad;\n  });\n\n  test('knows if profile exists', () => {\n    const nonExistingProfile = {\n      directoryPath: '/path/to/nonexisting-profile',\n    };\n    const existentProfile = { directoryPath: '/path/to/profile' };\n    game.getProfileDirectoriesPaths = () => ['/path/to/profile'];\n    expect(game.isExistingProfile(nonExistingProfile)).toBe(false);\n    expect(game.isExistingProfile(existentProfile)).toBe(true);\n  });\n\n  test('returns paths to profile directories', async () => {\n    game.ensureGameDirectory = vi.fn();\n    mock({\n      '/path/to/game/warriorjs': {\n        profile1: {},\n        profile2: {},\n        'other-file': '',\n      },\n    });\n    const profileDirectoriesPaths = await game.getProfileDirectoriesPaths();\n    mock.restore();\n    expect(profileDirectoriesPaths).toEqual([\n      '/path/to/game/warriorjs/profile1',\n      '/path/to/game/warriorjs/profile2',\n    ]);\n    expect(game.ensureGameDirectory).toHaveBeenCalled();\n  });\n\n  describe('ensuring game directory', () => {\n    test(\"creates directory if it doesn't exist\", () => {\n      mock({ '/path/to/game': {} });\n      game.ensureGameDirectory();\n      expect(fs.statSync('/path/to/game/warriorjs').isDirectory()).toBe(true);\n      mock.restore();\n    });\n\n    test('does nothing if directory already exists', () => {\n      mock({ '/path/to/game/warriorjs': {} });\n      expect(fs.statSync('/path/to/game/warriorjs').isDirectory()).toBe(true);\n      game.ensureGameDirectory();\n      expect(fs.statSync('/path/to/game/warriorjs').isDirectory()).toBe(true);\n      mock.restore();\n    });\n\n    test('throws if a warriorjs file exists', () => {\n      mock({ '/path/to/game/warriorjs': '' });\n      expect(() => {\n        game.ensureGameDirectory();\n      }).toThrow(\n        new GameError(\n          'A file named warriorjs exists at this location. Please change the directory under which you are running warriorjs.',\n        ),\n      );\n      mock.restore();\n    });\n  });\n\n  test('prepares next level', () => {\n    game.profile = { goToNextLevel: vi.fn() };\n    game.generateProfileFiles = vi.fn();\n    game.prepareNextLevel();\n    expect(game.profile.goToNextLevel).toHaveBeenCalled();\n    expect(game.generateProfileFiles).toHaveBeenCalled();\n  });\n\n  test('generates player', () => {\n    game.profile = {\n      tower: 'tower',\n      levelNumber: 1,\n      warriorName: 'Joe',\n      epic: false,\n      getReadmeFilePath: () => '/path/to/profile/readme',\n    };\n    (getLevelConfig as any).mockReturnValue('config');\n    const mockGenerate = vi.fn();\n    (ProfileGenerator as any).mockImplementation(function (this: any) {\n      this.generate = mockGenerate;\n    });\n    game.generateProfileFiles();\n    expect(getLevelConfig).toHaveBeenCalledWith('tower', 1, 'Joe', false);\n    expect(ProfileGenerator).toHaveBeenCalledWith(game.profile, 'config');\n    expect(mockGenerate).toHaveBeenCalled();\n  });\n\n  test('prepares epic mode', () => {\n    game.profile = { enableEpicMode: vi.fn() };\n    game.prepareEpicMode();\n    expect(game.profile.enableEpicMode).toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/Game.ts",
    "content": "import fs from 'node:fs';\nimport { createRequire } from 'node:module';\nimport path from 'node:path';\nimport { getLevelConfig } from '@warriorjs/core';\nimport { globbySync } from 'globby';\n\nimport GameError from './GameError.js';\nimport loadTowers from './loadTowers.js';\nimport Profile from './Profile.js';\nimport ProfileGenerator from './ProfileGenerator.js';\nimport type Tower from './Tower.js';\n\nconst require = createRequire(import.meta.url);\nconst { version: cliVersion } = require('../package.json');\n\nconst gameDirectory = 'warriorjs';\n\nexport interface GameContext {\n  version: string;\n  runDirectoryPath: string;\n  practiceLevel: number | undefined;\n  silencePlay: boolean;\n  towers: Tower[];\n  profile: Profile | null;\n  profiles: Profile[];\n  needsProfileSetup: boolean;\n  error?: string;\n  onCreateProfile: (\n    warriorName: string,\n    language: 'javascript' | 'typescript',\n    tower: Tower,\n  ) => Profile;\n  onIsExistingProfile: (profile: Profile) => boolean;\n  onPrepareNextLevel: () => void;\n  onPrepareEpicMode: () => void;\n  onGenerateProfileFiles: () => void;\n  onProfileSelected: (profile: Profile) => void;\n}\n\nclass Game {\n  runDirectoryPath: string;\n  practiceLevel: number | undefined;\n  silencePlay: boolean;\n  gameDirectoryPath: string;\n  towers: Tower[] = [];\n  profile: Profile | null = null;\n\n  constructor(runDirectoryPath: string, practiceLevel: number | undefined, silencePlay: boolean) {\n    this.runDirectoryPath = runDirectoryPath;\n    this.practiceLevel = practiceLevel;\n    this.silencePlay = silencePlay;\n    this.gameDirectoryPath = path.join(this.runDirectoryPath, gameDirectory);\n  }\n\n  buildContext(): GameContext {\n    let error: string | undefined;\n\n    try {\n      this.towers = loadTowers();\n    } catch (err: any) {\n      error =\n        err instanceof GameError || err.code === 'InvalidPlayerCode' ? err.message : String(err);\n    }\n\n    let profiles: Profile[] = [];\n    let needsProfileSetup = false;\n\n    if (!error) {\n      this.profile = Profile.load(this.runDirectoryPath, this.towers);\n      if (!this.profile) {\n        try {\n          profiles = this.getProfiles();\n          needsProfileSetup = true;\n        } catch (err: any) {\n          error = err instanceof GameError ? err.message : String(err);\n        }\n      }\n    }\n\n    return {\n      version: `v${cliVersion}`,\n      runDirectoryPath: this.runDirectoryPath,\n      practiceLevel: this.practiceLevel,\n      silencePlay: this.silencePlay,\n      towers: this.towers,\n      profile: this.profile,\n      profiles,\n      needsProfileSetup,\n      error,\n      onCreateProfile: (warriorName, language, tower) =>\n        this.createProfile(warriorName, language, tower),\n      onIsExistingProfile: (profile) => this.isExistingProfile(profile),\n      onPrepareNextLevel: () => this.prepareNextLevel(),\n      onPrepareEpicMode: () => this.prepareEpicMode(),\n      onGenerateProfileFiles: () => this.generateProfileFiles(),\n      onProfileSelected: (profile) => {\n        this.profile = profile;\n      },\n    };\n  }\n\n  createProfile(warriorName: string, language: 'javascript' | 'typescript', tower: Tower): Profile {\n    const profileDirectoryPath = path.join(\n      this.gameDirectoryPath,\n      `${warriorName}-${tower.id}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'),\n    );\n    return new Profile(warriorName, tower, profileDirectoryPath, language);\n  }\n\n  isExistingProfile(profile: Profile): boolean {\n    const profileDirectoriesPaths = this.getProfileDirectoriesPaths();\n    return profileDirectoriesPaths.some(\n      (profileDirectoryPath) => profileDirectoryPath === profile.directoryPath,\n    );\n  }\n\n  getProfiles(): Profile[] {\n    const profileDirectoriesPaths = this.getProfileDirectoriesPaths();\n    return profileDirectoriesPaths\n      .map((profileDirectoryPath) => Profile.load(profileDirectoryPath, this.towers))\n      .filter((p): p is Profile => p !== null);\n  }\n\n  getProfileDirectoriesPaths(): string[] {\n    this.ensureGameDirectory();\n    const profileDirectoryPattern = path.join(this.gameDirectoryPath, '*');\n    return globbySync(profileDirectoryPattern, { onlyDirectories: true });\n  }\n\n  ensureGameDirectory(): void {\n    try {\n      if (!fs.statSync(this.gameDirectoryPath).isDirectory()) {\n        throw new GameError(\n          'A file named warriorjs exists at this location. Please change the directory under which you are running warriorjs.',\n        );\n      }\n    } catch (err: any) {\n      if (err.code !== 'ENOENT') {\n        throw err;\n      }\n\n      fs.mkdirSync(this.gameDirectoryPath);\n    }\n  }\n\n  prepareNextLevel(): void {\n    this.profile!.goToNextLevel();\n    this.generateProfileFiles();\n  }\n\n  generateProfileFiles(): void {\n    const { tower, levelNumber, warriorName, epic } = this.profile!;\n    const levelConfig = getLevelConfig(tower, levelNumber, warriorName, epic);\n    new ProfileGenerator(this.profile!, levelConfig!).generate();\n  }\n\n  prepareEpicMode(): void {\n    this.profile!.enableEpicMode();\n  }\n}\n\nexport default Game;\n"
  },
  {
    "path": "apps/cli/src/GameError.ts",
    "content": "class GameError extends Error {\n  constructor(message: string) {\n    super(message);\n    Error.captureStackTrace(this, GameError);\n  }\n}\n\nexport default GameError;\n"
  },
  {
    "path": "apps/cli/src/Profile.test.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\nimport mock from 'mock-fs';\nimport { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport GameError from './GameError.js';\nimport Profile from './Profile.js';\nimport type Tower from './Tower.js';\n\ndescribe('Profile.load', () => {\n  const originalRead = Profile.read;\n  const originalIsProfileDirectory = Profile.isProfileDirectory;\n  const profileTower = { id: 'foo', name: 'Foo' } as any as Tower;\n  const towers = [profileTower, { id: 'bar', name: 'Bar' } as any as Tower];\n\n  afterEach(() => {\n    Profile.read = originalRead;\n    Profile.isProfileDirectory = originalIsProfileDirectory;\n  });\n\n  test('instances Profile with contents of profile file', () => {\n    Profile.isProfileDirectory = () => true;\n    Profile.read = () =>\n      'eyJ3YXJyaW9yTmFtZSI6ICJKb2UiLCAidG93ZXJJZCI6ICJmb28iLCAiYW5vdGhlcktleSI6IDQyfQ==';\n    const profile = Profile.load('/path/to/profile', towers)!;\n    expect(profile).toBeInstanceOf(Profile);\n    expect(profile.warriorName).toBe('Joe');\n    expect(profile.tower).toBe(profileTower);\n    expect((profile as any).anotherKey).toBe(42);\n  });\n\n  test('sets the directory path to the path from where the profile is being loaded', () => {\n    Profile.isProfileDirectory = () => true;\n    Profile.read = () => 'eyJ3YXJyaW9yTmFtZSI6ICJKb2UiLCAidG93ZXJJZCI6ICJmb28ifQ==';\n    const profile = Profile.load('/path/to/profile', towers)!;\n    expect(profile.directoryPath).toBe('/path/to/profile');\n  });\n\n  test('ignores keys that were once part of the encoded profile', () => {\n    Profile.isProfileDirectory = () => true;\n    Profile.read = () =>\n      'eyJ3YXJyaW9yTmFtZSI6ICJKb2UiLCAidG93ZXJJZCI6ICJmb28iLCAiZGlyZWN0b3J5UGF0aCI6ICJsZWdhY3kiLCAidG93ZXJOYW1lIjogImxlZ2FjeSIsICJjdXJyZW50RXBpY1Njb3JlIjogImxlZ2FjeSIsICJjdXJyZW50RXBpY0dyYWRlcyI6ICJsZWdhY3kifQ==';\n    const profile = Profile.load('/path/to/profile', towers)!;\n    expect(profile).not.toHaveProperty('towerName');\n    expect(profile.directoryPath).toBe('/path/to/profile');\n    expect(profile.currentEpicScore).toBe(0);\n    expect(profile.currentEpicGrades).toEqual({});\n  });\n\n  test('returns null if not a profile directory', () => {\n    Profile.isProfileDirectory = () => false;\n    const profile = Profile.load('/path/to/profile', towers);\n    expect(profile).toBeNull();\n  });\n\n  test('returns null if no encoded profile', () => {\n    Profile.isProfileDirectory = () => true;\n    Profile.read = () => null;\n    const profile = Profile.load('/path/to/profile', towers);\n    expect(profile).toBeNull();\n  });\n\n  test('throws if profile tower is not available', () => {\n    Profile.isProfileDirectory = () => true;\n    Profile.read = () =>\n      'eyJ3YXJyaW9yTmFtZSI6ICJKb2UiLCAidG93ZXJJZCI6ICJmb28iLCAiYW5vdGhlcktleSI6IDQyfQ==';\n    expect(() => {\n      Profile.load('/path/to/profile', []);\n    }).toThrow(new GameError(`Unable to find tower 'foo', make sure it is available.`));\n  });\n});\n\ndescribe('Profile.isProfileDirectory', () => {\n  test('returns false if only the profile file exists', () => {\n    mock({ '/path/to/profile/.profile': 'encoded profile' });\n    expect(Profile.isProfileDirectory('/path/to/profile')).toBe(false);\n    mock.restore();\n  });\n\n  test('returns false if only the player code file exists', () => {\n    mock({ '/path/to/profile/Player.js': 'player code' });\n    expect(Profile.isProfileDirectory('/path/to/profile')).toBe(false);\n    mock.restore();\n  });\n\n  test('returns false if neither file exists', () => {\n    expect(Profile.isProfileDirectory('/path/to/profile')).toBe(false);\n  });\n\n  test('returns true if both files exist', () => {\n    mock({\n      '/path/to/profile/.profile': 'encoded profile',\n      '/path/to/profile/Player.js': 'player code',\n    });\n    expect(Profile.isProfileDirectory('/path/to/profile')).toBe(true);\n    mock.restore();\n  });\n});\n\ndescribe('Profile.read', () => {\n  test('returns contents of profile file', () => {\n    mock({ '/path/to/profile/file': 'encoded profile' });\n    expect(Profile.read('/path/to/profile/file')).toBe('encoded profile');\n    mock.restore();\n  });\n\n  test(\"returns null if profile file doesn't exist\", () => {\n    mock({ '/path/to/profile': {} });\n    expect(Profile.read('/path/to/profile/file')).toBeNull();\n    mock.restore();\n  });\n});\n\ndescribe('Profile.decode', () => {\n  test('decodes from JSON + base64', () => {\n    expect(\n      Profile.decode(\n        'eyJ3YXJyaW9yTmFtZSI6IkpvZSIsInRvd2VySWQiOiJmb28iLCJsZXZlbE51bWJlciI6MCwiY2x1ZSI6ZmFsc2UsImVwaWMiOmZhbHNlLCJzY29yZSI6MCwiZXBpY1Njb3JlIjowLCJhdmVyYWdlR3JhZGUiOm51bGx9',\n      ),\n    ).toEqual({\n      warriorName: 'Joe',\n      towerId: 'foo',\n      levelNumber: 0,\n      clue: false,\n      epic: false,\n      score: 0,\n      epicScore: 0,\n      averageGrade: null,\n    });\n  });\n\n  test('throws if invalid encoded profile', () => {\n    expect(() => {\n      Profile.decode('invalid encoded profile');\n    }).toThrow(\n      new GameError(\n        'Corrupted .profile file. Run warriorjs from a directory with a valid profile.',\n      ),\n    );\n  });\n});\n\ndescribe('Profile', () => {\n  let profile: Profile;\n  let tower: any;\n\n  beforeEach(() => {\n    tower = { id: 'foo', name: 'Foo' };\n    profile = new Profile('Joe', tower, '/path/to/profile');\n  });\n\n  test('has a warrior name', () => {\n    expect(profile.warriorName).toBe('Joe');\n  });\n\n  test('has a tower', () => {\n    expect(profile.tower).toBe(tower);\n  });\n\n  test('has a directory path', () => {\n    expect(profile.directoryPath).toBe('/path/to/profile');\n  });\n\n  test('starts level number at zero', () => {\n    expect(profile.levelNumber).toBe(0);\n  });\n\n  test('starts score at zero', () => {\n    expect(profile.score).toBe(0);\n    expect(profile.epicScore).toBe(0);\n    expect(profile.currentEpicScore).toBe(0);\n    expect(profile.currentEpicGrades).toEqual({});\n  });\n\n  test('starts in normal mode', () => {\n    expect(profile.epic).toBe(false);\n  });\n\n  test(\"doesn't show clue at the beginning\", () => {\n    expect(profile.clue).toBe(false);\n  });\n\n  test('makes directory', () => {\n    mock({ '/path/to': {} });\n    profile.makeProfileDirectory();\n    expect(fs.statSync('/path/to/profile').isDirectory()).toBe(true);\n    mock.restore();\n  });\n\n  describe('when reading player code', () => {\n    beforeEach(() => {\n      profile.getPlayerCodeFilePath = () => '/path/to/profile/player-code';\n    });\n\n    test('returns contents of player code file', () => {\n      mock({ '/path/to/profile/player-code': 'class Player {}' });\n      expect(profile.readPlayerCode()).toBe('class Player {}');\n      mock.restore();\n    });\n\n    test(\"returns null if player code file doesn't exist\", () => {\n      mock({ '/path/to/profile': {} });\n      expect(profile.readPlayerCode()).toBeNull();\n      mock.restore();\n    });\n  });\n\n  test('knows the path to the player code file', () => {\n    expect(profile.getPlayerCodeFilePath()).toBe(path.normalize('/path/to/profile/Player.js'));\n  });\n\n  test('knows the path to the README file', () => {\n    expect(profile.getReadmeFilePath()).toBe(path.normalize('/path/to/profile/README.md'));\n  });\n\n  describe('when going to the next level', () => {\n    beforeEach(() => {\n      profile.save = vi.fn();\n    });\n\n    test('increments the level number', () => {\n      profile.levelNumber = 0;\n      profile.goToNextLevel();\n      expect(profile.levelNumber).toBe(1);\n    });\n\n    test('resets the clue status', () => {\n      profile.clue = true;\n      profile.goToNextLevel();\n      expect(profile.clue).toBe(false);\n    });\n\n    test('saves the profile', () => {\n      profile.goToNextLevel();\n      expect(profile.save).toHaveBeenCalled();\n    });\n  });\n\n  describe('when requesting the clue', () => {\n    beforeEach(() => {\n      profile.save = vi.fn();\n    });\n\n    test('sets the clue status', () => {\n      profile.clue = false;\n      profile.requestClue();\n      expect(profile.clue).toBe(true);\n    });\n\n    test('saves the profile', () => {\n      profile.requestClue();\n      expect(profile.save).toHaveBeenCalled();\n    });\n  });\n\n  test('knows if clue is being shown', () => {\n    expect(profile.isShowingClue()).toBe(false);\n    profile.clue = true;\n    expect(profile.isShowingClue()).toBe(true);\n  });\n\n  describe('enabling epic mode', () => {\n    beforeEach(() => {\n      profile.save = vi.fn();\n    });\n\n    test('sets the epic status', () => {\n      profile.epic = false;\n      profile.enableEpicMode();\n      expect(profile.epic).toBe(true);\n    });\n\n    test('saves the profile', () => {\n      profile.enableEpicMode();\n      expect(profile.save).toHaveBeenCalled();\n    });\n  });\n\n  test(\"knows if it's epic\", () => {\n    expect(profile.isEpic()).toBe(false);\n    profile.epic = true;\n    expect(profile.isEpic()).toBe(true);\n  });\n\n  test('tallies the points by adding to the score', () => {\n    profile.score = 0;\n    profile.tallyPoints(1, 123);\n    expect(profile.score).toBe(123);\n  });\n\n  test('writes the encoded profile to the profile file when saving', () => {\n    profile.getProfileFilePath = () => '/path/to/profile/file';\n    profile.encode = () => 'encoded';\n    mock({ '/path/to/profile': {} });\n    profile.save();\n    expect(fs.readFileSync('/path/to/profile/file', 'utf8')).toBe('encoded');\n    mock.restore();\n  });\n\n  test('knows the path to the profile file', () => {\n    expect(profile.getProfileFilePath()).toBe(path.normalize('/path/to/profile/.profile'));\n  });\n\n  test('encodes with JSON + base64', () => {\n    expect(profile.encode()).toBe(\n      'eyJ3YXJyaW9yTmFtZSI6IkpvZSIsInRvd2VySWQiOiJmb28iLCJsYW5ndWFnZSI6ImphdmFzY3JpcHQiLCJsZXZlbE51bWJlciI6MCwiY2x1ZSI6ZmFsc2UsImVwaWMiOmZhbHNlLCJzY29yZSI6MCwiZXBpY1Njb3JlIjowLCJhdmVyYWdlR3JhZGUiOm51bGx9',\n    );\n  });\n\n  test('serializes to JSON ignoring properties', () => {\n    (profile as any).currentEpicScore = 'ignored';\n    (profile as any).currentEpicGrades = 'ignored';\n    (profile as any).directoryPath = 'ignored';\n    (profile as any).tower = 'ignored';\n    const serializedProfile = JSON.parse(JSON.stringify(profile));\n    expect(serializedProfile).not.toHaveProperty('currentEpicScore');\n    expect(serializedProfile).not.toHaveProperty('currentEpicGrades');\n    expect(serializedProfile).not.toHaveProperty('directoryPath');\n    expect(serializedProfile).not.toHaveProperty('tower');\n  });\n\n  test('has a nice string representation', () => {\n    profile.tower.toString = () => 'Foo';\n    profile.levelNumber = 4;\n    profile.score = 123;\n    expect(profile.toString()).toBe('Joe - JavaScript - Foo - level 4 - score 123');\n  });\n\n  describe('epic mode', () => {\n    beforeEach(() => {\n      profile.epic = true;\n    });\n\n    test('tallies the points by adding to the current epic score and grades', () => {\n      profile.epicScore = 0;\n      profile.currentEpicGrades = {};\n      profile.tallyPoints(1, 124, 0.8);\n      expect(profile.currentEpicScore).toBe(124);\n      expect(profile.currentEpicGrades[1]).toBe(0.8);\n    });\n\n    test('calculates average grade as the average of the current epic grades', () => {\n      profile.currentEpicGrades = { 1: 0.7, 2: 0.9 };\n      expect(profile.calculateAverageGrade()).toBe(0.8);\n    });\n\n    test(\"doesn't calculate average grade if no grades are present\", () => {\n      expect(profile.calculateAverageGrade()).toBeNull();\n    });\n\n    test('returns only epic score if no average grade', () => {\n      profile.epicScore = 124;\n      expect(profile.getEpicScoreWithGrade()).toBe('124');\n    });\n\n    test('returns epic score with grade if average grade', () => {\n      profile.epicScore = 124;\n      profile.averageGrade = 0.7;\n      expect(profile.getEpicScoreWithGrade()).toBe('124 (C)');\n    });\n\n    describe('updating epic score', () => {\n      beforeEach(() => {\n        profile.save = vi.fn();\n      });\n\n      test('should override epic score and average grade with current ones if current epic score is higher', () => {\n        profile.currentEpicScore = 123;\n        profile.currentEpicGrades = { 1: 0.7, 2: 0.9 };\n        profile.updateEpicScore();\n        expect(profile.epicScore).toBe(123);\n        expect(profile.averageGrade).toBe(0.8);\n      });\n\n      test('should not override epic score and average grade if it is lower', () => {\n        profile.epicScore = 124;\n        profile.averageGrade = 0.9;\n        profile.currentEpicScore = 123;\n        profile.currentEpicGrades = { 1: 0.7, 2: 0.9 };\n        profile.updateEpicScore();\n        expect(profile.epicScore).toBe(124);\n        expect(profile.averageGrade).toBe(0.9);\n      });\n\n      test('saves the profile', () => {\n        profile.updateEpicScore();\n        expect(profile.save).toHaveBeenCalled();\n      });\n    });\n\n    test('includes epic score in string representation', () => {\n      profile.tower.toString = () => 'Foo';\n      profile.score = 123;\n      profile.getEpicScoreWithGrade = () => '124 (C)';\n      expect(profile.toString()).toBe(\n        'Joe - JavaScript - Foo - first score 123 - epic score 124 (C)',\n      );\n    });\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/Profile.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\nimport { getGradeLetter } from '@warriorjs/scoring';\n\nimport GameError from './GameError.js';\nimport type Tower from './Tower.js';\n\nconst profileFile = '.profile';\nconst playerCodeFileJs = 'Player.js';\nconst playerCodeFileTs = 'Player.ts';\nconst readmeFile = 'README.md';\n\nclass Profile {\n  warriorName: string;\n  tower: Tower;\n  directoryPath: string;\n  language: 'javascript' | 'typescript';\n  levelNumber: number;\n  score: number;\n  clue: boolean;\n  epic: boolean;\n  epicScore: number;\n  averageGrade: number | null;\n  currentEpicScore: number;\n  currentEpicGrades: Record<number, number>;\n  [key: string]: unknown;\n\n  static load(profileDirectoryPath: string, towers: Tower[]): Profile | null {\n    if (!Profile.isProfileDirectory(profileDirectoryPath)) {\n      return null;\n    }\n\n    const profileFilePath = path.join(profileDirectoryPath, profileFile);\n    const encodedProfile = Profile.read(profileFilePath);\n    if (!encodedProfile) {\n      return null;\n    }\n\n    const decodedProfile = Profile.decode(encodedProfile);\n    const {\n      warriorName,\n      towerId,\n      towerName, // TODO: Remove before v1.0.0.\n      directoryPath, // TODO: Remove before v1.0.0.\n      currentEpicScore, // TODO: Remove before v1.0.0.\n      currentEpicGrades, // TODO: Remove before v1.0.0.\n      ...profileData\n    } = decodedProfile;\n\n    const towerKey = towerId || towerName; // Support legacy profiles.\n    const profileTower = towers.find((tower) => tower.id === towerKey);\n    if (!profileTower) {\n      throw new GameError(`Unable to find tower '${towerKey}', make sure it is available.`);\n    }\n\n    const profile = new Profile(warriorName as string, profileTower, profileDirectoryPath);\n    return Object.assign(profile, profileData);\n  }\n\n  static isProfileDirectory(profileDirectoryPath: string): boolean {\n    const profileFilePath = path.join(profileDirectoryPath, profileFile);\n    const playerCodeFilePathJs = path.join(profileDirectoryPath, playerCodeFileJs);\n    const playerCodeFilePathTs = path.join(profileDirectoryPath, playerCodeFileTs);\n\n    const fileExists = (p: string) => {\n      try {\n        return fs.statSync(p).isFile();\n      } catch {\n        return false;\n      }\n    };\n\n    return (\n      fileExists(profileFilePath) &&\n      (fileExists(playerCodeFilePathJs) || fileExists(playerCodeFilePathTs))\n    );\n  }\n\n  static read(profileFilePath: string): string | null {\n    try {\n      return fs.readFileSync(profileFilePath, 'utf8');\n    } catch (err: any) {\n      if (err.code === 'ENOENT') {\n        return null;\n      }\n\n      throw err;\n    }\n  }\n\n  static decode(encodedProfile: string): Record<string, unknown> {\n    try {\n      return JSON.parse(Buffer.from(encodedProfile, 'base64').toString());\n    } catch (err) {\n      if (err instanceof SyntaxError) {\n        throw new GameError(\n          'Corrupted .profile file. Run warriorjs from a directory with a valid profile.',\n        );\n      }\n\n      throw err;\n    }\n  }\n\n  constructor(\n    warriorName: string,\n    tower: Tower,\n    directoryPath: string,\n    language: 'javascript' | 'typescript' = 'javascript',\n  ) {\n    this.warriorName = warriorName;\n    this.tower = tower;\n    this.directoryPath = directoryPath;\n    this.language = language;\n    this.levelNumber = 0;\n    this.score = 0;\n    this.clue = false;\n    this.epic = false;\n    this.epicScore = 0;\n    this.averageGrade = null;\n    this.currentEpicScore = 0;\n    this.currentEpicGrades = {};\n  }\n\n  makeProfileDirectory(): void {\n    fs.mkdirSync(this.directoryPath);\n  }\n\n  readPlayerCode(): string | null {\n    try {\n      return fs.readFileSync(this.getPlayerCodeFilePath(), 'utf8');\n    } catch (err: any) {\n      if (err.code === 'ENOENT') {\n        return null;\n      }\n\n      throw err;\n    }\n  }\n\n  getPlayerCodeFilePath(): string {\n    const playerCodeFile = this.language === 'typescript' ? playerCodeFileTs : playerCodeFileJs;\n    return path.join(this.directoryPath, playerCodeFile);\n  }\n\n  getReadmeFilePath(): string {\n    return path.join(this.directoryPath, readmeFile);\n  }\n\n  goToNextLevel(): void {\n    this.levelNumber += 1;\n    this.clue = false;\n    this.save();\n  }\n\n  requestClue(): void {\n    this.clue = true;\n    this.save();\n  }\n\n  isShowingClue(): boolean {\n    return this.clue;\n  }\n\n  enableEpicMode(): void {\n    this.epic = true;\n    this.save();\n  }\n\n  isEpic(): boolean {\n    return this.epic;\n  }\n\n  tallyPoints(levelNumber: number, totalScore: number, grade?: number): void {\n    if (this.isEpic()) {\n      this.currentEpicGrades[levelNumber] = grade!;\n      this.currentEpicScore += totalScore;\n    } else {\n      this.score += totalScore;\n    }\n  }\n\n  getEpicScoreWithGrade(): string {\n    if (this.averageGrade) {\n      return `${this.epicScore} (${getGradeLetter(this.averageGrade)})`;\n    }\n\n    return this.epicScore.toString();\n  }\n\n  updateEpicScore(): void {\n    if (this.currentEpicScore > this.epicScore) {\n      this.epicScore = this.currentEpicScore;\n      this.averageGrade = this.calculateAverageGrade();\n    }\n\n    this.save();\n  }\n\n  calculateAverageGrade(): number | null {\n    const grades = Object.values(this.currentEpicGrades);\n    if (!grades.length) {\n      return null;\n    }\n\n    return grades.reduce((sum, value) => sum + value) / grades.length;\n  }\n\n  save(): void {\n    fs.writeFileSync(this.getProfileFilePath(), this.encode());\n  }\n\n  getProfileFilePath(): string {\n    return path.join(this.directoryPath, profileFile);\n  }\n\n  encode(): string {\n    return Buffer.from(JSON.stringify(this)).toString('base64');\n  }\n\n  toJSON(): Record<string, unknown> {\n    return {\n      warriorName: this.warriorName,\n      towerId: this.tower.id,\n      language: this.language,\n      levelNumber: this.levelNumber,\n      clue: this.clue,\n      epic: this.epic,\n      score: this.score,\n      epicScore: this.epicScore,\n      averageGrade: this.averageGrade,\n    };\n  }\n\n  toString(): string {\n    const languageLabel = this.language === 'typescript' ? 'TypeScript' : 'JavaScript';\n    let result = `${this.warriorName} - ${languageLabel} - ${this.tower}`;\n    if (this.isEpic()) {\n      result += ` - first score ${this.score} - epic score ${this.getEpicScoreWithGrade()}`;\n    } else {\n      result += ` - level ${this.levelNumber} - score ${this.score}`;\n    }\n\n    return result;\n  }\n}\n\nexport default Profile;\n"
  },
  {
    "path": "apps/cli/src/ProfileGenerator.test.ts",
    "content": "import fs from 'node:fs';\nimport mock from 'mock-fs';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport ProfileGenerator from './ProfileGenerator.js';\nimport renderPlayerCode from './utils/renderPlayerCode.js';\nimport renderReadme from './utils/renderReadme.js';\nimport renderTypes from './utils/renderTypes.js';\n\nvi.mock('./utils/renderReadme.js');\nvi.mock('./utils/renderPlayerCode.js');\nvi.mock('./utils/renderTypes.js');\n\ndescribe('ProfileGenerator', () => {\n  let profileGenerator: ProfileGenerator;\n  let profile: any;\n  let levelConfig: any;\n\n  beforeEach(() => {\n    profile = {\n      getPlayerCodeFilePath: () => '/path/to/profile/player-code',\n      getReadmeFilePath: () => '/path/to/profile/readme',\n      directoryPath: '/path/to/profile',\n    };\n    levelConfig = {\n      floor: {\n        warrior: { abilities: {} },\n      },\n    };\n    profileGenerator = new ProfileGenerator(profile, levelConfig);\n  });\n\n  test('has a profile', () => {\n    expect(profileGenerator.profile).toBe(profile);\n  });\n\n  test('has a level config', () => {\n    expect(profileGenerator.levelConfig).toBe(levelConfig);\n  });\n\n  describe('when generating', () => {\n    beforeEach(() => {\n      profileGenerator.generateReadmeFile = vi.fn();\n      profileGenerator.generatePlayerCodeFile = vi.fn();\n    });\n\n    test('generates readme file', () => {\n      profileGenerator.generate();\n      expect(profileGenerator.generateReadmeFile).toHaveBeenCalled();\n      expect(profileGenerator.generatePlayerCodeFile).not.toHaveBeenCalled();\n    });\n\n    test('generates player code file if first level', () => {\n      profile.levelNumber = 1;\n      profileGenerator.generate();\n      expect(profileGenerator.generatePlayerCodeFile).toHaveBeenCalled();\n    });\n\n    test('generates types file for typescript profiles', () => {\n      profile.language = 'typescript';\n      profileGenerator.generateTypesFile = vi.fn();\n      profileGenerator.generate();\n      expect(profileGenerator.generateTypesFile).toHaveBeenCalled();\n    });\n\n    test('does not generate types file for javascript profiles', () => {\n      profile.language = 'javascript';\n      profileGenerator.generateTypesFile = vi.fn();\n      profileGenerator.generate();\n      expect(profileGenerator.generateTypesFile).not.toHaveBeenCalled();\n    });\n  });\n\n  test('generates readme file', () => {\n    (renderReadme as any).mockReturnValue('rendered readme');\n    mock({ '/path/to/profile': {} });\n    profileGenerator.generateReadmeFile();\n    expect(renderReadme).toHaveBeenCalledWith(profile, levelConfig);\n    expect(fs.readFileSync('/path/to/profile/readme', 'utf8')).toBe('rendered readme');\n    mock.restore();\n  });\n\n  test('generates player code file', () => {\n    (renderPlayerCode as any).mockReturnValue('rendered player code');\n    mock({ '/path/to/profile': {} });\n    profileGenerator.generatePlayerCodeFile();\n    expect(renderPlayerCode).toHaveBeenCalledWith(profile, levelConfig);\n    expect(fs.readFileSync('/path/to/profile/player-code', 'utf8')).toBe('rendered player code');\n    mock.restore();\n  });\n\n  test('generates types file', () => {\n    (renderTypes as any).mockReturnValue('rendered types');\n    mock({ '/path/to/profile': {} });\n    profileGenerator.generateTypesFile();\n    expect(renderTypes).toHaveBeenCalledWith(profile, levelConfig);\n    expect(fs.readFileSync('/path/to/profile/types.ts', 'utf8')).toBe('rendered types');\n    mock.restore();\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ProfileGenerator.ts",
    "content": "import fs from 'node:fs';\nimport path from 'node:path';\nimport { type LevelConfig } from '@warriorjs/core';\n\nimport type Profile from './Profile.js';\nimport renderPlayerCode from './utils/renderPlayerCode.js';\nimport renderReadme from './utils/renderReadme.js';\nimport renderTypes from './utils/renderTypes.js';\n\nclass ProfileGenerator {\n  profile: Profile;\n  levelConfig: LevelConfig;\n\n  constructor(profile: Profile, levelConfig: LevelConfig) {\n    this.profile = profile;\n    this.levelConfig = levelConfig;\n  }\n\n  generate(): void {\n    this.generateReadmeFile();\n    if (this.profile.levelNumber === 1) {\n      this.generatePlayerCodeFile();\n    }\n    if (this.profile.language === 'typescript') {\n      this.generateTypesFile();\n    }\n  }\n\n  generateReadmeFile(): void {\n    const readme = renderReadme(this.profile, this.levelConfig);\n    fs.writeFileSync(this.profile.getReadmeFilePath(), readme);\n  }\n\n  generatePlayerCodeFile(): void {\n    const code = renderPlayerCode(this.profile, this.levelConfig);\n    fs.writeFileSync(this.profile.getPlayerCodeFilePath(), code);\n  }\n\n  generateTypesFile(): void {\n    const rendered = renderTypes(this.profile, this.levelConfig);\n    const typesFilePath = path.join(this.profile.directoryPath, 'types.ts');\n    fs.writeFileSync(typesFilePath, rendered);\n  }\n}\n\nexport default ProfileGenerator;\n"
  },
  {
    "path": "apps/cli/src/Tower.test.ts",
    "content": "import { beforeEach, describe, expect, test } from 'vitest';\n\nimport Tower from './Tower.js';\n\ndescribe('Tower', () => {\n  let tower: Tower;\n\n  beforeEach(() => {\n    tower = new Tower('foo', 'Foo', 'bar baz', 'warrior' as any, ['level1', 'level2'] as any);\n  });\n\n  test('has an id', () => {\n    expect(tower.id).toBe('foo');\n  });\n\n  test('has a name', () => {\n    expect(tower.name).toBe('Foo');\n  });\n\n  test('has a description', () => {\n    expect(tower.description).toBe('bar baz');\n  });\n\n  test('has a warrior', () => {\n    expect(tower.warrior).toEqual('warrior');\n  });\n\n  test('has some levels', () => {\n    expect(tower.levels).toEqual(['level1', 'level2']);\n  });\n\n  test('knows if it has a given level', () => {\n    expect(tower.hasLevel(1)).toBe(true);\n    expect(tower.hasLevel(3)).toBe(false);\n  });\n\n  test('returns the level with the given number', () => {\n    expect(tower.getLevel(1)).toBe('level1');\n  });\n\n  test('has a nice string representation', () => {\n    expect(tower.toString()).toBe('Foo');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/Tower.ts",
    "content": "import { type LevelDefinition, type WarriorDefinition } from '@warriorjs/core';\n\nclass Tower {\n  id: string;\n  name: string;\n  description: string;\n  warrior: WarriorDefinition;\n  levels: LevelDefinition[];\n\n  constructor(\n    id: string,\n    name: string,\n    description: string,\n    warrior: WarriorDefinition,\n    levels: LevelDefinition[],\n  ) {\n    this.id = id;\n    this.name = name;\n    this.description = description;\n    this.warrior = warrior;\n    this.levels = levels;\n  }\n\n  hasLevel(levelNumber: number): boolean {\n    return !!this.getLevel(levelNumber);\n  }\n\n  getLevel(levelNumber: number): LevelDefinition | undefined {\n    return this.levels[levelNumber - 1];\n  }\n\n  toString(): string {\n    return this.name;\n  }\n}\n\nexport default Tower;\n"
  },
  {
    "path": "apps/cli/src/cli.test.ts",
    "content": "import { expect, test, vi } from 'vitest';\n\nimport { run } from './cli.js';\nimport Game from './Game.js';\n\nvi.mock('ink', () => ({\n  render: vi.fn(() => ({\n    waitUntilExit: () => Promise.resolve(),\n  })),\n}));\n\nvi.mock('./ui/components/App.js', () => ({\n  default: vi.fn(),\n}));\n\nvi.mock('./Game.js', () => {\n  const MockGame = vi.fn(function (this: any) {});\n  return { default: MockGame, __esModule: true };\n});\n\ntest('builds context and renders the app', async () => {\n  const mockContext = { towers: [] };\n  const mockBuildContext = vi.fn(() => mockContext);\n  (Game as any).mockImplementation(function (this: any) {\n    this.buildContext = mockBuildContext;\n  });\n  await run(['-d', '/path/to/game', '-l', '2', '-s']);\n  expect(Game).toHaveBeenCalledWith('/path/to/game', 2, true);\n  expect(mockBuildContext).toHaveBeenCalled();\n\n  const { render } = await import('ink');\n  expect(render).toHaveBeenCalled();\n});\n"
  },
  {
    "path": "apps/cli/src/cli.ts",
    "content": "import { render } from 'ink';\nimport React from 'react';\n\nimport Game from './Game.js';\nimport parseArgs from './parseArgs.js';\nimport App from './ui/components/App.js';\n\n/**\n * Starts the game.\n *\n * @param args The command line arguments.\n */\nasync function run(args: string[]): Promise<void> {\n  const { directory, level, silent } = parseArgs(args);\n  const game = new Game(directory, level, silent);\n  const context = game.buildContext();\n\n  const { waitUntilExit } = render(React.createElement(App, { context }));\n  await waitUntilExit();\n}\n\nexport { run };\n"
  },
  {
    "path": "apps/cli/src/loadTowers.test.ts",
    "content": "import mock from 'mock-fs';\nimport { expect, test, vi } from 'vitest';\n\nimport loadTowers from './loadTowers.js';\nimport Tower from './Tower.js';\n\nconst { mockRequire } = vi.hoisted(() => {\n  const mockRequire = vi.fn();\n  return { mockRequire };\n});\n\nvi.mock('module', async (importOriginal) => {\n  const original = (await importOriginal()) as any;\n  return {\n    ...original,\n    createRequire: () => mockRequire,\n  };\n});\n\nvi.mock('find-up', () => ({\n  findUpSync: vi.fn().mockReturnValue('/path/to/node_modules'),\n}));\nvi.mock('./Tower.js');\n\ntest('loads internal towers', () => {\n  mockRequire.mockReturnValue({\n    name: 'The Narrow Path',\n    description: 'A corridor of stone where the only way out is forward',\n    warrior: 'warrior',\n    levels: ['level1', 'level2'],\n  });\n  mock({ '/path/to/node_modules/@warriorjs/cli': {} });\n  loadTowers();\n  mock.restore();\n  expect(Tower).toHaveBeenCalledWith(\n    'the-narrow-path',\n    'The Narrow Path',\n    'A corridor of stone where the only way out is forward',\n    'warrior',\n    ['level1', 'level2'],\n  );\n});\n\ntest('loads external official towers', () => {\n  mockRequire.mockImplementation((path: string) => {\n    if (path.includes('tower-foo')) {\n      return {\n        name: 'Foo',\n        description: 'bar',\n        warrior: 'warrior',\n        levels: ['level1', 'level2'],\n      };\n    }\n    return {\n      name: 'The Narrow Path',\n      description: 'A corridor of stone where the only way out is forward',\n      warrior: 'warrior',\n      levels: ['level1', 'level2'],\n    };\n  });\n  mock({\n    '/path/to/node_modules': {\n      '@warriorjs': {\n        cli: {},\n        'tower-foo': {\n          'package.json': '',\n          'index.js':\n            \"module.exports = { name: 'Foo', description: 'bar', warrior: 'warrior, levels: ['level1', 'level2'] }\",\n        },\n      },\n    },\n  });\n  loadTowers();\n  mock.restore();\n  expect(Tower).toHaveBeenCalledWith('foo', 'Foo', 'bar', 'warrior', ['level1', 'level2']);\n});\n\ntest('loads external community towers', () => {\n  mockRequire.mockImplementation((path: string) => {\n    if (path.includes('warriorjs-tower-foo')) {\n      return {\n        name: 'Foo',\n        description: 'bar',\n        warrior: 'warrior',\n        levels: ['level1', 'level2'],\n      };\n    }\n    return {\n      name: 'The Narrow Path',\n      description: 'A corridor of stone where the only way out is forward',\n      warrior: 'warrior',\n      levels: ['level1', 'level2'],\n    };\n  });\n  mock({\n    '/path/to/node_modules': {\n      '@warriorjs': {\n        cli: {},\n      },\n      'warriorjs-tower-foo': {\n        'package.json': '',\n        'index.js':\n          \"module.exports = { name: 'Foo', description: 'bar', warrior: 'warrior, levels: ['level1', 'level2'] }\",\n      },\n    },\n  });\n  loadTowers();\n  mock.restore();\n  expect(Tower).toHaveBeenCalledWith('foo', 'Foo', 'bar', 'warrior', ['level1', 'level2']);\n});\n\ntest(\"ignores directories that are seemingly towers but don't have a package.json\", () => {\n  mockRequire.mockReturnValue({\n    name: 'The Narrow Path',\n    description: 'A corridor of stone where the only way out is forward',\n    warrior: 'warrior',\n    levels: ['level1', 'level2'],\n  });\n  mock({\n    '/path/to/node_modules': {\n      '@warriorjs': {\n        cli: {},\n        'tower-foo': {\n          'index.js':\n            \"module.exports = { name: 'Foo', description: 'baz', warrior: 'warrior, levels: ['level1', 'level2'] }\",\n        },\n      },\n      'warriorjs-tower-bar': {\n        'index.js':\n          \"module.exports = { name: 'Bar', description: 'baz', warrior: 'warrior, levels: ['level1', 'level2'] }\",\n      },\n    },\n  });\n  loadTowers();\n  mock.restore();\n  expect(Tower).not.toHaveBeenCalledWith('foo', 'Foo', 'baz', 'warrior', ['level1', 'level2']);\n  expect(Tower).not.toHaveBeenCalledWith('bar', 'Bar', 'baz', 'warrior', ['level1', 'level2']);\n});\n\ntest(\"doesn't throw when @warriorjs/cli doesn't exist\", async () => {\n  mockRequire.mockReturnValue({\n    name: 'The Narrow Path',\n    description: 'A corridor of stone where the only way out is forward',\n    warrior: 'warrior',\n    levels: ['level1', 'level2'],\n  });\n  const { findUpSync } = await import('find-up');\n  (findUpSync as any).mockReturnValue(null);\n  loadTowers();\n});\n"
  },
  {
    "path": "apps/cli/src/loadTowers.ts",
    "content": "import { createRequire } from 'node:module';\nimport path from 'node:path';\nimport { findUpSync } from 'find-up';\nimport { globbySync } from 'globby';\n\nimport Tower from './Tower.js';\nimport getTowerId from './utils/getTowerId.js';\n\nconst require = createRequire(import.meta.url);\n\nconst internalTowerPackageNames = ['@warriorjs/tower-the-narrow-path'];\n\nconst officialTowerPackageJsonPattern = '@warriorjs/tower-*/package.json';\nconst communityTowerPackageJsonPattern = 'warriorjs-tower-*/package.json';\n\ninterface TowerInfo {\n  id: string;\n  requirePath: string;\n}\n\nfunction getInternalTowersInfo(): TowerInfo[] {\n  return internalTowerPackageNames.map((towerPackageName) => ({\n    id: getTowerId(towerPackageName),\n    requirePath: towerPackageName,\n  }));\n}\n\nfunction getExternalTowersInfo(): TowerInfo[] {\n  const cliDir = findUpSync('@warriorjs/cli', { cwd: import.meta.dirname, type: 'directory' });\n  if (!cliDir) {\n    return [];\n  }\n\n  const cliParentDir = path.resolve(cliDir, '..');\n  const towerSearchDir = findUpSync('node_modules', { cwd: cliParentDir, type: 'directory' });\n  if (!towerSearchDir) {\n    return [];\n  }\n\n  const towerPackageJsonPaths = globbySync(\n    [officialTowerPackageJsonPattern, communityTowerPackageJsonPattern],\n    { cwd: towerSearchDir },\n  );\n  const towerPackageNames = towerPackageJsonPaths.map((p: string) => path.dirname(p));\n  const seen = new Map<string, TowerInfo>();\n  for (const towerPackageName of towerPackageNames) {\n    const id = getTowerId(towerPackageName);\n    if (!seen.has(id)) {\n      seen.set(id, {\n        id,\n        requirePath: path.resolve(towerSearchDir, towerPackageName),\n      });\n    }\n  }\n  return [...seen.values()];\n}\n\nfunction loadTowers(): Tower[] {\n  const internalTowersInfo = getInternalTowersInfo();\n  const externalTowersInfo = getExternalTowersInfo();\n  const allInfo = internalTowersInfo.concat(externalTowersInfo);\n  const uniqueInfo = [...new Map(allInfo.map((item) => [item.id, item])).values()];\n  return uniqueInfo.map(({ id, requirePath }) => {\n    const mod = require(requirePath);\n    const { name, description, warrior, levels } = mod.default || mod;\n    return new Tower(id, name, description, warrior, levels);\n  });\n}\n\nexport default loadTowers;\n"
  },
  {
    "path": "apps/cli/src/parseArgs.test.ts",
    "content": "import { describe, expect, test, vi } from 'vitest';\n\nimport parseArgs from './parseArgs.js';\n\ntest(\"doesn't fail when no args are supplied\", () => {\n  parseArgs([]);\n});\n\ndescribe('-d', () => {\n  test('parses correctly', () => {\n    expect(parseArgs(['-d', '/path/to/run']).d).toBe('/path/to/run');\n  });\n\n  test('has alias --directory', () => {\n    expect(parseArgs(['--directory', '/path/to/run']).directory).toBe('/path/to/run');\n  });\n\n  test(\"defaults to '.'\", () => {\n    expect(parseArgs([]).d).toBe('.');\n  });\n});\n\ndescribe('-l', () => {\n  test('parses correctly', () => {\n    expect(parseArgs(['-l', '4']).l).toBe(4);\n  });\n\n  test('has alias --level', () => {\n    expect(parseArgs(['--level', '4']).level).toBe(4);\n  });\n\n  test('exits with error if not a number', () => {\n    const originalExit = process.exit;\n    const originalError = console.error;\n    process.exit = vi.fn() as any;\n    console.error = vi.fn();\n    try {\n      parseArgs(['-l', 'invalid']);\n    } catch {\n      // yargs may throw after calling process.exit in test environments\n    }\n    expect(process.exit).toHaveBeenCalledWith(1);\n    expect(console.error).toHaveBeenCalledWith('Invalid argument: level must be a number');\n    process.exit = originalExit;\n    console.error = originalError;\n  });\n});\n\ntest('exits with error on unknown option', () => {\n  const originalExit = process.exit;\n  const originalError = console.error;\n  process.exit = vi.fn() as any;\n  console.error = vi.fn();\n  try {\n    parseArgs(['--unknown']);\n  } catch {\n    // yargs may throw after calling process.exit in test environments\n  }\n  expect(process.exit).toHaveBeenCalledWith(1);\n  expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Unknown'));\n  process.exit = originalExit;\n  console.error = originalError;\n});\n\ndescribe('-s', () => {\n  test('parses correctly', () => {\n    expect(parseArgs(['-s']).s).toBe(true);\n  });\n\n  test('has alias --silent', () => {\n    expect(parseArgs(['--silent']).silent).toBe(true);\n  });\n\n  test('defaults to false', () => {\n    expect(parseArgs([]).s).toBe(false);\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/parseArgs.ts",
    "content": "import yargs from 'yargs';\n\ninterface ParsedArgs {\n  directory: string;\n  level: number | undefined;\n  silent: boolean;\n  d: string;\n  l: number | undefined;\n  s: boolean;\n  [key: string]: unknown;\n}\n\nfunction parseArgs(args: string[]): ParsedArgs {\n  return yargs(args)\n    .usage('Usage: $0 [options]')\n    .options({\n      d: {\n        alias: 'directory',\n        default: '.',\n        describe: 'Run under given directory',\n        type: 'string' as const,\n      },\n      l: {\n        alias: 'level',\n        coerce: (arg: string) => {\n          const parsed = Number.parseInt(arg, 10);\n          if (Number.isNaN(parsed)) {\n            throw new Error('Invalid argument: level must be a number');\n          }\n\n          return parsed;\n        },\n        describe: 'Practice level (epic mode only)',\n        type: 'number' as const,\n      },\n      s: {\n        alias: 'silent',\n        default: false,\n        describe: 'Suppress play log',\n        type: 'boolean' as const,\n      },\n    })\n    .version()\n    .help()\n    .strict()\n    .fail((msg: string, err: Error | undefined) => {\n      if (err) {\n        console.error(err.message);\n      } else if (msg) {\n        console.error(msg);\n      }\n      process.exit(1);\n    })\n    .parseSync() as unknown as ParsedArgs;\n}\n\nexport default parseArgs;\n"
  },
  {
    "path": "apps/cli/src/ui/components/App.tsx",
    "content": "import type React from 'react';\nimport { useState } from 'react';\n\nimport { type GameContext } from '../../Game.js';\nimport type Profile from '../../Profile.js';\nimport GameMenu from './GameMenu.js';\nimport PlaySession from './PlaySession.js';\n\ninterface AppProps {\n  context: GameContext;\n}\n\nexport default function App({ context }: AppProps): React.ReactElement {\n  const [session, setSession] = useState<{\n    profile: Profile;\n    initialLevel: number;\n  } | null>(null);\n\n  if (session) {\n    return (\n      <PlaySession\n        context={context}\n        profile={session.profile}\n        initialLevel={session.initialLevel}\n      />\n    );\n  }\n\n  return (\n    <GameMenu\n      context={context}\n      onStart={(profile, level) => setSession({ profile, initialLevel: level })}\n    />\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/ConfirmPrompt.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { waitForRender } from '../testing.js';\nimport ConfirmPrompt from './ConfirmPrompt.js';\n\ndescribe('ConfirmPrompt', () => {\n  test('renders message with y/N hint by default', () => {\n    const { lastFrame } = render(<ConfirmPrompt message=\"Continue?\" onConfirm={vi.fn()} />);\n    const output = lastFrame()!;\n    expect(output).toContain('Continue?');\n    expect(output).toContain('(y/N)');\n  });\n\n  test('shows Y/n hint when defaultValue is true', () => {\n    const { lastFrame } = render(\n      <ConfirmPrompt message=\"Continue?\" defaultValue={true} onConfirm={vi.fn()} />,\n    );\n    expect(lastFrame()!).toContain('(Y/n)');\n  });\n\n  test('confirms with true on y', () => {\n    const onConfirm = vi.fn();\n    const { stdin } = render(<ConfirmPrompt message=\"Continue?\" onConfirm={onConfirm} />);\n    stdin.write('y');\n    expect(onConfirm).toHaveBeenCalledWith(true);\n  });\n\n  test('confirms with false on n', () => {\n    const onConfirm = vi.fn();\n    const { stdin } = render(<ConfirmPrompt message=\"Continue?\" onConfirm={onConfirm} />);\n    stdin.write('n');\n    expect(onConfirm).toHaveBeenCalledWith(false);\n  });\n\n  test('uses default value on enter', () => {\n    const onConfirm = vi.fn();\n    const { stdin } = render(\n      <ConfirmPrompt message=\"Continue?\" defaultValue={true} onConfirm={onConfirm} />,\n    );\n    stdin.write('\\r');\n    expect(onConfirm).toHaveBeenCalledWith(true);\n  });\n\n  test('shows Yes in submitted state', async () => {\n    const { stdin, lastFrame } = render(<ConfirmPrompt message=\"Continue?\" onConfirm={vi.fn()} />);\n    stdin.write('y');\n    await waitForRender();\n    const output = lastFrame()!;\n    expect(output).toContain('Continue?');\n    expect(output).toContain('Yes');\n  });\n\n  test('shows No in submitted state', async () => {\n    const { stdin, lastFrame } = render(<ConfirmPrompt message=\"Continue?\" onConfirm={vi.fn()} />);\n    stdin.write('n');\n    await waitForRender();\n    const output = lastFrame()!;\n    expect(output).toContain('No');\n  });\n\n  test('ignores input after submission', async () => {\n    const onConfirm = vi.fn();\n    const { stdin } = render(<ConfirmPrompt message=\"Continue?\" onConfirm={onConfirm} />);\n    stdin.write('y');\n    await waitForRender();\n    stdin.write('n');\n    expect(onConfirm).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/ConfirmPrompt.tsx",
    "content": "import { Box, Text, useInput } from 'ink';\nimport type React from 'react';\nimport { useState } from 'react';\n\ninterface ConfirmPromptProps {\n  message: string;\n  defaultValue?: boolean;\n  onConfirm: (value: boolean) => void;\n}\n\nexport default function ConfirmPrompt({\n  message,\n  defaultValue = false,\n  onConfirm,\n}: ConfirmPromptProps): React.ReactElement {\n  const [submitted, setSubmitted] = useState(false);\n  const [result, setResult] = useState(defaultValue);\n\n  useInput((input, key) => {\n    if (submitted) return;\n\n    if (input === 'y' || input === 'Y') {\n      setResult(true);\n      setSubmitted(true);\n      onConfirm(true);\n      return;\n    }\n\n    if (input === 'n' || input === 'N') {\n      setResult(false);\n      setSubmitted(true);\n      onConfirm(false);\n      return;\n    }\n\n    if (key.return) {\n      setSubmitted(true);\n      onConfirm(defaultValue);\n    }\n  });\n\n  if (submitted) {\n    return (\n      <Text>\n        <Text bold>{message}</Text> {result ? 'Yes' : 'No'}\n      </Text>\n    );\n  }\n\n  const hint = defaultValue === true ? 'Y/n' : 'y/N';\n  return (\n    <Box gap={1}>\n      <Text bold>{message}</Text>\n      <Text dimColor>{`(${hint})`}</Text>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/Divider.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport Divider from './Divider.js';\n\ndescribe('Divider', () => {\n  test('renders a line of dashes matching stdout columns', () => {\n    const { lastFrame } = render(<Divider />);\n    const output = lastFrame()!;\n    // ink-testing-library stdout has 100 columns.\n    expect(output).toBe('─'.repeat(100));\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/Divider.tsx",
    "content": "import { Text, useStdout } from 'ink';\nimport type React from 'react';\n\nexport default function Divider(): React.ReactElement {\n  const { stdout } = useStdout();\n  return <Text dimColor>{'─'.repeat(stdout.columns || 80)}</Text>;\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/ErrorMessage.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { waitForRender } from '../testing.js';\nimport ErrorMessage from './ErrorMessage.js';\n\ndescribe('ErrorMessage', () => {\n  test('renders error message in output', () => {\n    const { lastFrame } = render(\n      <ErrorMessage message=\"Something went wrong\" onDismiss={vi.fn()} />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('Something went wrong');\n    expect(output).toContain('Press any key to continue...');\n  });\n\n  test('calls onDismiss on any key press', () => {\n    const onDismiss = vi.fn();\n    const { stdin } = render(<ErrorMessage message=\"Error\" onDismiss={onDismiss} />);\n    stdin.write('x');\n    expect(onDismiss).toHaveBeenCalled();\n  });\n\n  test('renders null after dismissal', async () => {\n    const { stdin, lastFrame } = render(<ErrorMessage message=\"Error\" onDismiss={vi.fn()} />);\n    stdin.write('x');\n    await waitForRender();\n    expect(lastFrame()!).toBe('');\n  });\n\n  test('ignores subsequent key presses', async () => {\n    const onDismiss = vi.fn();\n    const { stdin } = render(<ErrorMessage message=\"Error\" onDismiss={onDismiss} />);\n    stdin.write('x');\n    await waitForRender();\n    stdin.write('y');\n    expect(onDismiss).toHaveBeenCalledTimes(1);\n  });\n\n  test('renders without dismiss hint when onDismiss is not provided', () => {\n    const { lastFrame } = render(<ErrorMessage message=\"Fatal error\" />);\n    const output = lastFrame()!;\n    expect(output).toContain('Fatal error');\n    expect(output).not.toContain('Press any key to continue...');\n  });\n\n  test('does not respond to key presses when onDismiss is not provided', async () => {\n    const { stdin, lastFrame } = render(<ErrorMessage message=\"Fatal error\" />);\n    stdin.write('x');\n    await waitForRender();\n    expect(lastFrame()!).toContain('Fatal error');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/ErrorMessage.tsx",
    "content": "import { Box, Text, useInput } from 'ink';\nimport type React from 'react';\nimport { useState } from 'react';\n\ninterface ErrorMessageProps {\n  message: string;\n  onDismiss?: () => void;\n}\n\nexport default function ErrorMessage({\n  message,\n  onDismiss,\n}: ErrorMessageProps): React.ReactElement | null {\n  const [dismissed, setDismissed] = useState(false);\n\n  const dismissable = !!onDismiss;\n\n  useInput(\n    () => {\n      if (dismissed) return;\n      setDismissed(true);\n      onDismiss!();\n    },\n    { isActive: dismissable },\n  );\n\n  if (dismissed) return null;\n\n  return (\n    <Box flexDirection=\"column\">\n      <Text color=\"red\">{message}</Text>\n      {dismissable && <Text dimColor>Press any key to continue...</Text>}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/FloorMap.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport FloorMap from './FloorMap.js';\n\ndescribe('FloorMap', () => {\n  test('renders floor map characters', () => {\n    const floorMap = [\n      [{ character: '╔' }, { character: '═' }, { character: '╗' }],\n      [{ character: '║' }, { character: '@', unit: { color: '#00ff00' } }, { character: '║' }],\n      [{ character: '╚' }, { character: '═' }, { character: '╝' }],\n    ];\n    const { lastFrame } = render(<FloorMap floorMap={floorMap} />);\n    const output = lastFrame()!;\n    expect(output).toContain('╔');\n    expect(output).toContain('@');\n    expect(output).toContain('╝');\n  });\n\n  test('renders empty spaces without unit styling', () => {\n    const floorMap = [[{ character: ' ' }, { character: '>' }]];\n    const { lastFrame } = render(<FloorMap floorMap={floorMap} />);\n    const output = lastFrame()!;\n    expect(output).toContain('>');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/FloorMap.tsx",
    "content": "import { Box, Text } from 'ink';\nimport type React from 'react';\n\ninterface FloorSpace {\n  character: string;\n  unit?: { color: string };\n}\n\ninterface FloorMapProps {\n  floorMap: FloorSpace[][];\n}\n\nconst STAIR_CHAR = '>';\n\nfunction getSpaceColor(space: FloorSpace): string | undefined {\n  if (space.unit) return space.unit.color;\n  if (space.character === STAIR_CHAR) return 'yellow';\n  if (space.character !== ' ') return 'gray';\n  return undefined;\n}\n\nexport default function FloorMap({ floorMap }: FloorMapProps): React.ReactElement {\n  return (\n    <Box flexDirection=\"column\" marginX={1} marginY={1}>\n      {floorMap.map((row, rowIndex) => (\n        // biome-ignore lint/suspicious/noArrayIndexKey: static grid\n        <Box key={rowIndex}>\n          {row.map((space, colIndex) => (\n            // biome-ignore lint/suspicious/noArrayIndexKey: static grid\n            <Text key={colIndex} color={getSpaceColor(space)}>\n              {space.character}\n            </Text>\n          ))}\n        </Box>\n      ))}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/GameMenu.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport { type GameContext } from '../../Game.js';\nimport type Profile from '../../Profile.js';\nimport type Tower from '../../Tower.js';\nimport { getLastContentFrame, waitForRender } from '../testing.js';\nimport GameMenu from './GameMenu.js';\n\nvi.mock('../../utils/getWarriorNameSuggestions.js', () => ({\n  default: () => ['TestHero'],\n}));\n\nfunction createMockTower(name: string, overrides: Partial<Tower> = {}): Tower {\n  return {\n    id: name.toLowerCase().replace(/\\s+/g, '-'),\n    name,\n    description: '',\n    levels: [{}, {}, {}],\n    hasLevel: (n: number) => n >= 1 && n <= 3,\n    getLevel: (n: number) => (n >= 1 && n <= 3 ? {} : undefined),\n    toString: () => name,\n    ...overrides,\n  } as unknown as Tower;\n}\n\nfunction createMockProfile(name: string, overrides: Partial<Profile> = {}): Profile {\n  return {\n    warriorName: name,\n    tower: createMockTower('The Narrow Path'),\n    directoryPath: `/tmp/warriorjs/${name.toLowerCase()}`,\n    language: 'javascript',\n    levelNumber: 1,\n    score: 0,\n    epic: false,\n    isEpic: () => false,\n    getReadmeFilePath: () => `/tmp/warriorjs/${name.toLowerCase()}/README.md`,\n    makeProfileDirectory: vi.fn(),\n    toString: () => name,\n    ...overrides,\n  } as unknown as Profile;\n}\n\nfunction createMockContext(overrides: Partial<GameContext> = {}): GameContext {\n  return {\n    version: 'v1.0.0',\n    runDirectoryPath: '/tmp',\n    practiceLevel: undefined,\n    silencePlay: false,\n    towers: [createMockTower('The Narrow Path')],\n    profile: null,\n    profiles: [],\n    needsProfileSetup: true,\n    onCreateProfile: vi.fn(() => createMockProfile('TestHero')),\n    onIsExistingProfile: vi.fn(() => false),\n    onPrepareNextLevel: vi.fn(),\n    onPrepareEpicMode: vi.fn(),\n    onGenerateProfileFiles: vi.fn(),\n    onProfileSelected: vi.fn(),\n    ...overrides,\n  };\n}\n\ndescribe('GameMenu', () => {\n  let onStart: ReturnType<typeof vi.fn<(profile: Profile, levelNumber: number) => void>>;\n\n  beforeEach(() => {\n    onStart = vi.fn<(profile: Profile, levelNumber: number) => void>();\n  });\n\n  test('renders error message when context has error', async () => {\n    const context = createMockContext({\n      error: 'Tower not found',\n      needsProfileSetup: false,\n    });\n\n    const { frames } = render(<GameMenu context={context} onStart={onStart} />);\n    await waitForRender();\n\n    const output = getLastContentFrame(frames);\n    expect(output).toContain('Tower not found');\n    expect(onStart).not.toHaveBeenCalled();\n  });\n\n  test('calls onStart for returning player with existing profile', async () => {\n    const profile = createMockProfile('Warrior', { levelNumber: 3 });\n    const context = createMockContext({\n      profile,\n      needsProfileSetup: false,\n    });\n\n    render(<GameMenu context={context} onStart={onStart} />);\n    await waitForRender();\n\n    expect(context.onProfileSelected).toHaveBeenCalledWith(profile);\n    expect(onStart).toHaveBeenCalledWith(profile, 3);\n  });\n\n  test('renders first-level message for returning player at level 0', async () => {\n    const profile = createMockProfile('Warrior', { levelNumber: 0 });\n    const context = createMockContext({\n      profile,\n      needsProfileSetup: false,\n    });\n\n    const { frames } = render(<GameMenu context={context} onStart={onStart} />);\n    await waitForRender();\n\n    const output = getLastContentFrame(frames);\n    expect(output).toContain('Level 1 is ready');\n    expect(output).toContain('README.md');\n    expect(context.onPrepareNextLevel).toHaveBeenCalled();\n    expect(onStart).not.toHaveBeenCalled();\n  });\n\n  test('renders start prompt for new player', async () => {\n    const context = createMockContext({ needsProfileSetup: true, profiles: [] });\n\n    const { lastFrame } = render(<GameMenu context={context} onStart={onStart} />);\n    await waitForRender();\n\n    const output = lastFrame()!;\n    expect(output).toContain('A tower of enemies awaits');\n    expect(output).toContain('Venture forth');\n    expect(output).toContain('Retreat');\n  });\n\n  test('selecting \"Venture forth\" transitions to wizard', async () => {\n    const context = createMockContext({ needsProfileSetup: true, profiles: [] });\n\n    const { stdin, lastFrame } = render(<GameMenu context={context} onStart={onStart} />);\n    await waitForRender();\n\n    // Select \"Venture forth\" (first item, press enter).\n    stdin.write('\\r');\n    await waitForRender();\n\n    const output = lastFrame()!;\n    expect(output).toContain('Venture forth');\n    expect(output).toContain('Enter one for your warrior');\n  });\n\n  test('selecting \"Retreat\" shows exit message', async () => {\n    const context = createMockContext({ needsProfileSetup: true, profiles: [] });\n\n    const { stdin, frames } = render(<GameMenu context={context} onStart={onStart} />);\n    await waitForRender();\n\n    // Move down to \"Retreat\" and select.\n    stdin.write('\\x1B[B');\n    await waitForRender();\n    stdin.write('\\r');\n    await waitForRender();\n\n    const output = getLastContentFrame(frames);\n    expect(output).toContain('Even the bravest need a moment to prepare');\n  });\n\n  test('renders wizard with choose-profile when profiles exist', async () => {\n    const profile = createMockProfile('Warrior1');\n    const context = createMockContext({\n      needsProfileSetup: true,\n      profiles: [profile],\n    });\n\n    const { lastFrame } = render(<GameMenu context={context} onStart={onStart} />);\n    await waitForRender();\n\n    const output = lastFrame()!;\n    expect(output).toContain('Which warrior answers the call?');\n    expect(output).toContain('Warrior1');\n  });\n\n  test('renders error for practice level in normal mode', async () => {\n    const profile = createMockProfile('Warrior', { levelNumber: 2 });\n    const context = createMockContext({\n      profile,\n      needsProfileSetup: false,\n      practiceLevel: 1,\n    });\n\n    const { frames } = render(<GameMenu context={context} onStart={onStart} />);\n    await waitForRender();\n\n    const output = getLastContentFrame(frames);\n    expect(output).toContain('The -l option is only available in epic mode');\n    expect(onStart).not.toHaveBeenCalled();\n  });\n\n  test('calls onStart with level 1 for epic mode', async () => {\n    const profile = createMockProfile('Warrior', {\n      epic: true,\n      isEpic: () => true,\n    });\n    const context = createMockContext({\n      profile,\n      needsProfileSetup: false,\n    });\n\n    render(<GameMenu context={context} onStart={onStart} />);\n    await waitForRender();\n\n    expect(onStart).toHaveBeenCalledWith(profile, 1);\n  });\n\n  test('calls onStart with practice level for epic mode', async () => {\n    const profile = createMockProfile('Warrior', {\n      epic: true,\n      isEpic: () => true,\n    });\n    const context = createMockContext({\n      profile,\n      needsProfileSetup: false,\n      practiceLevel: 2,\n    });\n\n    render(<GameMenu context={context} onStart={onStart} />);\n    await waitForRender();\n\n    expect(onStart).toHaveBeenCalledWith(profile, 2);\n  });\n\n  test('renders error for invalid practice level in epic mode', async () => {\n    const tower = createMockTower('The Narrow Path', {\n      levels: [{}, {}, {}] as Tower['levels'],\n      hasLevel: (n: number) => n >= 1 && n <= 3,\n    });\n    const profile = createMockProfile('Warrior', {\n      epic: true,\n      isEpic: () => true,\n      tower,\n    });\n    const context = createMockContext({\n      profile,\n      needsProfileSetup: false,\n      practiceLevel: 10,\n    });\n\n    const { frames } = render(<GameMenu context={context} onStart={onStart} />);\n    await waitForRender();\n\n    const output = getLastContentFrame(frames);\n    expect(output).toContain(\"Level 10 doesn't exist\");\n    expect(output).toContain('3 levels');\n    expect(onStart).not.toHaveBeenCalled();\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/GameMenu.tsx",
    "content": "import path from 'node:path';\nimport { Box, Text, useApp } from 'ink';\nimport Link from 'ink-link';\nimport type React from 'react';\nimport { useEffect, useRef, useState } from 'react';\n\nimport { type GameContext } from '../../Game.js';\nimport type Profile from '../../Profile.js';\nimport getWarriorNameSuggestions from '../../utils/getWarriorNameSuggestions.js';\nimport { type GameMenuStep } from '../types.js';\nimport Divider from './Divider.js';\nimport ErrorMessage from './ErrorMessage.js';\nimport ProfileWizard from './ProfileWizard.js';\nimport SelectPrompt from './SelectPrompt.js';\nimport WelcomeScreen from './WelcomeScreen.js';\n\ninterface GameMenuProps {\n  context: GameContext;\n  onStart: (profile: Profile, levelNumber: number) => void;\n}\n\nexport default function GameMenu({ context, onStart }: GameMenuProps): React.ReactElement {\n  const { exit } = useApp();\n  const [step, setStep] = useState<GameMenuStep | null>(() => {\n    if (context.error || context.profile) return null; // handled in mount effect\n    if (context.needsProfileSetup) {\n      return context.profiles.length > 0\n        ? { type: 'wizard', initialStep: 'choose-profile' as const }\n        : { type: 'start' as const };\n    }\n    return null;\n  });\n  const [history, setHistory] = useState<React.ReactElement[]>([]);\n  const [finalMessage, setFinalMessage] = useState<React.ReactElement | null>(null);\n  const [suggestedName] = useState(() => getWarriorNameSuggestions()[0]);\n  const initialized = useRef(false);\n\n  const pushHistory = (element: React.ReactElement) => {\n    setHistory((prev) => [...prev, element]);\n  };\n\n  const popHistory = () => {\n    setHistory((prev) => prev.slice(0, -1));\n  };\n\n  function showFinalMessage(element: React.ReactElement) {\n    setFinalMessage(element);\n  }\n\n  useEffect(() => {\n    if (finalMessage) {\n      exit();\n    }\n  }, [finalMessage, exit]);\n\n  function handleProfileReady(selectedProfile: Profile) {\n    context.onProfileSelected(selectedProfile);\n\n    try {\n      if (selectedProfile.isEpic()) {\n        if (context.practiceLevel) {\n          if (!selectedProfile.tower.hasLevel(context.practiceLevel)) {\n            showFinalMessage(\n              <ErrorMessage\n                message={`Level ${context.practiceLevel} doesn't exist. This tower has ${selectedProfile.tower.levels.length} levels.`}\n              />,\n            );\n            return;\n          }\n          onStart(selectedProfile, context.practiceLevel);\n        } else {\n          onStart(selectedProfile, 1);\n        }\n      } else {\n        if (context.practiceLevel) {\n          showFinalMessage(\n            <ErrorMessage message=\"The -l option is only available in epic mode. Remove it to play normally.\" />,\n          );\n          return;\n        }\n\n        if (selectedProfile.levelNumber === 0) {\n          context.onPrepareNextLevel();\n          const readmePath = selectedProfile.getReadmeFilePath();\n          showFinalMessage(\n            <Box flexDirection=\"column\">\n              <Divider />\n              <Text bold>\n                {'Level 1 is ready. See '}\n                <Link url={`file://${path.resolve(readmePath)}`} fallback={false}>\n                  {readmePath}\n                </Link>\n                {' for instructions.'}\n              </Text>\n            </Box>,\n          );\n          return;\n        }\n\n        onStart(selectedProfile, selectedProfile.levelNumber);\n      }\n    } catch (err: unknown) {\n      showFinalMessage(<ErrorMessage message={err instanceof Error ? err.message : String(err)} />);\n    }\n  }\n\n  useEffect(() => {\n    if (initialized.current) return;\n    initialized.current = true;\n\n    if (context.error) {\n      showFinalMessage(<ErrorMessage message={context.error} />);\n    } else if (context.profile && !context.needsProfileSetup) {\n      handleProfileReady(context.profile);\n    }\n  });\n\n  const renderStep = (): React.ReactElement | null => {\n    if (!step) return null;\n\n    switch (step.type) {\n      case 'start': {\n        const items = [\n          { label: 'Venture forth', value: 'start' },\n          { label: 'Retreat', value: 'quit' },\n        ];\n        return (\n          <SelectPrompt\n            message=\"A tower of enemies awaits. Do you dare enter?\"\n            items={items}\n            onSelect={(value) => {\n              if (value === 'start') {\n                pushHistory(\n                  <Text key={history.length}>\n                    <Text bold>A tower of enemies awaits. Do you dare enter?</Text> Venture forth\n                  </Text>,\n                );\n                setStep({ type: 'wizard', initialStep: 'new' });\n              } else {\n                showFinalMessage(<Text>Even the bravest need a moment to prepare.</Text>);\n              }\n            }}\n          />\n        );\n      }\n\n      case 'wizard':\n        return (\n          <ProfileWizard\n            context={context}\n            suggestedName={suggestedName!}\n            initialStep={step.initialStep}\n            onComplete={(selectedProfile) => handleProfileReady(selectedProfile)}\n            onCancel={() => {\n              popHistory();\n              setStep({ type: 'start' });\n            }}\n            onError={(message) => showFinalMessage(<ErrorMessage message={message} />)}\n          />\n        );\n    }\n  };\n\n  return (\n    <Box flexDirection=\"column\">\n      <WelcomeScreen version={context.version} directory={context.runDirectoryPath} />\n      <Divider />\n      {history}\n      {renderStep()}\n      {finalMessage}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/Header.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport Header from './Header.js';\n\ndescribe('Header', () => {\n  test('renders warrior name and tower info', () => {\n    const { lastFrame } = render(\n      <Header warriorName=\"Olric\" towerName=\"The Narrow Path\" levelNumber={3} score={125} />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('WarriorJS');\n    expect(output).toContain('Olric');\n    expect(output).toContain('The Narrow Path');\n    expect(output).toContain('Level 3');\n    expect(output).toContain('125');\n  });\n\n  test('renders without level/score when not provided', () => {\n    const { lastFrame } = render(<Header warriorName=\"Olric\" towerName=\"The Narrow Path\" />);\n    const output = lastFrame()!;\n    expect(output).toContain('Olric');\n    expect(output).not.toContain('Level');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/Header.tsx",
    "content": "import { Box, Text } from 'ink';\nimport type React from 'react';\n\ninterface HeaderProps {\n  warriorName: string;\n  towerName: string;\n  levelNumber?: number;\n  score?: number;\n}\n\nexport default function Header({\n  warriorName,\n  towerName,\n  levelNumber,\n  score,\n}: HeaderProps): React.ReactElement {\n  return (\n    <Box flexDirection=\"row\" justifyContent=\"space-between\" width=\"100%\">\n      <Text bold>{'0={==> WarriorJS'}</Text>\n      <Box gap={1}>\n        <Text bold>{warriorName}</Text>\n        <Text dimColor>{'·'}</Text>\n        <Text>{towerName}</Text>\n        {levelNumber !== undefined && (\n          <>\n            <Text dimColor>{'·'}</Text>\n            <Text dimColor>{`Level ${levelNumber}`}</Text>\n          </>\n        )}\n        {score !== undefined && (\n          <>\n            <Text dimColor>{'·'}</Text>\n            <Text dimColor>{`Score ${score}`}</Text>\n          </>\n        )}\n      </Box>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/LevelCompleteScreen.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { makeLevelReport, makeLevelRun, waitForRender } from '../testing.js';\nimport LevelCompleteScreen from './LevelCompleteScreen.js';\n\ndescribe('LevelCompleteScreen', () => {\n  test('shows passed menu with Next level when hasNextLevel', () => {\n    const { lastFrame } = render(\n      <LevelCompleteScreen\n        levelReport={makeLevelReport({ passed: true, hasNextLevel: true })}\n        levelRun={makeLevelRun()}\n        action={{ type: 'prompt' }}\n        onSelect={vi.fn()}\n      />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('Next level');\n    expect(output).toContain('Review turns');\n    expect(output).toContain('Stay and hone');\n  });\n\n  test('shows Enter epic mode when no next level', () => {\n    const { lastFrame } = render(\n      <LevelCompleteScreen\n        levelReport={makeLevelReport({ passed: true, hasNextLevel: false })}\n        levelRun={makeLevelRun()}\n        action={{ type: 'prompt' }}\n        onSelect={vi.fn()}\n      />,\n    );\n    expect(lastFrame()!).toContain('Enter epic mode');\n  });\n\n  test('shows failed menu with Try again', () => {\n    const { lastFrame } = render(\n      <LevelCompleteScreen\n        levelReport={makeLevelReport({ passed: false })}\n        levelRun={makeLevelRun()}\n        action={{ type: 'prompt' }}\n        onSelect={vi.fn()}\n      />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('Try again');\n    expect(output).toContain('Review turns');\n  });\n\n  test('shows Reveal clues when clue available and not showing', () => {\n    const { lastFrame } = render(\n      <LevelCompleteScreen\n        levelReport={makeLevelReport({ passed: false, hasClue: true, isShowingClue: false })}\n        levelRun={makeLevelRun()}\n        action={{ type: 'prompt' }}\n        onSelect={vi.fn()}\n      />,\n    );\n    expect(lastFrame()!).toContain('Reveal clues');\n  });\n\n  test('hides Reveal clues when already showing', () => {\n    const { lastFrame } = render(\n      <LevelCompleteScreen\n        levelReport={makeLevelReport({ passed: false, hasClue: true, isShowingClue: true })}\n        levelRun={makeLevelRun()}\n        action={{ type: 'prompt' }}\n        onSelect={vi.fn()}\n      />,\n    );\n    expect(lastFrame()!).not.toContain('Reveal clues');\n  });\n\n  test('renders next-level action message', () => {\n    const { lastFrame } = render(\n      <LevelCompleteScreen\n        levelReport={makeLevelReport()}\n        levelRun={makeLevelRun()}\n        action={{ type: 'next-level', readmePath: 'path/to/README' }}\n        onSelect={vi.fn()}\n      />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('path/to/README');\n    expect(output).toContain('instructions');\n  });\n\n  test('renders clue action message', () => {\n    const { lastFrame } = render(\n      <LevelCompleteScreen\n        levelReport={makeLevelReport()}\n        levelRun={makeLevelRun()}\n        action={{ type: 'clue', readmePath: 'path/to/README' }}\n        onSelect={vi.fn()}\n      />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('path/to/README');\n    expect(output).toContain('clues');\n  });\n\n  test('renders stay action message', () => {\n    const { lastFrame } = render(\n      <LevelCompleteScreen\n        levelReport={makeLevelReport()}\n        levelRun={makeLevelRun()}\n        action={{ type: 'stay' }}\n        onSelect={vi.fn()}\n      />,\n    );\n    expect(lastFrame()!).toContain('stayed on the current level');\n  });\n\n  test('renders epic-mode action message', () => {\n    const { lastFrame } = render(\n      <LevelCompleteScreen\n        levelReport={makeLevelReport()}\n        levelRun={makeLevelRun()}\n        action={{ type: 'epic-mode' }}\n        onSelect={vi.fn()}\n      />,\n    );\n    expect(lastFrame()!).toContain('Run warriorjs again to play epic mode');\n  });\n\n  test('calls onSelect when menu item is chosen', async () => {\n    const onSelect = vi.fn();\n    const { stdin } = render(\n      <LevelCompleteScreen\n        levelReport={makeLevelReport({ passed: true, hasNextLevel: true })}\n        levelRun={makeLevelRun()}\n        action={{ type: 'prompt' }}\n        onSelect={onSelect}\n      />,\n    );\n    stdin.write('\\r');\n    await waitForRender();\n    expect(onSelect).toHaveBeenCalledWith('next-level');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/LevelCompleteScreen.tsx",
    "content": "import path from 'node:path';\nimport { Text } from 'ink';\nimport Link from 'ink-link';\nimport type React from 'react';\nimport { useMemo } from 'react';\n\nimport {\n  type LevelCompleteAction,\n  type LevelCompleteChoice,\n  type LevelReport,\n  type LevelRun,\n} from '../types.js';\nimport Divider from './Divider.js';\nimport PlayLayout from './PlayLayout.js';\nimport ResultScreen from './ResultScreen.js';\nimport SelectPrompt from './SelectPrompt.js';\n\nfunction buildMenuItems(levelReport: LevelReport): { label: string; value: LevelCompleteChoice }[] {\n  if (levelReport.passed) {\n    return [\n      levelReport.hasNextLevel\n        ? { label: 'Next level', value: 'next-level' }\n        : { label: 'Enter epic mode', value: 'epic-mode' },\n      { label: 'Review turns', value: 'review' },\n      { label: 'Stay and hone', value: 'stay' },\n    ];\n  }\n\n  return [\n    { label: 'Try again', value: 'try-again' },\n    { label: 'Review turns', value: 'review' },\n    ...(levelReport.hasClue && !levelReport.isShowingClue\n      ? [{ label: 'Reveal clues', value: 'clue' as const }]\n      : []),\n  ];\n}\n\ninterface LevelCompleteScreenProps {\n  levelReport: LevelReport;\n  levelRun: LevelRun;\n  action: LevelCompleteAction;\n  onSelect: (value: LevelCompleteChoice) => void;\n}\n\nexport default function LevelCompleteScreen({\n  levelReport,\n  levelRun,\n  action,\n  onSelect,\n}: LevelCompleteScreenProps): React.ReactElement {\n  const menuItems = useMemo(() => buildMenuItems(levelReport), [levelReport]);\n\n  return (\n    <PlayLayout\n      turns={levelRun.turns}\n      warriorName={levelRun.warriorName}\n      towerName={levelRun.towerName}\n      levelNumber={levelRun.levelNumber}\n      totalScore={levelRun.totalScore}\n    >\n      <ResultScreen {...levelReport} />\n      <Divider />\n      {action.type === 'prompt' && (\n        <SelectPrompt message=\"\" items={menuItems} onSelect={onSelect} />\n      )}\n      {action.type === 'next-level' && (\n        <Text bold>\n          {'See '}\n          <Link url={`file://${path.resolve(action.readmePath)}`} fallback={false}>\n            {action.readmePath}\n          </Link>\n          {' for instructions.'}\n        </Text>\n      )}\n      {action.type === 'clue' && (\n        <Text bold>\n          {'See '}\n          <Link url={`file://${path.resolve(action.readmePath)}`} fallback={false}>\n            {action.readmePath}\n          </Link>\n          {' for the clues.'}\n        </Text>\n      )}\n      {action.type === 'stay' && (\n        <Text bold>You stayed on the current level. Aim for more points next time.</Text>\n      )}\n      {action.type === 'epic-mode' && <Text bold>Run warriorjs again to play epic mode.</Text>}\n    </PlayLayout>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/LogArea.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport LogArea from './LogArea.js';\n\ndescribe('LogArea', () => {\n  const turns = [\n    [\n      {\n        message: '',\n        unit: { name: 'Warrior', color: '#ffffff' },\n        floorMap: [],\n        warriorStatus: { health: 20, score: 0 },\n      },\n    ],\n    [\n      {\n        message: 'walks forward',\n        unit: { name: 'Warrior', color: '#ffffff' },\n        floorMap: [],\n        warriorStatus: { health: 20, score: 0 },\n      },\n    ],\n    [\n      {\n        message: 'attacks forward',\n        unit: { name: 'Warrior', color: '#ffffff' },\n        floorMap: [],\n        warriorStatus: { health: 18, score: 5 },\n      },\n      {\n        message: 'takes 5 damage, dies',\n        unit: { name: 'Sludge', color: '#00ff00' },\n        floorMap: [],\n        warriorStatus: { health: 18, score: 10 },\n      },\n    ],\n  ];\n\n  test('renders log messages up to current turn', () => {\n    const { lastFrame } = render(<LogArea turns={turns} currentTurn={2} />);\n    const output = lastFrame()!;\n    expect(output).toContain('Turn 2');\n    expect(output).toContain('attacks forward');\n    expect(output).toContain('Sludge');\n    expect(output).toContain('Turn 1');\n    expect(output).toContain('walks forward');\n  });\n\n  test('truncates to maxLines', () => {\n    const { lastFrame } = render(<LogArea turns={turns} currentTurn={2} maxLines={3} />);\n    const output = lastFrame()!;\n    expect(output).toContain('Turn 2');\n    expect(output).toContain('attacks forward');\n    expect(output).toContain('Sludge');\n    expect(output).not.toContain('Turn 1');\n  });\n\n  test('shows nothing on turn zero', () => {\n    const { lastFrame } = render(<LogArea turns={turns} currentTurn={0} />);\n    const output = lastFrame()!;\n    expect(output).not.toContain('Turn');\n  });\n\n  test('shows only turns up to currentTurn', () => {\n    const { lastFrame } = render(<LogArea turns={turns} currentTurn={1} />);\n    const output = lastFrame()!;\n    expect(output).toContain('Turn 1');\n    expect(output).toContain('walks forward');\n    expect(output).not.toContain('Turn 2');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/LogArea.tsx",
    "content": "import { Box, Text } from 'ink';\nimport type React from 'react';\nimport { useMemo } from 'react';\n\nimport { type TurnEvent } from '../types.js';\n\ninterface LogAreaProps {\n  turns: TurnEvent[][];\n  currentTurn: number;\n  maxLines?: number;\n}\n\ninterface LogLine {\n  type: 'turn' | 'event';\n  turnNumber: number;\n  event?: TurnEvent;\n}\n\nfunction buildUnitColorMap(turns: TurnEvent[][]): Map<string, string> {\n  const map = new Map<string, string>();\n  for (const turn of turns) {\n    for (const event of turn) {\n      if (event.unit) {\n        map.set(event.unit.name, event.unit.color);\n      }\n    }\n  }\n  return map;\n}\n\nconst STAT_RE = /\\d+ damage|\\d+ HP/;\n\nfunction buildColorizeRegex(unitColors: Map<string, string>): RegExp {\n  const unitNames = Array.from(unitColors.keys()).map((name) =>\n    name.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\$&'),\n  );\n  const alternatives = [STAT_RE.source, ...unitNames];\n  return new RegExp(`(${alternatives.join('|')})`);\n}\n\nfunction colorizeMessage(\n  message: string,\n  unitColors: Map<string, string>,\n  regex: RegExp,\n): React.ReactNode[] {\n  const parts = message.split(regex);\n\n  return parts.map((part, i) => {\n    const unitColor = unitColors.get(part);\n    if (unitColor) {\n      return (\n        // biome-ignore lint/suspicious/noArrayIndexKey: split parts are positional\n        <Text key={i} color={unitColor}>\n          {part}\n        </Text>\n      );\n    }\n    if (STAT_RE.test(part)) {\n      return (\n        // biome-ignore lint/suspicious/noArrayIndexKey: split parts are positional\n        <Text key={i} bold>\n          {part}\n        </Text>\n      );\n    }\n    // biome-ignore lint/suspicious/noArrayIndexKey: split parts are positional\n    return <Text key={i}>{part}</Text>;\n  });\n}\n\nexport default function LogArea({\n  turns,\n  currentTurn,\n  maxLines = 10,\n}: LogAreaProps): React.ReactElement {\n  const unitColors = useMemo(() => buildUnitColorMap(turns), [turns]);\n  const colorizeRegex = useMemo(() => buildColorizeRegex(unitColors), [unitColors]);\n\n  // Build a flat list of lines (turn headers + events), newest first.\n  const visibleLines = useMemo(() => {\n    const lines: LogLine[] = [];\n    const visibleTurns = turns.slice(0, currentTurn + 1);\n\n    for (let i = visibleTurns.length - 1; i >= 0; i--) {\n      const turnEvents = visibleTurns[i]!;\n      const hasMessages = turnEvents.some((e) => e.message);\n      if (!hasMessages) continue;\n      const turnNumber = i;\n      lines.push({ type: 'turn', turnNumber });\n      for (const event of turnEvents) {\n        if (event.message) {\n          lines.push({ type: 'event', turnNumber, event });\n        }\n      }\n    }\n\n    return lines.slice(0, maxLines);\n  }, [turns, currentTurn, maxLines]);\n\n  return (\n    <Box flexDirection=\"column\">\n      {visibleLines.map((line, index) => {\n        if (line.type === 'turn') {\n          return (\n            <Text key={`t${line.turnNumber}`} dimColor>\n              {`Turn ${line.turnNumber}`}\n            </Text>\n          );\n        }\n        const event = line.event!;\n        const text = event.unit ? `${event.unit.name} ${event.message}` : event.message;\n        return (\n          // biome-ignore lint/suspicious/noArrayIndexKey: unique with turnNumber\n          <Box key={`e${line.turnNumber}-${index}`} gap={1}>\n            <Text dimColor>{'>'}</Text>\n            <Text>{colorizeMessage(text, unitColors, colorizeRegex)}</Text>\n          </Box>\n        );\n      })}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/PlayLayout.test.tsx",
    "content": "import { Text } from 'ink';\nimport { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport { type TurnEvent } from '../types.js';\nimport PlayLayout from './PlayLayout.js';\n\nfunction makeEvent(overrides: Partial<TurnEvent> = {}): TurnEvent {\n  return {\n    message: 'test',\n    unit: null,\n    floorMap: [[{ character: '║' }, { character: '@' }, { character: '║' }]],\n    warriorStatus: { health: 20, score: 0 },\n    ...overrides,\n  };\n}\n\ndescribe('PlayLayout', () => {\n  test('renders header, floor map, divider, and children', () => {\n    const turns = [[makeEvent()]];\n    const { lastFrame } = render(\n      <PlayLayout\n        turns={turns}\n        warriorName=\"Olric\"\n        towerName=\"The Narrow Path\"\n        levelNumber={3}\n        totalScore={42}\n      >\n        <Text>child content</Text>\n      </PlayLayout>,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('Olric');\n    expect(output).toContain('The Narrow Path');\n    expect(output).toContain('3');\n    expect(output).toContain('@');\n    expect(output).toContain('child content');\n    expect(output).toContain('─');\n  });\n\n  test('renders without floor map when turns are empty', () => {\n    const { lastFrame } = render(\n      <PlayLayout\n        turns={[]}\n        warriorName=\"Olric\"\n        towerName=\"The Narrow Path\"\n        levelNumber={1}\n        totalScore={0}\n      >\n        <Text>content</Text>\n      </PlayLayout>,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('content');\n    expect(output).not.toContain('@');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/PlayLayout.tsx",
    "content": "import { Box } from 'ink';\nimport type React from 'react';\n\nimport { type TurnEvent } from '../types.js';\nimport Divider from './Divider.js';\nimport FloorMap from './FloorMap.js';\nimport Header from './Header.js';\n\ninterface PlayLayoutProps {\n  turns: TurnEvent[][];\n  warriorName: string;\n  towerName: string;\n  levelNumber: number;\n  totalScore: number;\n  children: React.ReactNode;\n}\n\nexport default function PlayLayout({\n  turns,\n  warriorName,\n  towerName,\n  levelNumber,\n  totalScore,\n  children,\n}: PlayLayoutProps): React.ReactElement {\n  const lastTurnEvents = turns[turns.length - 1];\n  const lastEvent = lastTurnEvents?.[lastTurnEvents.length - 1];\n\n  return (\n    <Box flexDirection=\"column\" width=\"100%\">\n      <Header\n        warriorName={warriorName}\n        towerName={towerName}\n        levelNumber={levelNumber}\n        score={totalScore}\n      />\n      <Box flexDirection=\"column\">{lastEvent && <FloorMap floorMap={lastEvent.floorMap} />}</Box>\n      <Divider />\n      {children}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/PlayScreen.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport PlayScreen from './PlayScreen.js';\n\ndescribe('PlayScreen', () => {\n  const initialState = {\n    message: '',\n    unit: null,\n    floorMap: [\n      [{ character: '╔' }, { character: '═' }, { character: '╗' }],\n      [{ character: '║' }, { character: '@', unit: { color: '#ffffff' } }, { character: '║' }],\n      [{ character: '╚' }, { character: '═' }, { character: '╝' }],\n    ],\n    warriorStatus: { health: 20, score: 0 },\n  };\n\n  const turns = [\n    [\n      {\n        message: 'walks forward',\n        unit: { name: 'Warrior', color: '#ffffff' },\n        floorMap: [\n          [{ character: '╔' }, { character: '═' }, { character: '╗' }],\n          [{ character: '║' }, { character: ' ' }, { character: '║' }],\n          [{ character: '╚' }, { character: '═' }, { character: '╝' }],\n        ],\n        warriorStatus: { health: 20, score: 0 },\n      },\n    ],\n  ];\n\n  test('renders initial state on turn zero', () => {\n    const { lastFrame } = render(\n      <PlayScreen\n        turns={turns}\n        initialState={initialState}\n        warriorName=\"Olric\"\n        towerName=\"The Narrow Path\"\n        levelNumber={1}\n        totalScore={0}\n        maxHealth={20}\n        onPlaybackComplete={vi.fn()}\n      />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('WarriorJS');\n    expect(output).toContain('Olric');\n    expect(output).toContain('@');\n    expect(output).toContain('❤');\n    expect(output).toContain('Turn 0/1');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/PlayScreen.tsx",
    "content": "import { Box } from 'ink';\nimport type React from 'react';\nimport { useMemo } from 'react';\n\nimport { usePlayback } from '../hooks/usePlayback.js';\nimport { type TurnEvent } from '../types.js';\nimport Divider from './Divider.js';\nimport FloorMap from './FloorMap.js';\nimport Header from './Header.js';\nimport LogArea from './LogArea.js';\nimport Scrubber from './Scrubber.js';\nimport WarriorStatus from './WarriorStatus.js';\n\ninterface PlayScreenProps {\n  turns: TurnEvent[][];\n  initialState: TurnEvent;\n  warriorName: string;\n  towerName: string;\n  levelNumber: number;\n  totalScore: number;\n  maxHealth: number;\n  reviewMode?: boolean;\n  onPlaybackComplete: () => void;\n}\n\nexport default function PlayScreen({\n  turns,\n  initialState,\n  warriorName,\n  towerName,\n  levelNumber,\n  totalScore,\n  maxHealth,\n  reviewMode,\n  onPlaybackComplete,\n}: PlayScreenProps): React.ReactElement {\n  const turnsWithInitial = useMemo(() => [[initialState], ...turns], [initialState, turns]);\n  const { state } = usePlayback(turnsWithInitial.length, onPlaybackComplete, reviewMode);\n  const currentTurnEvents = turnsWithInitial[state.currentTurn];\n  const lastEvent = currentTurnEvents?.[currentTurnEvents.length - 1];\n\n  return (\n    <Box flexDirection=\"column\" width=\"100%\">\n      <Header\n        warriorName={warriorName}\n        towerName={towerName}\n        levelNumber={levelNumber}\n        score={totalScore}\n      />\n      <Box flexDirection=\"column\">\n        {lastEvent && (\n          <>\n            <FloorMap floorMap={lastEvent.floorMap} />\n            <WarriorStatus\n              health={lastEvent.warriorStatus.health}\n              maxHealth={maxHealth}\n              score={lastEvent.warriorStatus.score}\n            />\n          </>\n        )}\n      </Box>\n      <Divider />\n      <Box flexDirection=\"column\" height={10}>\n        <LogArea turns={turnsWithInitial} currentTurn={state.currentTurn} />\n      </Box>\n      <Divider />\n      <Scrubber\n        currentTurn={state.currentTurn}\n        totalTurns={turnsWithInitial.length}\n        speed={state.speed}\n        mode={state.mode}\n        isPlaying={state.isPlaying}\n      />\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/PlaySession.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { type GameContext } from '../../Game.js';\nimport type Profile from '../../Profile.js';\nimport { usePlaySession } from '../hooks/usePlaySession.js';\nimport { getLastContentFrame, makeLevelReport, makeLevelRun, waitForRender } from '../testing.js';\nimport { type PlaySessionState } from '../types.js';\nimport PlaySession from './PlaySession.js';\n\nvi.mock('../hooks/usePlaySession.js', () => ({\n  usePlaySession: vi.fn(),\n}));\n\nconst mockUsePlaySession = vi.mocked(usePlaySession);\n\nconst mockContext = {} as GameContext;\nconst mockProfile = {\n  calculateAverageGrade: vi.fn(() => 0.9),\n  currentEpicGrades: { '1': 1.0, '2': 0.8 } as Record<string, number>,\n} as unknown as Profile;\n\ndescribe('PlaySession', () => {\n  test('renders PlayScreen when state is playing', () => {\n    const levelRun = makeLevelRun();\n    mockUsePlaySession.mockReturnValue({\n      state: { type: 'playing', levelRun },\n      handlePlayComplete: vi.fn(),\n      handleLevelCompleteChoice: vi.fn(),\n    });\n\n    const { lastFrame } = render(\n      <PlaySession context={mockContext} profile={mockProfile} initialLevel={1} />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('Olric');\n    expect(output).toContain('@');\n  });\n\n  test('renders LevelCompleteScreen when state is levelComplete with prompt action', () => {\n    const levelRun = makeLevelRun();\n    const levelReport = makeLevelReport({ passed: true, hasNextLevel: true });\n    mockUsePlaySession.mockReturnValue({\n      state: { type: 'levelComplete', levelRun, levelReport, action: { type: 'prompt' } },\n      handlePlayComplete: vi.fn(),\n      handleLevelCompleteChoice: vi.fn(),\n    });\n\n    const { lastFrame } = render(\n      <PlaySession context={mockContext} profile={mockProfile} initialLevel={1} />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('Success');\n    expect(output).toContain('Next level');\n  });\n\n  test('renders TowerCompleteScreen when state is towerComplete', () => {\n    mockUsePlaySession.mockReturnValue({\n      state: { type: 'towerComplete' },\n      handlePlayComplete: vi.fn(),\n      handleLevelCompleteChoice: vi.fn(),\n    });\n\n    const { lastFrame } = render(\n      <PlaySession context={mockContext} profile={mockProfile} initialLevel={1} />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('average grade');\n    expect(output).toContain('Level 1');\n    expect(output).toContain('Level 2');\n  });\n\n  test('renders error message when state is error', () => {\n    mockUsePlaySession.mockReturnValue({\n      state: { type: 'error', message: 'Something went wrong' },\n      handlePlayComplete: vi.fn(),\n      handleLevelCompleteChoice: vi.fn(),\n    });\n\n    const { lastFrame } = render(\n      <PlaySession context={mockContext} profile={mockProfile} initialLevel={1} />,\n    );\n    expect(lastFrame()!).toContain('Something went wrong');\n  });\n\n  test('renders nothing when state is null', () => {\n    mockUsePlaySession.mockReturnValue({\n      state: null as unknown as PlaySessionState,\n      handlePlayComplete: vi.fn(),\n      handleLevelCompleteChoice: vi.fn(),\n    });\n\n    const { lastFrame } = render(\n      <PlaySession context={mockContext} profile={mockProfile} initialLevel={1} />,\n    );\n    expect(lastFrame()!).toBe('');\n  });\n\n  test('renders LevelCompleteScreen for terminal actions', async () => {\n    const levelRun = makeLevelRun();\n    const levelReport = makeLevelReport();\n    mockUsePlaySession.mockReturnValue({\n      state: {\n        type: 'levelComplete',\n        levelRun,\n        levelReport,\n        action: { type: 'next-level', readmePath: 'path/to/README' },\n      },\n      handlePlayComplete: vi.fn(),\n      handleLevelCompleteChoice: vi.fn(),\n    });\n\n    const { frames } = render(\n      <PlaySession context={mockContext} profile={mockProfile} initialLevel={1} />,\n    );\n    await waitForRender();\n    const output = getLastContentFrame(frames);\n    expect(output).toContain('path/to/README');\n    expect(output).toContain('instructions');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/PlaySession.tsx",
    "content": "import { useApp } from 'ink';\nimport type React from 'react';\nimport { useEffect } from 'react';\n\nimport { type GameContext } from '../../Game.js';\nimport type Profile from '../../Profile.js';\nimport { usePlaySession } from '../hooks/usePlaySession.js';\nimport ErrorMessage from './ErrorMessage.js';\nimport LevelCompleteScreen from './LevelCompleteScreen.js';\nimport PlayScreen from './PlayScreen.js';\nimport TowerCompleteScreen from './TowerCompleteScreen.js';\n\ninterface PlaySessionProps {\n  context: GameContext;\n  profile: Profile;\n  initialLevel: number;\n}\n\nexport default function PlaySession({\n  context,\n  profile,\n  initialLevel,\n}: PlaySessionProps): React.ReactElement | null {\n  const { exit } = useApp();\n  const { state, handlePlayComplete, handleLevelCompleteChoice } = usePlaySession({\n    context,\n    profile,\n    initialLevel,\n    exit,\n  });\n\n  // Exit when a terminal level-complete action is selected.\n  useEffect(() => {\n    if (state?.type === 'levelComplete' && state.action.type !== 'prompt') {\n      exit();\n    }\n  }, [state, exit]);\n\n  if (!state) return null;\n\n  switch (state.type) {\n    case 'playing':\n      return (\n        <PlayScreen\n          {...state.levelRun}\n          reviewMode={state.reviewMode}\n          onPlaybackComplete={handlePlayComplete}\n        />\n      );\n    case 'levelComplete':\n      return (\n        <LevelCompleteScreen\n          levelReport={state.levelReport}\n          levelRun={state.levelRun}\n          action={state.action}\n          onSelect={handleLevelCompleteChoice}\n        />\n      );\n    case 'towerComplete':\n      return (\n        <TowerCompleteScreen\n          averageGrade={profile.calculateAverageGrade() ?? 0}\n          levelGrades={profile.currentEpicGrades as Record<string, number>}\n        />\n      );\n    case 'error':\n      return <ErrorMessage message={state.message} />;\n  }\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/ProfileWizard.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport { type GameContext } from '../../Game.js';\nimport type Profile from '../../Profile.js';\nimport type Tower from '../../Tower.js';\nimport { waitForRender } from '../testing.js';\nimport ProfileWizard from './ProfileWizard.js';\n\nfunction createMockTower(name: string): Tower {\n  return {\n    toString: () => name,\n  } as unknown as Tower;\n}\n\nfunction createMockProfile(name: string): Profile {\n  return {\n    toString: () => name,\n    makeProfileDirectory: vi.fn(),\n  } as unknown as Profile;\n}\n\nfunction createMockContext(overrides: Partial<GameContext> = {}): GameContext {\n  return {\n    version: '1.0.0',\n    runDirectoryPath: '/tmp',\n    practiceLevel: undefined,\n    silencePlay: false,\n    towers: [createMockTower('The Narrow Path')],\n    profile: null,\n    profiles: [],\n    needsProfileSetup: true,\n    onCreateProfile: vi.fn(() => createMockProfile('TestWarrior - The Narrow Path')),\n    onIsExistingProfile: vi.fn(() => false),\n    onPrepareNextLevel: vi.fn(),\n    onPrepareEpicMode: vi.fn(),\n    onGenerateProfileFiles: vi.fn(),\n    onProfileSelected: vi.fn(),\n    ...overrides,\n  };\n}\n\ndescribe('ProfileWizard', () => {\n  let onComplete: ReturnType<typeof vi.fn<(profile: Profile) => void>>;\n  let onCancel: ReturnType<typeof vi.fn<() => void>>;\n  let onError: ReturnType<typeof vi.fn<(message: string) => void>>;\n\n  beforeEach(() => {\n    onComplete = vi.fn<(profile: Profile) => void>();\n    onCancel = vi.fn<() => void>();\n    onError = vi.fn<(message: string) => void>();\n  });\n\n  describe('choose-profile step', () => {\n    test('renders existing profiles and \"New profile\" option', () => {\n      const profile1 = createMockProfile('Warrior1 - Tower1');\n      const profile2 = createMockProfile('Warrior2 - Tower2');\n      const context = createMockContext({ profiles: [profile1, profile2] });\n\n      const { lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"choose-profile\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n      const output = lastFrame()!;\n      expect(output).toContain('Warrior1 - Tower1');\n      expect(output).toContain('Warrior2 - Tower2');\n      expect(output).toContain('New profile');\n      expect(output).toContain('Which warrior answers the call?');\n    });\n\n    test('selecting an existing profile calls onComplete', async () => {\n      const profile = createMockProfile('Warrior1 - Tower1');\n      const context = createMockContext({ profiles: [profile] });\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"choose-profile\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // First item is selected by default, press enter.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      expect(lastFrame()!).toContain('Warrior1 - Tower1');\n      expect(onComplete).toHaveBeenCalledWith(profile);\n    });\n\n    test('selecting \"New profile\" transitions to name input', async () => {\n      const profile = createMockProfile('Warrior1 - Tower1');\n      const context = createMockContext({ profiles: [profile] });\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"choose-profile\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Move down past the profile and separator to \"New profile\".\n      await waitForRender();\n      stdin.write('\\x1B[B');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('Enter one for your warrior');\n      expect(output).toContain('New profile');\n    });\n  });\n\n  describe('create-name step', () => {\n    test('renders name input with suggested name as default', () => {\n      const context = createMockContext();\n\n      const { lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n      const output = lastFrame()!;\n      expect(output).toContain('Enter one for your warrior');\n      expect(output).toContain('Hero');\n    });\n\n    test('typing a name and submitting transitions to language selection', async () => {\n      const context = createMockContext();\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      await waitForRender();\n      stdin.write('Braveheart');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('Choose your language');\n      expect(output).toContain('Braveheart');\n    });\n\n    test('submitting with default name uses suggested name', async () => {\n      const context = createMockContext();\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('Choose your language');\n      expect(output).toContain('Hero');\n    });\n\n    test('submitting empty name with no default shows error', async () => {\n      const context = createMockContext();\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('A warrior without a name is just a shadow');\n    });\n\n    test('escape from name step when from \"new\" calls onCancel', async () => {\n      const context = createMockContext();\n\n      const { stdin } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      await waitForRender();\n      stdin.write('\\x1B');\n      await waitForRender();\n\n      expect(onCancel).toHaveBeenCalled();\n    });\n\n    test('escape from name step when from \"choose-profile\" goes back to choose-profile', async () => {\n      const profile = createMockProfile('Warrior1 - Tower1');\n      const context = createMockContext({ profiles: [profile] });\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"choose-profile\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Navigate to \"New profile\" and select it.\n      await waitForRender();\n      stdin.write('\\x1B[B');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      // Verify we transitioned to name step (history entry for the selection was added).\n      expect(lastFrame()!).toContain('Enter one for your warrior');\n\n      // Now we should be on the name step, press escape to go back.\n      stdin.write('\\x1B');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('Which warrior answers the call?');\n    });\n  });\n\n  describe('create-name-error step', () => {\n    test('dismissing error goes back to name input', async () => {\n      const context = createMockContext();\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Submit empty name to trigger error.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      expect(lastFrame()!).toContain('A warrior without a name is just a shadow');\n\n      // Press any key to dismiss.\n      stdin.write(' ');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('Enter one for your warrior');\n    });\n  });\n\n  describe('create-language step', () => {\n    test('renders language options', async () => {\n      const context = createMockContext();\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Submit default name.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('Choose your language');\n      expect(output).toContain('TypeScript (recommended)');\n      expect(output).toContain('JavaScript');\n    });\n\n    test('selecting TypeScript transitions to tower selection', async () => {\n      const context = createMockContext();\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Submit name.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      // Select TypeScript (default).\n      stdin.write('\\r');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('Choose a tower');\n      expect(output).toContain('TypeScript');\n    });\n\n    test('selecting JavaScript transitions to tower selection', async () => {\n      const context = createMockContext();\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Submit name.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      // Move to JavaScript and select.\n      stdin.write('\\x1B[B');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('Choose a tower');\n      expect(output).toContain('JavaScript');\n    });\n\n    test('escape from language goes back to name input', async () => {\n      const context = createMockContext();\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Submit name.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      // Verify history entry was added.\n      expect(lastFrame()!).toContain('Hero');\n\n      // Escape from language.\n      stdin.write('\\x1B');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('Enter one for your warrior');\n    });\n  });\n\n  describe('create-tower step', () => {\n    test('renders tower options', async () => {\n      const tower1 = createMockTower('The Narrow Path');\n      const tower2 = createMockTower('The Powder Keep');\n      const context = createMockContext({ towers: [tower1, tower2] });\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Submit name, then select language.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('Choose a tower');\n      expect(output).toContain('The Narrow Path');\n      expect(output).toContain('The Powder Keep');\n    });\n\n    test('selecting tower with no existing profile completes wizard', async () => {\n      const tower = createMockTower('The Narrow Path');\n      const mockProfile = createMockProfile('Hero - The Narrow Path');\n      const context = createMockContext({\n        towers: [tower],\n        onCreateProfile: vi.fn(() => mockProfile),\n        onIsExistingProfile: vi.fn(() => false),\n      });\n\n      const { stdin } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Submit name, select language, select tower.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      expect(context.onCreateProfile).toHaveBeenCalledWith('Hero', 'typescript', tower);\n      expect(context.onIsExistingProfile).toHaveBeenCalledWith(mockProfile);\n      expect(mockProfile.makeProfileDirectory).toHaveBeenCalled();\n      expect(onComplete).toHaveBeenCalledWith(mockProfile);\n    });\n\n    test('selecting tower with existing profile shows confirm-replace', async () => {\n      const tower = createMockTower('The Narrow Path');\n      const mockProfile = createMockProfile('Hero - The Narrow Path');\n      const context = createMockContext({\n        towers: [tower],\n        onCreateProfile: vi.fn(() => mockProfile),\n        onIsExistingProfile: vi.fn(() => true),\n      });\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Submit name, select language, select tower.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('already climbing');\n      expect(output).toContain('The Narrow Path');\n      expect(output).toContain('Do you want to replace');\n      expect(onComplete).not.toHaveBeenCalled();\n    });\n\n    test('escape from tower goes back to language selection', async () => {\n      const context = createMockContext();\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Submit name, select language.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      // Verify history entries exist.\n      expect(lastFrame()!).toContain('TypeScript');\n\n      // Escape from tower.\n      stdin.write('\\x1B');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('Choose your language');\n    });\n\n    test('escape from tower after selecting JavaScript restores language index', async () => {\n      const context = createMockContext();\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Submit name.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      // Select JavaScript (index ).\n      stdin.write('\\x1B[B');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      // Escape from tower to go back to language.\n      stdin.write('\\x1B');\n      await waitForRender();\n\n      const output = lastFrame()!;\n      expect(output).toContain('Choose your language');\n      // JavaScript should be pre-selected (indicator on JavaScript line).\n      expect(output).toContain('JavaScript');\n    });\n  });\n\n  describe('create-confirm-replace step', () => {\n    test('confirming replace calls onComplete', async () => {\n      const tower = createMockTower('The Narrow Path');\n      const mockProfile = createMockProfile('Hero - The Narrow Path');\n      const context = createMockContext({\n        towers: [tower],\n        onCreateProfile: vi.fn(() => mockProfile),\n        onIsExistingProfile: vi.fn(() => true),\n      });\n\n      const { stdin } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Submit name, select language, select tower.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      // Confirm replace with 'y'.\n      stdin.write('y');\n      await waitForRender();\n\n      expect(context.onCreateProfile).toHaveBeenCalledTimes(2);\n      expect(onComplete).toHaveBeenCalledWith(mockProfile);\n    });\n\n    test('declining replace calls onError', async () => {\n      const tower = createMockTower('The Narrow Path');\n      const mockProfile = createMockProfile('Hero - The Narrow Path');\n      const context = createMockContext({\n        towers: [tower],\n        onCreateProfile: vi.fn(() => mockProfile),\n        onIsExistingProfile: vi.fn(() => true),\n      });\n\n      const { stdin } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Submit name, select language, select tower.\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      // Decline replace with 'n'.\n      stdin.write('n');\n      await waitForRender();\n\n      expect(onComplete).not.toHaveBeenCalled();\n      expect(onError).toHaveBeenCalledWith('Unable to continue without a profile.');\n    });\n  });\n\n  describe('full flow', () => {\n    test('complete flow from new: name -> language -> tower -> done', async () => {\n      const tower = createMockTower('The Narrow Path');\n      const mockProfile = createMockProfile('Braveheart - The Narrow Path');\n      const context = createMockContext({\n        towers: [tower],\n        onCreateProfile: vi.fn(() => mockProfile),\n        onIsExistingProfile: vi.fn(() => false),\n      });\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"\"\n          initialStep=\"new\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      // Type name.\n      await waitForRender();\n      stdin.write('Braveheart');\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      // Select TypeScript.\n      stdin.write('\\r');\n      await waitForRender();\n\n      // Select tower.\n      stdin.write('\\r');\n      await waitForRender();\n\n      expect(context.onCreateProfile).toHaveBeenCalledWith('Braveheart', 'typescript', tower);\n      expect(mockProfile.makeProfileDirectory).toHaveBeenCalled();\n      expect(onComplete).toHaveBeenCalledWith(mockProfile);\n      // Verify history entries are rendered.\n      const output = lastFrame()!;\n      expect(output).toContain('Braveheart');\n      expect(output).toContain('TypeScript');\n      expect(output).toContain('The Narrow Path');\n    });\n\n    test('complete flow from choose-profile: select existing -> done', async () => {\n      const profile = createMockProfile('Warrior1 - Tower1');\n      const context = createMockContext({ profiles: [profile] });\n\n      const { stdin, lastFrame } = render(\n        <ProfileWizard\n          context={context}\n          suggestedName=\"Hero\"\n          initialStep=\"choose-profile\"\n          onComplete={onComplete}\n          onCancel={onCancel}\n          onError={onError}\n        />,\n      );\n\n      await waitForRender();\n      stdin.write('\\r');\n      await waitForRender();\n\n      expect(onComplete).toHaveBeenCalledWith(profile);\n      // Verify history entry is rendered.\n      expect(lastFrame()!).toContain('Warrior1 - Tower1');\n    });\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/ProfileWizard.tsx",
    "content": "import { Box, Text } from 'ink';\nimport type React from 'react';\nimport { useState } from 'react';\n\nimport { type GameContext } from '../../Game.js';\nimport type Profile from '../../Profile.js';\nimport type Tower from '../../Tower.js';\nimport ConfirmPrompt from './ConfirmPrompt.js';\nimport ErrorMessage from './ErrorMessage.js';\nimport SelectPrompt from './SelectPrompt.js';\nimport TextPrompt from './TextPrompt.js';\n\ntype WizardStep =\n  | { type: 'choose-profile' }\n  | { type: 'create-name'; from?: 'new' | 'choose-profile'; initialValue?: string }\n  | { type: 'create-name-error' }\n  | { type: 'create-language'; warriorName: string; initialIndex?: number }\n  | {\n      type: 'create-tower';\n      warriorName: string;\n      language: 'javascript' | 'typescript';\n      initialIndex?: number;\n    }\n  | {\n      type: 'create-confirm-replace';\n      warriorName: string;\n      language: 'javascript' | 'typescript';\n      tower: Tower;\n    };\n\ninterface ProfileWizardProps {\n  context: GameContext;\n  suggestedName: string;\n  initialStep: 'new' | 'choose-profile';\n  onComplete: (profile: Profile) => void;\n  onCancel: () => void;\n  onError: (message: string) => void;\n}\n\nexport default function ProfileWizard({\n  context,\n  suggestedName,\n  initialStep,\n  onComplete,\n  onCancel,\n  onError,\n}: ProfileWizardProps): React.ReactElement | null {\n  const [step, setStep] = useState<WizardStep>(\n    initialStep === 'choose-profile'\n      ? { type: 'choose-profile' }\n      : { type: 'create-name', from: 'new' },\n  );\n  const [history, setHistory] = useState<React.ReactElement[]>([]);\n\n  const pushHistory = (question: string, answer: string) => {\n    setHistory((prev) => [\n      ...prev,\n      <Text key={prev.length}>\n        <Text bold>{question}</Text> {answer}\n      </Text>,\n    ]);\n  };\n\n  const popHistory = () => {\n    setHistory((prev) => prev.slice(0, -1));\n  };\n\n  const renderStep = (): React.ReactElement | null => {\n    switch (step.type) {\n      case 'choose-profile': {\n        const items: ({ label: string; value: Profile | 'new' } | { separator: true })[] = [\n          ...context.profiles.map((p) => ({ label: p.toString(), value: p as Profile | 'new' })),\n          { separator: true as const },\n          { label: 'New profile', value: 'new' as const },\n        ];\n        return (\n          <SelectPrompt\n            key=\"choose-profile\"\n            message=\"Your sword awaits. Which warrior answers the call?\"\n            items={items}\n            onSelect={(value) => {\n              if (value === 'new') {\n                pushHistory('Your sword awaits. Which warrior answers the call?', 'New profile');\n                setStep({ type: 'create-name', from: 'choose-profile' });\n              } else {\n                onComplete(value);\n              }\n            }}\n          />\n        );\n      }\n\n      case 'create-name':\n        return (\n          <TextPrompt\n            key=\"create-name\"\n            message=\"Every legend needs a name. Enter one for your warrior:\"\n            defaultValue={suggestedName}\n            initialValue={step.initialValue}\n            onSubmit={(name) => {\n              if (!name) {\n                setStep({ type: 'create-name-error' });\n                return;\n              }\n              pushHistory('Every legend needs a name. Enter one for your warrior:', name);\n              setStep({ type: 'create-language', warriorName: name });\n            }}\n            onCancel={() => {\n              popHistory();\n              if (step.from === 'choose-profile') {\n                setStep({ type: 'choose-profile' });\n              } else {\n                onCancel();\n              }\n            }}\n          />\n        );\n\n      case 'create-name-error':\n        return (\n          <ErrorMessage\n            key=\"create-name-error\"\n            message=\"A warrior without a name is just a shadow. Try again.\"\n            onDismiss={() => setStep({ type: 'create-name' })}\n          />\n        );\n\n      case 'create-language': {\n        const items: { label: string; value: 'typescript' | 'javascript' }[] = [\n          { label: 'TypeScript (recommended)', value: 'typescript' },\n          { label: 'JavaScript', value: 'javascript' },\n        ];\n        return (\n          <SelectPrompt\n            key=\"create-language\"\n            message=\"Choose your language:\"\n            items={items}\n            initialIndex={step.initialIndex}\n            onSelect={(value) => {\n              const label = value === 'typescript' ? 'TypeScript' : 'JavaScript';\n              pushHistory('Choose your language:', label);\n              setStep({\n                type: 'create-tower',\n                warriorName: step.warriorName,\n                language: value,\n              });\n            }}\n            onCancel={() => {\n              popHistory();\n              setStep({ type: 'create-name', initialValue: step.warriorName });\n            }}\n          />\n        );\n      }\n\n      case 'create-tower': {\n        const items = context.towers.map((t) => ({ label: t.toString(), value: t }));\n        return (\n          <SelectPrompt\n            key=\"create-tower\"\n            message=\"Choose a tower:\"\n            items={items}\n            initialIndex={step.initialIndex}\n            onSelect={(tower) => {\n              const profile = context.onCreateProfile(step.warriorName, step.language, tower);\n              if (context.onIsExistingProfile(profile)) {\n                pushHistory('Choose a tower:', tower.toString());\n                setStep({\n                  type: 'create-confirm-replace',\n                  warriorName: step.warriorName,\n                  language: step.language,\n                  tower,\n                });\n              } else {\n                profile.makeProfileDirectory();\n                onComplete(profile);\n              }\n            }}\n            onCancel={() => {\n              popHistory();\n              const languageIndex = step.language === 'typescript' ? 0 : 1;\n              setStep({\n                type: 'create-language',\n                warriorName: step.warriorName,\n                initialIndex: languageIndex,\n              });\n            }}\n          />\n        );\n      }\n\n      case 'create-confirm-replace':\n        return (\n          <Box flexDirection=\"column\">\n            <Text>{`A warrior named ${step.warriorName} is already climbing ${step.tower}.`}</Text>\n            <ConfirmPrompt\n              message=\"Do you want to replace your existing profile for this tower?\"\n              onConfirm={(yes) => {\n                if (!yes) {\n                  onError('Unable to continue without a profile.');\n                  return;\n                }\n                const profile = context.onCreateProfile(\n                  step.warriorName,\n                  step.language,\n                  step.tower,\n                );\n                onComplete(profile);\n              }}\n            />\n          </Box>\n        );\n    }\n  };\n\n  return (\n    <Box flexDirection=\"column\">\n      {history}\n      {renderStep()}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/ResultScreen.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport ResultScreen from './ResultScreen.js';\n\ndescribe('ResultScreen', () => {\n  test('renders success message when passed', () => {\n    const { lastFrame } = render(\n      <ResultScreen\n        passed={true}\n        levelNumber={3}\n        hasNextLevel={true}\n        scoreParts={{ warrior: 10, timeBonus: 5, clearBonus: 3 }}\n        totalScore={18}\n        grade={0.9}\n        isEpic={false}\n        previousScore={100}\n      />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('Success');\n    expect(output).toContain('Warrior Score: 10');\n    expect(output).toContain('Time Bonus: 5');\n    expect(output).toContain('Clear Bonus: 3');\n  });\n\n  test('renders failure message when not passed', () => {\n    const { lastFrame } = render(\n      <ResultScreen\n        passed={false}\n        levelNumber={3}\n        hasNextLevel={true}\n        scoreParts={{ warrior: 0, timeBonus: 0, clearBonus: 0 }}\n        totalScore={0}\n        grade={0}\n        isEpic={false}\n        previousScore={100}\n      />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('failed');\n  });\n\n  test('renders congratulations when tower complete', () => {\n    const { lastFrame } = render(\n      <ResultScreen\n        passed={true}\n        levelNumber={5}\n        hasNextLevel={false}\n        scoreParts={{ warrior: 20, timeBonus: 10, clearBonus: 5 }}\n        totalScore={35}\n        grade={1.0}\n        isEpic={false}\n        previousScore={200}\n      />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('CONGRATULATIONS');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/ResultScreen.tsx",
    "content": "import { getGradeLetter } from '@warriorjs/scoring';\nimport { Box, Text } from 'ink';\nimport type React from 'react';\n\ninterface ResultScreenProps {\n  passed: boolean;\n  levelNumber: number;\n  hasNextLevel: boolean;\n  scoreParts: { warrior: number; timeBonus: number; clearBonus: number };\n  totalScore: number;\n  grade: number;\n  isEpic: boolean;\n  previousScore: number;\n}\n\nexport default function ResultScreen({\n  passed,\n  levelNumber,\n  hasNextLevel,\n  scoreParts,\n  totalScore,\n  grade,\n  isEpic,\n  previousScore,\n}: ResultScreenProps): React.ReactElement {\n  if (!passed) {\n    return (\n      <Text\n        color={'red'}\n      >{`You failed level ${levelNumber}. Update your code and try again.`}</Text>\n    );\n  }\n\n  const successMessage = hasNextLevel\n    ? 'Success! You have found the stairs.'\n    : 'CONGRATULATIONS! You have climbed to the top of the tower.';\n\n  return (\n    <Box flexDirection=\"column\">\n      <Text color={'green'}>{successMessage}</Text>\n      <Box flexDirection=\"column\" marginTop={1}>\n        <Text>\n          {'Warrior Score: '}\n          <Text color={'yellow'}>{scoreParts.warrior}</Text>\n        </Text>\n        <Text>\n          {'Time Bonus: '}\n          <Text color={'yellow'}>{scoreParts.timeBonus}</Text>\n        </Text>\n        <Text>\n          {'Clear Bonus: '}\n          <Text color={'yellow'}>{scoreParts.clearBonus}</Text>\n        </Text>\n        {isEpic && (\n          <Text>\n            {'Level Grade: '}\n            <Text color={'yellow'}>{getGradeLetter(grade)}</Text>\n          </Text>\n        )}\n        <Text>\n          {'Total Score: '}\n          <Text\n            color={'yellow'}\n          >{`${previousScore} + ${totalScore} = ${previousScore + totalScore}`}</Text>\n        </Text>\n      </Box>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/Scrubber.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport Scrubber from './Scrubber.js';\n\ndescribe('Scrubber', () => {\n  test('renders turn position in playback mode', () => {\n    const { lastFrame } = render(\n      <Scrubber currentTurn={4} totalTurns={15} speed={1} mode=\"playback\" isPlaying={true} />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('Turn 4/14');\n    expect(output).toContain('[space] pause');\n  });\n\n  test('shows play hint when paused', () => {\n    const { lastFrame } = render(\n      <Scrubber currentTurn={4} totalTurns={15} speed={2} mode=\"playback\" isPlaying={false} />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('[space] play');\n  });\n\n  test('highlights active speed', () => {\n    const { lastFrame } = render(\n      <Scrubber currentTurn={0} totalTurns={10} speed={2} mode=\"playback\" isPlaying={true} />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('2x');\n  });\n\n  test('shows review mode controls', () => {\n    const { lastFrame } = render(\n      <Scrubber currentTurn={14} totalTurns={15} speed={1} mode=\"review\" isPlaying={false} />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('[←/→] step');\n    expect(output).toContain('[esc] go back');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/Scrubber.tsx",
    "content": "import { Box, Text } from 'ink';\nimport type React from 'react';\n\ninterface ScrubberProps {\n  currentTurn: number;\n  totalTurns: number;\n  speed: number;\n  mode: 'playback' | 'review';\n  isPlaying: boolean;\n}\n\nexport default function Scrubber({\n  currentTurn,\n  totalTurns,\n  speed,\n  mode,\n  isPlaying,\n}: ScrubberProps): React.ReactElement {\n  const turnDisplay = `Turn ${currentTurn}/${totalTurns - 1}`;\n\n  if (mode === 'playback') {\n    return (\n      <Box gap={2}>\n        <Text dimColor>{turnDisplay}</Text>\n        <Box gap={1}>\n          <Text dimColor>{'[tab]'}</Text>\n          {[1, 2, 4].map((s) => (\n            <Text key={s} dimColor={s !== speed} bold={s === speed}>\n              {`${s}x`}\n            </Text>\n          ))}\n        </Box>\n        <Text dimColor>{isPlaying ? '[space] pause' : '[space] play'}</Text>\n        <Text dimColor>{'[s] skip'}</Text>\n      </Box>\n    );\n  }\n\n  return (\n    <Box gap={2}>\n      <Text dimColor>{turnDisplay}</Text>\n      <Text dimColor>{'[←/→] step'}</Text>\n      <Text dimColor>{'[esc] go back'}</Text>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/SelectPrompt.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { waitForRender } from '../testing.js';\nimport SelectPrompt from './SelectPrompt.js';\n\nconst items = [\n  { label: 'Option A', value: 'a' },\n  { label: 'Option B', value: 'b' },\n  { label: 'Option C', value: 'c' },\n];\n\ndescribe('SelectPrompt', () => {\n  test('renders message and items', () => {\n    const { lastFrame } = render(\n      <SelectPrompt message=\"Pick one:\" items={items} onSelect={vi.fn()} />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('Pick one:');\n    expect(output).toContain('Option A');\n    expect(output).toContain('Option B');\n    expect(output).toContain('Option C');\n  });\n\n  test('first item is selected by default', () => {\n    const { lastFrame } = render(<SelectPrompt message=\"Pick:\" items={items} onSelect={vi.fn()} />);\n    const output = lastFrame()!;\n    expect(output).toContain('❯');\n  });\n\n  test('calls onSelect with value on enter', () => {\n    const onSelect = vi.fn();\n    const { stdin } = render(<SelectPrompt message=\"Pick:\" items={items} onSelect={onSelect} />);\n    stdin.write('\\r');\n    expect(onSelect).toHaveBeenCalledWith('a');\n  });\n\n  test('navigates down and selects second item', async () => {\n    const onSelect = vi.fn();\n    const { stdin } = render(<SelectPrompt message=\"Pick:\" items={items} onSelect={onSelect} />);\n    stdin.write('\\x1B[B'); // down arrow\n    await waitForRender();\n    stdin.write('\\r');\n    expect(onSelect).toHaveBeenCalledWith('b');\n  });\n\n  test('wraps around when navigating past last item', async () => {\n    const onSelect = vi.fn();\n    const { stdin } = render(<SelectPrompt message=\"Pick:\" items={items} onSelect={onSelect} />);\n    stdin.write('\\x1B[A'); // up arrow wraps to last\n    await waitForRender();\n    stdin.write('\\r');\n    expect(onSelect).toHaveBeenCalledWith('c');\n  });\n\n  test('shows submitted state after selection', async () => {\n    const { stdin, lastFrame } = render(\n      <SelectPrompt message=\"Pick:\" items={items} onSelect={vi.fn()} />,\n    );\n    stdin.write('\\r');\n    await waitForRender();\n    const output = lastFrame()!;\n    expect(output).toContain('Pick:');\n    expect(output).toContain('Option A');\n    expect(output).not.toContain('❯');\n  });\n\n  test('renders without message', async () => {\n    const { stdin, lastFrame } = render(\n      <SelectPrompt message=\"\" items={items} onSelect={vi.fn()} />,\n    );\n    stdin.write('\\r');\n    await waitForRender();\n    const output = lastFrame()!;\n    expect(output).toContain('Option A');\n  });\n\n  test('calls onCancel on escape', async () => {\n    const onCancel = vi.fn();\n    const { stdin } = render(\n      <SelectPrompt message=\"Pick:\" items={items} onSelect={vi.fn()} onCancel={onCancel} />,\n    );\n    stdin.write('\\x1B');\n    await waitForRender();\n    expect(onCancel).toHaveBeenCalled();\n  });\n\n  test('supports separator items', () => {\n    const itemsWithSep = [\n      { label: 'Option A', value: 'a' },\n      { separator: true as const },\n      { label: 'Option B', value: 'b' },\n    ];\n    const { lastFrame } = render(\n      <SelectPrompt message=\"Pick:\" items={itemsWithSep} onSelect={vi.fn()} />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('───');\n  });\n\n  test('respects initialIndex', () => {\n    const onSelect = vi.fn();\n    const { stdin } = render(\n      <SelectPrompt message=\"Pick:\" items={items} initialIndex={2} onSelect={onSelect} />,\n    );\n    stdin.write('\\r');\n    expect(onSelect).toHaveBeenCalledWith('c');\n  });\n\n  test('ignores input after submission', async () => {\n    const onSelect = vi.fn();\n    const { stdin } = render(<SelectPrompt message=\"Pick:\" items={items} onSelect={onSelect} />);\n    stdin.write('\\r');\n    await waitForRender();\n    stdin.write('\\x1B[B');\n    await waitForRender();\n    stdin.write('\\r');\n    expect(onSelect).toHaveBeenCalledTimes(1);\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/SelectPrompt.tsx",
    "content": "import { Box, Text, useInput } from 'ink';\nimport type React from 'react';\nimport { useState } from 'react';\n\ninterface SelectChoice<T> {\n  label: string;\n  value: T;\n  separator?: false;\n}\n\ninterface SelectSeparator {\n  separator: true;\n}\n\ntype SelectItem<T> = SelectChoice<T> | SelectSeparator;\n\ninterface SelectPromptProps<T> {\n  message: string;\n  items: SelectItem<T>[];\n  initialIndex?: number;\n  onSelect: (value: T) => void;\n  onCancel?: () => void;\n}\n\nexport default function SelectPrompt<T>({\n  message,\n  items,\n  initialIndex = 0,\n  onSelect,\n  onCancel,\n}: SelectPromptProps<T>): React.ReactElement {\n  const choices = items.filter((item): item is SelectChoice<T> => !item.separator);\n  const [selectedIndex, setSelectedIndex] = useState(initialIndex);\n  const [submitted, setSubmitted] = useState(false);\n\n  useInput((_input, key) => {\n    if (submitted) return;\n\n    if (key.escape && onCancel) {\n      onCancel();\n      return;\n    }\n\n    if (key.return) {\n      setSubmitted(true);\n      onSelect(choices[selectedIndex]!.value);\n      return;\n    }\n\n    if (key.upArrow) {\n      setSelectedIndex((prev) => (prev > 0 ? prev - 1 : choices.length - 1));\n    }\n\n    if (key.downArrow) {\n      setSelectedIndex((prev) => (prev < choices.length - 1 ? prev + 1 : 0));\n    }\n  });\n\n  if (submitted) {\n    const answer = choices[selectedIndex]!.label;\n    return message ? (\n      <Text>\n        <Text bold>{message}</Text> {answer}\n      </Text>\n    ) : (\n      <Text>{answer}</Text>\n    );\n  }\n\n  let choiceIndex = 0;\n  return (\n    <Box flexDirection=\"column\">\n      {message ? <Text bold>{message}</Text> : null}\n      {items.map((item, index) => {\n        if (item.separator) {\n          return (\n            // biome-ignore lint/suspicious/noArrayIndexKey: static item list\n            <Text key={`sep-${index}`} dimColor>\n              {'───'}\n            </Text>\n          );\n        }\n        const currentChoiceIndex = choiceIndex++;\n        const isSelected = currentChoiceIndex === selectedIndex;\n        return (\n          // biome-ignore lint/suspicious/noArrayIndexKey: static item list\n          <Box key={`choice-${index}`} gap={1}>\n            <Text color={isSelected ? 'yellow' : undefined}>{isSelected ? '❯' : ' '}</Text>\n            <Text dimColor={!isSelected}>{item.label}</Text>\n          </Box>\n        );\n      })}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/TextPrompt.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { waitForRender } from '../testing.js';\nimport TextPrompt from './TextPrompt.js';\n\ndescribe('TextPrompt', () => {\n  test('renders message', () => {\n    const { lastFrame } = render(<TextPrompt message=\"Enter name:\" onSubmit={vi.fn()} />);\n    expect(lastFrame()!).toContain('Enter name:');\n  });\n\n  test('shows default value as hint', () => {\n    const { lastFrame } = render(\n      <TextPrompt message=\"Name:\" defaultValue=\"Olric\" onSubmit={vi.fn()} />,\n    );\n    expect(lastFrame()!).toContain('(Olric)');\n  });\n\n  test('submits typed value on enter', async () => {\n    const onSubmit = vi.fn();\n    const { stdin } = render(<TextPrompt message=\"Name:\" onSubmit={onSubmit} />);\n    stdin.write('Corwin');\n    await waitForRender();\n    stdin.write('\\r');\n    expect(onSubmit).toHaveBeenCalledWith('Corwin');\n  });\n\n  test('submits default value when empty', () => {\n    const onSubmit = vi.fn();\n    const { stdin } = render(\n      <TextPrompt message=\"Name:\" defaultValue=\"Olric\" onSubmit={onSubmit} />,\n    );\n    stdin.write('\\r');\n    expect(onSubmit).toHaveBeenCalledWith('Olric');\n  });\n\n  test('backspace removes last character', async () => {\n    const onSubmit = vi.fn();\n    const { stdin } = render(<TextPrompt message=\"Name:\" onSubmit={onSubmit} />);\n    stdin.write('abc');\n    await waitForRender();\n    stdin.write('\\x7F'); // backspace\n    await waitForRender();\n    stdin.write('\\r');\n    expect(onSubmit).toHaveBeenCalledWith('ab');\n  });\n\n  test('shows submitted state', async () => {\n    const { stdin, lastFrame } = render(<TextPrompt message=\"Name:\" onSubmit={vi.fn()} />);\n    stdin.write('Corwin');\n    await waitForRender();\n    stdin.write('\\r');\n    await waitForRender();\n    const output = lastFrame()!;\n    expect(output).toContain('Name:');\n    expect(output).toContain('Corwin');\n  });\n\n  test('calls onCancel on escape', async () => {\n    const onCancel = vi.fn();\n    const { stdin } = render(<TextPrompt message=\"Name:\" onSubmit={vi.fn()} onCancel={onCancel} />);\n    stdin.write('\\x1B');\n    await waitForRender();\n    expect(onCancel).toHaveBeenCalled();\n  });\n\n  test('ignores input after submission', async () => {\n    const onSubmit = vi.fn();\n    const { stdin } = render(<TextPrompt message=\"Name:\" onSubmit={onSubmit} />);\n    stdin.write('a');\n    await waitForRender();\n    stdin.write('\\r');\n    await waitForRender();\n    stdin.write('b');\n    await waitForRender();\n    stdin.write('\\r');\n    expect(onSubmit).toHaveBeenCalledTimes(1);\n  });\n\n  test('uses initialValue as starting text', () => {\n    const onSubmit = vi.fn();\n    const { stdin } = render(<TextPrompt message=\"Name:\" initialValue=\"pre\" onSubmit={onSubmit} />);\n    stdin.write('\\r');\n    expect(onSubmit).toHaveBeenCalledWith('pre');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/TextPrompt.tsx",
    "content": "import { Box, Text, useInput } from 'ink';\nimport type React from 'react';\nimport { useState } from 'react';\n\ninterface TextPromptProps {\n  message: string;\n  defaultValue?: string;\n  initialValue?: string;\n  onSubmit: (value: string) => void;\n  onCancel?: () => void;\n}\n\nexport default function TextPrompt({\n  message,\n  defaultValue = '',\n  initialValue = '',\n  onSubmit,\n  onCancel,\n}: TextPromptProps): React.ReactElement {\n  const [value, setValue] = useState(initialValue);\n  const [submitted, setSubmitted] = useState(false);\n\n  useInput((input, key) => {\n    if (submitted) return;\n\n    if (key.return) {\n      const result = value || defaultValue;\n      setSubmitted(true);\n      onSubmit(result);\n      return;\n    }\n\n    if (key.backspace || key.delete) {\n      setValue((prev) => prev.slice(0, -1));\n      return;\n    }\n\n    if (key.escape && onCancel) {\n      onCancel();\n      return;\n    }\n\n    // Ignore control characters.\n    if (key.ctrl || key.meta || key.escape) return;\n    if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) return;\n    if (key.tab) return;\n\n    setValue((prev) => prev + input);\n  });\n\n  if (submitted) {\n    const display = value || defaultValue;\n    return (\n      <Text>\n        <Text bold>{message}</Text> {display}\n      </Text>\n    );\n  }\n\n  return (\n    <Box gap={1}>\n      <Text bold>{message}</Text>\n      {value ? (\n        <Text>{value}</Text>\n      ) : defaultValue ? (\n        <Text dimColor>{`(${defaultValue})`}</Text>\n      ) : null}\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/TowerCompleteScreen.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport TowerCompleteScreen from './TowerCompleteScreen.js';\n\ndescribe('TowerCompleteScreen', () => {\n  test('renders average grade and per-level grades', () => {\n    const { lastFrame } = render(\n      <TowerCompleteScreen averageGrade={0.95} levelGrades={{ '1': 1.0, '2': 0.9 }} />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('average grade');\n    expect(output).toContain('Level 1');\n    expect(output).toContain('Level 2');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/TowerCompleteScreen.tsx",
    "content": "import { getGradeLetter } from '@warriorjs/scoring';\nimport { Box, Text } from 'ink';\nimport type React from 'react';\n\ninterface TowerCompleteScreenProps {\n  averageGrade: number;\n  levelGrades: Record<string, number>;\n}\n\nexport default function TowerCompleteScreen({\n  averageGrade,\n  levelGrades,\n}: TowerCompleteScreenProps): React.ReactElement {\n  return (\n    <Box flexDirection=\"column\">\n      <Text>\n        {'Your average grade for this tower is: '}\n        <Text color={'yellow'}>{getGradeLetter(averageGrade)}</Text>\n      </Text>\n      <Box flexDirection=\"column\" marginTop={1}>\n        {Object.keys(levelGrades)\n          .sort()\n          .map((levelNumber) => (\n            <Text key={levelNumber}>\n              {'  Level '}\n              {levelNumber}\n              {': '}\n              <Text color={'yellow'}>{getGradeLetter(levelGrades[levelNumber]!)}</Text>\n            </Text>\n          ))}\n      </Box>\n      <Text dimColor>{'\\nTo practice a level, use the -l option.'}</Text>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/WarriorArt.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport WarriorArt from './WarriorArt.js';\n\ndescribe('WarriorArt', () => {\n  test('renders warrior art', () => {\n    const { lastFrame } = render(<WarriorArt />);\n    const output = lastFrame()!;\n    expect(output.split('\\n').length).toBeGreaterThan(1);\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/WarriorArt.tsx",
    "content": "import { Text } from 'ink';\nimport type React from 'react';\n\nimport { brandColor } from '../theme.js';\n\nconst art = [\n  // A warrior holding a sword.\n  '       ▌',\n  ' ▐▛██▜▌▌',\n  '▝▜████▛▛',\n  '  ▘▘▝▝',\n].join('\\n');\n\nexport default function WarriorArt(): React.ReactElement {\n  return <Text color={brandColor}>{art}</Text>;\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/WarriorStatus.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport WarriorStatus from './WarriorStatus.js';\n\ndescribe('WarriorStatus', () => {\n  test('renders health and score', () => {\n    const { lastFrame } = render(<WarriorStatus health={12} maxHealth={20} score={25} />);\n    const output = lastFrame()!;\n    expect(output).toContain('❤');\n    expect(output).toContain('12/20');\n    expect(output).toContain('◆');\n    expect(output).toContain('25');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/WarriorStatus.tsx",
    "content": "import { Box, Text } from 'ink';\nimport type React from 'react';\n\ninterface WarriorStatusProps {\n  health: number;\n  maxHealth: number;\n  score: number;\n}\n\nexport default function WarriorStatus({\n  health,\n  maxHealth,\n  score,\n}: WarriorStatusProps): React.ReactElement {\n  return (\n    <Box gap={2}>\n      <Text color=\"red\">{`❤ ${health}/${maxHealth}`}</Text>\n      <Text color=\"yellow\">{`◆ ${score}`}</Text>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/components/WelcomeScreen.test.tsx",
    "content": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\n// @ts-expect-error -- JSON import\nimport { version } from '../../../package.json';\nimport WelcomeScreen from './WelcomeScreen.js';\n\ndescribe('WelcomeScreen', () => {\n  test('renders warrior art, title, version, and directory', () => {\n    const versionString = `v${version}`;\n    const { lastFrame } = render(\n      <WelcomeScreen version={versionString} directory=\"~/Projects/warriorjs\" />,\n    );\n    const output = lastFrame()!;\n    expect(output).toContain('WarriorJS');\n    expect(output).toContain(versionString);\n    expect(output).toContain('~/Projects/warriorjs');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/components/WelcomeScreen.tsx",
    "content": "import { Box, Text } from 'ink';\nimport type React from 'react';\n\nimport formatDirectory from '../../utils/formatDirectory.js';\nimport WarriorArt from './WarriorArt.js';\n\ninterface WelcomeScreenProps {\n  version: string;\n  directory: string;\n}\n\nexport default function WelcomeScreen({\n  version,\n  directory,\n}: WelcomeScreenProps): React.ReactElement {\n  return (\n    <Box gap={3}>\n      <WarriorArt />\n      <Box flexDirection=\"column\" marginTop={1}>\n        <Box gap={1}>\n          <Text bold>WarriorJS</Text>\n          <Text dimColor>{version}</Text>\n        </Box>\n        <Text dimColor>Write code. Fight enemies. Climb the tower.</Text>\n        <Text dimColor>{formatDirectory(directory)}</Text>\n      </Box>\n    </Box>\n  );\n}\n"
  },
  {
    "path": "apps/cli/src/ui/hooks/usePlaySession.test.ts",
    "content": "import { render } from 'ink-testing-library';\nimport React, { act } from 'react';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport { type GameContext } from '../../Game.js';\nimport type Profile from '../../Profile.js';\nimport {\n  type LevelCompleteChoice,\n  type LevelConfig,\n  type LevelReport,\n  type LevelResult,\n  type PlaySessionState,\n} from '../types.js';\n\nvi.mock('@warriorjs/core', () => ({\n  getLevelConfig: vi.fn(),\n  runLevel: vi.fn(),\n}));\n\nvi.mock('../utils/buildLevelReport.js', () => ({\n  buildLevelReport: vi.fn(),\n}));\n\n// Re-import after mocks are set up\nconst { getLevelConfig, runLevel } = await import('@warriorjs/core');\nconst { buildLevelReport } = await import('../utils/buildLevelReport.js');\nconst { usePlaySession } = await import('./usePlaySession.js');\n\nconst mockedGetLevelConfig = vi.mocked(getLevelConfig);\nconst mockedRunLevel = vi.mocked(runLevel);\nconst mockedBuildLevelReport = vi.mocked(buildLevelReport);\n\n// --- Helpers ---\n\nfunction createMockProfile(overrides: Partial<Record<string, unknown>> = {}): Profile {\n  return {\n    tower: {\n      name: 'The Narrow Path',\n      hasLevel: vi.fn().mockReturnValue(true),\n      levels: [1, 2, 3],\n    },\n    warriorName: 'Olric',\n    epic: false,\n    levelNumber: 1,\n    language: 'javascript',\n    score: 100,\n    currentEpicScore: 0,\n    currentEpicGrades: [],\n    isEpic: vi.fn().mockReturnValue(false),\n    readPlayerCode: vi.fn().mockReturnValue('warrior.walk();'),\n    isShowingClue: vi.fn().mockReturnValue(false),\n    tallyPoints: vi.fn(),\n    updateEpicScore: vi.fn(),\n    requestClue: vi.fn(),\n    getReadmeFilePath: vi.fn().mockReturnValue('/path/to/README.md'),\n    calculateAverageGrade: vi.fn().mockReturnValue(0.8),\n    makeProfileDirectory: vi.fn(),\n    ...overrides,\n  } as unknown as Profile;\n}\n\nfunction createMockContext(overrides: Partial<GameContext> = {}): GameContext {\n  return {\n    version: 'v1.0.0',\n    runDirectoryPath: '/tmp/warriorjs',\n    practiceLevel: undefined,\n    silencePlay: false,\n    towers: [],\n    profile: null,\n    profiles: [],\n    needsProfileSetup: false,\n    onCreateProfile: vi.fn(),\n    onIsExistingProfile: vi.fn(),\n    onPrepareNextLevel: vi.fn(),\n    onPrepareEpicMode: vi.fn(),\n    onGenerateProfileFiles: vi.fn(),\n    onProfileSelected: vi.fn(),\n    ...overrides,\n  };\n}\n\nconst defaultLevelConfig: LevelConfig = {\n  clue: 'Try walking',\n  timeBonus: 10,\n  aceScore: 30,\n  floor: { warrior: { maxHealth: 20 } },\n};\n\nconst defaultLevelResult: LevelResult = {\n  passed: true,\n  turns: [\n    [\n      {\n        message: 'walks forward',\n        unit: { name: 'Warrior', color: '#fff' },\n        floorMap: [[{ character: '@', unit: { color: '#fff' } }]],\n        warriorStatus: { health: 20, score: 10 },\n      },\n    ],\n  ],\n  initialState: {\n    message: '',\n    unit: null,\n    floorMap: [[{ character: '@', unit: { color: '#fff' } }]],\n    warriorStatus: { health: 20, score: 0 },\n  },\n};\n\nconst defaultLevelReport: LevelReport = {\n  passed: true,\n  levelNumber: 1,\n  hasNextLevel: true,\n  scoreParts: { warrior: 10, timeBonus: 5, clearBonus: 0 },\n  totalScore: 15,\n  grade: 0.5,\n  isEpic: false,\n  previousScore: 100,\n  hasClue: true,\n  isShowingClue: false,\n};\n\ninterface HookRef {\n  state: PlaySessionState;\n  handlePlayComplete: () => void;\n  handleLevelCompleteChoice: (v: LevelCompleteChoice) => void;\n}\n\nfunction renderHook(params: {\n  context: GameContext;\n  profile: Profile;\n  initialLevel: number;\n  exit: () => void;\n}) {\n  const ref = React.createRef<HookRef>();\n\n  function TestComponent() {\n    const hook = usePlaySession(params);\n    (ref as React.MutableRefObject<HookRef>).current = hook;\n    return null;\n  }\n\n  const instance = render(React.createElement(TestComponent));\n  return { ref: ref as React.RefObject<HookRef>, ...instance };\n}\n\n// --- Tests ---\n\ndescribe('usePlaySession', () => {\n  let exit: ReturnType<typeof vi.fn<() => void>>;\n  let context: GameContext;\n  let mockProfile: Profile;\n\n  beforeEach(() => {\n    vi.clearAllMocks();\n\n    exit = vi.fn();\n    context = createMockContext();\n    mockProfile = createMockProfile();\n\n    mockedGetLevelConfig.mockReturnValue(defaultLevelConfig as ReturnType<typeof getLevelConfig>);\n    mockedRunLevel.mockReturnValue(defaultLevelResult as ReturnType<typeof runLevel>);\n    mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport });\n  });\n\n  describe('playLevel (via mount)', () => {\n    test('sets state to playing on mount', () => {\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      expect(ref.current!.state).toEqual(expect.objectContaining({ type: 'playing' }));\n      expect(mockedGetLevelConfig).toHaveBeenCalledWith(mockProfile.tower, 1, 'Olric', false);\n      expect(mockedRunLevel).toHaveBeenCalled();\n    });\n\n    test('sets error state when playerCode is null', () => {\n      const noCodeProfile = createMockProfile({\n        readPlayerCode: vi.fn().mockReturnValue(null),\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: noCodeProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      expect(ref.current!.state).toEqual({\n        type: 'error',\n        message: 'No player code found. Check your profile directory.',\n      });\n    });\n\n    test('sets error state when getLevelConfig throws', () => {\n      mockedGetLevelConfig.mockImplementation(() => {\n        throw new Error('Config not found');\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      expect(ref.current!.state).toEqual({\n        type: 'error',\n        message: 'Config not found',\n      });\n    });\n\n    test('sets error state when runLevel throws', () => {\n      mockedRunLevel.mockImplementation(() => {\n        throw new Error('Syntax error in player code');\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      expect(ref.current!.state).toEqual({\n        type: 'error',\n        message: 'Syntax error in player code',\n      });\n    });\n\n    test('handles non-Error thrown values', () => {\n      mockedRunLevel.mockImplementation(() => {\n        // biome-ignore lint/style/useThrowOnlyError: testing non-Error throw handling\n        throw 'string error';\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      expect(ref.current!.state).toEqual({\n        type: 'error',\n        message: 'string error',\n      });\n    });\n\n    test('defaults language to javascript when profile language is falsy', () => {\n      const noLangProfile = createMockProfile({ language: '' });\n\n      renderHook({\n        context,\n        profile: noLangProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      expect(mockedRunLevel).toHaveBeenCalledWith(\n        defaultLevelConfig,\n        'warrior.walk();',\n        'javascript',\n      );\n    });\n\n    test('skips playing state when silencePlay is true', () => {\n      context = createMockContext({ silencePlay: true });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      // Should go directly to levelComplete (evaluateLevel), not playing.\n      expect(mockedBuildLevelReport).toHaveBeenCalled();\n      expect(ref.current!.state).toEqual(expect.objectContaining({ type: 'levelComplete' }));\n    });\n\n    test('uses currentEpicScore for epic profiles in levelRun', () => {\n      const epicProfile = createMockProfile({\n        isEpic: vi.fn().mockReturnValue(true),\n        currentEpicScore: 42,\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: epicProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      expect(ref.current!.state).toEqual(expect.objectContaining({ type: 'playing' }));\n      const playingState = ref.current!.state as {\n        type: 'playing';\n        levelRun: { totalScore: number };\n      };\n      expect(playingState.levelRun.totalScore).toBe(42);\n    });\n\n    test('uses profile.score for non-epic profiles in levelRun', () => {\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      const playingState = ref.current!.state as {\n        type: 'playing';\n        levelRun: { totalScore: number };\n      };\n      expect(playingState.levelRun.totalScore).toBe(100);\n    });\n  });\n\n  describe('evaluateLevel (via handlePlayComplete)', () => {\n    test('tallies points when result is passed', () => {\n      mockedBuildLevelReport.mockReturnValue({\n        ...defaultLevelReport,\n        passed: true,\n        totalScore: 15,\n        grade: 0.5,\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      act(() => {\n        ref.current!.handlePlayComplete();\n      });\n\n      expect(mockProfile.tallyPoints).toHaveBeenCalledWith(1, 15, 0.5);\n    });\n\n    test('does not tally points when result failed', () => {\n      mockedBuildLevelReport.mockReturnValue({\n        ...defaultLevelReport,\n        passed: false,\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      act(() => {\n        ref.current!.handlePlayComplete();\n      });\n\n      expect(mockProfile.tallyPoints).not.toHaveBeenCalled();\n    });\n\n    test('epic auto-advance: plays next level instead of showing result', () => {\n      mockedBuildLevelReport.mockReturnValue({\n        ...defaultLevelReport,\n        passed: true,\n        isEpic: true,\n        hasNextLevel: true,\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      mockedGetLevelConfig.mockClear();\n\n      act(() => {\n        ref.current!.handlePlayComplete();\n      });\n\n      // Should have called getLevelConfig for level 2.\n      expect(mockedGetLevelConfig).toHaveBeenCalledWith(mockProfile.tower, 2, 'Olric', false);\n    });\n\n    test('epic auto-advance does not happen when practiceLevel is set', () => {\n      context = createMockContext({ practiceLevel: 1 });\n      mockedBuildLevelReport.mockReturnValue({\n        ...defaultLevelReport,\n        passed: true,\n        isEpic: true,\n        hasNextLevel: true,\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      act(() => {\n        ref.current!.handlePlayComplete();\n      });\n\n      // Should show levelComplete instead of auto-advancing.\n      expect(ref.current!.state).toEqual(\n        expect.objectContaining({\n          type: 'levelComplete',\n          action: { type: 'prompt' },\n        }),\n      );\n    });\n\n    test('epic tower complete: updates epic score and shows towerComplete', () => {\n      mockedBuildLevelReport.mockReturnValue({\n        ...defaultLevelReport,\n        passed: true,\n        isEpic: true,\n        hasNextLevel: false,\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 3,\n        exit,\n      });\n\n      act(() => {\n        ref.current!.handlePlayComplete();\n      });\n\n      expect(mockProfile.updateEpicScore).toHaveBeenCalled();\n      expect(ref.current!.state).toEqual({ type: 'towerComplete' });\n    });\n\n    test('epic tower complete does not happen when practiceLevel is set', () => {\n      context = createMockContext({ practiceLevel: 3 });\n      mockedBuildLevelReport.mockReturnValue({\n        ...defaultLevelReport,\n        passed: true,\n        isEpic: true,\n        hasNextLevel: false,\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 3,\n        exit,\n      });\n\n      act(() => {\n        ref.current!.handlePlayComplete();\n      });\n\n      // Should show levelComplete instead of towerComplete.\n      expect(ref.current!.state).toEqual(\n        expect.objectContaining({\n          type: 'levelComplete',\n          action: { type: 'prompt' },\n        }),\n      );\n    });\n\n    test('shows levelComplete state for normal passed result', () => {\n      mockedBuildLevelReport.mockReturnValue({\n        ...defaultLevelReport,\n        passed: true,\n        isEpic: false,\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      act(() => {\n        ref.current!.handlePlayComplete();\n      });\n\n      expect(ref.current!.state).toEqual(\n        expect.objectContaining({\n          type: 'levelComplete',\n          action: { type: 'prompt' },\n        }),\n      );\n    });\n\n    test('shows levelComplete state for failed result', () => {\n      mockedBuildLevelReport.mockReturnValue({\n        ...defaultLevelReport,\n        passed: false,\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      act(() => {\n        ref.current!.handlePlayComplete();\n      });\n\n      expect(ref.current!.state).toEqual(\n        expect.objectContaining({\n          type: 'levelComplete',\n          action: { type: 'prompt' },\n        }),\n      );\n    });\n\n    test('passes isShowingClue false when level passed', () => {\n      context = createMockContext({ silencePlay: true });\n\n      mockedRunLevel.mockReturnValue({\n        ...defaultLevelResult,\n        passed: true,\n      } as ReturnType<typeof runLevel>);\n\n      renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      expect(mockedBuildLevelReport).toHaveBeenCalledWith(\n        expect.objectContaining({ isShowingClue: false }),\n      );\n    });\n\n    test('passes profile isShowingClue when level failed', () => {\n      context = createMockContext({ silencePlay: true });\n\n      mockedRunLevel.mockReturnValue({\n        ...defaultLevelResult,\n        passed: false,\n      } as ReturnType<typeof runLevel>);\n      (mockProfile.isShowingClue as ReturnType<typeof vi.fn>).mockReturnValue(true);\n\n      renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      expect(mockedBuildLevelReport).toHaveBeenCalledWith(\n        expect.objectContaining({ isShowingClue: true }),\n      );\n    });\n\n    test('uses currentEpicScore for epic profiles in buildLevelReport', () => {\n      const epicProfile = createMockProfile({\n        isEpic: vi.fn().mockReturnValue(true),\n        currentEpicScore: 200,\n      });\n\n      context = createMockContext({ silencePlay: true });\n\n      renderHook({\n        context,\n        profile: epicProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      expect(mockedBuildLevelReport).toHaveBeenCalledWith(\n        expect.objectContaining({\n          isEpic: true,\n          currentScore: 200,\n        }),\n      );\n    });\n  });\n\n  describe('handlePlayComplete', () => {\n    test('processes pending result when present', () => {\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      // State is 'playing' after mount, pending result is stored.\n      act(() => {\n        ref.current!.handlePlayComplete();\n      });\n\n      expect(mockedBuildLevelReport).toHaveBeenCalled();\n    });\n\n    test('returns to levelComplete state after review', () => {\n      mockedBuildLevelReport.mockReturnValue({\n        ...defaultLevelReport,\n        passed: true,\n        isEpic: false,\n      });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      // Complete playback to get to levelComplete.\n      act(() => {\n        ref.current!.handlePlayComplete();\n      });\n\n      // Select review to set reviewReturnRef.\n      act(() => {\n        ref.current!.handleLevelCompleteChoice('review');\n      });\n\n      // handlePlayComplete should now return to levelComplete.\n      act(() => {\n        ref.current!.handlePlayComplete();\n      });\n\n      expect(ref.current!.state).toEqual(\n        expect.objectContaining({\n          type: 'levelComplete',\n          action: { type: 'prompt' },\n        }),\n      );\n    });\n\n    test('calls exit when no pending result or review return', () => {\n      // Use silencePlay so playLevel goes directly to evaluateLevel (no pending).\n      context = createMockContext({ silencePlay: true });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      // State is levelComplete after mount (silencePlay skipped playing),\n      // so handlePlayComplete with no pending and no review should exit.\n      act(() => {\n        ref.current!.handlePlayComplete();\n      });\n\n      expect(exit).toHaveBeenCalled();\n    });\n  });\n\n  describe('handleLevelCompleteChoice', () => {\n    test('returns early when currentReportRef is null', () => {\n      // Use silencePlay to make evaluateLevel run immediately on mount.\n      // When the result is towerComplete, currentReportRef won't be set.\n      mockedBuildLevelReport.mockReturnValue({\n        ...defaultLevelReport,\n        passed: true,\n        isEpic: true,\n        hasNextLevel: false,\n      });\n      context = createMockContext({ silencePlay: true });\n\n      const { ref } = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      // State is towerComplete; currentReportRef was not set.\n      const stateBeforeSelect = ref.current!.state;\n\n      act(() => {\n        ref.current!.handleLevelCompleteChoice('review');\n      });\n\n      // State should be unchanged (no-op).\n      expect(ref.current!.state).toEqual(stateBeforeSelect);\n    });\n\n    // Helper to get the hook into a state where currentReportRef is set.\n    function setupWithResultScreen() {\n      mockedBuildLevelReport.mockReturnValue({\n        ...defaultLevelReport,\n        passed: true,\n        isEpic: false,\n      });\n\n      const hookResult = renderHook({\n        context,\n        profile: mockProfile,\n        initialLevel: 1,\n        exit,\n      });\n\n      // Complete playback to trigger evaluateLevel, which sets currentReportRef.\n      act(() => {\n        hookResult.ref.current!.handlePlayComplete();\n      });\n\n      return hookResult;\n    }\n\n    test('review: sets playing state with reviewMode', () => {\n      const { ref } = setupWithResultScreen();\n\n      act(() => {\n        ref.current!.handleLevelCompleteChoice('review');\n      });\n\n      expect(ref.current!.state).toEqual(\n        expect.objectContaining({\n          type: 'playing',\n          reviewMode: true,\n        }),\n      );\n    });\n\n    test('next-level: calls onPrepareNextLevel and shows next-level action', () => {\n      const { ref } = setupWithResultScreen();\n\n      act(() => {\n        ref.current!.handleLevelCompleteChoice('next-level');\n      });\n\n      expect(context.onPrepareNextLevel).toHaveBeenCalled();\n      expect(ref.current!.state).toEqual(\n        expect.objectContaining({\n          type: 'levelComplete',\n          action: {\n            type: 'next-level',\n            readmePath: '/path/to/README.md',\n          },\n        }),\n      );\n    });\n\n    test('clue: calls requestClue, onGenerateProfileFiles, and shows clue action', () => {\n      const { ref } = setupWithResultScreen();\n\n      act(() => {\n        ref.current!.handleLevelCompleteChoice('clue');\n      });\n\n      expect(mockProfile.requestClue).toHaveBeenCalled();\n      expect(context.onGenerateProfileFiles).toHaveBeenCalled();\n      expect(ref.current!.state).toEqual(\n        expect.objectContaining({\n          type: 'levelComplete',\n          action: {\n            type: 'clue',\n            readmePath: '/path/to/README.md',\n          },\n        }),\n      );\n    });\n\n    test('stay: shows levelComplete state with stay action', () => {\n      const { ref } = setupWithResultScreen();\n\n      act(() => {\n        ref.current!.handleLevelCompleteChoice('stay');\n      });\n\n      expect(ref.current!.state).toEqual(\n        expect.objectContaining({\n          type: 'levelComplete',\n          action: { type: 'stay' },\n        }),\n      );\n    });\n\n    test('epic-mode: calls onPrepareEpicMode and shows epic-mode action', () => {\n      const { ref } = setupWithResultScreen();\n\n      act(() => {\n        ref.current!.handleLevelCompleteChoice('epic-mode');\n      });\n\n      expect(context.onPrepareEpicMode).toHaveBeenCalled();\n      expect(ref.current!.state).toEqual(\n        expect.objectContaining({\n          type: 'levelComplete',\n          action: { type: 'epic-mode' },\n        }),\n      );\n    });\n\n    test('try-again: replays the same level', () => {\n      const { ref } = setupWithResultScreen();\n\n      mockedGetLevelConfig.mockClear();\n      mockedRunLevel.mockClear();\n\n      act(() => {\n        ref.current!.handleLevelCompleteChoice('try-again');\n      });\n\n      // Should call playLevel with the same level number from levelReport.\n      expect(mockedGetLevelConfig).toHaveBeenCalledWith(\n        mockProfile.tower,\n        defaultLevelReport.levelNumber,\n        'Olric',\n        false,\n      );\n      expect(mockedRunLevel).toHaveBeenCalled();\n    });\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/hooks/usePlaySession.ts",
    "content": "import { getLevelConfig, runLevel } from '@warriorjs/core';\nimport { useCallback, useRef, useState } from 'react';\n\nimport { type GameContext } from '../../Game.js';\nimport type Profile from '../../Profile.js';\nimport {\n  type LevelCompleteChoice,\n  type LevelConfig,\n  type LevelEvaluation,\n  type LevelReport,\n  type LevelResult,\n  type LevelRun,\n  type PlaySessionState,\n} from '../types.js';\nimport { buildLevelReport } from '../utils/buildLevelReport.js';\n\ninterface UsePlaySessionParams {\n  context: GameContext;\n  profile: Profile;\n  initialLevel: number;\n  exit: () => void;\n}\n\ninterface UsePlaySessionReturn {\n  state: PlaySessionState;\n  handlePlayComplete: () => void;\n  handleLevelCompleteChoice: (value: LevelCompleteChoice) => void;\n}\n\ninterface LevelOutcome {\n  state: PlaySessionState;\n  evaluation: LevelEvaluation | null;\n  currentReport: { levelReport: LevelReport; levelRun: LevelRun } | null;\n}\n\nfunction executeLevel(profile: Profile, levelNumber: number, context: GameContext): LevelOutcome {\n  try {\n    const { tower, warriorName, epic } = profile;\n    const levelConfig = getLevelConfig(tower, levelNumber, warriorName, epic)!;\n    const playerCode = profile.readPlayerCode();\n    if (!playerCode) {\n      return {\n        state: { type: 'error', message: 'No player code found. Check your profile directory.' },\n        evaluation: null,\n        currentReport: null,\n      };\n    }\n    const language = profile.language || 'javascript';\n    const rawResult = runLevel(levelConfig, playerCode, language);\n    if (!rawResult.initialState) {\n      return {\n        state: { type: 'error', message: 'Level produced no initial state.' },\n        evaluation: null,\n        currentReport: null,\n      };\n    }\n    const levelResult = rawResult as LevelResult;\n\n    const levelRun: LevelRun = {\n      turns: levelResult.turns,\n      initialState: levelResult.initialState,\n      warriorName,\n      towerName: tower.name,\n      levelNumber,\n      totalScore: profile.isEpic() ? profile.currentEpicScore : profile.score,\n      maxHealth: levelConfig.floor.warrior.maxHealth,\n    };\n\n    if (context.silencePlay) {\n      return evaluateLevel(profile, levelNumber, levelResult, levelConfig, levelRun, context);\n    }\n\n    return {\n      state: { type: 'playing', levelRun },\n      evaluation: { profile, levelNumber, levelResult, levelConfig, levelRun },\n      currentReport: null,\n    };\n  } catch (err: unknown) {\n    return {\n      state: { type: 'error', message: err instanceof Error ? err.message : String(err) },\n      evaluation: null,\n      currentReport: null,\n    };\n  }\n}\n\nfunction evaluateLevel(\n  profile: Profile,\n  levelNumber: number,\n  levelResult: LevelResult,\n  levelConfig: LevelConfig,\n  levelRun: LevelRun,\n  context: GameContext,\n): LevelOutcome {\n  const levelReport = buildLevelReport({\n    levelResult,\n    levelConfig,\n    levelNumber,\n    hasNextLevel: profile.tower.hasLevel(levelNumber + 1),\n    isEpic: profile.isEpic(),\n    currentScore: profile.isEpic() ? profile.currentEpicScore : profile.score,\n    isShowingClue: levelResult.passed ? false : profile.isShowingClue(),\n  });\n\n  if (levelReport.passed) {\n    profile.tallyPoints(levelNumber, levelReport.totalScore, levelReport.grade);\n  }\n\n  // Epic auto-advance: skip result screen and play next level.\n  if (\n    levelReport.passed &&\n    levelReport.isEpic &&\n    levelReport.hasNextLevel &&\n    !context.practiceLevel\n  ) {\n    return executeLevel(profile, levelNumber + 1, context);\n  }\n\n  // Epic tower complete: update score and show tower-complete screen.\n  if (\n    levelReport.passed &&\n    levelReport.isEpic &&\n    !levelReport.hasNextLevel &&\n    !context.practiceLevel\n  ) {\n    profile.updateEpicScore();\n    return {\n      state: { type: 'towerComplete' },\n      evaluation: null,\n      currentReport: null,\n    };\n  }\n\n  return {\n    state: { type: 'levelComplete', levelRun, levelReport, action: { type: 'prompt' } },\n    evaluation: null,\n    currentReport: { levelReport, levelRun },\n  };\n}\n\nexport function usePlaySession({\n  context,\n  profile,\n  initialLevel,\n  exit,\n}: UsePlaySessionParams): UsePlaySessionReturn {\n  const evaluationRef = useRef<LevelEvaluation | null>(null);\n  const reviewReturnRef = useRef<{ levelReport: LevelReport; levelRun: LevelRun } | null>(null);\n  const currentReportRef = useRef<{ levelReport: LevelReport; levelRun: LevelRun } | null>(null);\n\n  const [state, setState] = useState<PlaySessionState>(() => {\n    const outcome = executeLevel(profile, initialLevel, context);\n    evaluationRef.current = outcome.evaluation;\n    if (outcome.currentReport) {\n      currentReportRef.current = outcome.currentReport;\n    }\n    return outcome.state;\n  });\n\n  const playLevel = useCallback(\n    (levelNumber: number) => {\n      const outcome = executeLevel(profile, levelNumber, context);\n      evaluationRef.current = outcome.evaluation;\n      if (outcome.currentReport) {\n        currentReportRef.current = outcome.currentReport;\n      }\n      setState(outcome.state);\n    },\n    [profile, context],\n  );\n\n  const handlePlayComplete = useCallback(() => {\n    const pending = evaluationRef.current;\n    if (pending) {\n      const outcome = evaluateLevel(\n        profile,\n        pending.levelNumber,\n        pending.levelResult,\n        pending.levelConfig,\n        pending.levelRun,\n        context,\n      );\n      evaluationRef.current = outcome.evaluation;\n      if (outcome.currentReport) {\n        currentReportRef.current = outcome.currentReport;\n      }\n      setState(outcome.state);\n    } else if (reviewReturnRef.current) {\n      const { levelReport, levelRun } = reviewReturnRef.current;\n      reviewReturnRef.current = null;\n      setState({ type: 'levelComplete', levelReport, levelRun, action: { type: 'prompt' } });\n    } else {\n      exit();\n    }\n  }, [profile, context, exit]);\n\n  const handleLevelCompleteChoice = useCallback(\n    (value: LevelCompleteChoice) => {\n      const current = currentReportRef.current;\n      if (!current) {\n        return;\n      }\n\n      const { levelReport, levelRun } = current;\n\n      switch (value) {\n        case 'review':\n          reviewReturnRef.current = { levelReport, levelRun };\n          setState({ type: 'playing', levelRun, reviewMode: true });\n          evaluationRef.current = null;\n          break;\n        case 'stay':\n          setState({\n            type: 'levelComplete',\n            levelReport,\n            levelRun,\n            action: { type: 'stay' },\n          });\n          break;\n        case 'next-level':\n          context.onPrepareNextLevel();\n          setState({\n            type: 'levelComplete',\n            levelReport,\n            levelRun,\n            action: {\n              type: 'next-level',\n              readmePath: profile.getReadmeFilePath(),\n            },\n          });\n          break;\n        case 'epic-mode':\n          context.onPrepareEpicMode();\n          setState({\n            type: 'levelComplete',\n            levelReport,\n            levelRun,\n            action: { type: 'epic-mode' },\n          });\n          break;\n        case 'clue':\n          profile.requestClue();\n          context.onGenerateProfileFiles();\n          setState({\n            type: 'levelComplete',\n            levelReport,\n            levelRun,\n            action: {\n              type: 'clue',\n              readmePath: profile.getReadmeFilePath(),\n            },\n          });\n          break;\n        case 'try-again':\n          playLevel(levelReport.levelNumber);\n          break;\n      }\n    },\n    [profile, context, playLevel],\n  );\n\n  return { state, handlePlayComplete, handleLevelCompleteChoice };\n}\n"
  },
  {
    "path": "apps/cli/src/ui/hooks/usePlayback.test.ts",
    "content": "import { describe, expect, test } from 'vitest';\n\nimport { type PlaybackState, playbackReducer } from './usePlayback.js';\n\ndescribe('playbackReducer', () => {\n  const initial: PlaybackState = {\n    currentTurn: 0,\n    totalTurns: 10,\n    isPlaying: true,\n    speed: 1,\n    mode: 'playback',\n  };\n\n  test('toggles pause/resume during playback', () => {\n    const paused = playbackReducer(initial, { type: 'TOGGLE_PLAY_PAUSE' });\n    expect(paused.isPlaying).toBe(false);\n    const resumed = playbackReducer(paused, { type: 'TOGGLE_PLAY_PAUSE' });\n    expect(resumed.isPlaying).toBe(true);\n  });\n\n  test('cycles speed forward: 1 -> 2 -> 4 -> 1', () => {\n    const s2 = playbackReducer(initial, { type: 'CYCLE_SPEED' });\n    expect(s2.speed).toBe(2);\n    const s4 = playbackReducer(s2, { type: 'CYCLE_SPEED' });\n    expect(s4.speed).toBe(4);\n    const s1 = playbackReducer(s4, { type: 'CYCLE_SPEED' });\n    expect(s1.speed).toBe(1);\n  });\n\n  test('cycles speed backward: 1 -> 4 -> 2 -> 1', () => {\n    const s4 = playbackReducer(initial, { type: 'CYCLE_SPEED_BACK' });\n    expect(s4.speed).toBe(4);\n    const s2 = playbackReducer(s4, { type: 'CYCLE_SPEED_BACK' });\n    expect(s2.speed).toBe(2);\n    const s1 = playbackReducer(s2, { type: 'CYCLE_SPEED_BACK' });\n    expect(s1.speed).toBe(1);\n  });\n\n  test('skip goes to last turn and enters review mode', () => {\n    const skipped = playbackReducer(initial, { type: 'SKIP' });\n    expect(skipped.currentTurn).toBe(9);\n    expect(skipped.isPlaying).toBe(false);\n    expect(skipped.mode).toBe('review');\n  });\n\n  test('advance_turn moves forward during playback', () => {\n    const next = playbackReducer(initial, { type: 'ADVANCE_TURN' });\n    expect(next.currentTurn).toBe(1);\n  });\n\n  test('advance_turn at last turn transitions to review mode', () => {\n    const atEnd = { ...initial, currentTurn: 9 };\n    const result = playbackReducer(atEnd, { type: 'ADVANCE_TURN' });\n    expect(result.mode).toBe('review');\n    expect(result.isPlaying).toBe(false);\n  });\n\n  test('step_forward in review mode', () => {\n    const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false, currentTurn: 5 };\n    const next = playbackReducer(review, { type: 'STEP_FORWARD' });\n    expect(next.currentTurn).toBe(6);\n  });\n\n  test('step_forward at last turn is no-op', () => {\n    const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false, currentTurn: 9 };\n    const next = playbackReducer(review, { type: 'STEP_FORWARD' });\n    expect(next.currentTurn).toBe(9);\n  });\n\n  test('step_back in review mode', () => {\n    const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false, currentTurn: 5 };\n    const prev = playbackReducer(review, { type: 'STEP_BACK' });\n    expect(prev.currentTurn).toBe(4);\n  });\n\n  test('step_back at turn 0 is no-op', () => {\n    const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false, currentTurn: 0 };\n    const prev = playbackReducer(review, { type: 'STEP_BACK' });\n    expect(prev.currentTurn).toBe(0);\n  });\n\n  test('restart goes back to playback mode from turn 0', () => {\n    const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false, currentTurn: 9 };\n    const restarted = playbackReducer(review, { type: 'RESTART' });\n    expect(restarted.currentTurn).toBe(0);\n    expect(restarted.isPlaying).toBe(true);\n    expect(restarted.mode).toBe('playback');\n    expect(restarted.speed).toBe(1);\n  });\n\n  test('toggle_play_pause is ignored in review mode', () => {\n    const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false };\n    const result = playbackReducer(review, { type: 'TOGGLE_PLAY_PAUSE' });\n    expect(result.isPlaying).toBe(false);\n  });\n\n  test('cycle_speed is ignored in review mode', () => {\n    const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false };\n    const result = playbackReducer(review, { type: 'CYCLE_SPEED' });\n    expect(result.speed).toBe(review.speed);\n  });\n\n  test('startInReview initializes at last turn in review mode', () => {\n    const startInReview: PlaybackState = {\n      currentTurn: 9,\n      totalTurns: 10,\n      isPlaying: false,\n      speed: 1,\n      mode: 'review',\n    };\n    // Verify stepping back works from the review start position.\n    const stepped = playbackReducer(startInReview, { type: 'STEP_BACK' });\n    expect(stepped.currentTurn).toBe(8);\n    // Verify restart works.\n    const restarted = playbackReducer(startInReview, { type: 'RESTART' });\n    expect(restarted.currentTurn).toBe(0);\n    expect(restarted.isPlaying).toBe(true);\n    expect(restarted.mode).toBe('playback');\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/hooks/usePlayback.ts",
    "content": "import { useInput } from 'ink';\nimport { useEffect, useReducer, useRef } from 'react';\n\nexport interface PlaybackState {\n  currentTurn: number;\n  totalTurns: number;\n  isPlaying: boolean;\n  speed: number;\n  mode: 'playback' | 'review';\n}\n\nexport type PlaybackAction =\n  | { type: 'TOGGLE_PLAY_PAUSE' }\n  | { type: 'CYCLE_SPEED' }\n  | { type: 'CYCLE_SPEED_BACK' }\n  | { type: 'SKIP' }\n  | { type: 'ADVANCE_TURN' }\n  | { type: 'STEP_FORWARD' }\n  | { type: 'STEP_BACK' }\n  | { type: 'RESTART' };\n\nconst SPEEDS = [1, 2, 4];\n\nexport function playbackReducer(state: PlaybackState, action: PlaybackAction): PlaybackState {\n  switch (action.type) {\n    case 'TOGGLE_PLAY_PAUSE': {\n      if (state.mode !== 'playback') return state;\n      return { ...state, isPlaying: !state.isPlaying };\n    }\n    case 'CYCLE_SPEED': {\n      if (state.mode !== 'playback') return state;\n      const currentIndex = SPEEDS.indexOf(state.speed);\n      const nextSpeed = SPEEDS[(currentIndex + 1) % SPEEDS.length]!;\n      return { ...state, speed: nextSpeed };\n    }\n    case 'CYCLE_SPEED_BACK': {\n      if (state.mode !== 'playback') return state;\n      const currentIndex = SPEEDS.indexOf(state.speed);\n      const prevSpeed = SPEEDS[(currentIndex - 1 + SPEEDS.length) % SPEEDS.length]!;\n      return { ...state, speed: prevSpeed };\n    }\n    case 'SKIP': {\n      return {\n        ...state,\n        currentTurn: state.totalTurns - 1,\n        isPlaying: false,\n        mode: 'review',\n      };\n    }\n    case 'ADVANCE_TURN': {\n      if (state.currentTurn >= state.totalTurns - 1) {\n        return { ...state, isPlaying: false, mode: 'review' };\n      }\n      return { ...state, currentTurn: state.currentTurn + 1 };\n    }\n    case 'STEP_FORWARD': {\n      if (state.mode !== 'review') return state;\n      if (state.currentTurn >= state.totalTurns - 1) return state;\n      return { ...state, currentTurn: state.currentTurn + 1 };\n    }\n    case 'STEP_BACK': {\n      if (state.mode !== 'review') return state;\n      if (state.currentTurn <= 0) return state;\n      return { ...state, currentTurn: state.currentTurn - 1 };\n    }\n    case 'RESTART': {\n      return {\n        ...state,\n        currentTurn: 0,\n        isPlaying: true,\n        mode: 'playback',\n        speed: 1,\n      };\n    }\n    default:\n      return state;\n  }\n}\n\nexport function usePlayback(\n  totalTurns: number,\n  onPlaybackComplete: () => void,\n  startInReview = false,\n): { state: PlaybackState; dispatch: React.Dispatch<PlaybackAction> } {\n  const [state, dispatch] = useReducer(playbackReducer, {\n    currentTurn: startInReview ? totalTurns - 1 : 0,\n    totalTurns,\n    isPlaying: !startInReview,\n    speed: 1,\n    mode: startInReview ? 'review' : 'playback',\n  });\n\n  const hasFiredComplete = useRef(startInReview);\n\n  useEffect(() => {\n    if (!state.isPlaying) return;\n    const interval = setInterval(() => dispatch({ type: 'ADVANCE_TURN' }), 600 / state.speed);\n    return () => clearInterval(interval);\n  }, [state.isPlaying, state.speed]);\n\n  useEffect(() => {\n    if (state.mode === 'review' && !hasFiredComplete.current) {\n      hasFiredComplete.current = true;\n      onPlaybackComplete();\n    }\n  }, [state.mode, onPlaybackComplete]);\n\n  useInput((input, key) => {\n    if (state.mode === 'playback') {\n      if (input === ' ') dispatch({ type: 'TOGGLE_PLAY_PAUSE' });\n      if (key.tab && key.shift) dispatch({ type: 'CYCLE_SPEED_BACK' });\n      else if (key.tab) dispatch({ type: 'CYCLE_SPEED' });\n      if (input === 's') dispatch({ type: 'SKIP' });\n    } else {\n      if (key.escape) onPlaybackComplete();\n      if (key.leftArrow) dispatch({ type: 'STEP_BACK' });\n      if (key.rightArrow) dispatch({ type: 'STEP_FORWARD' });\n    }\n  });\n\n  return { state, dispatch };\n}\n"
  },
  {
    "path": "apps/cli/src/ui/testing.ts",
    "content": "import { type LevelReport, type LevelRun } from './types.js';\n\nexport const waitForRender = () => new Promise((resolve) => setTimeout(resolve, 50));\n\n/** Returns the last non-empty frame (exit() can write an empty frame after unmount). */\nexport function getLastContentFrame(frames: string[]): string {\n  for (let i = frames.length - 1; i >= 0; i--) {\n    if (frames[i]!.trim()) return frames[i]!;\n  }\n  return '';\n}\n\nexport function makeLevelRun(overrides: Partial<LevelRun> = {}): LevelRun {\n  return {\n    turns: [\n      [\n        {\n          message: 'test',\n          unit: null,\n          floorMap: [[{ character: '@' }]],\n          warriorStatus: { health: 20, score: 0 },\n        },\n      ],\n    ],\n    initialState: {\n      message: '',\n      unit: null,\n      floorMap: [[{ character: '@' }]],\n      warriorStatus: { health: 20, score: 0 },\n    },\n    warriorName: 'Olric',\n    towerName: 'The Narrow Path',\n    levelNumber: 1,\n    totalScore: 10,\n    maxHealth: 20,\n    ...overrides,\n  };\n}\n\nexport function makeLevelReport(overrides: Partial<LevelReport> = {}): LevelReport {\n  return {\n    passed: true,\n    levelNumber: 1,\n    hasNextLevel: true,\n    scoreParts: { warrior: 10, timeBonus: 5, clearBonus: 0 },\n    totalScore: 15,\n    grade: 0.75,\n    isEpic: false,\n    previousScore: 0,\n    hasClue: false,\n    isShowingClue: false,\n    ...overrides,\n  };\n}\n"
  },
  {
    "path": "apps/cli/src/ui/theme.ts",
    "content": "export const brandColor = '#C5832B';\n"
  },
  {
    "path": "apps/cli/src/ui/types.ts",
    "content": "import type Profile from '../Profile.js';\n\n// UI-specific narrowings of @warriorjs/core types.\n// These pick only the fields the UI layer needs, keeping a clean boundary\n// between core engine types and rendering concerns.\n\nexport interface LevelConfig {\n  clue?: string;\n  timeBonus?: number;\n  aceScore?: number;\n  floor: { warrior: { maxHealth: number } };\n}\n\nexport interface TurnEvent {\n  message: string;\n  unit: { name: string; color: string } | null;\n  floorMap: { character: string; unit?: { color: string } }[][];\n  warriorStatus: { health: number; score: number };\n}\n\nexport interface LevelResult {\n  passed: boolean;\n  turns: TurnEvent[][];\n  initialState: TurnEvent;\n}\n\nexport interface LevelRun {\n  turns: TurnEvent[][];\n  initialState: TurnEvent;\n  warriorName: string;\n  towerName: string;\n  levelNumber: number;\n  totalScore: number;\n  maxHealth: number;\n}\n\nexport interface LevelReport {\n  passed: boolean;\n  levelNumber: number;\n  hasNextLevel: boolean;\n  scoreParts: { warrior: number; timeBonus: number; clearBonus: number };\n  totalScore: number;\n  grade: number;\n  isEpic: boolean;\n  previousScore: number;\n  hasClue: boolean;\n  isShowingClue: boolean;\n}\n\nexport interface LevelEvaluation {\n  profile: Profile;\n  levelNumber: number;\n  levelResult: LevelResult;\n  levelConfig: LevelConfig;\n  levelRun: LevelRun;\n}\n\nexport type LevelCompleteChoice =\n  | 'review'\n  | 'stay'\n  | 'next-level'\n  | 'epic-mode'\n  | 'clue'\n  | 'try-again';\n\nexport type LevelCompleteAction =\n  | { type: 'prompt' }\n  | { type: 'stay' }\n  | { type: 'next-level'; readmePath: string }\n  | { type: 'epic-mode' }\n  | { type: 'clue'; readmePath: string };\n\nexport type PlaySessionState =\n  | { type: 'playing'; levelRun: LevelRun; reviewMode?: boolean }\n  | {\n      type: 'levelComplete';\n      levelReport: LevelReport;\n      levelRun: LevelRun;\n      action: LevelCompleteAction;\n    }\n  | { type: 'towerComplete' }\n  | { type: 'error'; message: string };\n\nexport type GameMenuStep =\n  | { type: 'start' }\n  | { type: 'wizard'; initialStep: 'new' | 'choose-profile' };\n"
  },
  {
    "path": "apps/cli/src/ui/utils/buildLevelReport.test.ts",
    "content": "import { describe, expect, test } from 'vitest';\n\nimport { type LevelConfig, type LevelResult, type TurnEvent } from '../types.js';\nimport { buildLevelReport } from './buildLevelReport.js';\n\nfunction makeEvent(score: number, floorMap: { unit?: unknown }[][] = [[]]): TurnEvent {\n  return { warriorStatus: { score }, floorMap } as unknown as TurnEvent;\n}\n\ndescribe('buildLevelReport', () => {\n  test('returns failure result when level not passed', () => {\n    const result = buildLevelReport({\n      levelResult: { passed: false, turns: [] } as unknown as LevelResult,\n      levelConfig: { clue: 'Try walking', timeBonus: 10, aceScore: 30 } as LevelConfig,\n      levelNumber: 2,\n      hasNextLevel: true,\n      isEpic: false,\n      currentScore: 50,\n      isShowingClue: false,\n    });\n\n    expect(result).toEqual({\n      passed: false,\n      levelNumber: 2,\n      hasNextLevel: true,\n      scoreParts: { warrior: 0, timeBonus: 0, clearBonus: 0 },\n      totalScore: 0,\n      grade: 0,\n      isEpic: false,\n      previousScore: 50,\n      hasClue: true,\n      isShowingClue: false,\n    });\n  });\n\n  test('returns failure result with isShowingClue when clue already requested', () => {\n    const result = buildLevelReport({\n      levelResult: { passed: false, turns: [] } as unknown as LevelResult,\n      levelConfig: { clue: 'Try walking', timeBonus: 10, aceScore: 30 } as LevelConfig,\n      levelNumber: 2,\n      hasNextLevel: true,\n      isEpic: false,\n      currentScore: 50,\n      isShowingClue: true,\n    });\n\n    expect(result.hasClue).toBe(true);\n    expect(result.isShowingClue).toBe(true);\n  });\n\n  test('returns failure result with hasClue false when no clue exists', () => {\n    const result = buildLevelReport({\n      levelResult: { passed: false, turns: [] } as unknown as LevelResult,\n      levelConfig: { timeBonus: 10, aceScore: 30 } as LevelConfig,\n      levelNumber: 1,\n      hasNextLevel: true,\n      isEpic: false,\n      currentScore: 0,\n      isShowingClue: false,\n    });\n\n    expect(result.hasClue).toBe(false);\n  });\n\n  test('returns success result with calculated scores', () => {\n    const result = buildLevelReport({\n      levelResult: {\n        passed: true,\n        turns: [[makeEvent(5), makeEvent(10)], [makeEvent(15)]],\n      } as unknown as LevelResult,\n      levelConfig: { timeBonus: 10, aceScore: 30 } as LevelConfig,\n      levelNumber: 3,\n      hasNextLevel: true,\n      isEpic: false,\n      currentScore: 100,\n      isShowingClue: false,\n    });\n\n    expect(result.passed).toBe(true);\n    expect(result.levelNumber).toBe(3);\n    expect(result.previousScore).toBe(100);\n    expect(result.hasClue).toBe(false);\n    expect(result.isShowingClue).toBe(false);\n    expect(result.scoreParts.warrior).toBe(15);\n    expect(result.totalScore).toBe(\n      result.scoreParts.warrior + result.scoreParts.timeBonus + result.scoreParts.clearBonus,\n    );\n  });\n\n  test('calculates grade as totalScore / aceScore', () => {\n    const result = buildLevelReport({\n      levelResult: {\n        passed: true,\n        turns: [[makeEvent(20)]],\n      } as unknown as LevelResult,\n      levelConfig: { timeBonus: 0, aceScore: 100 } as LevelConfig,\n      levelNumber: 1,\n      hasNextLevel: true,\n      isEpic: true,\n      currentScore: 0,\n      isShowingClue: false,\n    });\n\n    expect(result.grade).toBe(result.totalScore / 100);\n  });\n\n  test('returns grade 0 when aceScore is missing', () => {\n    const result = buildLevelReport({\n      levelResult: {\n        passed: true,\n        turns: [[makeEvent(20)]],\n      } as unknown as LevelResult,\n      levelConfig: { timeBonus: 0 } as LevelConfig,\n      levelNumber: 1,\n      hasNextLevel: true,\n      isEpic: false,\n      currentScore: 0,\n      isShowingClue: false,\n    });\n\n    expect(result.grade).toBe(0);\n  });\n\n  test('defaults timeBonus to 0 when undefined', () => {\n    const result = buildLevelReport({\n      levelResult: {\n        passed: true,\n        turns: [[makeEvent(10)]],\n      } as unknown as LevelResult,\n      levelConfig: { aceScore: 100 } as LevelConfig,\n      levelNumber: 1,\n      hasNextLevel: false,\n      isEpic: false,\n      currentScore: 0,\n      isShowingClue: false,\n    });\n\n    expect(result.passed).toBe(true);\n    expect(result.scoreParts.timeBonus).toBe(0);\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/ui/utils/buildLevelReport.ts",
    "content": "import { getLevelScore } from '@warriorjs/scoring';\n\nimport { type LevelConfig, type LevelReport, type LevelResult } from '../types.js';\n\ninterface BuildLevelReportParams {\n  levelResult: LevelResult;\n  levelConfig: LevelConfig;\n  levelNumber: number;\n  hasNextLevel: boolean;\n  isEpic: boolean;\n  currentScore: number;\n  isShowingClue: boolean;\n}\n\nexport function buildLevelReport({\n  levelResult,\n  levelConfig,\n  levelNumber,\n  hasNextLevel,\n  isEpic,\n  currentScore,\n  isShowingClue,\n}: BuildLevelReportParams): LevelReport {\n  if (!levelResult.passed) {\n    return {\n      passed: false,\n      levelNumber,\n      hasNextLevel,\n      scoreParts: { warrior: 0, timeBonus: 0, clearBonus: 0 },\n      totalScore: 0,\n      grade: 0,\n      isEpic,\n      previousScore: currentScore,\n      hasClue: !!levelConfig.clue,\n      isShowingClue,\n    };\n  }\n\n  const scoreParts = getLevelScore(levelResult, { timeBonus: levelConfig.timeBonus ?? 0 })!;\n  const totalScore = scoreParts.warrior + scoreParts.timeBonus + scoreParts.clearBonus;\n  const grade = levelConfig.aceScore ? totalScore / levelConfig.aceScore : 0;\n\n  return {\n    passed: true,\n    levelNumber,\n    hasNextLevel,\n    scoreParts,\n    totalScore,\n    grade,\n    isEpic,\n    previousScore: currentScore,\n    hasClue: false,\n    isShowingClue: false,\n  };\n}\n"
  },
  {
    "path": "apps/cli/src/utils/formatDirectory.test.ts",
    "content": "import os from 'node:os';\nimport path from 'node:path';\nimport { describe, expect, test } from 'vitest';\n\nimport formatDirectory from './formatDirectory.js';\n\nconst home = os.homedir();\n\ndescribe('formatDirectory', () => {\n  test('replaces home directory with ~', () => {\n    expect(formatDirectory(home)).toBe('~');\n  });\n\n  test('replaces home prefix with ~/', () => {\n    expect(formatDirectory(`${home}/Projects/warriorjs`)).toBe('~/Projects/warriorjs');\n  });\n\n  test('resolves relative paths before replacing', () => {\n    expect(formatDirectory('.')).toBe(path.resolve('.').replace(home, '~'));\n  });\n\n  test('returns absolute path when not under home', () => {\n    expect(formatDirectory('/tmp/warriorjs')).toBe('/tmp/warriorjs');\n  });\n\n  test('does not replace partial home match', () => {\n    expect(formatDirectory(`${home}-extra/foo`)).toBe(`${home}-extra/foo`);\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/utils/formatDirectory.ts",
    "content": "import os from 'node:os';\nimport path from 'node:path';\n\nexport default function formatDirectory(directory: string): string {\n  const resolved = path.resolve(directory);\n  const home = os.homedir();\n  if (resolved === home) return '~';\n  if (resolved.startsWith(`${home}/`)) return `~/${resolved.slice(home.length + 1)}`;\n  return resolved;\n}\n"
  },
  {
    "path": "apps/cli/src/utils/getFloorMap.test.ts",
    "content": "import { expect, test } from 'vitest';\n\nimport getFloorMap from './getFloorMap.js';\n\ntest('returns the floor map', () => {\n  const map = [\n    [{ character: 'a' }, { character: 'b' }],\n    [{ character: 'c' }, { character: 'd' }],\n  ];\n  expect(getFloorMap(map)).toBe('ab\\ncd');\n});\n"
  },
  {
    "path": "apps/cli/src/utils/getFloorMap.ts",
    "content": "interface FloorSpace {\n  character: string;\n  [key: string]: unknown;\n}\n\nfunction getFloorMap(map: FloorSpace[][]): string {\n  return map.map((row) => row.map((space) => space.character).join('')).join('\\n');\n}\n\nexport default getFloorMap;\n"
  },
  {
    "path": "apps/cli/src/utils/getFloorMapKey.test.ts",
    "content": "import { expect, test } from 'vitest';\n\nimport getFloorMapKey from './getFloorMapKey.js';\n\ntest('returns the floor map key', () => {\n  const map = [\n    [{ character: '@', unit: { name: 'Joe', maxHealth: 20 } }, { character: 'b' }],\n    [{ character: 'c' }],\n  ];\n  expect(getFloorMapKey(map as any)).toBe('@ = Joe (20 HP)\\n> = stairs');\n});\n"
  },
  {
    "path": "apps/cli/src/utils/getFloorMapKey.ts",
    "content": "interface FloorSpace {\n  character: string;\n  unit?: {\n    name: string;\n    maxHealth: number;\n  };\n}\n\nfunction getFloorMapKey(map: FloorSpace[][]): string {\n  return map\n    .reduce<FloorSpace[]>((acc, row) => acc.concat(row), [])\n    .filter((space) => space.unit)\n    .filter(\n      (space, index, arr) =>\n        arr.findIndex((anotherSpace) => anotherSpace.character === space.character) === index,\n    )\n    .map(({ character, unit }) => {\n      const { name, maxHealth } = unit!;\n      return `${character} = ${name} (${maxHealth} HP)`;\n    })\n    .concat(['> = stairs'])\n    .join('\\n');\n}\n\nexport default getFloorMapKey;\n"
  },
  {
    "path": "apps/cli/src/utils/getTowerId.test.ts",
    "content": "import { expect, test } from 'vitest';\n\nimport getTowerId from './getTowerId.js';\n\ntest('returns the tower id for official towers', () => {\n  expect(getTowerId('@warriorjs/tower-foo')).toBe('foo');\n});\n\ntest('returns the tower id for community towers', () => {\n  expect(getTowerId('warriorjs-tower-foo')).toBe('foo');\n});\n"
  },
  {
    "path": "apps/cli/src/utils/getTowerId.ts",
    "content": "const towerIdRegex = /(?:@warriorjs\\/|warriorjs-)tower-(.+)/;\n\nfunction getTowerId(towerPackageName: string): string {\n  return towerPackageName.match(towerIdRegex)?.[1] ?? '';\n}\n\nexport default getTowerId;\n"
  },
  {
    "path": "apps/cli/src/utils/getWarriorNameSuggestions.test.ts",
    "content": "import arrayShuffle from 'array-shuffle';\nimport { expect, test, vi } from 'vitest';\n\nimport getWarriorNameSuggestions from './getWarriorNameSuggestions.js';\n\nvi.mock('array-shuffle');\n\ntest('returns shuffled list of warrior names', () => {\n  getWarriorNameSuggestions();\n  expect(arrayShuffle).toHaveBeenCalledWith(expect.arrayContaining(['Aldric', 'Brenna', 'Cedric']));\n});\n"
  },
  {
    "path": "apps/cli/src/utils/getWarriorNameSuggestions.ts",
    "content": "import arrayShuffle from 'array-shuffle';\n\nconst warriorNames = [\n  'Aldric',\n  'Brenna',\n  'Cedric',\n  'Dahlia',\n  'Elric',\n  'Freya',\n  'Gareth',\n  'Hilde',\n  'Isolde',\n  'Jareth',\n  'Kael',\n  'Liora',\n  'Maren',\n  'Nyx',\n  'Orin',\n  'Petra',\n  'Rowan',\n  'Sable',\n  'Theron',\n  'Ursa',\n  'Vesper',\n  'Wren',\n  'Xara',\n  'Yara',\n  'Zephyr',\n  'Ashwin',\n  'Briar',\n  'Corwin',\n  'Dusk',\n  'Ember',\n  'Flint',\n  'Gale',\n  'Hazel',\n  'Ingrid',\n  'Jorin',\n  'Kyra',\n  'Leander',\n  'Morrigan',\n  'Nerys',\n  'Onyx',\n  'Pike',\n  'Riven',\n  'Sage',\n  'Tormund',\n  'Ulric',\n  'Voss',\n  'Wynne',\n  'Xander',\n  'Ylva',\n  'Zareth',\n];\n\nfunction getWarriorNameSuggestions(): string[] {\n  return arrayShuffle(warriorNames);\n}\n\nexport default getWarriorNameSuggestions;\n"
  },
  {
    "path": "apps/cli/src/utils/renderPlayerCode.test.ts",
    "content": "import { describe, expect, test } from 'vitest';\n\nimport renderPlayerCode from './renderPlayerCode.js';\n\ndescribe('renderPlayerCode', () => {\n  const levelConfig: any = {\n    floor: { warrior: { abilities: {} } },\n  };\n\n  test('renders javascript player code', () => {\n    const profile: any = { language: 'javascript' };\n    expect(renderPlayerCode(profile, levelConfig)).toBe(\n      `class Player {\n  playTurn(warrior) {\n    // Decide what your warrior should do.\n  }\n}\n\nexport default Player;\n`,\n    );\n  });\n\n  test('renders typescript player code', () => {\n    const profile: any = { language: 'typescript' };\n    expect(renderPlayerCode(profile, levelConfig)).toBe(\n      `import type { Warrior } from './types.js';\n\nclass Player {\n  playTurn(warrior: Warrior) {\n    // Decide what your warrior should do.\n  }\n}\n\nexport default Player;\n`,\n    );\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/utils/renderPlayerCode.ts",
    "content": "import { type LevelConfig } from '@warriorjs/core';\n\nimport type Profile from '../Profile.js';\n\nfunction renderPlayerCode(profile: Profile, _levelConfig: LevelConfig): string {\n  if (profile.language === 'typescript') {\n    return `import type { Warrior } from './types.js';\n\nclass Player {\n  playTurn(warrior: Warrior) {\n    // Decide what your warrior should do.\n  }\n}\n\nexport default Player;\n`;\n  }\n\n  return `class Player {\n  playTurn(warrior) {\n    // Decide what your warrior should do.\n  }\n}\n\nexport default Player;\n`;\n}\n\nexport default renderPlayerCode;\n"
  },
  {
    "path": "apps/cli/src/utils/renderReadme.test.ts",
    "content": "import { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport renderReadme from './renderReadme.js';\n\nvi.mock('@warriorjs/core', () => ({\n  getLevel: vi.fn(),\n}));\n\nimport { getLevel } from '@warriorjs/core';\n\ndescribe('renderReadme', () => {\n  let profile: any;\n  let levelConfig: any;\n  let level: any;\n\n  beforeEach(() => {\n    profile = {\n      warriorName: 'Aldric',\n      tower: { name: 'The Narrow Path', description: 'A tower for beginners' },\n      language: 'javascript',\n      isShowingClue: () => false,\n    };\n    levelConfig = {\n      floor: { warrior: { abilities: {} } },\n    };\n    level = {\n      number: 1,\n      description: 'You see a light in the distance.',\n      tip: 'Walk towards the light.',\n      clue: 'Use warrior.walk()',\n      floorMap: [[{ character: '@' }, { character: ' ' }, { character: '>' }]],\n      warriorAbilities: {\n        actions: [{ name: 'walk', description: 'Walks forward' }],\n        senses: [],\n      },\n    };\n    (getLevel as any).mockReturnValue(level);\n  });\n\n  test('calls getLevel with levelConfig', () => {\n    renderReadme(profile, levelConfig);\n    expect(getLevel).toHaveBeenCalledWith(levelConfig);\n  });\n\n  test('renders readme for javascript profile', () => {\n    expect(renderReadme(profile, levelConfig)).toBe(\n      [\n        '# Aldric - The Narrow Path',\n        '',\n        '### _A tower for beginners_',\n        '',\n        '## Level 1',\n        '',\n        '_You see a light in the distance._',\n        '',\n        '> **TIP:** Walk towards the light.',\n        '',\n        '### Floor Map',\n        '',\n        '```',\n        '@ >',\n        '',\n        '> = stairs',\n        '```',\n        '',\n        '## Abilities',\n        '',\n        '### Actions (only one per turn)',\n        '',\n        '- `warrior.walk()`: Walks forward',\n        '',\n        '## Next Steps',\n        '',\n        \"When you're done editing `Player.js`, run the `warriorjs` command again.\",\n        '',\n      ].join('\\n'),\n    );\n  });\n\n  test('renders readme for typescript profile', () => {\n    profile.language = 'typescript';\n    const result = renderReadme(profile, levelConfig);\n    expect(result).toMatch(/`Player\\.ts`/);\n    expect(result).not.toMatch(/`Player\\.js`/);\n  });\n\n  test('renders readme without tower description', () => {\n    profile.tower.description = '';\n    const result = renderReadme(profile, levelConfig);\n    expect(result).toBe(\n      [\n        '# Aldric - The Narrow Path',\n        '',\n        '## Level 1',\n        '',\n        '_You see a light in the distance._',\n        '',\n        '> **TIP:** Walk towards the light.',\n        '',\n        '### Floor Map',\n        '',\n        '```',\n        '@ >',\n        '',\n        '> = stairs',\n        '```',\n        '',\n        '## Abilities',\n        '',\n        '### Actions (only one per turn)',\n        '',\n        '- `warrior.walk()`: Walks forward',\n        '',\n        '## Next Steps',\n        '',\n        \"When you're done editing `Player.js`, run the `warriorjs` command again.\",\n        '',\n      ].join('\\n'),\n    );\n  });\n\n  test('renders readme with clue when showing', () => {\n    profile.isShowingClue = () => true;\n    const result = renderReadme(profile, levelConfig);\n    expect(result).toBe(\n      [\n        '# Aldric - The Narrow Path',\n        '',\n        '### _A tower for beginners_',\n        '',\n        '## Level 1',\n        '',\n        '_You see a light in the distance._',\n        '',\n        '> **TIP:** Walk towards the light.',\n        '',\n        '> **CLUE:** Use warrior.walk()',\n        '',\n        '### Floor Map',\n        '',\n        '```',\n        '@ >',\n        '',\n        '> = stairs',\n        '```',\n        '',\n        '## Abilities',\n        '',\n        '### Actions (only one per turn)',\n        '',\n        '- `warrior.walk()`: Walks forward',\n        '',\n        '## Next Steps',\n        '',\n        \"When you're done editing `Player.js`, run the `warriorjs` command again.\",\n        '',\n      ].join('\\n'),\n    );\n  });\n\n  test('renders readme with actions and senses', () => {\n    level.warriorAbilities.senses = [{ name: 'feel', description: 'Feels the space ahead' }];\n    const result = renderReadme(profile, levelConfig);\n    expect(result).toBe(\n      [\n        '# Aldric - The Narrow Path',\n        '',\n        '### _A tower for beginners_',\n        '',\n        '## Level 1',\n        '',\n        '_You see a light in the distance._',\n        '',\n        '> **TIP:** Walk towards the light.',\n        '',\n        '### Floor Map',\n        '',\n        '```',\n        '@ >',\n        '',\n        '> = stairs',\n        '```',\n        '',\n        '## Abilities',\n        '',\n        '### Actions (only one per turn)',\n        '',\n        '- `warrior.walk()`: Walks forward',\n        '',\n        '### Senses',\n        '',\n        '- `warrior.feel()`: Feels the space ahead',\n        '',\n        '## Next Steps',\n        '',\n        \"When you're done editing `Player.js`, run the `warriorjs` command again.\",\n        '',\n      ].join('\\n'),\n    );\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/utils/renderReadme.ts",
    "content": "import { getLevel, type LevelConfig } from '@warriorjs/core';\n\nimport type Profile from '../Profile.js';\nimport getFloorMap from './getFloorMap.js';\nimport getFloorMapKey from './getFloorMapKey.js';\n\ninterface Ability {\n  name: string;\n  description: string;\n}\n\nfunction renderHeader(profile: Profile): string {\n  return `# ${profile.warriorName} - ${profile.tower.name}`;\n}\n\nfunction renderTowerDescription(description: string): string {\n  if (!description) return '';\n  return `### _${description}_`;\n}\n\nfunction renderLevelInfo(level: any): string {\n  return `## Level ${level.number}\\n\\n_${level.description}_\\n\\n> **TIP:** ${level.tip}`;\n}\n\nfunction renderClue(profile: Profile, clue: string): string {\n  if (!profile.isShowingClue()) return '';\n  return `> **CLUE:** ${clue}`;\n}\n\nfunction renderFloorMap(level: any): string {\n  return `### Floor Map\\n\\n\\`\\`\\`\\n${getFloorMap(level.floorMap)}\\n\\n${getFloorMapKey(level.floorMap)}\\n\\`\\`\\``;\n}\n\nfunction renderAbilityList(abilities: Ability[]): string {\n  return abilities.map((a) => `- \\`warrior.${a.name}()\\`: ${a.description}`).join('\\n');\n}\n\nfunction renderActions(actions: Ability[]): string {\n  return `### Actions (only one per turn)\\n\\n${renderAbilityList(actions)}`;\n}\n\nfunction renderSenses(senses: Ability[]): string {\n  if (!senses.length) return '';\n  return `### Senses\\n\\n${renderAbilityList(senses)}`;\n}\n\nfunction renderAbilities(level: any): string {\n  const sections = [\n    '## Abilities',\n    renderActions(level.warriorAbilities.actions),\n    renderSenses(level.warriorAbilities.senses),\n  ].filter(Boolean);\n  return sections.join('\\n\\n');\n}\n\nfunction renderNextSteps(profile: Profile): string {\n  const playerFile = profile.language === 'typescript' ? 'Player.ts' : 'Player.js';\n  return `## Next Steps\\n\\nWhen you're done editing \\`${playerFile}\\`, run the \\`warriorjs\\` command again.`;\n}\n\nfunction renderReadme(profile: Profile, levelConfig: LevelConfig): string {\n  const level = getLevel(levelConfig);\n\n  const sections = [\n    renderHeader(profile),\n    renderTowerDescription(profile.tower.description),\n    renderLevelInfo(level),\n    renderClue(profile, level.clue),\n    renderFloorMap(level),\n    renderAbilities(level),\n    renderNextSteps(profile),\n  ].filter(Boolean);\n\n  return `${sections.join('\\n\\n')}\\n`;\n}\n\nexport default renderReadme;\n"
  },
  {
    "path": "apps/cli/src/utils/renderTypes.test.ts",
    "content": "import { type AbilityMeta, Action, Sense } from '@warriorjs/core';\nimport { describe, expect, test } from 'vitest';\n\nimport renderTypes from './renderTypes.js';\n\nclass MockWalk extends Action {\n  readonly description = 'Walks forward';\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'void',\n  };\n  perform() {}\n}\n\nclass MockFeel extends Sense {\n  readonly description = 'Feels the space ahead';\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'Space',\n  };\n  perform() {}\n}\n\nclass MockHealth extends Sense {\n  readonly description = 'Returns current health';\n  readonly meta: AbilityMeta = {\n    params: [],\n    returns: 'number',\n  };\n  perform() {}\n}\n\nconst profile: any = { language: 'typescript' };\n\nfunction makeLevelConfig(abilities: Record<string, any>): any {\n  return { floor: { warrior: { abilities } } };\n}\n\ndescribe('renderTypes', () => {\n  test('renders types with a single action', () => {\n    expect(renderTypes(profile, makeLevelConfig({ walk: MockWalk }))).toBe(\n      [\n        '// @generated — Auto-generated each level. Do not edit.',\n        '',\n        \"export type Direction = 'forward' | 'right' | 'backward' | 'left';\",\n        '',\n        'export interface Warrior {',\n        '  /** Walks forward */',\n        '  walk(direction?: Direction): void;',\n        '}',\n        '',\n      ].join('\\n'),\n    );\n  });\n\n  test('renders types with actions before senses, both sorted alphabetically', () => {\n    expect(\n      renderTypes(\n        profile,\n        makeLevelConfig({\n          health: MockHealth,\n          walk: MockWalk,\n          feel: MockFeel,\n        }),\n      ),\n    ).toBe(\n      [\n        '// @generated — Auto-generated each level. Do not edit.',\n        '',\n        \"export type Direction = 'forward' | 'right' | 'backward' | 'left';\",\n        '',\n        'export interface Unit {',\n        '  /** Determines if the unit is bound. */',\n        '  isBound(): boolean;',\n        '  /** Determines if the unit is an enemy. */',\n        '  isEnemy(): boolean;',\n        '  /** Determines if the unit is under the given effect. */',\n        '  isUnderEffect(name: string): boolean;',\n        '}',\n        '',\n        'export interface Space {',\n        '  /** Returns the relative location of this space as the offset `[forward, right]`. */',\n        '  getLocation(): [number, number];',\n        '  /** Returns the unit located at this space, or `null` if there is none. */',\n        '  getUnit(): Unit | null;',\n        '  /** Determines if nothing (except maybe stairs) is at this space. */',\n        '  isEmpty(): boolean;',\n        '  /** Determines if the stairs are at this space. */',\n        '  isStairs(): boolean;',\n        '  /** Determines if there is a unit at this space. */',\n        '  isUnit(): boolean;',\n        '  /** Determines if this space is the edge of the level. */',\n        '  isWall(): boolean;',\n        '}',\n        '',\n        'export interface Warrior {',\n        '  /** Walks forward */',\n        '  walk(direction?: Direction): void;',\n        '  /** Feels the space ahead */',\n        '  feel(direction?: Direction): Space;',\n        '  /** Returns current health */',\n        '  health(): number;',\n        '}',\n        '',\n      ].join('\\n'),\n    );\n  });\n\n  test('omits Space and Unit interfaces when no abilities use Space', () => {\n    expect(renderTypes(profile, makeLevelConfig({ walk: MockWalk, health: MockHealth }))).toBe(\n      [\n        '// @generated — Auto-generated each level. Do not edit.',\n        '',\n        \"export type Direction = 'forward' | 'right' | 'backward' | 'left';\",\n        '',\n        'export interface Warrior {',\n        '  /** Walks forward */',\n        '  walk(direction?: Direction): void;',\n        '  /** Returns current health */',\n        '  health(): number;',\n        '}',\n        '',\n      ].join('\\n'),\n    );\n  });\n\n  test('handles rest parameters', () => {\n    class RestAction extends Action {\n      readonly description = 'Does something with rest params';\n      readonly meta: AbilityMeta = {\n        params: [{ name: 'targets', type: 'any', rest: true }],\n        returns: 'void',\n      };\n      perform() {}\n    }\n    expect(renderTypes(profile, makeLevelConfig({ multi: RestAction }))).toBe(\n      [\n        '// @generated — Auto-generated each level. Do not edit.',\n        '',\n        \"export type Direction = 'forward' | 'right' | 'backward' | 'left';\",\n        '',\n        'export interface Warrior {',\n        '  /** Does something with rest params */',\n        '  multi(...targets: any[]): void;',\n        '}',\n        '',\n      ].join('\\n'),\n    );\n  });\n\n  test('handles empty abilities', () => {\n    expect(renderTypes(profile, makeLevelConfig({}))).toBe(\n      [\n        '// @generated — Auto-generated each level. Do not edit.',\n        '',\n        \"export type Direction = 'forward' | 'right' | 'backward' | 'left';\",\n        '',\n        'export interface Warrior {',\n        '}',\n        '',\n      ].join('\\n'),\n    );\n  });\n});\n"
  },
  {
    "path": "apps/cli/src/utils/renderTypes.ts",
    "content": "import { type Ability, type AbilityEntry, Action, type LevelConfig } from '@warriorjs/core';\n\nimport type Profile from '../Profile.js';\n\ninterface MethodEntry {\n  name: string;\n  action: boolean;\n  description: string;\n  signature: string;\n}\n\nfunction renderHeader(): string {\n  return '// @generated — Auto-generated each level. Do not edit.';\n}\n\nfunction renderDirectionType(): string {\n  return \"export type Direction = 'forward' | 'right' | 'backward' | 'left';\";\n}\n\nfunction renderSpaceInterfaces(): string {\n  return [\n    'export interface Unit {',\n    '  /** Determines if the unit is bound. */',\n    '  isBound(): boolean;',\n    '  /** Determines if the unit is an enemy. */',\n    '  isEnemy(): boolean;',\n    '  /** Determines if the unit is under the given effect. */',\n    '  isUnderEffect(name: string): boolean;',\n    '}',\n    '',\n    'export interface Space {',\n    '  /** Returns the relative location of this space as the offset `[forward, right]`. */',\n    '  getLocation(): [number, number];',\n    '  /** Returns the unit located at this space, or `null` if there is none. */',\n    '  getUnit(): Unit | null;',\n    '  /** Determines if nothing (except maybe stairs) is at this space. */',\n    '  isEmpty(): boolean;',\n    '  /** Determines if the stairs are at this space. */',\n    '  isStairs(): boolean;',\n    '  /** Determines if there is a unit at this space. */',\n    '  isUnit(): boolean;',\n    '  /** Determines if this space is the edge of the level. */',\n    '  isWall(): boolean;',\n    '}',\n  ].join('\\n');\n}\n\nfunction renderMethod(method: MethodEntry): string {\n  return `  /** ${method.description} */\\n  ${method.signature};`;\n}\n\nfunction renderWarriorInterface(methods: MethodEntry[]): string {\n  if (!methods.length) {\n    return 'export interface Warrior {\\n}';\n  }\n  const body = methods.map(renderMethod).join('\\n');\n  return `export interface Warrior {\\n${body}\\n}`;\n}\n\nfunction instantiateAbility(entry: AbilityEntry): Ability {\n  if (Array.isArray(entry)) {\n    const [AbilityClass, config] = entry;\n    return new AbilityClass({} as any, config);\n  }\n  const AbilityClass = entry;\n  return new AbilityClass({} as any);\n}\n\nfunction renderTypes(_profile: Profile, levelConfig: LevelConfig): string {\n  const abilities = levelConfig.floor.warrior.abilities ?? {};\n\n  const methods: MethodEntry[] = [];\n  let needsSpace = false;\n\n  for (const [name, entry] of Object.entries(abilities)) {\n    const ability = instantiateAbility(entry);\n\n    const { description, meta } = ability;\n\n    const params: string[] = meta.params.map((param: any) => {\n      const tsType = param.type;\n      if (tsType === 'Space') {\n        needsSpace = true;\n      }\n\n      if (param.rest) {\n        return `...${param.name}: ${tsType}[]`;\n      }\n\n      const optional = param.optional ? '?' : '';\n      return `${param.name}${optional}: ${tsType}`;\n    });\n\n    const returnType = meta.returns;\n    if (returnType === 'Space' || returnType === 'Space[]') {\n      needsSpace = true;\n    }\n\n    methods.push({\n      name,\n      description,\n      action: ability instanceof Action,\n      signature: `${name}(${params.join(', ')}): ${returnType}`,\n    });\n  }\n\n  const actions = methods.filter((m) => m.action).sort((a, b) => a.name.localeCompare(b.name));\n  const senses = methods.filter((m) => !m.action).sort((a, b) => a.name.localeCompare(b.name));\n  const sortedMethods = [...actions, ...senses];\n\n  const sections = [\n    renderHeader(),\n    renderDirectionType(),\n    needsSpace ? renderSpaceInterfaces() : '',\n    renderWarriorInterface(sortedMethods),\n  ].filter(Boolean);\n\n  return `${sections.join('\\n\\n')}\\n`;\n}\n\nexport default renderTypes;\n"
  },
  {
    "path": "apps/cli/tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\"\n  },\n  \"exclude\": [\"**/*.test.ts\"]\n}\n"
  },
  {
    "path": "apps/cli/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\",\n    \"jsx\": \"react-jsx\"\n  },\n  \"include\": [\"src\", \"declarations.d.ts\"]\n}\n"
  },
  {
    "path": "apps/website/core/Footer.js",
    "content": "const PropTypes = require('prop-types');\nconst React = require('react');\n\nconst GitHubButton = require('./GitHubButton');\nconst TwitterButton = require('./TwitterButton');\nconst getDocUrl = require('../utils/getDocUrl');\n\nconst Footer = ({ config, language }) => (\n  <footer className=\"footerSection nav-footer\" id=\"footer\">\n    <section className=\"sitemap\">\n      <a href={config.baseUrl} className=\"nav-home\">\n        <img src={`${config.baseUrl}${config.footerIcon}`} alt={config.title} />\n      </a>\n      <div>\n        <h5>Docs</h5>\n        <a href={getDocUrl('player/overview', language)}>Player</a>\n        <a href={getDocUrl('maker/introduction', language)}>Maker</a>\n      </div>\n      <div>\n        <h5>Community</h5>\n        <a\n          href=\"https://spectrum.chat/warriorjs\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n        >\n          Spectrum\n        </a>\n        <a\n          href=\"https://twitter.com/warrior_js\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n        >\n          Twitter\n        </a>\n        <TwitterButton username={config.twitterUsername} />\n      </div>\n      <div>\n        <h5>More</h5>\n        <a\n          href=\"https://opencollective.com/warriorjs\"\n          target=\"_blank\"\n          rel=\"noopener noreferrer\"\n        >\n          Donate\n        </a>\n        <a href={config.gitHubUrl} target=\"_blank\" rel=\"noopener noreferrer\">\n          GitHub\n        </a>\n        <GitHubButton\n          username={config.organizationName}\n          repo={config.projectName}\n        />\n      </div>\n    </section>\n  </footer>\n);\n\nFooter.propTypes = {\n  config: PropTypes.shape({\n    baseUrl: PropTypes.string.isRequired,\n    footerIcon: PropTypes.string.isRequired,\n    gitHubUrl: PropTypes.string.isRequired,\n    title: PropTypes.string.isRequired,\n  }).isRequired,\n  language: PropTypes.string.isRequired,\n};\n\nmodule.exports = Footer;\n"
  },
  {
    "path": "apps/website/core/GitHubButton.js",
    "content": "const PropTypes = require('prop-types');\nconst React = require('react');\n\nconst GitHubButton = ({ username, repo }) => (\n  <a\n    className=\"github-button\"\n    href={`https://github.com/${username}/${repo}`}\n    data-icon=\"octicon-star\"\n    data-show-count\n    aria-label=\"Star this project on GitHub\"\n  >\n    Star\n  </a>\n);\n\nGitHubButton.propTypes = {\n  username: PropTypes.string.isRequired,\n  repo: PropTypes.string.isRequired,\n};\n\nmodule.exports = GitHubButton;\n"
  },
  {
    "path": "apps/website/core/TwitterButton.js",
    "content": "const PropTypes = require('prop-types');\nconst React = require('react');\n\nconst TwitterButton = ({ username }) => (\n  <a\n    href={`https://twitter.com/${username}`}\n    target=\"_blank\"\n    rel=\"noopener noreferrer\"\n  >\n    <img\n      alt=\"Follow WarriorJS on Twitter\"\n      src={`https://img.shields.io/twitter/follow/${username}.svg?label=Follow+WarriorJS&style=social`}\n    />\n  </a>\n);\n\nTwitterButton.propTypes = {\n  username: PropTypes.string.isRequired,\n};\n\nmodule.exports = TwitterButton;\n"
  },
  {
    "path": "apps/website/crowdin.yaml",
    "content": "project_identifier_env: CROWDIN_WARRIORJS_PROJECT_ID\napi_key_env: CROWDIN_WARRIORJS_API_KEY\nbase_path: \"../\"\npreserve_hierarchy: true\n\nfiles:\n  -\n    source: '/docs/**/*.md'\n    translation: '/website/translated_docs/%locale%/**/%original_file_name%'\n    languages_mapping: &anchor\n      locale:\n        'af': 'af'\n        'ar': 'ar'\n        'bs-BA': 'bs-BA'\n        'ca': 'ca'\n        'cs': 'cs'\n        'da': 'da'\n        'de': 'de'\n        'el': 'el'\n        'es-ES': 'es-ES'\n        'fa': 'fa-IR'\n        'fi': 'fi'\n        'fr': 'fr'\n        'he': 'he'\n        'hu': 'hu'\n        'id': 'id-ID'\n        'it': 'it'\n        'ja': 'ja'\n        'ko': 'ko'\n        'mr': 'mr-IN'\n        'nl': 'nl'\n        'no': 'no-NO'\n        'pl': 'pl'\n        'pt-BR': 'pt-BR'\n        'pt-PT': 'pt-PT'\n        'ro': 'ro'\n        'ru': 'ru'\n        'sk': 'sk-SK'\n        'sr': 'sr'\n        'sv-SE': 'sv-SE'\n        'tr': 'tr'\n        'uk': 'uk'\n        'vi': 'vi'\n        'zh-CN': 'zh-CN'\n        'zh-TW': 'zh-TW'\n  -\n    source: '/website/i18n/en.json'\n    translation: '/website/i18n/%locale%.json'\n    languages_mapping: *anchor\n"
  },
  {
    "path": "apps/website/data/sponsors.json",
    "content": "[]\n"
  },
  {
    "path": "apps/website/i18n/en.json",
    "content": "{\n  \"_comment\": \"This file is auto-generated by write-translations.js\",\n  \"localized-strings\": {\n    \"next\": \"Next\",\n    \"previous\": \"Previous\",\n    \"tagline\": \"An exciting game of programming and Artificial Intelligence\",\n    \"community/ecosystem\": \"Ecosystem\",\n    \"community/roadmap\": \"Roadmap & Contribution\",\n    \"community/socialize\": \"Socialize\",\n    \"maker/adding-levels\": \"Adding Levels\",\n    \"maker/creating-tower\": \"Creating Your Tower\",\n    \"maker/defining-abilities\": \"Defining Abilities\",\n    \"maker/defining-units\": \"Defining Units\",\n    \"maker/introduction\": \"Introduction\",\n    \"maker/publishing\": \"Publishing\",\n    \"maker/refactoring\": \"Refactoring\",\n    \"maker/space-api\": \"Space API\",\n    \"maker/testing\": \"Testing\",\n    \"maker/unit-api\": \"Unit API\",\n    \"player/abilities\": \"Abilities\",\n    \"player/ai-tips\": \"AI Tips\",\n    \"Artificial Intelligence\": \"Artificial Intelligence\",\n    \"player/cli-tips\": \"CLI Tips\",\n    \"CLI\": \"CLI\",\n    \"player/effects\": \"Effects\",\n    \"player/epic-mode\": \"Epic Mode\",\n    \"player/gameplay\": \"Gameplay\",\n    \"player/general-tips\": \"General Tips\",\n    \"General\": \"General\",\n    \"player/install\": \"Install\",\n    \"player/js-tips\": \"JavaScript Tips\",\n    \"JavaScript\": \"JavaScript\",\n    \"player/object\": \"Object\",\n    \"player/options\": \"Options\",\n    \"player/overview\": \"Overview\",\n    \"player/perspective\": \"Perspective\",\n    \"player/scoring\": \"Scoring\",\n    \"player/space-api\": \"Space API\",\n    \"player/spaces\": \"Spaces\",\n    \"player/towers\": \"Towers\",\n    \"player/turn-api\": \"Turn API\",\n    \"player/unit-api\": \"Unit API\",\n    \"player/units\": \"Units\",\n    \"player/warrior\": \"Warrior\",\n    \"Player\": \"Player\",\n    \"Maker\": \"Maker\",\n    \"Community\": \"Community\",\n    \"GitHub\": \"GitHub\",\n    \"Game\": \"Game\",\n    \"Concepts\": \"Concepts\",\n    \"Player API\": \"Player API\",\n    \"Tips & Tricks\": \"Tips & Tricks\",\n    \"Guide\": \"Guide\",\n    \"Maker API\": \"Maker API\",\n    \"Play\": \"Play\",\n    \"Make\": \"Make\",\n    \"player/cli-options\": \"CLI Options\",\n    \"player/set-up\": \"Set Up\",\n    \"Introduction\": \"Introduction\",\n    \"Configuration\": \"Configuration\"\n  },\n  \"pages-strings\": {\n    \"Play Now|no description given\": \"Play Now\",\n    \"Docs|no description given\": \"Docs\",\n    \"Write JavaScript to teach your warrior what to do depending on the situation. Select abilities to customize how your warrior plays.|no description given\": \"Write JavaScript to teach your warrior what to do depending on the situation. Select abilities to customize how your warrior plays.\",\n    \"Code|no description given\": \"Code\",\n    \"Run your code and check how your warrior does.|no description given\": \"Run your code and check how your warrior does.\",\n    \"Play|no description given\": \"Play\",\n    \"Make your own towers and share them with the world. Add new abilities, effects, and units to the game.|no description given\": \"Make your own towers and share them with the world. Add new abilities, effects, and units to the game.\",\n    \"Make|no description given\": \"Make\",\n    \"Quick Start|no description given\": \"Quick Start\",\n    \"Install WarriorJS:|no description given\": \"Install WarriorJS:\",\n    \"Launch the game:|no description given\": \"Launch the game:\",\n    \"Create your warrior and begin your journey!|no description given\": \"Create your warrior and begin your journey!\",\n    \"Sponsors|no description given\": \"Sponsors\",\n    \"Become a sponsor|no description given\": \"Become a sponsor\",\n    \"Help Translate|recruit community translators for your project\": \"Help Translate\",\n    \"Edit this Doc|recruitment message asking to edit the doc source\": \"Edit\",\n    \"Translate this Doc|recruitment message asking to translate the docs\": \"Translate\",\n    \"Launch the game from your terminal and check how your warrior does.|no description given\": \"Launch the game from your terminal and check how your warrior does.\",\n    \"Documentation|no description given\": \"Documentation\",\n    \"Get Started|no description given\": \"Get Started\"\n  }\n}\n"
  },
  {
    "path": "apps/website/languages.js",
    "content": "const languages = [\n  {\n    enabled: true,\n    name: 'English',\n    tag: 'en',\n  },\n  {\n    enabled: false,\n    name: '日本語',\n    tag: 'ja',\n  },\n  {\n    enabled: true,\n    name: 'العربية',\n    tag: 'ar',\n  },\n  {\n    enabled: false,\n    name: 'Bosanski',\n    tag: 'bs-BA',\n  },\n  {\n    enabled: true,\n    name: 'Català',\n    tag: 'ca',\n  },\n  {\n    enabled: true,\n    name: 'Čeština',\n    tag: 'cs',\n  },\n  {\n    enabled: false,\n    name: 'Dansk',\n    tag: 'da',\n  },\n  {\n    enabled: true,\n    name: 'Deutsch',\n    tag: 'de',\n  },\n  {\n    enabled: true,\n    name: 'Ελληνικά',\n    tag: 'el',\n  },\n  {\n    enabled: true,\n    name: 'Español',\n    tag: 'es-ES',\n  },\n  {\n    enabled: false,\n    name: 'فارسی',\n    tag: 'fa-IR',\n  },\n  {\n    enabled: false,\n    name: 'Suomi',\n    tag: 'fi',\n  },\n  {\n    enabled: true,\n    name: 'Français',\n    tag: 'fr',\n  },\n  {\n    enabled: false,\n    name: 'עִברִית',\n    tag: 'he',\n  },\n  {\n    enabled: false,\n    name: 'Magyar',\n    tag: 'hu',\n  },\n  {\n    enabled: false,\n    name: 'Bahasa Indonesia',\n    tag: 'id-ID',\n  },\n  {\n    enabled: true,\n    name: 'Italiano',\n    tag: 'it',\n  },\n  {\n    enabled: false,\n    name: 'Afrikaans',\n    tag: 'af',\n  },\n  {\n    enabled: false,\n    name: '한국어',\n    tag: 'ko',\n  },\n  {\n    enabled: false,\n    name: 'मराठी',\n    tag: 'mr-IN',\n  },\n  {\n    enabled: false,\n    name: 'Nederlands',\n    tag: 'nl',\n  },\n  {\n    enabled: false,\n    name: 'Norsk',\n    tag: 'no-NO',\n  },\n  {\n    enabled: true,\n    name: 'Polskie',\n    tag: 'pl',\n  },\n  {\n    enabled: false,\n    name: 'Português',\n    tag: 'pt-PT',\n  },\n  {\n    enabled: false,\n    name: 'Português (Brasil)',\n    tag: 'pt-BR',\n  },\n  {\n    enabled: false,\n    name: 'Română',\n    tag: 'ro',\n  },\n  {\n    enabled: true,\n    name: 'Русский',\n    tag: 'ru',\n  },\n  {\n    enabled: false,\n    name: 'Slovenský',\n    tag: 'sk-SK',\n  },\n  {\n    enabled: true,\n    name: 'Српски језик (Ћирилица)',\n    tag: 'sr',\n  },\n  {\n    enabled: true,\n    name: 'Svenska',\n    tag: 'sv-SE',\n  },\n  {\n    enabled: true,\n    name: 'Türkçe',\n    tag: 'tr',\n  },\n  {\n    enabled: false,\n    name: 'Українська',\n    tag: 'uk',\n  },\n  {\n    enabled: false,\n    name: 'Tiếng Việt',\n    tag: 'vi',\n  },\n  {\n    enabled: true,\n    name: '中文',\n    tag: 'zh-CN',\n  },\n  {\n    enabled: true,\n    name: '繁體中文',\n    tag: 'zh-TW',\n  },\n];\nmodule.exports = languages;\n"
  },
  {
    "path": "apps/website/package.json",
    "content": "{\n  \"name\": \"warriorjs-website\",\n  \"version\": \"0.0.0\",\n  \"private\": true,\n  \"scripts\": {\n    \"start\": \"docusaurus-start\",\n    \"build\": \"docusaurus-build\",\n    \"publish-gh-pages\": \"docusaurus-publish\",\n    \"write-translations\": \"docusaurus-write-translations\",\n    \"version\": \"docusaurus-version\",\n    \"rename-version\": \"docusaurus-rename-version\",\n    \"crowdin-upload\": \"crowdin upload sources --auto-update -b master\",\n    \"crowdin-download\": \"crowdin download -b master\"\n  },\n  \"devDependencies\": {\n    \"docusaurus\": \"^1.2.0\"\n  },\n  \"dependencies\": {\n    \"prop-types\": \"^15.6.2\"\n  }\n}\n"
  },
  {
    "path": "apps/website/pages/en/index.js",
    "content": "const PropTypes = require('prop-types');\nconst React = require('react');\n\nconst GitHubButton = require(`${process.cwd()}/core/GitHubButton`);\nconst getDocUrl = require(`${process.cwd()}/utils/getDocUrl`);\nconst getImgUrl = require(`${process.cwd()}/utils/getImgUrl`);\nconst siteConfig = require(`${process.cwd()}/siteConfig`);\nconst translation = require('../../server/translation.js'); // eslint-disable-line import/no-unresolved\nconst {\n  Container,\n  GridBlock,\n  MarkdownBlock,\n} = require('../../core/CompLibrary'); // eslint-disable-line import/no-unresolved\nconst { translate } = require('../../server/translate'); // eslint-disable-line import/no-unresolved\n\nconst PromoSection = ({ children }) => (\n  <div className=\"section promoSection\">\n    <div className=\"promoRow\">\n      <div className=\"pluginRowBlock\">{children}</div>\n    </div>\n  </div>\n);\n\nPromoSection.propTypes = {\n  children: PropTypes.node.isRequired,\n};\n\nconst Button = ({ children, href, primary, target }) => (\n  <div className=\"pluginWrapper buttonWrapper\">\n    <a\n      className={`button ${primary ? 'primary' : ''}`}\n      href={href}\n      target={target}\n    >\n      {children}\n    </a>\n  </div>\n);\n\nButton.propTypes = {\n  children: PropTypes.node.isRequired,\n  href: PropTypes.string.isRequired,\n  primary: PropTypes.bool,\n  target: PropTypes.string,\n};\n\nButton.defaultProps = {\n  primary: false,\n  target: '_self',\n};\n\nconst HomeSplash = ({ language }) => (\n  <div className=\"homeContainer\">\n    <div className=\"homeSplashFade\">\n      <div className=\"wrapper homeWrapper\">\n        <div className=\"inner\">\n          <h2 className=\"projectTitle\">\n            <img\n              alt=\"WarriorJS Banner\"\n              title={siteConfig.title}\n              src={getImgUrl('warriorjs.svg')}\n            />\n            <small>{translation[language]['localized-strings'].tagline}</small>\n          </h2>\n          <PromoSection>\n            <Button href=\"https://warriorjs.com/warriors/new?ref=docs\" primary>\n              <translate>Play Now</translate>\n            </Button>\n            <Button href={getDocUrl('player/overview', language)}>\n              <translate>Docs</translate>\n            </Button>\n          </PromoSection>\n          <div className=\"githubButton\" style={{ minHeight: '20px' }}>\n            <GitHubButton\n              username={siteConfig.organizationName}\n              repo={siteConfig.projectName}\n            />\n          </div>\n        </div>\n      </div>\n    </div>\n  </div>\n);\n\nHomeSplash.propTypes = {\n  language: PropTypes.string.isRequired,\n};\n\nconst Code = () => (\n  <Container padding={['bottom', 'top']}>\n    <GridBlock\n      contents={[\n        {\n          content: (\n            <translate>\n              Write JavaScript to teach your warrior what to do depending on the\n              situation. Select abilities to customize how your warrior plays.\n            </translate>\n          ),\n          imageAlign: 'right',\n          image: getImgUrl('code-preview.png'),\n          imageAlt: 'Code Preview',\n          title: <translate>Code</translate>,\n        },\n      ]}\n      layout=\"twoColumn\"\n    />\n  </Container>\n);\n\nconst Play = () => (\n  <Container padding={['bottom', 'top']} background=\"light\">\n    <GridBlock\n      contents={[\n        {\n          content: (\n            <translate>\n              Run your code and check how your warrior does.\n            </translate>\n          ),\n          imageAlign: 'left',\n          image: getImgUrl('play-preview.png'),\n          imageAlt: 'Play Preview',\n          title: <translate>Play</translate>,\n        },\n      ]}\n      layout=\"twoColumn\"\n    />\n  </Container>\n);\n\nconst Make = () => (\n  <Container padding={['bottom', 'top']}>\n    <GridBlock\n      contents={[\n        {\n          content: (\n            <translate>\n              Make your own towers and share them with the world. Add new\n              abilities, effects, and units to the game.\n            </translate>\n          ),\n          imageAlign: 'right',\n          image: getImgUrl('make-preview.png'),\n          imageAlt: 'Make Preview',\n          title: <translate>Make</translate>,\n        },\n      ]}\n      layout=\"twoColumn\"\n    />\n  </Container>\n);\n\nconst sh = (...args) => `~~~sh\\n${String.raw(...args)}\\n~~~`;\n\nconst QuickStart = () => (\n  <div className=\"quickStart productShowcaseSection paddingTop paddingBottom\">\n    <h2>\n      <translate>Quick Start</translate>\n    </h2>\n    <ol>\n      <li>\n        <translate>Install WarriorJS:</translate>\n        <MarkdownBlock>{sh`npm install --global @warriorjs/cli`}</MarkdownBlock>\n      </li>\n      <li>\n        <translate>Launch the game:</translate>\n        <MarkdownBlock>{sh`warriorjs`}</MarkdownBlock>\n      </li>\n      <li>\n        <translate>Create your warrior and begin your journey!</translate>\n      </li>\n    </ol>\n  </div>\n);\n\nconst Sponsors = () => {\n  if (!siteConfig.sponsors.length) {\n    return null;\n  }\n\n  return (\n    <div className=\"productShowcaseSection lightBackground paddingTop paddingBottom\">\n      <h2>\n        <translate>Sponsors</translate>\n      </h2>\n      <div className=\"logos\">\n        {siteConfig.sponsors.map((sponsor, index) => (\n          <a href={sponsor.url} title={sponsor.name} key={index}>\n            <img\n              src={getImgUrl(`sponsors/${sponsor.logo}`)}\n              alt={`Sponsored by ${sponsor.name}`}\n            />\n          </a>\n        ))}\n      </div>\n      <PromoSection>\n        <Button href=\"https://opencollective.com/warriorjs\" target=\"_blank\">\n          <translate>Become a sponsor</translate>\n        </Button>\n      </PromoSection>\n    </div>\n  );\n};\n\nconst Index = ({ language }) => (\n  <div>\n    <HomeSplash language={language} />\n    <div className=\"mainContainer\">\n      <Code />\n      <Play />\n      <Make />\n      <Sponsors />\n    </div>\n  </div>\n);\n\nIndex.propTypes = {\n  language: PropTypes.string.isRequired,\n};\n\nmodule.exports = Index;\n"
  },
  {
    "path": "apps/website/sidebars.json",
    "content": "{\n  \"play\": {\n    \"Game\": [\n      \"player/overview\",\n      \"player/object\",\n      \"player/gameplay\",\n      \"player/perspective\",\n      \"player/scoring\",\n      \"player/epic-mode\",\n      \"player/towers\"\n    ],\n    \"Concepts\": [\n      \"player/units\",\n      \"player/warrior\",\n      \"player/abilities\",\n      \"player/spaces\"\n    ],\n    \"Player API\": [\"player/space-api\", \"player/unit-api\", \"player/turn-api\"],\n    \"Tips & Tricks\": [\n      \"player/general-tips\",\n      \"player/js-tips\",\n      \"player/ai-tips\",\n      \"player/cli-tips\"\n    ],\n    \"CLI\": [\"player/install\", \"player/options\"]\n  },\n  \"make\": {\n    \"Guide\": [\n      \"maker/introduction\",\n      \"maker/creating-tower\",\n      \"maker/adding-levels\",\n      \"maker/defining-abilities\",\n      \"maker/defining-units\",\n      \"maker/refactoring\",\n      \"maker/testing\",\n      \"maker/publishing\"\n    ],\n    \"Maker API\": [\"maker/space-api\", \"maker/unit-api\"]\n  },\n  \"community\": {\n    \"Community\": [\n      \"community/socialize\",\n      \"community/ecosystem\",\n      \"community/roadmap\"\n    ]\n  }\n}\n"
  },
  {
    "path": "apps/website/siteConfig.js",
    "content": "const pkg = require('../package');\nconst sponsors = require('./data/sponsors');\n\nconst gitHubUrl = pkg.repository;\nconst twitterUsername = 'warrior_js';\n\nconst siteConfig = {\n  gitHubUrl,\n  twitterUsername,\n  sponsors,\n  title: 'WarriorJS Docs',\n  tagline: 'An exciting game of programming and Artificial Intelligence',\n  url: pkg.homepage,\n  baseUrl: '/',\n  projectName: 'warriorjs',\n  organizationName: 'olistic',\n  cname: 'warrior.js.org',\n  noIndex: false,\n  cleanUrl: true,\n  editUrl: `${gitHubUrl}/edit/master/docs/`,\n  translationRecruitingLink: 'https://crowdin.com/project/warriorjs',\n  headerLinks: [\n    { doc: 'player/overview', label: 'Player' },\n    { doc: 'maker/introduction', label: 'Maker' },\n    { doc: 'community/socialize', label: 'Community' },\n    { languages: true },\n    { search: true },\n    { href: gitHubUrl, label: 'GitHub' },\n  ],\n  headerIcon: 'img/warriorjs-text.svg',\n  footerIcon: 'img/warriorjs-sword.svg',\n  favicon: 'img/favicon.png',\n  twitterImage: 'img/warriorjs.png',\n  ogImage: 'img/warriorjs.png',\n  colors: {\n    primaryColor: '#2e3440',\n    secondaryColor: '#3b4252',\n    accentColor: '#8fbcbb',\n  },\n  scripts: ['https://buttons.github.io/buttons.js'],\n  disableHeaderTitle: true,\n  disableTitleTagline: true,\n  onPageNav: 'separate',\n  gaTrackingId: 'UA-118632697-1',\n  algolia: {\n    apiKey: 'af0d3f56837aacc96ccd573d9208966c',\n    indexName: 'warriorjs',\n  },\n};\n\nmodule.exports = siteConfig;\n"
  },
  {
    "path": "apps/website/static/.circleci/config.yml",
    "content": "# This config file will prevent tests from being run on the gh-pages branch.\nversion: 2\njobs:\n  build:\n    machine: true\n\n    branches:\n      ignore: gh-pages\n\n    steps:\n      - run: echo \"Skipping tests on gh-pages branch\"\n"
  },
  {
    "path": "apps/website/static/css/custom.css",
    "content": "/*\n * Global\n */\n\nblockquote {\n  background: #eceff4;\n  border-left-color: #e5e9f0;\n}\n\n.container .wrapper h3 {\n  margin: 12px 0 0 0;\n  padding: 6px 0;\n}\n\n/*\n * Links\n */\n\n.mainContainer a {\n  color: $accentColor;\n}\n\n.mainContainer .button {\n  background: transparent;\n  border: 1px solid $primaryColor;\n  color: $primaryColor;\n}\n\n.mainContainer .button:hover {\n  background: $primaryColor;\n  color: #eceff4;\n}\n\n/*\n * Header\n */\n\n.fixedHeaderContainer .headerWrapper .logo {\n  height: 50%;\n}\n\n@media only screen and (max-width: 375px) {\n  .fixedHeaderContainer .headerWrapper .logo {\n    display: block;\n  }\n}\n\n/* Languages */\n\n#languages-menu {\n  font-size: 0px;\n  padding: 0;\n}\n#languages-menu .languages-icon {\n  margin: 0;\n  padding: 6px 10px;\n}\n#languages-dropdown {\n  width: auto;\n  right: 0;\n}\n@media only screen and (min-width: 1024px) {\n  .nav-site > span > li {\n    position: relative;\n  }\n}\n\n/* Search */\n\n.reactNavSearchWrapper input#search_input_react {\n  background-color: $secondaryColor;\n}\n\n@media only screen and (max-width: 735px) {\n  .reactNavSearchWrapper input#search_input_react {\n    background-color: $secondaryColor;\n  }\n}\n\n/*\n * Docs Sidebar\n */\n\n.docsNavContainer nav.toc .toggleNav .navItem:hover {\n  color: $accentColor;\n}\n\n.docsNavContainer nav.toc .toggleNav .navItem.navItemActive {\n  color: $accentColor;\n}\n\n/*\n * Footer\n */\n\n.footerSection {\n  background: $primaryColor !important;\n}\n\n.footerSection .sitemap .nav-home {\n  height: 80px;\n  margin: 0px 20px 0 0;\n  padding: 7px;\n  width: 80px;\n}\n\n.footerSection .sitemap .nav-home img {\n  transform-origin: left;\n  transform: translate(50%, -50%) rotate(90deg);\n}\n\n@media only screen and (max-width: 735px) {\n  .footerSection .sitemap div {\n    margin: 0 0 20px 0;\n  }\n\n  .footerSection .sitemap .nav-home img {\n    transform: translate(0, 50%);\n  }\n}\n\n.footerSection .sitemap iframe {\n  margin: 2px -10px;\n  padding: 0px 10px;\n}\n\n/*\n * Home\n */\n\n.homeContainer {\n  background: $primaryColor;\n}\n\n.homeContainer .homeWrapper .projectTitle img {\n  height: 100%;\n  margin-bottom: 0.5em;\n  max-height: 200px;\n}\n\n.homeContainer .homeWrapper .projectTitle small {\n  color: $accentColor;\n}\n\n.homeContainer .homeWrapper .button {\n  border-color: $accentColor;\n  color: $accentColor;\n}\n\n.homeContainer .homeWrapper .button:hover {\n  background: $accentColor;\n  color: $primaryColor;\n}\n\n.homeContainer .homeWrapper .button.primary {\n  background: $accentColor;\n  color: $primaryColor;\n}\n\n.mainContainer .productShowcaseSection,\n.mainContainer .container {\n  background: #e5e9f0;\n}\n\n.mainContainer .lightBackground {\n  background: #eceff4;\n}\n\n/* Quick Start */\n\n.quickStart ol {\n  list-style: none;\n  margin: 0 auto;\n  max-width: 800px;\n  padding: 1em 0;\n  text-align: left;\n}\n\n.quickStart ol li {\n  margin-bottom: 42px;\n}\n\n.quickStart ol li::before {\n  color: $accentColor;\n  font-size: 42px;\n  margin-right: 10px;\n}\n\n.quickStart ol li:nth-of-type(1)::before {\n  content: '1.';\n}\n.quickStart ol li:nth-of-type(2)::before {\n  content: '2.';\n}\n.quickStart ol li:nth-of-type(3)::before {\n  content: '3.';\n}\n"
  },
  {
    "path": "apps/website/static/css/nord.css",
    "content": "/*\n++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\ntitle      Nord highlight.js                                   +\nproject    nord-highlightjs                                    +\nversion    0.1.0                                               +\nrepository https://github.com/arcticicestudio/nord-highlightjs +\nauthor     Arctic Ice Studio                                   +\nemail      development@arcticicestudio.com                     +\ncopyright  Copyright (C) 2017                                  +\n++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\n\n[References]\nNord\n  https://github.com/arcticicestudio/nord\nhighlight.js\n  http://highlightjs.readthedocs.io/en/latest/style-guide.html\n  http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html\n*/\n.hljs {\n  display: block;\n  overflow-x: auto;\n  padding: 0.5em;\n  background: #2e3440;\n}\n\n.hljs,\n.hljs-subst {\n  color: #d8dee9;\n}\n\n.hljs-selector-tag {\n  color: #81a1c1;\n}\n\n.hljs-selector-id {\n  color: #8fbcbb;\n  font-weight: bold;\n}\n\n.hljs-selector-class {\n  color: #8fbcbb;\n}\n\n.hljs-selector-attr {\n  color: #8fbcbb;\n}\n\n.hljs-selector-pseudo {\n  color: #88c0d0;\n}\n\n.hljs-addition {\n  background-color: rgba(163, 190, 140, 0.5);\n}\n\n.hljs-deletion {\n  background-color: rgba(191, 97, 106, 0.5);\n}\n\n.hljs-built_in,\n.hljs-type {\n  color: #8fbcbb;\n}\n\n.hljs-class {\n  color: #8fbcbb;\n}\n\n.hljs-function {\n  color: #88c0d0;\n}\n\n.hljs-function > .hljs-title {\n  color: #88c0d0;\n}\n\n.hljs-keyword,\n.hljs-literal,\n.hljs-symbol {\n  color: #81a1c1;\n}\n\n.hljs-number {\n  color: #b48ead;\n}\n\n.hljs-regexp {\n  color: #ebcb8b;\n}\n\n.hljs-string {\n  color: #a3be8c;\n}\n\n.hljs-title {\n  color: #8fbcbb;\n}\n\n.hljs-params {\n  color: #d8dee9;\n}\n\n.hljs-bullet {\n  color: #81a1c1;\n}\n\n.hljs-code {\n  color: #8fbcbb;\n}\n\n.hljs-emphasis {\n  font-style: italic;\n}\n\n.hljs-formula {\n  color: #8fbcbb;\n}\n\n.hljs-strong {\n  font-weight: bold;\n}\n\n.hljs-link:hover {\n  text-decoration: underline;\n}\n\n.hljs-quote {\n  color: #4c566a;\n}\n\n.hljs-comment {\n  color: #4c566a;\n}\n\n.hljs-doctag {\n  color: #8fbcbb;\n}\n\n.hljs-meta,\n.hljs-meta-keyword {\n  color: #5e81ac;\n}\n\n.hljs-meta-string {\n  color: #a3be8c;\n}\n\n.hljs-attr {\n  color: #8fbcbb;\n}\n\n.hljs-attribute {\n  color: #d8dee9;\n}\n\n.hljs-builtin-name {\n  color: #81a1c1;\n}\n\n.hljs-name {\n  color: #81a1c1;\n}\n\n.hljs-section {\n  color: #88c0d0;\n}\n\n.hljs-tag {\n  color: #81a1c1;\n}\n\n.hljs-variable {\n  color: #d8dee9;\n}\n\n.hljs-template-variable {\n  color: #d8dee9;\n}\n\n.hljs-template-tag {\n  color: #5e81ac;\n}\n\n.abnf .hljs-attribute {\n  color: #88c0d0;\n}\n\n.abnf .hljs-symbol {\n  color: #ebcb8b;\n}\n\n.apache .hljs-attribute {\n  color: #88c0d0;\n}\n\n.apache .hljs-section {\n  color: #81a1c1;\n}\n\n.arduino .hljs-built_in {\n  color: #88c0d0;\n}\n\n.aspectj .hljs-meta {\n  color: #d08770;\n}\n\n.aspectj > .hljs-title {\n  color: #88c0d0;\n}\n\n.bnf .hljs-attribute {\n  color: #8fbcbb;\n}\n\n.clojure .hljs-name {\n  color: #88c0d0;\n}\n\n.clojure .hljs-symbol {\n  color: #ebcb8b;\n}\n\n.coq .hljs-built_in {\n  color: #88c0d0;\n}\n\n.cpp .hljs-meta-string {\n  color: #8fbcbb;\n}\n\n.css .hljs-built_in {\n  color: #88c0d0;\n}\n\n.css .hljs-keyword {\n  color: #d08770;\n}\n\n.diff .hljs-meta {\n  color: #8fbcbb;\n}\n\n.ebnf .hljs-attribute {\n  color: #8fbcbb;\n}\n\n.glsl .hljs-built_in {\n  color: #88c0d0;\n}\n\n.groovy .hljs-meta:not(:first-child) {\n  color: #d08770;\n}\n\n.haxe .hljs-meta {\n  color: #d08770;\n}\n\n.java .hljs-meta {\n  color: #d08770;\n}\n\n.ldif .hljs-attribute {\n  color: #8fbcbb;\n}\n\n.lisp .hljs-name {\n  color: #88c0d0;\n}\n\n.lua .hljs-built_in {\n  color: #88c0d0;\n}\n\n.moonscript .hljs-built_in {\n  color: #88c0d0;\n}\n\n.nginx .hljs-attribute {\n  color: #88c0d0;\n}\n\n.nginx .hljs-section {\n  color: #5e81ac;\n}\n\n.pf .hljs-built_in {\n  color: #88c0d0;\n}\n\n.processing .hljs-built_in {\n  color: #88c0d0;\n}\n\n.scss .hljs-keyword {\n  color: #81a1c1;\n}\n\n.stylus .hljs-keyword {\n  color: #81a1c1;\n}\n\n.swift .hljs-meta {\n  color: #d08770;\n}\n\n.vim .hljs-built_in {\n  color: #88c0d0;\n  font-style: italic;\n}\n\n.yaml .hljs-meta {\n  color: #d08770;\n}\n"
  },
  {
    "path": "apps/website/static/googlee0ff7b5bc8d30f78.html",
    "content": "google-site-verification: googlee0ff7b5bc8d30f78.html"
  },
  {
    "path": "apps/website/utils/getDocUrl.js",
    "content": "const siteConfig = require(`${process.cwd()}/siteConfig.js`);\n\nconst docsUrl = `${siteConfig.baseUrl}docs`;\n\nfunction getDocUrl(doc, language) {\n  if (language) {\n    return `${docsUrl}/${language}/${doc}`;\n  }\n\n  return `${docsUrl}/${doc}`;\n}\n\nmodule.exports = getDocUrl;\n"
  },
  {
    "path": "apps/website/utils/getImgUrl.js",
    "content": "const siteConfig = require(`${process.cwd()}/siteConfig.js`);\n\nconst imgUrl = `${siteConfig.baseUrl}img`;\n\nfunction getImgUrl(img) {\n  return `${imgUrl}/${img}`;\n}\n\nmodule.exports = getImgUrl;\n"
  },
  {
    "path": "biome.json",
    "content": "{\n  \"$schema\": \"https://biomejs.dev/schemas/2.4.6/schema.json\",\n  \"assist\": {\n    \"actions\": {\n      \"source\": {\n        \"organizeImports\": {\n          \"level\": \"on\",\n          \"options\": {\n            \"groups\": [\":NODE:\", \":PACKAGE:\", \":BLANK_LINE:\", \":PATH:\"]\n          }\n        }\n      }\n    }\n  },\n  \"formatter\": {\n    \"enabled\": true,\n    \"indentStyle\": \"space\",\n    \"indentWidth\": 2,\n    \"lineWidth\": 100\n  },\n  \"linter\": {\n    \"enabled\": true,\n    \"rules\": {\n      \"recommended\": true,\n      \"correctness\": {\n        \"noUnusedImports\": \"error\",\n        \"noUnusedVariables\": \"error\"\n      },\n      \"style\": {\n        \"noNonNullAssertion\": \"off\",\n        \"useConst\": \"error\",\n        \"useNodejsImportProtocol\": \"error\",\n        \"useTemplate\": \"error\",\n        \"useImportType\": {\n          \"level\": \"error\",\n          \"options\": {\n            \"style\": \"inlineType\"\n          }\n        },\n        \"useExportType\": \"error\",\n        \"useForOf\": \"error\",\n        \"useThrowOnlyError\": \"error\",\n        \"useDefaultParameterLast\": \"error\",\n        \"useCollapsedElseIf\": \"error\"\n      },\n      \"suspicious\": {\n        \"noExplicitAny\": \"off\",\n        \"useIterableCallbackReturn\": \"off\"\n      }\n    }\n  },\n  \"javascript\": {\n    \"formatter\": {\n      \"quoteStyle\": \"single\",\n      \"trailingCommas\": \"all\"\n    }\n  },\n  \"files\": {\n    \"includes\": [\"apps/**\", \"libs/**\", \"towers/**\", \"!apps/website\", \"!**/dist\"]\n  }\n}\n"
  },
  {
    "path": "docs/community/ecosystem.md",
    "content": "---\nid: ecosystem\ntitle: Ecosystem\n---\n\nWarriorJS uses NPM/Yarn, so any WarriorJS related project can live on\n[npm](https://npmjs.com).\n\nOfficial WarriorJS packages live under the\n[@warriorjs scope](https://npmjs.com/org/warriorjs), whereas community packages\nshould be prefixed with `warriorjs-`. Please also put the keywords \"warriorjs\"\nand \"warriorjs-tower\" (if you're publishing a tower) in package.json's keywords\nfield.\n"
  },
  {
    "path": "docs/community/roadmap.md",
    "content": "---\nid: roadmap\ntitle: Roadmap & Contribution\n---\n\n## WarriorJS TODOs\n\nSome future plans are briefly discussed in the repo's\n[issues page](https://github.com/olistic/warriorjs/issues).\n\n## Contribution Opportunities\n\nThese are the many ways you can help:\n\n- Submit patches and features\n- Make [towers](player/towers.md) (new levels for the game)\n- Improve this documentation and website\n- Report bugs\n- Follow us on [Twitter](https://twitter.com/warrior_js)\n- Participate in the [Spectrum community](https://spectrum.chat/warriorjs)\n- And [donate financially](https://opencollective.com/warriorjs)!\n\nPlease read our\n[contribution guide](https://github.com/olistic/warriorjs/blob/master/CONTRIBUTING.md)\nto get started.\n"
  },
  {
    "path": "docs/community/socialize.md",
    "content": "---\nid: socialize\ntitle: Socialize\n---\n\nDo you have any questions, ideas, or suggestions?\n\n- Come say hi in [Spectrum](https://spectrum.chat/warriorjs)\n- Tweet us [@warrior_js](https://twitter.com/warrior_js)\n"
  },
  {
    "path": "docs/maker/adding-levels.md",
    "content": "---\nid: adding-levels\ntitle: Adding Levels\n---\n\nA level is another JavaScript object:\n\n```js\nconst Level1 = {\n  // Level definition.\n};\n```\n\nLet's start off by writing a description and a tip for our level:\n\n```js\nconst Level1 = {\n  description:\n    \"You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.\",\n  tip:\n    \"Call `warrior.walk()` to walk forward in the Player's `playTurn` method.\",\n};\n```\n\nWe also need to define two numbers: the time bonus and the ace score. The time\nbonus is earned by the player depending on how fast they complete the level (it\nis decremented turn by turn until it reaches zero). The ace score, on the other\nhand, is used to calculate the level grade (in epic mode only). Any score\ngreater or equal to the ace score will get an S. Let's add those numbers:\n\n```js\nconst Level1 = {\n  description:\n    \"You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.\",\n  tip:\n    \"Call `warrior.walk()` to walk forward in the Player's `playTurn` method.\",\n  timeBonus: 15,\n  aceScore: 10,\n};\n```\n\n> These two numbers will need to be fine-tuned when play testing the tower. For\n> this guide, we've already done that.\n\nThe next thing to do is to define the floor of the level, starting by its size:\n\n```js\nconst Level1 = {\n  description:\n    \"You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.\",\n  tip:\n    \"Call `warrior.walk()` to walk forward in the Player's `playTurn` method.\",\n  timeBonus: 15,\n  aceScore: 10,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n  },\n};\n```\n\nThen, we need to position the stairs so that the Warrior can move to the next\nlevel:\n\n```js\nconst Level1 = {\n  description:\n    \"You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.\",\n  tip:\n    \"Call `warrior.walk()` to walk forward in the Player's `playTurn` method.\",\n  timeBonus: 15,\n  aceScore: 10,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n    stairs: {\n      x: 7,\n      y: 0,\n    },\n  },\n};\n```\n\nSpeaking of warrior, let's define the Warrior for this level:\n\n```js\nconst Level1 = {\n  description:\n    \"You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.\",\n  tip:\n    \"Call `warrior.walk()` to walk forward in the Player's `playTurn` method.\",\n  timeBonus: 15,\n  aceScore: 10,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n    stairs: {\n      x: 7,\n      y: 0,\n    },\n    warrior: {\n      character: '@',\n      maxHealth: 20,\n      position: {\n        x: 0,\n        y: 0,\n        facing: 'east',\n      },\n    },\n  },\n};\n```\n\nWith that, the level is complete. But before continuing, let's define another\nlevel:\n\n```js\nconst Level2 = {\n  description:\n    'The cold became more intense. In the distance, you see a pair of deep and blue eyes, a blue that burns like ice.',\n  tip:\n    \"Use `warrior.feel().isEmpty()` to see if there's anything in front of you, and `warrior.attack()` to fight it. Remember, you can only do one action per turn.\",\n  clue:\n    'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.',\n  timeBonus: 20,\n  aceScore: 26,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n    stairs: {\n      x: 7,\n      y: 0,\n    },\n    warrior: {\n      character: '@',\n      maxHealth: 20,\n      position: {\n        x: 0,\n        y: 0,\n        facing: 'east',\n      },\n    },\n  },\n};\n```\n\n> As things started to get more challenging for the player, this time we added a\n> clue. Clues are optional and will only be shown upon demand.\n\nNow we need to add these two levels to the tower. Levels are added to the\n`levels` array of the tower:\n\n```js\nmodule.exports = {\n  name: 'Game of Thrones',\n  description:\n    'There is only one war that matters: the Great War. And it is here.',\n  levels: [Level1, Level2],\n};\n```\n\nSuperb! But as you could have noticed, we instruct the player to call\n`warrior.attack()`, `warrior.feel()`, and `warrior.walk()` but we haven't taught\nthe Warrior how to do any of that. Let's do that next!\n"
  },
  {
    "path": "docs/maker/creating-tower.md",
    "content": "---\nid: creating-tower\ntitle: Creating Your Tower\n---\n\nA WarriorJS tower is a regular JavaScript module with a single export:\n\n```js\nmodule.exports = {\n  // Tower definition.\n};\n```\n\nLet's define the name of our tower and write a brief description:\n\n```js\nmodule.exports = {\n  name: 'Game of Thrones',\n  description:\n    'There is only one war that matters: the Great War. And it is here.',\n};\n```\n\nCool! But there's nothing to climb yet. Let's add some levels to this tower!\n"
  },
  {
    "path": "docs/maker/defining-abilities.md",
    "content": "---\nid: defining-abilities\ntitle: Defining Abilities\n---\n\nAn ability is a JavaScript function that receives the unit that possesses the\nability as the only parameter and returns a JavaScript object:\n\n```js\nfunction walk(unit) {\n  return {\n    // Ability definition.\n  };\n}\n```\n\nThe walk ability is an action, so first of all let's indicate that:\n\n```js\nfunction walk(unit) {\n  return {\n    action: true,\n  };\n}\n```\n\nThen, we need to write a description for the ability so that the player knows\nwhat it does:\n\n```js\nfunction walk(unit) {\n  return {\n    action: true,\n    description: 'Move one space in the given direction (forward by default).',\n  };\n}\n```\n\nAnd last but not least, we need to write the ability's logic in the `perform`\nfunction. Here, we can use any of the methods in the\n[Unit Maker API](maker/unit-api.md). Let's do that:\n\n```js\nfunction walk(unit) {\n  return {\n    action: true,\n    description: 'Move one space in the given direction (forward by default).',\n    perform(direction = 'forward') {\n      const space = unit.getSpaceAt(direction);\n      if (space.isEmpty()) {\n        unit.move(direction);\n        unit.log(`walks ${direction}`);\n      } else {\n        unit.log(`walks ${direction} and bumps into ${space}`);\n      }\n    },\n  };\n}\n```\n\nAbilities are are added to the units under a key in the `abilities` object.\nLet's add the walk ability to the Warrior under the `walk` key (because we want\nthe player to invoke it by calling `warrior.walk()`):\n\n```js\nconst Level1 = {\n  description:\n    \"You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.\",\n  tip:\n    \"Call `warrior.walk()` to walk forward in the Player's `playTurn` method.\",\n  timeBonus: 15,\n  aceScore: 10,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n    stairs: {\n      x: 7,\n      y: 0,\n    },\n    warrior: {\n      character: '@',\n      maxHealth: 20,\n      position: {\n        x: 0,\n        y: 0,\n        facing: 'east',\n      },\n      abilities: {\n        walk: walk,\n      },\n    },\n  },\n};\n```\n\nFor the second level, we need to add two more abilities: attack and feel.\n\nFirst, let's define the attack ability:\n\n```js\nfunction valyrianSteelSwordAttack(unit) {\n  return {\n    action: true,\n    description:\n      'Attack a unit in the given direction (forward by default) with your Valyrian steel sword, dealing 5 HP of damage.',\n    perform(direction = 'forward') {\n      const receiver = unit.getSpaceAt(direction).getUnit();\n      if (receiver) {\n        unit.log(`attacks ${direction} and hits ${receiver}`);\n        unit.damage(receiver, 5);\n      } else {\n        unit.log(`attacks ${direction} and hits nothing`);\n      }\n    },\n  };\n}\n```\n\nSecondly, let's define the feel ability. Contrary to attack, feel is a sense, so\nwe can omit the `action` key:\n\n```js\nfunction feel(unit) {\n  return {\n    description:\n      'Return the adjacent space in the given direction (forward by default).',\n    perform(direction = 'forward') {\n      return unit.getSensedSpaceAt(direction);\n    },\n  };\n}\n```\n\n> **IMPORTANT:** When returning one or multiple spaces from senses, use\n> `unit.getSensedSpaceAt()` instead of `unit.getSpaceAt()`. The former returns a\n> space that exposes only the [Space Player API](player/space-api.md), whereas\n> the latter exposes the [Space Maker API](maker/space-api.md) and is meant to\n> be used internally, like in the attack ability before.\n\nFinally, let's add them to the Warrior of the second level:\n\n```js\nconst Level2 = {\n  description:\n    'The cold became more intense. In the distance, you see a pair of deep and blue eyes, a blue that burns like ice.',\n  tip:\n    \"Use `warrior.feel().isEmpty()` to see if there's anything in front of you, and `warrior.attack()` to fight it. Remember, you can only do one action per turn.\",\n  clue:\n    'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.',\n  timeBonus: 20,\n  aceScore: 26,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n    stairs: {\n      x: 7,\n      y: 0,\n    },\n    warrior: {\n      character: '@',\n      maxHealth: 20,\n      position: {\n        x: 0,\n        y: 0,\n        facing: 'east',\n      },\n      abilities: {\n        attack: valyrianSteelSwordAttack,\n        feel: feel,\n      },\n    },\n  },\n};\n```\n\n> We don't add the walk ability again in the second level because the Warrior\n> already learned it in the first level.\n\nThis is very nice, but the Warrior is not wielding that Valyrian steel sword for\nnothing. Let's add an enemy he can fight!\n"
  },
  {
    "path": "docs/maker/defining-units.md",
    "content": "---\nid: defining-units\ntitle: Defining Units\n---\n\nA unit is a JavaScript object:\n\n```js\nconst WhiteWalker = {\n  // Unit definition.\n};\n```\n\nLet's start off by adding the name of the unit:\n\n```js\nconst WhiteWalker = {\n  name: 'White Walker',\n};\n```\n\n> We didn't add a name for the Warrior because it's supplied by the player\n> during the game.\n\nJust like the Warrior, other units also need a character and a max health value:\n\n```js\nconst WhiteWalker = {\n  name: 'White Walker',\n  character: 'w',\n  maxHealth: 12,\n};\n```\n\nLet's define a new attack ability:\n\n```js\nfunction iceCrystalSwordAttack(unit) {\n  return {\n    action: true,\n    description:\n      'Attack a unit in the given direction (forward by default) with your ice blade, dealing 3 HP of damage.',\n    perform(direction = 'forward') {\n      const receiver = unit.getSpaceAt(direction).getUnit();\n      if (receiver) {\n        unit.log(`attacks ${direction} and hits ${receiver}`);\n        unit.damage(receiver, 3);\n      } else {\n        unit.log(`attacks ${direction} and hits nothing`);\n      }\n    },\n  };\n}\n```\n\nAnd add it to the White Walker. Let's also add the same feel ability we'd\nalready defined:\n\n```js\nconst WhiteWalker = {\n  name: 'White Walker',\n  character: 'w',\n  maxHealth: 12,\n  abilities: {\n    attack: iceCrystalSwordAttack,\n    feel: feel,\n  },\n};\n```\n\nFinally, we need to define the AI of our White Walker. It'll be a very\nrudimentary AI: the White Walker will start his turn by feeling in every\ndirection looking for an enemy (the Warrior). If he finds it in any direction,\nhe'll attack in that direction. Let's write that logic in the `playTurn`\nfunction:\n\n```js\nconst WhiteWalker = {\n  name: 'White Walker',\n  character: 'w',\n  maxHealth: 12,\n  abilities: {\n    attack: iceCrystalSwordAttack,\n    feel: feel,\n  },\n  playTurn(whiteWalker) {\n    const enemyDirection = ['forward', 'right', 'backward', 'left'].find(\n      direction => {\n        const unit = whiteWalker.feel(direction).getUnit();\n        return unit && unit.isEnemy();\n      },\n    );\n    if (enemyDirection) {\n      whiteWalker.attack(enemyDirection);\n    }\n  },\n};\n```\n\n> We didn't write the AI for the Warrior either because it's also supplied by\n> the player during the game.\n\nNow we need to add the White Walker to the second level. Units other than the\nWarrior are added to the `units` array of the floor:\n\n```js\nconst Level2 = {\n  description:\n    'The cold became more intense. In the distance, you see a pair of deep and blue eyes, a blue that burns like ice.',\n  tip:\n    \"Use `warrior.feel().isEmpty()` to see if there's anything in front of you, and `warrior.attack()` to fight it. Remember, you can only do one action per turn.\",\n  clue:\n    'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.',\n  timeBonus: 20,\n  aceScore: 26,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n    stairs: {\n      x: 7,\n      y: 0,\n    },\n    warrior: {\n      character: '@',\n      maxHealth: 20,\n      position: {\n        x: 0,\n        y: 0,\n        facing: 'east',\n      },\n      abilities: {\n        attack: valyrianSteelSwordAttack,\n        feel: feel,\n      },\n    },\n    units: [\n      {\n        ...WhiteWalker,\n        position: {\n          x: 4,\n          y: 0,\n          facing: 'west',\n        },\n      },\n    ],\n  },\n};\n```\n\n> Here, we used spread properties to merge the unit definition with its position\n> in the floor.\n\nCongratulations! You've created your first tower. At this point, this tower is\nfully playable, but it can use some refactoring.\n"
  },
  {
    "path": "docs/maker/introduction.md",
    "content": "---\nid: introduction\ntitle: Introduction\n---\n\nIn this guide, you'll learn how to make your own tower by following a quick\nexample.\n\nThis guide assumes that you're familiar with the basic concepts of WarriorJS.\nIt's also important that you have played the game, ideally having completed at\nleast the \"The Narrow Path\" tower.\n\nLet's get started!\n"
  },
  {
    "path": "docs/maker/publishing.md",
    "content": "---\nid: publishing\ntitle: Publishing\n---\n\nThis is the minimal structure of a tower package:\n\n```sh\nwarriorjs-tower-got\n├── index.js\n└── package.json\n```\n\nWhere `index.js` would contain the code we've been writing through this guide,\nand `package.json` the npm package info:\n\n```json\n{\n  \"name\": \"warriorjs-tower-got\",\n  \"version\": \"0.1.0\",\n  \"description\": \"There is only one war that matters: the Great War. And it is here.\",\n  \"main\": \"index.js\",\n  \"keywords\": [\"warriorjs-tower\"],\n  \"dependencies\": {\n    \"@warriorjs/geography\": \"^0.4.0\"\n  }\n}\n```\n\nSome special considerations:\n\n- The package name must start with `warriorjs-tower-` for the tower to be\n  automatically loaded by WarriorJS.\n- `warriorjs-tower` should be in the \"keywords\" field for better discoverability\n  of your tower.\n\nWhen working on a tower, you can use\n[`npm pack`](https://docs.npmjs.com/cli/pack) to create a tarball for it, and\nthen install it where you installed `@warriorjs/cli` by doing:\n\n```sh\nnpm install <path/to/tarball>\n```\n\nAfter doing that, running `warriorjs` should load your tower automatically.\n\nOnce you've tested and adjusted your tower, you're ready to publish it to\n[npm](https://npmjs.com) for others to play it. Follow this\n[guide](https://docs.npmjs.com/getting-started/publishing-npm-packages) to learn\nhow to publish a package to npm.\n"
  },
  {
    "path": "docs/maker/refactoring.md",
    "content": "---\nid: refactoring\ntitle: Refactoring\n---\n\nAt this point, you should have the following code:\n\n```js\nfunction valyrianSteelSwordAttack(unit) {\n  return {\n    action: true,\n    description:\n      'Attack a unit in the given direction (forward by default) with your Valyrian steel sword, dealing 5 HP of damage.',\n    perform(direction = 'forward') {\n      const receiver = unit.getSpaceAt(direction).getUnit();\n      if (receiver) {\n        unit.log(`attacks ${direction} and hits ${receiver}`);\n        unit.damage(receiver, 5);\n      } else {\n        unit.log(`attacks ${direction} and hits nothing`);\n      }\n    },\n  };\n}\n\nfunction iceCrystalSwordAttack(unit) {\n  return {\n    action: true,\n    description:\n      'Attack a unit in the given direction (forward by default) with your ice blade, dealing 3 HP of damage.',\n    perform(direction = 'forward') {\n      const receiver = unit.getSpaceAt(direction).getUnit();\n      if (receiver) {\n        unit.log(`attacks ${direction} and hits ${receiver}`);\n        unit.damage(receiver, 3);\n      } else {\n        unit.log(`attacks ${direction} and hits nothing`);\n      }\n    },\n  };\n}\n\nfunction feel(unit) {\n  return {\n    description:\n      'Return the adjacent space in the given direction (forward by default).',\n    perform(direction = 'forward') {\n      return unit.getSensedSpaceAt(direction);\n    },\n  };\n}\n\nfunction walk(unit) {\n  return {\n    action: true,\n    description: 'Move one space in the given direction (forward by default).',\n    perform(direction = 'forward') {\n      const space = unit.getSpaceAt(direction);\n      if (space.isEmpty()) {\n        unit.move(direction);\n        unit.log(`walks ${direction}`);\n      } else {\n        unit.log(`walks ${direction} and bumps into ${space}`);\n      }\n    },\n  };\n}\n\nconst WhiteWalker = {\n  name: 'White Walker',\n  character: 'w',\n  maxHealth: 12,\n  abilities: {\n    attack: iceCrystalSwordAttack,\n    feel: feel,\n  },\n  playTurn(whiteWalker) {\n    const enemyDirection = ['forward', 'right', 'backward', 'left'].find(\n      direction => {\n        const unit = whiteWalker.feel(direction).getUnit();\n        return unit && unit.isEnemy();\n      },\n    );\n    if (enemyDirection) {\n      whiteWalker.attack(enemyDirection);\n    }\n  },\n};\n\nconst Level1 = {\n  description:\n    \"You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.\",\n  tip:\n    \"Call `warrior.walk()` to walk forward in the Player's `playTurn` method.\",\n  timeBonus: 15,\n  aceScore: 10,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n    stairs: {\n      x: 7,\n      y: 0,\n    },\n    warrior: {\n      character: '@',\n      maxHealth: 20,\n      position: {\n        x: 0,\n        y: 0,\n        facing: 'east',\n      },\n      abilities: {\n        walk: walk,\n      },\n    },\n  },\n};\n\nconst Level2 = {\n  description:\n    'The cold became more intense. In the distance, you see a pair of deep and blue eyes, a blue that burns like ice.',\n  tip:\n    \"Use `warrior.feel().isEmpty()` to see if there's anything in front of you, and `warrior.attack()` to fight it. Remember, you can only do one action per turn.\",\n  clue:\n    'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.',\n  timeBonus: 20,\n  aceScore: 26,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n    stairs: {\n      x: 7,\n      y: 0,\n    },\n    warrior: {\n      character: '@',\n      maxHealth: 20,\n      position: {\n        x: 0,\n        y: 0,\n        facing: 'east',\n      },\n      abilities: {\n        attack: valyrianSteelSwordAttack,\n        feel: feel,\n      },\n    },\n    units: [\n      {\n        ...WhiteWalker,\n        position: {\n          x: 4,\n          y: 0,\n          facing: 'west',\n        },\n      },\n    ],\n  },\n};\n\nmodule.exports = {\n  name: 'Game of Thrones',\n  description:\n    'There is only one war that matters: the Great War. And it is here.',\n  levels: [Level1, Level2],\n};\n```\n\nJust like what we did with the White Walker definition, we can extract the\ncommon fields of the Warrior to an object and then use spread properties to add\nit to every level:\n\n```js\nconst Warrior = {\n  character: '@',\n  maxHealth: 20,\n};\n\nconst Level1 = {\n  description:\n    \"You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.\",\n  tip:\n    \"Call `warrior.walk()` to walk forward in the Player's `playTurn` method.\",\n  timeBonus: 15,\n  aceScore: 10,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n    stairs: {\n      x: 7,\n      y: 0,\n    },\n    warrior: {\n      ...Warrior,\n      abilities: {\n        walk: walk,\n      },\n      position: {\n        x: 0,\n        y: 0,\n        facing: 'east',\n      },\n    },\n  },\n};\n\nconst Level2 = {\n  description:\n    'The cold became more intense. In the distance, you see a pair of deep and blue eyes, a blue that burns like ice.',\n  tip:\n    \"Use `warrior.feel().isEmpty()` to see if there's anything in front of you, and `warrior.attack()` to fight it. Remember, you can only do one action per turn.\",\n  clue:\n    'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.',\n  timeBonus: 20,\n  aceScore: 26,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n    stairs: {\n      x: 7,\n      y: 0,\n    },\n    warrior: {\n      ...Warrior,\n      abilities: {\n        attack: valyrianSteelSwordAttack,\n        feel: feel,\n      },\n      position: {\n        x: 0,\n        y: 0,\n        facing: 'east',\n      },\n    },\n    units: [\n      {\n        ...WhiteWalker,\n        position: {\n          x: 4,\n          y: 0,\n          facing: 'west',\n        },\n      },\n    ],\n  },\n};\n```\n\nWith regard to the abilities, we can see that both attack abilities are very\nsimilar. Let's do something about that:\n\n```js\nfunction attackCreator({ power, weapon }) {\n  return unit => ({\n    action: true,\n    description: `Attack a unit in the given direction (forward by default) with your ${weapon}, dealing ${power} HP of damage.`,\n    perform(direction = 'forward') {\n      const receiver = unit.getSpaceAt(direction).getUnit();\n      if (receiver) {\n        unit.log(`attacks ${direction} and hits ${receiver}`);\n        unit.damage(receiver, power);\n      } else {\n        unit.log(`attacks ${direction} and hits nothing`);\n      }\n    },\n  });\n}\n\nconst valyrianSteelSwordAttack = attackCreator({\n  power: 5,\n  weapon: 'Valyrian steel sword',\n});\n\nconst iceCrystalSwordAttack = attackCreator({\n  power: 3,\n  weapon: 'ice blade',\n});\n```\n\n> We can call this the \"ability creator\" pattern, where we define a function\n> (the ability creator) which returns another function (the ability) customized\n> with the parameters we passed to the creator.\n\nTo end with the refactor, let's get rid of all those magic strings representing\ndirections. There's an official package called\n[`@warriorjs/geography`](https://github.com/olistic/warriorjs/tree/master/libs/warriorjs-geography)\nthat exposes a bunch of constants and methods related to directioning. Let's use\nit:\n\n```js\nconst {\n  EAST,\n  FORWARD,\n  RELATIVE_DIRECTIONS,\n  WEST,\n} = require('@warriorjs/geography');\n\nfunction attackCreator({ power, weapon }) {\n  return unit => ({\n    action: true,\n    description: `Attack a unit in the given direction (forward by default) with your ${weapon}, dealing ${power} HP of damage.`,\n    perform(direction = FORWARD) {\n      const receiver = unit.getSpaceAt(direction).getUnit();\n      if (receiver) {\n        unit.log(`attacks ${direction} and hits ${receiver}`);\n        unit.damage(receiver, power);\n      } else {\n        unit.log(`attacks ${direction} and hits nothing`);\n      }\n    },\n  });\n}\n\nconst valyrianSteelSwordAttack = attackCreator({\n  power: 5,\n  weapon: 'Valyrian steel sword',\n});\n\nconst iceCrystalSwordAttack = attackCreator({\n  power: 3,\n  weapon: 'ice blade',\n});\n\nfunction feel(unit) {\n  return {\n    description:\n      'Return the adjacent space in the given direction (forward by default).',\n    perform(direction = FORWARD) {\n      return unit.getSensedSpaceAt(direction);\n    },\n  };\n}\n\nfunction walk(unit) {\n  return {\n    action: true,\n    description: 'Move one space in the given direction (forward by default).',\n    perform(direction = FORWARD) {\n      const space = unit.getSpaceAt(direction);\n      if (space.isEmpty()) {\n        unit.move(direction);\n        unit.log(`walks ${direction}`);\n      } else {\n        unit.log(`walks ${direction} and bumps into ${space}`);\n      }\n    },\n  };\n}\n\nconst Warrior = {\n  character: '@',\n  maxHealth: 20,\n};\n\nconst WhiteWalker = {\n  name: 'White Walker',\n  character: 'w',\n  maxHealth: 12,\n  abilities: {\n    attack: iceCrystalSwordAttack,\n    feel: feel,\n  },\n  playTurn(whiteWalker) {\n    const enemyDirection = RELATIVE_DIRECTIONS.find(direction => {\n      const unit = whiteWalker.feel(direction).getUnit();\n      return unit && unit.isEnemy();\n    });\n    if (enemyDirection) {\n      whiteWalker.attack(enemyDirection);\n    }\n  },\n};\n\nconst Level1 = {\n  description:\n    \"You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.\",\n  tip:\n    \"Call `warrior.walk()` to walk forward in the Player's `playTurn` method.\",\n  timeBonus: 15,\n  aceScore: 10,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n    stairs: {\n      x: 7,\n      y: 0,\n    },\n    warrior: {\n      ...Warrior,\n      abilities: {\n        walk: walk,\n      },\n      position: {\n        x: 0,\n        y: 0,\n        facing: EAST,\n      },\n    },\n  },\n};\n\nconst Level2 = {\n  description:\n    'The cold became more intense. In the distance, you see a pair of deep and blue eyes, a blue that burns like ice.',\n  tip:\n    \"Use `warrior.feel().isEmpty()` to see if there's anything in front of you, and `warrior.attack()` to fight it. Remember, you can only do one action per turn.\",\n  clue:\n    'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.',\n  timeBonus: 20,\n  aceScore: 26,\n  floor: {\n    size: {\n      width: 8,\n      height: 1,\n    },\n    stairs: {\n      x: 7,\n      y: 0,\n    },\n    warrior: {\n      ...Warrior,\n      abilities: {\n        attack: valyrianSteelSwordAttack,\n        feel: feel,\n      },\n      position: {\n        x: 0,\n        y: 0,\n        facing: EAST,\n      },\n    },\n    units: [\n      {\n        ...WhiteWalker,\n        position: {\n          x: 4,\n          y: 0,\n          facing: WEST,\n        },\n      },\n    ],\n  },\n};\n\nmodule.exports = {\n  name: 'Game of Thrones',\n  description:\n    'There is only one war that matters: the Great War. And it is here.',\n  levels: [Level1, Level2],\n};\n```\n\nMuch better! Keep reading to learn how to test and publish your tower so that\nother players can play!\n"
  },
  {
    "path": "docs/maker/space-api.md",
    "content": "---\nid: space-api\ntitle: Space API\n---\n\nAs a maker, you call the same methods the player call on a sensed space, but on\na regular space.\n\n## Class Methods\n\nHere are the various methods that are available to you:\n\n### `space.isEmpty()`:\n\nDetermines if nothing (except maybe stairs) is at this space.\n\n**Returns**\n\n_(boolean)_: Whether this space is empty or not.\n\n### `space.isStairs()`\n\nDetermines if the stairs are at this space.\n\n**Returns**\n\n_(boolean)_: Whether the stairs are at this space or not.\n\n### `space.isWall()`\n\nDetermines if this is the edge of the level.\n\n**Returns**\n\n_(boolean)_: Whether this space is a wall or not.\n\n### `space.isUnit()`\n\nDetermines if there's a unit at this space.\n\n**Returns**\n\n_(boolean)_: Whether a unit is at this space or not.\n\n### `space.getUnit()`\n\nReturns the unit located at this space (if any).\n\n**This unit will be a regular unit, not a sensed unit.**\n\n**Returns**\n\n_(Unit)_: The unit at this location or `undefined` if there's none.\n\n## Instance Properties\n\n### `location` _(number[])_\n\nThe absolute location of this space as the pair of coordinates `[x, y]`.\n"
  },
  {
    "path": "docs/maker/testing.md",
    "content": "---\nid: testing\ntitle: Testing\n---\n\nFirst, you want to make the top of your tower can be reached. And then, you'll\nwant to fine-tune the time bonus and ace score values for each level.\n"
  },
  {
    "path": "docs/maker/unit-api.md",
    "content": "---\nid: unit-api\ntitle: Unit API\n---\n\nAs a maker, you call methods on units when writing the logic for the abilities\nyou create.\n\n## Class Methods\n\nHere are the various methods that are available to you:\n\n### `unit.heal(amount)`\n\nAdds the given amount of health in HP.\n\nHealth can't go over max health.\n\n**Arguments**\n\n`amount` _(number)_: The amount of HP to add.\n\n### `unit.takeDamage(amount)`\n\nSubtracts the given amount of health in HP.\n\nIf the unit is bound, it will unbound when taking damage.\n\nHealth can't go under zero. If it reaches zero, the unit will die and vanish\nfrom the floor.\n\n**Arguments**\n\n`amount` _(number)_: The amount of HP to subtract.\n\n### `unit.damage(receiver, amount)`\n\nDamages another unit.\n\nIf the other unit dies, the damager will earn or lose points equal to the dead\nunit's reward depending on whether that unit was an enemy or a friend,\nrespectively.\n\n**Arguments**\n\n`receiver` _(Unit)_: The unit to damage.\n\n`amount` _(number)_: The amount of HP to inflict.\n\n### `unit.isAlive()`\n\nDetermines if the unit is alive.\n\nA unit is alive if it has a position.\n\n**Returns**\n\n_(boolean)_: Whether the unit is alive or not.\n\n### `unit.release(unit)`\n\nReleases (unbinds) another unit.\n\nIf the other unit was a friend, the releaser will earn points equal to the\nreleased unit's reward.\n\n**Arguments**\n\n`receiver` _(Unit)_: The unit to release.\n\n### `unit.unbind()`\n\nUnbinds the unit.\n\n### `unit.bind()`\n\nBinds the unit.\n\n### `unit.isBound()`\n\nDetermines if the unit is bound.\n\n**Returns**\n\n_(boolean)_: Whether this unit is bound or not.\n\n### `unit.earnPoints(points)`\n\nAdds the given points to the score.\n\n**Arguments**\n\n`points` _(number)_: The points to earn.\n\n### `unit.losePoints(points)`\n\nSubtracts the given points from the score.\n\n**Arguments**\n\n`points` _(number)_: The points to lose.\n\n### `unit.triggerEffect(effect)`\n\nTriggers the given effect.\n\n**Arguments**\n\n`effect` _(string)_: The name of the effect.\n\n### `unit.isUnderEffect(effect)`\n\nDetermines if the unit is under the given effect.\n\n**Arguments**\n\n`effect` _(string)_: The name of the effect.\n\n**Returns**\n\n_(boolean)_: Whether this unit is under the given effect or not.\n\n### `unit.getOtherUnits()`\n\nReturns the units in the floor minus this unit.\n\n**Returns**\n\n_(Unit[])_: The other units in the floor.\n\n### `unit.getSpace()`\n\nReturns the space where this unit is located.\n\n**Returns**\n\n_(Space)_: The space this unit is located at.\n\n### `unit.getSensedSpaceAt(direction, forward = 1, right = 0)`\n\nReturns the sensed space located at the direction and number of spaces.\n\nUse this method when returning spaces from senses. **Always return sensed spaces\nto the player.**\n\n**Arguments**\n\n`direction` _(string)_: The direction.\n\n`forward` _(number)_: The number of spaces forward.\n\n`right` _(number)_: The number of spaces to the right.\n\n**Returns**\n\n_(SensedSpace)_: The sensed space.\n\n### `unit.getSpaceAt(direction, forward = 1, right = 0)`\n\nReturns the space located at the direction and number of spaces.\n\nUse this method internally. **Never return a regular space to the player.**\n\n**Arguments**\n\n`direction` _(string)_: The direction.\n\n`forward` _(number)_: The number of spaces forward.\n\n`right` _(number)_: The number of spaces to the right.\n\n**Returns**\n\n_(Space)_: The space.\n\n### `unit.getDirectionOfStairs()`\n\nReturns the direction of the stairs with reference to this unit.\n\n**Returns**\n\n_(string)_: The relative direction of the stairs.\n\n### `unit.getDirectionOf(space)`\n\nReturns the direction of the given space with reference to this unit.\n\n**Arguments**\n\n`space` _(SensedSpace)_: The space to get the direction of.\n\n**Returns**\n\n_(string)_: The relative direction of the space.\n\n### `unit.getDistanceOf(space)`\n\nReturns the distance between the given space and this unit.\n\n**Arguments**\n\n`space` _(SensedSpace)_: The space to calculate the distance of.\n\n**Returns**\n\n_(number)_: The distance of the space.\n\n### `unit.move(direction, forward = 1, right = 0)`\n\nMoves the unit in the given direction and number of spaces.\n\n**Arguments**\n\n`direction` _(string)_: The direction.\n\n`forward` _(number)_: The number of spaces forward.\n\n`right` _(number)_: The number of spaces to the right.\n\n### `unit.rotate(direction)`\n\nRotates the unit in a given direction.\n\n**Arguments**\n\n`direction` _(string)_: The direction in which to rotate.\n\n### `unit.vanish()`\n\nVanishes the unit from the floor.\n\n### `unit.log(message)`\n\nLogs a message to the play log.\n\n**Arguments**\n\n`message` _(string)_: The message to log.\n\n## Instance Properties\n\n### `name` _(string)_\n\nThe name of the unit.\n\n### `character` _(string)_\n\nThe character that represents the unit in the floor map.\n\n### `health` _(number)_\n\nThe total damage the unit may take before dying, in HP.\n\n### `maxHealth` _(number)_\n\nThe maximum `health` value.\n\n### `reward` _(number)_\n\nThe number of points to reward when interacting.\n\n### `enemy` _(boolean)_\n\nWhether the unit belongs to the enemy side or not.\n\n### `bound` _(boolean)_\n\nWhether the unit is bound or not.\n"
  },
  {
    "path": "docs/player/abilities.md",
    "content": "---\nid: abilities\ntitle: Abilities\n---\n\nAn **ability** is a skill possessed by a unit. As the player, you activate\nabilities during your warrior's turn.\n\n> Ability selection is the way you can customize how your warrior plays.\n\n## Learning new abilities\n\nWhen you first start, your warrior will only have a few abilities. Additional\nabilities are acquired progressing through the tower. With each level, you'll\nlearn new things or find artifacts that will expand your capabilities.\n\n## Ability types\n\nThere are two types of abilities: actions and senses.\n\n### Actions\n\nAn **action** is an ability that affects the game in some way. Is through\nactions that you're able to inflict damage, protect yourself or other units, and\ninteract with your environment.\n\n> Only one action can be performed per turn, so choose wisely.\n\n### Senses\n\nA **sense**, on the contrary, doesn't affect the game but gathers information\nabout the floor. You can perform senses as often as you want per turn to collect\ninformation about your surroundings and to aid you in choosing the best action\naccording to the circumstances.\n\n> Since what you sense will change each turn, you may want to record the\n> information you gather for use on the next turn. For example, you can\n> determine if you are being attacked if your health has gone down since the\n> last turn.\n"
  },
  {
    "path": "docs/player/ai-tips.md",
    "content": "---\nid: ai-tips\nsidebar_label: Artificial Intelligence\ntitle: AI Tips\n---\n\n- Once you've made some progress in the tower, your code may have turned into a\n  bunch of nested if/else statements. If that's the case, you may want to apply\n  some AI concepts like\n  [FSMs and the State pattern](http://gameprogrammingpatterns.com/state.html),\n  or more trendy things like\n  [Behavior Trees](https://www.gamasutra.com/blogs/ChrisSimpson/20140717/221339/Behavior_trees_for_AI_How_they_work.php).\n"
  },
  {
    "path": "docs/player/cli-tips.md",
    "content": "---\nid: cli-tips\nsidebar_label: CLI\ntitle: CLI Tips\n---\n\n- Running `warriorjs` while you are in your profile's directory will auto-select\n  that profile so you don't have to each time.\n\n- Make sure to try the different options you can pass to the `warriorjs`\n  command. Run `warriorjs --help` to see them all.\n\n* If you're on Windows, consider using [cmder](http://cmder.net) instead of\n  `cmd.exe`.\n"
  },
  {
    "path": "docs/player/effects.md",
    "content": "---\nid: effects\ntitle: Effects\n---\n\nAn **effect** is any kind of status that affects a unit. They're generally\napplied by actions and can have a positive or negative impact. Most effects are\ntemporary.\n"
  },
  {
    "path": "docs/player/epic-mode.md",
    "content": "---\nid: epic-mode\ntitle: Epic Mode\n---\n\nOnce you reach the top of the tower, you'll have the option to enter **epic\nmode**. If you choose so, running `warriorjs` again will run your current\n`Player.js` through all levels in the tower without stopping.\n\nYou'll most likely not succeed the first time around in epic mode. If that's the\ncase, you'll need to make adjustments to your warrior by editing `Player.js`.\n\nOnce your warrior reaches the top again, you will receive a grade for each\nlevel, along with an average grade for the tower. The grades, from best to\nworst, are: S, A, B, C, D, and F. Try to get an S on each level for the ultimate\nscore!\n"
  },
  {
    "path": "docs/player/gameplay.md",
    "content": "---\nid: gameplay\ntitle: Gameplay\n---\n\nThe play happens through a series of turns. On each one and starting with your\nwarrior, the units in the floor will have the chance to use their abilities.\n\n## Code\n\nOpen the `Player.js` file in your profile's directory. You should see some\nstarting code:\n\n```js\nclass Player {\n  playTurn(warrior) {\n    // Cool code goes here.\n  }\n}\n```\n\nYou need to fill the `playTurn` method with logic to teach the warrior what to\ndo depending on the situation.\n\nSee the README in your profile's directory for details on what's on the current\nlevel and what abilities your warrior has available to deal with it.\n\nHere is an example from the \"The Narrow Path\" tower which will instruct the warrior\nto walk if there's nothing ahead, otherwise attack:\n\n```js\nclass Player {\n  playTurn(warrior) {\n    if (warrior.feel().isEmpty()) {\n      warrior.walk();\n    } else {\n      warrior.attack();\n    }\n  }\n}\n```\n\n> This is assuming your warrior has \"attack\", \"feel\", and \"walk\" abilities\n> available.\n\n## Play\n\nOnce you're done editing `Player.js`, save the file and run the `warriorjs`\ncommand again to start playing the level.\n\nYou cannot change your code in the middle of a level, so you must take into\naccount everything that may happen on that level and give your warrior the\nproper instructions from the start.\n\n## Outcome\n\nLosing all of your health will cause you to fail the level. You're not punished\nby this; just go back to the `Player.js` file, improve your code, and try again.\n\nOnce you pass a level (by reaching the stairs), the README will be updated for\nthe next level. Alter the `Player.js` file and run `warriorjs` again to play the\nnext level.\n"
  },
  {
    "path": "docs/player/general-tips.md",
    "content": "---\nid: general-tips\nsidebar_label: General\ntitle: General Tips\n---\n\n- **If you ever get stuck on a level, review the README** and be sure you're\n  trying each ability out.\n\n- **If you can't keep your health up, be sure to rest when no enemy is around**\n  (while keeping an eye on your health). Also, try to use far-ranged weapons\n  whenever possible (such as the bow in the \"The Narrow Path\" tower).\n\n- **Senses are cheap, so use them liberally.** Store the sensed information to\n  help you better determine what actions to take in the future.\n\n- **If you're aiming for points, remember to sweep the area.** Even if you're\n  close to the stairs, don't go in until you've gotten everything (if you have\n  the health). Use far-ranged senses (such as \"look\" and \"listen\" in the \"The\n  Narrow Path\" tower) to determine if there are any enemies left.\n"
  },
  {
    "path": "docs/player/install.md",
    "content": "---\nid: install\ntitle: Install\n---\n\nLet's start by installing WarriorJS globally with [npm](https://npmjs.com).\n\nOpen the terminal and run:\n\n```sh\nnpm install --global @warriorjs/cli\n```\n\n> **IMPORTANT:** [Node.js](https://nodejs.org) >=8 needs to be installed in your\n> computer before running the `npm` command. The recommended installation method\n> is through the [official installer](https://nodejs.org/en/download).\n\nAfter installing the game, you can execute it by running the `warriorjs` command\nin the terminal:\n\n```sh\nwarriorjs\n```\n\nThat's it! This will guide you through the creation of your warrior. Give your\nwarrior a proper name and choose the \"The Narrow Path\" tower.\n\nAfter you've done that, you should have the following file structure:\n\n```sh\nwarriorjs\n└── aldric-the-narrow-path\n    ├── Player.js\n    └── README.md\n```\n\n- `aldric-the-narrow-path` is your profile's directory.\n- `Player.js` is your warrior's brain, you'll be editing this file often.\n- `README.md` contains the instructions for the current level.\n\nGo ahead and open `README.md` to find the instructions for the first level.\n"
  },
  {
    "path": "docs/player/js-tips.md",
    "content": "---\nid: js-tips\nsidebar_label: JavaScript\ntitle: JavaScript Tips\n---\n\n- Don't simply fill up the `playTurn` method with a lot of code, **organize your\n  code with methods and classes**. For example:\n\n```js\nclass Player {\n  playTurn(warrior) {\n    if (this.isInjured(warrior)) {\n      warrior.rest();\n    }\n  }\n\n  isInjured(warrior) {\n    return warrior.health() < 20;\n  }\n}\n```\n\n- If you want some code to be executed at the beginning of each level, **define\n  a [constructor][] in the `Player` class**, like this:\n\n```js\nclass Player {\n  constructor() {\n    // This code will be executed only once, at the beginning of the level.\n    this.health = 20;\n  }\n\n  // ...\n}\n```\n\n- You can call methods of the Space API directly after a sense. For example, the\n  \"feel\" sense in the \"The Narrow Path\" tower returns one space. You can call\n  `isEmpty()` on this to determine if the space is clear before walking there:\n\n```js\nclass Player {\n  playTurn(warrior) {\n    if (warrior.feel().isEmpty()) {\n      warrior.walk();\n    }\n  }\n}\n```\n\n- Some senses (like \"look\" and \"listen\" in the \"The Narrow Path\" tower) return an\n  array of spaces instead, so **you might find many of the [Array prototype\n  methods][] really useful**. Here is an example of the [Array.prototype.find][]\n  method:\n\n```js\nclass Player {\n  // ...\n\n  isEnemyInSight(warrior) {\n    const spaceWithUnit = warrior.look().find(space => space.isUnit());\n    return spaceWithUnit && spaceWithUnit.getUnit().isEnemy();\n  }\n}\n```\n\n[constructor]:\n  https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/constructor\n[array prototype methods]:\n  https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/prototype#Methods\n[array.prototype.find]:\n  https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find\n"
  },
  {
    "path": "docs/player/object.md",
    "content": "---\nid: object\ntitle: Object\n---\n\nThe goal of the game is to climb to the top of a tower. You progress through the\ntower by reaching the stairs on each level, but the higher you are, the more\ndifficult it gets. With each level, your abilities will grow along with the\ndifficulty. But it's up to you to put those abilities to good use and make your\nwarrior smart enough to face the dangers that stand between him or her and the\nstairs.\n"
  },
  {
    "path": "docs/player/options.md",
    "content": "---\nid: options\ntitle: Options\n---\n\nThere are various options you can pass to the `warriorjs` command to customize\nthe game. You can run `warriorjs --help` to see all the available options.\n\nHere is a detailed list:\n\n## `--directory <path>`\n\nPath to a directory under which to run the game. By default, the current working\ndirectory is used.\n\n## `--level <number>` (epic mode only)\n\nPractice a level. Use this option on levels you are having difficulty or want to\nfine-tune the scoring.\n\n## `--silent`\n\nSuppress play log. Use this option if you just care about the outcome of playing\na level, and not each step of the play.\n\n## `--time <seconds>`\n\nDelay each turn by seconds. By default, each step of each turn is delayed by 0.6\nseconds.\n\n## `--yes`\n\nAssume yes in non-destructive confirmation dialogs.\n"
  },
  {
    "path": "docs/player/overview.md",
    "content": "---\nid: overview\ntitle: Overview\n---\n\nIn WarriorJS, you are a warrior climbing a tall tower to reach _The JavaScript\nSword_ at the top level. Legend has it that the sword bearer becomes enlightened\nin the JavaScript language, but be warned: the journey will not be easy. On each\nfloor, you need to write JavaScript to instruct the warrior to battle enemies,\nrescue captives, and reach the stairs alive...\n\n**No matter if you are new to programming or a JavaScript guru, WarriorJS will\nput your skills to the test. Will you dare?**\n"
  },
  {
    "path": "docs/player/perspective.md",
    "content": "---\nid: perspective\ntitle: Perspective\n---\n\nEven though this is a text-based game, think of it as two-dimensional where you\nare viewing the level's floor from overhead.\n\nEach floor is always rectangular in shape and is made up of a number of squares.\nAt its edge there are walls; you can't move there. You can move to any other\nsquare, including the stairs, that isn't already occupied by another unit (only\none unit can be on a given square at a time).\n\nHere is an example of a floor map and key:\n\n```\n╔════╗\n║C s>║\n║ S s║\n║C @ ║\n╚════╝\n\n> = stairs\n@ = Aldric (20 HP)\ns = Sludge (12 HP)\nS = Thick Sludge (24 HP)\nC = Captive (1 HP)\n```\n"
  },
  {
    "path": "docs/player/scoring.md",
    "content": "---\nid: scoring\ntitle: Scoring\n---\n\nYour objective is to not only reach the stairs, but to get the highest score you\ncan.\n\nThere are many ways you can earn points on a level:\n\n- **Defeat an enemy** to add his max health to your score.\n\n- **Rescue a captive** to earn a reward.\n\n- **Pass the level within the bonus time** to earn the amount of bonus time\n  remaining (each level has a bonus time that decreases turn by turn).\n\n- **Defeat all enemies and rescue all captives** to receive a 20% overall bonus.\n\nBut you must be careful, because you can also lose points:\n\n- **Kill a captive** and you'll receive a penalty.\n\nA total score is kept as you progress through the levels. When you pass a level,\nthat score is added to your total.\n"
  },
  {
    "path": "docs/player/space-api.md",
    "content": "---\nid: space-api\ntitle: Space API\n---\n\nWhenever you sense an area, often one or multiple spaces (in an array) will be\nreturned. For example, the \"feel\" sense in the \"The Narrow Path\" tower returns one\nspace:\n\n```js\nconst space = warrior.feel();\n```\n\nYou can call methods on a space to gather information about what's there.\n\n## Class Methods\n\nHere are the various methods that are available to you:\n\n### `space.getLocation()`:\n\nReturns the relative location of this space as the number of spaces forward and\nto the right of your position.\n\n**Returns**\n\n_(number[])_: The relative location of this space as the offset\n`[forward, right]`.\n\n### `space.isEmpty()`:\n\nDetermines if nothing (except maybe stairs) is at this space.\n\n**Returns**\n\n_(boolean)_: Whether this space is empty or not.\n\n### `space.isStairs()`\n\nDetermines if the stairs are at this space.\n\n**Returns**\n\n_(boolean)_: Whether the stairs are at this space or not.\n\n### `space.isWall()`\n\nDetermines if this is the edge of the level.\n\n**Returns**\n\n_(boolean)_: Whether this space is a wall or not.\n\n### `space.isUnit()`\n\nDetermines if there's a unit at this space.\n\n**Returns**\n\n_(boolean)_: Whether a unit is at this space or not.\n\n### `space.getUnit()`\n\nReturns the unit located at this space (if any).\n\n**Returns**\n\n_(Unit)_: The unit at this location or `undefined` if there's none.\n"
  },
  {
    "path": "docs/player/spaces.md",
    "content": "---\nid: spaces\ntitle: Spaces\n---\n\nA **space** is an object representing a square in the floor.\n\nA space can be empty, or it can have a wall or a unit located at it. One of the\nspaces in the floor has the stairs, which you need to climb to move on to the\nnext level.\n"
  },
  {
    "path": "docs/player/towers.md",
    "content": "---\nid: towers\ntitle: Towers\n---\n\nA **tower** is a WarriorJS world. In addition to defining levels, towers can\nalso add new abilities, effects, and units to the game.\n\nWarriorJS CLI ships with an entry-level tower built-in. You'll need to install\nany additional tower you want to play.\n\n## Installing Towers\n\nTowers are automatically loaded if you have them installed in the same\n`node_modules` directory where `@warriorjs/cli` is located. This means that if\nyou have installed the game globally, you'll need to install additional towers\nglobally. If, on the other hand, you're running the game from a local\ninstallation, you'll need to install additional towers locally.\n\nTower package names start with `@warriorjs/tower-` for official towers, or\n`warriorjs-tower-` for community towers.\n\n### Official Towers\n\n- [`@warriorjs/tower-the-narrow-path`][warriorjs-tower-the-narrow-path]\n- [`@warriorjs/tower-the-powder-keep`][warriorjs-tower-the-powder-keep] (beta)\n\n### Community Towers\n\nHave you made a tower? [Add it][add-community-tower] to the list!\n\n## Making Towers\n\nFollow this [guide](maker/introduction.md).\n\n[warriorjs-tower-the-narrow-path]:\n  https://github.com/olistic/warriorjs/tree/master/towers/the-narrow-path\n[warriorjs-tower-the-powder-keep]:\n  https://github.com/olistic/warriorjs/tree/master/towers/the-powder-keep\n[add-community-tower]:\n  https://github.com/olistic/warriorjs/edit/master/docs/player/towers.md\n"
  },
  {
    "path": "docs/player/turn-api.md",
    "content": "---\nid: turn-api\ntitle: Turn API\n---\n\nThe `playTurn` method in `Player.js` gets passed an instance of your warrior's\nturn. The methods you can call on that turn are determined by the abilities your\nwarrior has available in the current level. See the README in your profile's\ndirectory to find that out.\n\nHere is an example extracted from the README of the second level in the \"Baby\nSteps\" tower:\n\n```markdown\n### Abilities\n\n#### Actions\n\n- `warrior.attack()`\n- `warrior.walk()`\n\n#### Senses\n\n- `warrior.feel()`\n```\n\nIn this level, your warrior has the abilities \"attack\", \"feel\", and \"walk\",\nwhich means you can call these three methods on your turn: `warrior.attack()`,\n`warrior.feel()`, and `warrior.walk()`.\n\n> Many abilities can be performed in the following directions: \"forward\",\n> \"backward\", \"left\", and \"right\". You have to pass a string with the direction\n> as the first argument, e.g. `warrior.walk('backward')`.\n"
  },
  {
    "path": "docs/player/unit-api.md",
    "content": "---\nid: unit-api\ntitle: Unit API\n---\n\nYou can call `getUnit()` on a space to retrieve the unit located there (but keep\nin mind that not all spaces have units on them):\n\n```js\nconst unit = space.getUnit();\n```\n\nYou can call methods on a unit to know more about it.\n\n## Class Methods\n\nHere are the various methods that are available to you:\n\n### `unit.isBound()`\n\nDetermines if the unit is bound.\n\n**Returns**\n\n_(boolean)_: Whether this unit is bound or not.\n\n### `unit.isEnemy()`:\n\nDetermines if the unit is an enemy.\n\n**Returns**\n\n_(boolean)_: Whether this is an enemy unit or not.\n"
  },
  {
    "path": "docs/player/units.md",
    "content": "---\nid: units\ntitle: Units\n---\n\nA **unit** is any character that populates the floors of the tower, including\nyour warrior.\n\n## Attributes\n\nA unit has the following attributes:\n\n- **Health**: the total damage the unit may take before dying, measured in\n  Health Points (HP).\n- **Max Health**: the starting Health value.\n\n## Abilities & Effects\n\nA unit can also have abilities and be under effects.\n"
  },
  {
    "path": "docs/player/warrior.md",
    "content": "---\nid: warrior\ntitle: Warrior\n---\n\nThe **warrior** is the player-character in WarriorJS, which means it's\ncontrolled by you. To create a warrior, you'll be guided through a two-step\nprocess where you'll determine your warrior's name and the tower you want to\nclimb. Once created, you'll use the warrior to progress through that tower.\n\n> The warrior and tower combination is usually referred to as a **profile**. The\n> number of profiles you can have is unlimited, but you can't use the same\n> warrior/tower combination twice.\n\nA warrior has the same attributes that any other unit. It also has abilities and\ncan be under effects.\n"
  },
  {
    "path": "lefthook.yml",
    "content": "pre-commit:\n  commands:\n    lint:\n      glob: \"*.{js,ts,json,css,md,yaml}\"\n      run: pnpm biome check --write --no-errors-on-unmatched {staged_files}\n      stage_fixed: true\n"
  },
  {
    "path": "libs/README.md",
    "content": "# Packages\n\nShared libraries that power the WarriorJS game engine.\n\n| Package                                                                   | Version                                                                                  |\n| ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |\n| [`@warriorjs/core`][warriorjs-core]                                       | [![npm][warriorjs-core-badge]][warriorjs-core-npm]                                       |\n| [`@warriorjs/scoring`][warriorjs-scoring]                                 | [![npm][warriorjs-scoring-badge]][warriorjs-scoring-npm]                                 |\n| [`@warriorjs/spatial`][warriorjs-spatial]                                 | [![npm][warriorjs-spatial-badge]][warriorjs-spatial-npm]                                 |\n| [`@warriorjs/abilities`][warriorjs-abilities]                             | [![npm][warriorjs-abilities-badge]][warriorjs-abilities-npm]                             |\n| [`@warriorjs/effects`][warriorjs-effects]                                 | [![npm][warriorjs-effects-badge]][warriorjs-effects-npm]                                 |\n| [`@warriorjs/units`][warriorjs-units]                                     | [![npm][warriorjs-units-badge]][warriorjs-units-npm]                                     |\n\n- [`@warriorjs/core`][warriorjs-core] is where the game mechanics are\n  implemented; it exposes the `warriorjs.runLevel` function to run the given\n  level with the given code and return the result of that run.\n- [`@warriorjs/scoring`][warriorjs-scoring] exposes functions for computing\n  level scores and converting numeric grades to letter grades.\n- [`@warriorjs/spatial`][warriorjs-spatial] exposes some constants and\n  functions related to directioning (absolute and relative) of units and\n  abilities in the game.\n- [`@warriorjs/abilities`][warriorjs-abilities] defines the abilities that are\n  used in the official towers.\n- [`@warriorjs/effects`][warriorjs-effects] defines the effects that are used in\n  the official towers.\n- [`@warriorjs/units`][warriorjs-units] defines the units that are used in the\n  official towers.\n\n[warriorjs-core]: /libs/core\n[warriorjs-core-badge]:\n  https://img.shields.io/npm/v/@warriorjs/core.svg?style=flat-square\n[warriorjs-core-npm]: https://www.npmjs.com/package/@warriorjs/core\n[warriorjs-scoring]: /libs/scoring\n[warriorjs-scoring-badge]:\n  https://img.shields.io/npm/v/@warriorjs/scoring.svg?style=flat-square\n[warriorjs-scoring-npm]:\n  https://www.npmjs.com/package/@warriorjs/scoring\n[warriorjs-spatial]: /libs/spatial\n[warriorjs-spatial-badge]:\n  https://img.shields.io/npm/v/@warriorjs/spatial.svg?style=flat-square\n[warriorjs-spatial-npm]: https://www.npmjs.com/package/@warriorjs/spatial\n[warriorjs-abilities]: /libs/abilities\n[warriorjs-abilities-badge]:\n  https://img.shields.io/npm/v/@warriorjs/abilities.svg?style=flat-square\n[warriorjs-abilities-npm]: https://www.npmjs.com/package/@warriorjs/abilities\n[warriorjs-effects]: /libs/effects\n[warriorjs-effects-badge]:\n  https://img.shields.io/npm/v/@warriorjs/effects.svg?style=flat-square\n[warriorjs-effects-npm]: https://www.npmjs.com/package/@warriorjs/effects\n[warriorjs-units]: /libs/units\n[warriorjs-units-badge]:\n  https://img.shields.io/npm/v/@warriorjs/units.svg?style=flat-square\n[warriorjs-units-npm]: https://www.npmjs.com/package/@warriorjs/units\n"
  },
  {
    "path": "libs/abilities/README.md",
    "content": "# @warriorjs/abilities\n\n> WarriorJS official abilities.\n\n## [Actions][actions]\n\n### `unit.attack([direction])`:\n\nAttack a unit in the given direction (forward by default) dealing `[power]` HP\nof damage.\n\n### `unit.bind([direction])`:\n\nBind a unit in the given direction (forward by default) to keep him from moving.\n\n### `unit.detonate([direction])`:\n\nDetonate a bomb in a given direction (forward by default) dealing\n`[targetPower]` HP of damage to that space and `[surroundingPower]` HP of damage\nto surrounding 4 spaces (including yourself).\n\n### `unit.pivot([direction])`:\n\nRotate in the given direction (backward by default).\n\n### `unit.rescue([direction])`:\n\nRelease a unit from his chains in the given direction (forward by default).\n\n### `unit.rest()`:\n\nGain `[healthGainPercentage]` of max health back, but do nothing more.\n\n### `unit.shoot([direction])`:\n\nShoot your bow & arrow in the given direction (forward by default) dealing\n`[power]` HP of damage to the first unit in a range of `[range]` spaces.\n\n### `unit.walk([direction])`:\n\nMove one space in the given direction (forward by default).\n\n## [Senses][senses]\n\n### `unit.directionOf(space)`:\n\nReturn the direction (forward, right, backward or left) to the given\n[space][spaces].\n\n### `unit.directionOfStairs()`:\n\nReturn the direction (forward, right, backward or left) the stairs are from your\nlocation.\n\n### `unit.distanceOf(space)`:\n\nReturn an integer representing the distance to the given [space][spaces].\n\n### `unit.feel([direction])`:\n\nReturn the adjacent [space][spaces] in the given direction (forward by default).\n\n### `unit.health()`:\n\nReturn an integer representing your health.\n\n### `unit.listen()`:\n\nReturn an array of all [spaces][spaces] which have units in them (excluding\nyourself).\n\n### `unit.look([direction])`:\n\nReturns an array of up to `[range]` [spaces][spaces] in the given direction\n(forward by default).\n\n### `unit.think(thought)`:\n\nThink out loud (`console.log` replacement).\n\n[actions]: https://warrior.js.org/docs/player/abilities#actions\n[senses]: https://warrior.js.org/docs/player/abilities#senses\n[spaces]: https://warrior.js.org/docs/player/spaces\n"
  },
  {
    "path": "libs/abilities/package.json",
    "content": "{\n  \"name\": \"@warriorjs/abilities\",\n  \"version\": \"0.13.0\",\n  \"description\": \"WarriorJS base abilities\",\n  \"author\": \"Matias Olivera <hi@matiasolivera.com>\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://warrior.js.org\",\n  \"repository\": \"https://github.com/olistic/warriorjs/tree/master/libs/abilities\",\n  \"type\": \"module\",\n  \"main\": \"dist/index.js\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.js\",\n      \"types\": \"./dist/index.d.ts\"\n    }\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"engines\": {\n    \"node\": \">=24\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc -p tsconfig.build.json\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@warriorjs/core\": \"workspace:^\",\n    \"@warriorjs/spatial\": \"workspace:^\"\n  }\n}\n"
  },
  {
    "path": "libs/abilities/src/Attack.test.ts",
    "content": "import { Action } from '@warriorjs/core';\nimport { BACKWARD, FORWARD, LEFT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Attack from './Attack.js';\n\ndescribe('Attack', () => {\n  let attack: Attack;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = {\n      damage: vi.fn(),\n      log: vi.fn(),\n    };\n    attack = new Attack(unit, { power: 3 });\n  });\n\n  test('is an action', () => {\n    expect(attack).toBeInstanceOf(Action);\n  });\n\n  test('has a description', () => {\n    expect(attack.description).toBe(\n      `Attacks a unit in the given direction (\\`'${FORWARD}'\\` by default), dealing 3 HP of damage.`,\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(attack.meta).toEqual({\n      params: [{ name: 'direction', type: 'Direction', optional: true }],\n      returns: 'void',\n    });\n  });\n\n  test('.with() returns an AbilityBinding', () => {\n    const binding = Attack.with({ power: 5 });\n    expect(binding).toEqual([Attack, { power: 5 }]);\n  });\n\n  describe('performing', () => {\n    test('attacks forward by default', () => {\n      unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null }));\n      attack.perform();\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD);\n    });\n\n    test('allows to specify direction', () => {\n      unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null }));\n      attack.perform(LEFT);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(LEFT);\n    });\n\n    test('misses if no receiver', () => {\n      unit.getSpaceAt = () => ({ getUnit: () => null });\n      attack.perform();\n      expect(unit.log).toHaveBeenCalledWith(`attacks ${FORWARD} and hits nothing`);\n      expect(unit.damage).not.toHaveBeenCalled();\n    });\n\n    describe('with receiver', () => {\n      beforeEach(() => {\n        unit.getSpaceAt = () => ({ getUnit: () => 'receiver' });\n      });\n\n      test('damages receiver', () => {\n        attack.perform();\n        expect(unit.log).toHaveBeenCalledWith(`attacks ${FORWARD} and hits receiver`);\n        expect(unit.damage).toHaveBeenCalledWith('receiver', 3);\n      });\n\n      test('reduces power when attacking backward', () => {\n        attack.perform(BACKWARD);\n        expect(unit.log).toHaveBeenCalledWith(`attacks ${BACKWARD} and hits receiver`);\n        expect(unit.damage).toHaveBeenCalledWith('receiver', 2);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Attack.ts",
    "content": "import { type AbilityBinding, Action } from '@warriorjs/core';\nimport { BACKWARD, FORWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport { type AbilityMeta, type Unit } from './types.js';\n\nconst defaultDirection = FORWARD;\n\ninterface AttackConfig {\n  power: number;\n}\n\nclass Attack extends Action {\n  readonly description: string;\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'void',\n  };\n\n  private power: number;\n\n  constructor(unit: Unit, { power }: AttackConfig) {\n    super(unit);\n    this.description = `Attacks a unit in the given direction (\\`'${defaultDirection}'\\` by default), dealing ${power} HP of damage.`;\n    this.power = power;\n  }\n\n  perform(direction: RelativeDirection = defaultDirection): void {\n    const receiver = this.unit.getSpaceAt(direction).getUnit();\n    if (receiver) {\n      this.unit.log(`attacks ${direction} and hits ${receiver}`);\n      const attackingBackward = direction === BACKWARD;\n      const amount = attackingBackward ? Math.ceil(this.power / 2.0) : this.power;\n      this.unit.damage(receiver, amount);\n    } else {\n      this.unit.log(`attacks ${direction} and hits nothing`);\n    }\n  }\n\n  static with(config: AttackConfig): AbilityBinding {\n    return [Attack, config];\n  }\n}\n\nexport default Attack;\n"
  },
  {
    "path": "libs/abilities/src/Bind.test.ts",
    "content": "import { Action } from '@warriorjs/core';\nimport { FORWARD, LEFT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Bind from './Bind.js';\n\ndescribe('Bind', () => {\n  let bind: Bind;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = { log: vi.fn() };\n    bind = new Bind(unit);\n  });\n\n  test('is an action', () => {\n    expect(bind).toBeInstanceOf(Action);\n  });\n\n  test('has a description', () => {\n    expect(bind.description).toBe(\n      `Binds a unit in the given direction (\\`'${FORWARD}'\\` by default) to keep them from moving.`,\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(bind.meta).toEqual({\n      params: [{ name: 'direction', type: 'Direction', optional: true }],\n      returns: 'void',\n    });\n  });\n\n  describe('performing', () => {\n    test('binds forward by default', () => {\n      unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null }));\n      bind.perform();\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD);\n    });\n\n    test('allows to specify direction', () => {\n      unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null }));\n      bind.perform(LEFT);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(LEFT);\n    });\n\n    test('misses if no receiver', () => {\n      unit.getSpaceAt = () => ({ getUnit: () => null });\n      bind.perform();\n      expect(unit.log).toHaveBeenCalledWith(`binds ${FORWARD} and restricts nothing`);\n    });\n\n    describe('with receiver', () => {\n      let receiver: any;\n\n      beforeEach(() => {\n        receiver = {\n          bind: vi.fn(),\n          toString: () => 'receiver',\n        };\n        unit.getSpaceAt = () => ({ getUnit: () => receiver });\n      });\n\n      test('binds receiver', () => {\n        bind.perform();\n        expect(unit.log).toHaveBeenCalledWith(`binds ${FORWARD} and restricts receiver`);\n        expect(receiver.bind).toHaveBeenCalled();\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Bind.ts",
    "content": "import { Action } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport { type AbilityMeta } from './types.js';\n\nconst defaultDirection = FORWARD;\n\nclass Bind extends Action {\n  readonly description =\n    `Binds a unit in the given direction (\\`'${defaultDirection}'\\` by default) to keep them from moving.`;\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'void',\n  };\n\n  perform(direction: RelativeDirection = defaultDirection): void {\n    const receiver = this.unit.getSpaceAt(direction).getUnit();\n    if (receiver) {\n      this.unit.log(`binds ${direction} and restricts ${receiver}`);\n      receiver.bind();\n    } else {\n      this.unit.log(`binds ${direction} and restricts nothing`);\n    }\n  }\n}\n\nexport default Bind;\n"
  },
  {
    "path": "libs/abilities/src/Detonate.test.ts",
    "content": "import { Action } from '@warriorjs/core';\nimport { FORWARD, LEFT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Detonate from './Detonate.js';\n\ndescribe('Detonate', () => {\n  let detonate: Detonate;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = {\n      damage: vi.fn(),\n      isUnderEffect: () => false,\n      log: vi.fn(),\n    };\n    detonate = new Detonate(unit, { targetPower: 4, surroundingPower: 2 });\n  });\n\n  test('is an action', () => {\n    expect(detonate).toBeInstanceOf(Action);\n  });\n\n  test('has a description', () => {\n    expect(detonate.description).toBe(\n      `Detonates a bomb in a given direction (\\`'${FORWARD}'\\` by default), dealing 4 HP of damage to that space and 2 HP of damage to surrounding 4 spaces (including yourself).`,\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(detonate.meta).toEqual({\n      params: [{ name: 'direction', type: 'Direction', optional: true }],\n      returns: 'void',\n    });\n  });\n\n  test('.with() returns an AbilityBinding', () => {\n    const binding = Detonate.with({ targetPower: 4, surroundingPower: 2 });\n    expect(binding).toEqual([Detonate, { targetPower: 4, surroundingPower: 2 }]);\n  });\n\n  describe('performing', () => {\n    test('detonates forward by default', () => {\n      unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null }));\n      detonate.perform();\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD);\n    });\n\n    test('allows to specify direction', () => {\n      unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null }));\n      detonate.perform(LEFT);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(LEFT);\n    });\n\n    test('damages receivers depending on their position', () => {\n      const targetReceiver = { isUnderEffect: () => false };\n      const surroundingReceiver = { isUnderEffect: () => false };\n      unit.getSpaceAt = vi\n        .fn()\n        .mockReturnValueOnce({ getUnit: () => targetReceiver })\n        .mockReturnValueOnce({ getUnit: () => null })\n        .mockReturnValueOnce({ getUnit: () => null })\n        .mockReturnValueOnce({ getUnit: () => surroundingReceiver })\n        .mockReturnValueOnce({ getUnit: () => unit });\n      detonate.perform();\n      expect(unit.log).toHaveBeenCalledWith(\n        `detonates a bomb ${FORWARD} launching a deadly explosion`,\n      );\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 1, 1);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 1, -1);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 2, 0);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 0, 0);\n      expect(unit.damage).toHaveBeenCalledWith(targetReceiver, 4);\n      expect(unit.damage).toHaveBeenCalledWith(surroundingReceiver, 2);\n      expect(unit.damage).toHaveBeenCalledWith(unit, 2);\n    });\n\n    test('triggers ticking effect on receivers under effect', () => {\n      const receiver = {\n        isUnderEffect: () => true,\n        triggerEffect: vi.fn(),\n        log: vi.fn(),\n      };\n      unit.getSpaceAt = vi\n        .fn()\n        .mockReturnValueOnce({ getUnit: () => receiver })\n        .mockReturnValue({ getUnit: () => null });\n      detonate.perform();\n      expect(receiver.log).toHaveBeenCalledWith(\n        'caught in the blast, detonating the ticking explosive',\n      );\n      expect(receiver.triggerEffect).toHaveBeenCalledWith('ticking');\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Detonate.ts",
    "content": "import { type AbilityBinding, Action } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection, type RelativeOffset } from '@warriorjs/spatial';\n\nimport { type AbilityMeta, type Space, type Unit } from './types.js';\n\nconst defaultDirection = FORWARD;\nconst surroundingOffsets: RelativeOffset[] = [\n  [1, 1],\n  [1, -1],\n  [2, 0],\n  [0, 0],\n];\n\ninterface DetonateConfig {\n  targetPower: number;\n  surroundingPower: number;\n}\n\nclass Detonate extends Action {\n  readonly description: string;\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'void',\n  };\n\n  private targetPower: number;\n  private surroundingPower: number;\n\n  constructor(unit: Unit, { targetPower, surroundingPower }: DetonateConfig) {\n    super(unit);\n    this.description = `Detonates a bomb in a given direction (\\`'${defaultDirection}'\\` by default), dealing ${targetPower} HP of damage to that space and ${surroundingPower} HP of damage to surrounding 4 spaces (including yourself).`;\n    this.targetPower = targetPower;\n    this.surroundingPower = surroundingPower;\n  }\n\n  perform(direction: RelativeDirection = defaultDirection): void {\n    this.unit.log(`detonates a bomb ${direction} launching a deadly explosion`);\n    const targetSpace = this.unit.getSpaceAt(direction);\n    this.bomb(targetSpace, this.targetPower);\n    surroundingOffsets\n      .map(([forward, right]) => this.unit.getSpaceAt(direction, forward, right))\n      .forEach((surroundingSpace) => {\n        this.bomb(surroundingSpace, this.surroundingPower);\n      });\n  }\n\n  private bomb(space: Space, power: number): void {\n    const receiver = space.getUnit();\n    if (receiver) {\n      this.unit.damage(receiver, power);\n      if (receiver.isUnderEffect('ticking')) {\n        receiver.log('caught in the blast, detonating the ticking explosive');\n        receiver.triggerEffect('ticking');\n      }\n    }\n  }\n\n  static with(config: DetonateConfig): AbilityBinding {\n    return [Detonate, config];\n  }\n}\n\nexport default Detonate;\n"
  },
  {
    "path": "libs/abilities/src/DirectionOf.test.ts",
    "content": "import { Sense } from '@warriorjs/core';\nimport { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport DirectionOf from './DirectionOf.js';\n\ndescribe('DirectionOf', () => {\n  let directionOf: DirectionOf;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = { getDirectionOf: vi.fn() };\n    directionOf = new DirectionOf(unit);\n  });\n\n  test('is a sense', () => {\n    expect(directionOf).toBeInstanceOf(Sense);\n  });\n\n  test('has a description', () => {\n    expect(directionOf.description).toBe(\n      `Returns the direction (${FORWARD}, ${RIGHT}, ${BACKWARD} or ${LEFT}) to the given space.`,\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(directionOf.meta).toEqual({\n      params: [{ name: 'space', type: 'Space' }],\n      returns: 'Direction',\n    });\n  });\n\n  describe('performing', () => {\n    test('returns direction of specified space', () => {\n      unit.getDirectionOf.mockReturnValue(RIGHT);\n      expect(directionOf.perform({} as any)).toBe(RIGHT);\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/DirectionOf.ts",
    "content": "import { Sense, type SensedSpace } from '@warriorjs/core';\nimport { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial';\n\nimport { type AbilityMeta } from './types.js';\n\nclass DirectionOf extends Sense {\n  readonly description =\n    `Returns the direction (${FORWARD}, ${RIGHT}, ${BACKWARD} or ${LEFT}) to the given space.`;\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'space', type: 'Space' }],\n    returns: 'Direction',\n  };\n\n  perform(space: SensedSpace) {\n    return this.unit.getDirectionOf(space);\n  }\n}\n\nexport default DirectionOf;\n"
  },
  {
    "path": "libs/abilities/src/DirectionOfStairs.test.ts",
    "content": "import { Sense } from '@warriorjs/core';\nimport { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport DirectionOfStairs from './DirectionOfStairs.js';\n\ndescribe('DirectionOfStairs', () => {\n  let directionOfStairs: DirectionOfStairs;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = { getDirectionOfStairs: vi.fn() };\n    directionOfStairs = new DirectionOfStairs(unit);\n  });\n\n  test('is a sense', () => {\n    expect(directionOfStairs).toBeInstanceOf(Sense);\n  });\n\n  test('has a description', () => {\n    expect(directionOfStairs.description).toBe(\n      `Returns the direction (${FORWARD}, ${RIGHT}, ${BACKWARD} or ${LEFT}) the stairs are from your location.`,\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(directionOfStairs.meta).toEqual({\n      params: [],\n      returns: 'Direction',\n    });\n  });\n\n  describe('performing', () => {\n    test('returns direction of stairs', () => {\n      unit.getDirectionOfStairs.mockReturnValue(RIGHT);\n      expect(directionOfStairs.perform()).toBe(RIGHT);\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/DirectionOfStairs.ts",
    "content": "import { Sense } from '@warriorjs/core';\nimport { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial';\n\nimport { type AbilityMeta } from './types.js';\n\nclass DirectionOfStairs extends Sense {\n  readonly description =\n    `Returns the direction (${FORWARD}, ${RIGHT}, ${BACKWARD} or ${LEFT}) the stairs are from your location.`;\n  readonly meta: AbilityMeta = {\n    params: [],\n    returns: 'Direction',\n  };\n\n  perform() {\n    return this.unit.getDirectionOfStairs();\n  }\n}\n\nexport default DirectionOfStairs;\n"
  },
  {
    "path": "libs/abilities/src/DistanceOf.test.ts",
    "content": "import { Sense } from '@warriorjs/core';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport DistanceOf from './DistanceOf.js';\n\ndescribe('DistanceOf', () => {\n  let distanceOf: DistanceOf;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = { getDistanceOf: vi.fn() };\n    distanceOf = new DistanceOf(unit);\n  });\n\n  test('is a sense', () => {\n    expect(distanceOf).toBeInstanceOf(Sense);\n  });\n\n  test('has a description', () => {\n    expect(distanceOf.description).toBe(\n      'Returns an integer representing the distance to the given space.',\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(distanceOf.meta).toEqual({\n      params: [{ name: 'space', type: 'Space' }],\n      returns: 'number',\n    });\n  });\n\n  describe('performing', () => {\n    test('returns distance of specified space', () => {\n      unit.getDistanceOf.mockReturnValue(3);\n      expect(distanceOf.perform({} as any)).toBe(3);\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/DistanceOf.ts",
    "content": "import { Sense, type SensedSpace } from '@warriorjs/core';\n\nimport { type AbilityMeta } from './types.js';\n\nclass DistanceOf extends Sense {\n  readonly description = 'Returns an integer representing the distance to the given space.';\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'space', type: 'Space' }],\n    returns: 'number',\n  };\n\n  perform(space: SensedSpace) {\n    return this.unit.getDistanceOf(space);\n  }\n}\n\nexport default DistanceOf;\n"
  },
  {
    "path": "libs/abilities/src/Feel.test.ts",
    "content": "import { Sense } from '@warriorjs/core';\nimport { FORWARD, LEFT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Feel from './Feel.js';\n\ndescribe('Feel', () => {\n  let feel: Feel;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = { getSensedSpaceAt: vi.fn() };\n    feel = new Feel(unit);\n  });\n\n  test('is a sense', () => {\n    expect(feel).toBeInstanceOf(Sense);\n  });\n\n  test('has a description', () => {\n    expect(feel.description).toBe(\n      `Returns the adjacent space in the given direction (\\`'${FORWARD}'\\` by default).`,\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(feel.meta).toEqual({\n      params: [{ name: 'direction', type: 'Direction', optional: true }],\n      returns: 'Space',\n    });\n  });\n\n  describe('performing', () => {\n    test('feels forward by default', () => {\n      feel.perform();\n      expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(FORWARD);\n    });\n\n    test('allows to specify direction', () => {\n      feel.perform(LEFT);\n      expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(LEFT);\n    });\n\n    test('returns adjacent space in specified direction', () => {\n      unit.getSensedSpaceAt.mockReturnValue('space');\n      expect(feel.perform()).toBe('space');\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Feel.ts",
    "content": "import { Sense } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport { type AbilityMeta } from './types.js';\n\nconst defaultDirection = FORWARD;\n\nclass Feel extends Sense {\n  readonly description =\n    `Returns the adjacent space in the given direction (\\`'${defaultDirection}'\\` by default).`;\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'Space',\n  };\n\n  perform(direction: RelativeDirection = defaultDirection) {\n    return this.unit.getSensedSpaceAt(direction);\n  }\n}\n\nexport default Feel;\n"
  },
  {
    "path": "libs/abilities/src/Health.test.ts",
    "content": "import { Sense } from '@warriorjs/core';\nimport { beforeEach, describe, expect, test } from 'vitest';\n\nimport Health from './Health.js';\n\ndescribe('Health', () => {\n  let health: Health;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = { health: 10 };\n    health = new Health(unit);\n  });\n\n  test('is a sense', () => {\n    expect(health).toBeInstanceOf(Sense);\n  });\n\n  test('has a description', () => {\n    expect(health.description).toBe('Returns an integer representing your health.');\n  });\n\n  test('has meta for type generation', () => {\n    expect(health.meta).toEqual({\n      params: [],\n      returns: 'number',\n    });\n  });\n\n  describe('performing', () => {\n    test('returns the amount of health', () => {\n      expect(health.perform()).toBe(10);\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Health.ts",
    "content": "import { Sense } from '@warriorjs/core';\n\nimport { type AbilityMeta } from './types.js';\n\nclass Health extends Sense {\n  readonly description = 'Returns an integer representing your health.';\n  readonly meta: AbilityMeta = {\n    params: [],\n    returns: 'number',\n  };\n\n  perform() {\n    return this.unit.health;\n  }\n}\n\nexport default Health;\n"
  },
  {
    "path": "libs/abilities/src/Listen.test.ts",
    "content": "import { Sense } from '@warriorjs/core';\nimport { FORWARD, NORTH } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Listen from './Listen.js';\n\ndescribe('Listen', () => {\n  let listen: Listen;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = {\n      position: {\n        location: [1, 1],\n        orientation: NORTH,\n      },\n      getOtherUnits: () => [\n        { getSpace: () => ({ location: [0, 0] }) },\n        { getSpace: () => ({ location: [2, 3] }) },\n      ],\n      getSensedSpaceAt: vi.fn(),\n    };\n    listen = new Listen(unit);\n  });\n\n  test('is a sense', () => {\n    expect(listen).toBeInstanceOf(Sense);\n  });\n\n  test('has a description', () => {\n    expect(listen.description).toBe(\n      'Returns an array of all spaces which have units in them (excluding yourself).',\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(listen.meta).toEqual({\n      params: [],\n      returns: 'Space[]',\n    });\n  });\n\n  describe('performing', () => {\n    test('returns all spaces which have units in them', () => {\n      unit.getSensedSpaceAt.mockReturnValueOnce('space1').mockReturnValueOnce('space2');\n      expect(listen.perform()).toEqual(['space1', 'space2']);\n      expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(FORWARD, 1, -1);\n      expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(FORWARD, -2, 1);\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Listen.ts",
    "content": "import { Sense } from '@warriorjs/core';\nimport { FORWARD, getRelativeOffset } from '@warriorjs/spatial';\n\nimport { type AbilityMeta } from './types.js';\n\nclass Listen extends Sense {\n  readonly description =\n    'Returns an array of all spaces which have units in them (excluding yourself).';\n  readonly meta: AbilityMeta = {\n    params: [],\n    returns: 'Space[]',\n  };\n\n  perform() {\n    return this.unit\n      .getOtherUnits()\n      .map((anotherUnit: any) =>\n        getRelativeOffset(\n          anotherUnit.getSpace().location,\n          this.unit.position.location,\n          this.unit.position.orientation,\n        ),\n      )\n      .map(([forward, right]: [number, number]) =>\n        this.unit.getSensedSpaceAt(FORWARD, forward, right),\n      );\n  }\n}\n\nexport default Listen;\n"
  },
  {
    "path": "libs/abilities/src/Look.test.ts",
    "content": "import { Sense } from '@warriorjs/core';\nimport { FORWARD, LEFT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Look from './Look.js';\n\ndescribe('Look', () => {\n  let look: Look;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = { getSensedSpaceAt: vi.fn() };\n    look = new Look(unit, { range: 3 });\n  });\n\n  test('is a sense', () => {\n    expect(look).toBeInstanceOf(Sense);\n  });\n\n  test('has a description', () => {\n    expect(look.description).toBe(\n      `Returns an array of up to 3 spaces in the given direction (\\`'${FORWARD}'\\` by default).`,\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(look.meta).toEqual({\n      params: [{ name: 'direction', type: 'Direction', optional: true }],\n      returns: 'Space[]',\n    });\n  });\n\n  test('.with() returns an AbilityBinding', () => {\n    const binding = Look.with({ range: 3 });\n    expect(binding).toEqual([Look, { range: 3 }]);\n  });\n\n  describe('performing', () => {\n    test('looks forward by default', () => {\n      look.perform();\n      expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(FORWARD, 1);\n      expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(FORWARD, 2);\n      expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(FORWARD, 3);\n    });\n\n    test('allows to specify direction', () => {\n      look.perform(LEFT);\n      expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(LEFT, 1);\n      expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(LEFT, 2);\n      expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(LEFT, 3);\n    });\n\n    test('returns spaces in range in specified direction', () => {\n      const space1 = { isWall: () => false };\n      const space2 = { isWall: () => false };\n      const space3 = { isWall: () => false };\n      const space4 = { isWall: () => false };\n\n      unit.getSensedSpaceAt\n        .mockReturnValueOnce(space1)\n        .mockReturnValueOnce(space2)\n        .mockReturnValueOnce(space3)\n        .mockReturnValueOnce(space4);\n      expect(look.perform()).toEqual([space1, space2, space3]);\n    });\n\n    test(\"can't see through walls\", () => {\n      const space1 = { isWall: () => false };\n      const space2 = { isWall: () => true };\n      const space3 = { isWall: () => false };\n\n      unit.getSensedSpaceAt\n        .mockReturnValueOnce(space1)\n        .mockReturnValueOnce(space2)\n        .mockReturnValueOnce(space3);\n\n      expect(look.perform()).toEqual([space1, space2]);\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Look.ts",
    "content": "import { type AbilityBinding, Sense } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport { type AbilityMeta, type Unit } from './types.js';\n\nconst defaultDirection = FORWARD;\n\ninterface LookConfig {\n  range: number;\n}\n\nclass Look extends Sense {\n  readonly description: string;\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'Space[]',\n  };\n\n  private range: number;\n\n  constructor(unit: Unit, { range }: LookConfig) {\n    super(unit);\n    this.description = `Returns an array of up to ${range} spaces in the given direction (\\`'${defaultDirection}'\\` by default).`;\n    this.range = range;\n  }\n\n  perform(direction: RelativeDirection = defaultDirection) {\n    const offsets = Array.from(new Array(this.range), (_, index) => index + 1);\n    const spaces = offsets.map((offset) => this.unit.getSensedSpaceAt(direction, offset));\n    const firstWallIndex = spaces.findIndex((space) => space?.isWall());\n    return firstWallIndex === -1 ? spaces : spaces.slice(0, firstWallIndex + 1);\n  }\n\n  static with(config: LookConfig): AbilityBinding {\n    return [Look, config];\n  }\n}\n\nexport default Look;\n"
  },
  {
    "path": "libs/abilities/src/MaxHealth.test.ts",
    "content": "import { Sense } from '@warriorjs/core';\nimport { beforeEach, describe, expect, test } from 'vitest';\n\nimport MaxHealth from './MaxHealth.js';\n\ndescribe('MaxHealth', () => {\n  let maxHealth: MaxHealth;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = { maxHealth: 10 };\n    maxHealth = new MaxHealth(unit);\n  });\n\n  test('is a sense', () => {\n    expect(maxHealth).toBeInstanceOf(Sense);\n  });\n\n  test('has a description', () => {\n    expect(maxHealth.description).toBe('Returns an integer representing your maximum health.');\n  });\n\n  test('has meta for type generation', () => {\n    expect(maxHealth.meta).toEqual({\n      params: [],\n      returns: 'number',\n    });\n  });\n\n  describe('performing', () => {\n    test('returns the maximum health', () => {\n      expect(maxHealth.perform()).toBe(10);\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/MaxHealth.ts",
    "content": "import { Sense } from '@warriorjs/core';\n\nimport { type AbilityMeta } from './types.js';\n\nclass MaxHealth extends Sense {\n  readonly description = 'Returns an integer representing your maximum health.';\n  readonly meta: AbilityMeta = {\n    params: [],\n    returns: 'number',\n  };\n\n  perform() {\n    return this.unit.maxHealth;\n  }\n}\n\nexport default MaxHealth;\n"
  },
  {
    "path": "libs/abilities/src/Pivot.test.ts",
    "content": "import { Action } from '@warriorjs/core';\nimport { BACKWARD, RIGHT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Pivot from './Pivot.js';\n\ndescribe('Pivot', () => {\n  let pivot: Pivot;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = {\n      rotate: vi.fn(),\n      log: vi.fn(),\n    };\n    pivot = new Pivot(unit);\n  });\n\n  test('is an action', () => {\n    expect(pivot).toBeInstanceOf(Action);\n  });\n\n  test('has a description', () => {\n    expect(pivot.description).toBe(\n      `Rotates in the given direction (\\`'${BACKWARD}'\\` by default).`,\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(pivot.meta).toEqual({\n      params: [{ name: 'direction', type: 'Direction', optional: true }],\n      returns: 'void',\n    });\n  });\n\n  describe('performing', () => {\n    test('flips around when not passing direction', () => {\n      pivot.perform();\n      expect(unit.log).toHaveBeenCalledWith(`pivots ${BACKWARD}`);\n      expect(unit.rotate).toHaveBeenCalledWith(BACKWARD);\n    });\n\n    test('rotates in specified direction', () => {\n      pivot.perform(RIGHT);\n      expect(unit.log).toHaveBeenCalledWith(`pivots ${RIGHT}`);\n      expect(unit.rotate).toHaveBeenCalledWith(RIGHT);\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Pivot.ts",
    "content": "import { Action } from '@warriorjs/core';\nimport { BACKWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport { type AbilityMeta } from './types.js';\n\nconst defaultDirection = BACKWARD;\n\nclass Pivot extends Action {\n  readonly description = `Rotates in the given direction (\\`'${defaultDirection}'\\` by default).`;\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'void',\n  };\n\n  perform(direction: RelativeDirection = defaultDirection): void {\n    this.unit.rotate(direction);\n    this.unit.log(`pivots ${direction}`);\n  }\n}\n\nexport default Pivot;\n"
  },
  {
    "path": "libs/abilities/src/Rescue.test.ts",
    "content": "import { Action } from '@warriorjs/core';\nimport { FORWARD, RIGHT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Rescue from './Rescue.js';\n\ndescribe('Rescue', () => {\n  let rescue: Rescue;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = {\n      release: vi.fn(),\n      log: vi.fn(),\n    };\n    rescue = new Rescue(unit);\n  });\n\n  test('is an action', () => {\n    expect(rescue).toBeInstanceOf(Action);\n  });\n\n  test('has a description', () => {\n    expect(rescue.description).toBe(\n      `Releases a unit from their chains in the given direction (\\`'${FORWARD}'\\` by default).`,\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(rescue.meta).toEqual({\n      params: [{ name: 'direction', type: 'Direction', optional: true }],\n      returns: 'void',\n    });\n  });\n\n  describe('performing', () => {\n    test('rescues forward by default', () => {\n      unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null }));\n      rescue.perform();\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD);\n    });\n\n    test('allows to specify direction', () => {\n      unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null }));\n      rescue.perform(RIGHT);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(RIGHT);\n    });\n\n    test('misses if no receiver', () => {\n      unit.getSpaceAt = () => ({ getUnit: () => null });\n      rescue.perform();\n      expect(unit.log).toHaveBeenCalledWith(`unbinds ${FORWARD} and rescues nothing`);\n    });\n\n    describe('with receiver', () => {\n      let receiver: any;\n\n      beforeEach(() => {\n        receiver = {\n          isBound: () => true,\n          toString: () => 'receiver',\n        };\n        unit.getSpaceAt = () => ({ getUnit: () => receiver });\n      });\n\n      test(\"does nothing to receiver if it's not bound\", () => {\n        receiver.isBound = () => false;\n        rescue.perform();\n        expect(unit.log).toHaveBeenCalledWith(`unbinds ${FORWARD} and rescues nothing`);\n        expect(unit.release).not.toHaveBeenCalled();\n      });\n\n      test('releases receiver', () => {\n        rescue.perform();\n        expect(unit.log).toHaveBeenCalledWith(`unbinds ${FORWARD} and rescues receiver`);\n        expect(unit.release).toHaveBeenCalledWith(receiver);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Rescue.ts",
    "content": "import { Action } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport { type AbilityMeta } from './types.js';\n\nconst defaultDirection = FORWARD;\n\nclass Rescue extends Action {\n  readonly description =\n    `Releases a unit from their chains in the given direction (\\`'${defaultDirection}'\\` by default).`;\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'void',\n  };\n\n  perform(direction: RelativeDirection = defaultDirection): void {\n    const receiver = this.unit.getSpaceAt(direction).getUnit();\n    if (receiver?.isBound()) {\n      this.unit.log(`unbinds ${direction} and rescues ${receiver}`);\n      this.unit.release(receiver);\n    } else {\n      this.unit.log(`unbinds ${direction} and rescues nothing`);\n    }\n  }\n}\n\nexport default Rescue;\n"
  },
  {
    "path": "libs/abilities/src/Rest.test.ts",
    "content": "import { Action } from '@warriorjs/core';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Rest from './Rest.js';\n\ndescribe('Rest', () => {\n  let rest: Rest;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = {\n      maxHealth: 20,\n      health: 10,\n      heal: vi.fn(),\n      log: vi.fn(),\n    };\n    rest = new Rest(unit, { healthGain: 0.1 });\n  });\n\n  test('is an action', () => {\n    expect(rest).toBeInstanceOf(Action);\n  });\n\n  test('has a description', () => {\n    expect(rest.description).toBe('Gains 10% of max health back, but does nothing more.');\n  });\n\n  test('has meta for type generation', () => {\n    expect(rest.meta).toEqual({\n      params: [],\n      returns: 'void',\n    });\n  });\n\n  test('.with() returns an AbilityBinding', () => {\n    const binding = Rest.with({ healthGain: 0.1 });\n    expect(binding).toEqual([Rest, { healthGain: 0.1 }]);\n  });\n\n  describe('performing', () => {\n    test('gives health back', () => {\n      rest.perform();\n      expect(unit.log).toHaveBeenCalledWith('rests');\n      expect(unit.heal).toHaveBeenCalledWith(2);\n    });\n\n    test(\"doesn't add health when at max\", () => {\n      unit.health = 20;\n      rest.perform();\n      expect(unit.log).toHaveBeenCalledWith('has nothing to heal');\n      expect(unit.heal).not.toHaveBeenCalled();\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Rest.ts",
    "content": "import { type AbilityBinding, Action } from '@warriorjs/core';\n\nimport { type AbilityMeta, type Unit } from './types.js';\n\ninterface RestConfig {\n  healthGain: number;\n}\n\nclass Rest extends Action {\n  readonly description: string;\n  readonly meta: AbilityMeta = {\n    params: [],\n    returns: 'void',\n  };\n\n  private healthGain: number;\n\n  constructor(unit: Unit, { healthGain }: RestConfig) {\n    super(unit);\n    this.description = `Gains ${healthGain * 100}% of max health back, but does nothing more.`;\n    this.healthGain = healthGain;\n  }\n\n  perform(): void {\n    if (this.unit.health < this.unit.maxHealth) {\n      this.unit.log('rests');\n      const amount = Math.round(this.unit.maxHealth * this.healthGain);\n      this.unit.heal(amount);\n    } else {\n      this.unit.log('has nothing to heal');\n    }\n  }\n\n  static with(config: RestConfig): AbilityBinding {\n    return [Rest, config];\n  }\n}\n\nexport default Rest;\n"
  },
  {
    "path": "libs/abilities/src/Shoot.test.ts",
    "content": "import { Action } from '@warriorjs/core';\nimport { FORWARD, LEFT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Shoot from './Shoot.js';\n\ndescribe('Shoot', () => {\n  let shoot: Shoot;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = {\n      damage: vi.fn(),\n      log: vi.fn(),\n    };\n    shoot = new Shoot(unit, { power: 3, range: 3 });\n  });\n\n  test('is an action', () => {\n    expect(shoot).toBeInstanceOf(Action);\n  });\n\n  test('has a description', () => {\n    expect(shoot.description).toBe(\n      `Shoots the bow & arrow in the given direction (\\`'${FORWARD}'\\` by default), dealing 3 HP of damage to the first unit in a range of 3 spaces.`,\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(shoot.meta).toEqual({\n      params: [{ name: 'direction', type: 'Direction', optional: true }],\n      returns: 'void',\n    });\n  });\n\n  test('.with() returns an AbilityBinding', () => {\n    const binding = Shoot.with({ power: 3, range: 3 });\n    expect(binding).toEqual([Shoot, { power: 3, range: 3 }]);\n  });\n\n  describe('performing', () => {\n    test('shoots forward by default', () => {\n      unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null }));\n      shoot.perform();\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 1);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 2);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 3);\n    });\n\n    test('allows to specify direction', () => {\n      unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null }));\n      shoot.perform(LEFT);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(LEFT, 1);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(LEFT, 2);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(LEFT, 3);\n    });\n\n    test('misses if no receiver', () => {\n      unit.getSpaceAt = vi\n        .fn()\n        .mockReturnValueOnce({ getUnit: () => null })\n        .mockReturnValueOnce({ getUnit: () => null })\n        .mockReturnValueOnce({ getUnit: () => null })\n        .mockReturnValueOnce({ getUnit: () => 'anotherUnit' });\n      shoot.perform();\n      expect(unit.log).toHaveBeenCalledWith(`shoots ${FORWARD} and hits nothing`);\n      expect(unit.damage).not.toHaveBeenCalled();\n    });\n\n    describe('with receiver', () => {\n      beforeEach(() => {\n        unit.getSpaceAt = vi\n          .fn()\n          .mockReturnValueOnce({ getUnit: () => null })\n          .mockReturnValueOnce({ getUnit: () => 'receiver' })\n          .mockReturnValueOnce({ getUnit: () => 'anotherUnit' });\n      });\n\n      test('damages receiver', () => {\n        shoot.perform();\n        expect(unit.log).toHaveBeenCalledWith(`shoots ${FORWARD} and hits receiver`);\n        expect(unit.damage).toHaveBeenCalledWith('receiver', 3);\n      });\n\n      test('shoots only first unit', () => {\n        shoot.perform();\n        expect(unit.damage).not.toHaveBeenCalledWith('anotherUnit', 3);\n      });\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Shoot.ts",
    "content": "import { type AbilityBinding, Action } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport { type AbilityMeta, type Unit } from './types.js';\n\nconst defaultDirection = FORWARD;\n\ninterface ShootConfig {\n  power: number;\n  range: number;\n}\n\nclass Shoot extends Action {\n  readonly description: string;\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'void',\n  };\n\n  private power: number;\n  private range: number;\n\n  constructor(unit: Unit, { power, range }: ShootConfig) {\n    super(unit);\n    this.description = `Shoots the bow & arrow in the given direction (\\`'${defaultDirection}'\\` by default), dealing ${power} HP of damage to the first unit in a range of ${range} spaces.`;\n    this.power = power;\n    this.range = range;\n  }\n\n  perform(direction: RelativeDirection = defaultDirection): void {\n    const offsets = Array.from(new Array(this.range), (_, index) => index + 1);\n    const receiver = offsets\n      .map((offset) => this.unit.getSpaceAt(direction, offset).getUnit())\n      .find((unitInRange) => unitInRange);\n    if (receiver) {\n      this.unit.log(`shoots ${direction} and hits ${receiver}`);\n      this.unit.damage(receiver, this.power);\n    } else {\n      this.unit.log(`shoots ${direction} and hits nothing`);\n    }\n  }\n\n  static with(config: ShootConfig): AbilityBinding {\n    return [Shoot, config];\n  }\n}\n\nexport default Shoot;\n"
  },
  {
    "path": "libs/abilities/src/Think.test.ts",
    "content": "import { Sense } from '@warriorjs/core';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Think from './Think.js';\n\ndescribe('Think', () => {\n  let think: Think;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = { log: vi.fn() };\n    think = new Think(unit);\n  });\n\n  test('is a sense', () => {\n    expect(think).toBeInstanceOf(Sense);\n  });\n\n  test('has a description', () => {\n    expect(think.description).toBe('Thinks out loud (`console.log` replacement).');\n  });\n\n  test('has meta for type generation', () => {\n    expect(think.meta).toEqual({\n      params: [{ name: 'args', type: 'any', rest: true }],\n      returns: 'void',\n    });\n  });\n\n  describe('performing', () => {\n    test('thinks nothing by default', () => {\n      think.perform();\n      expect(unit.log).toHaveBeenCalledWith('thinks nothing');\n    });\n\n    test('allows to specify thought', () => {\n      think.perform('he should be brave');\n      expect(unit.log).toHaveBeenCalledWith('thinks he should be brave');\n    });\n\n    test('allows complex thoughts', () => {\n      think.perform('that %o', { brave: true });\n      expect(unit.log).toHaveBeenCalledWith('thinks that { brave: true }');\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Think.ts",
    "content": "import util from 'node:util';\nimport { Sense } from '@warriorjs/core';\n\nimport { type AbilityMeta } from './types.js';\n\nclass Think extends Sense {\n  readonly description = 'Thinks out loud (`console.log` replacement).';\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'args', type: 'any', rest: true }],\n    returns: 'void',\n  };\n\n  perform(...args: unknown[]) {\n    const thought = args.length > 0 ? util.format(...args) : 'nothing';\n    this.unit.log(`thinks ${thought}`);\n  }\n}\n\nexport default Think;\n"
  },
  {
    "path": "libs/abilities/src/Walk.test.ts",
    "content": "import { Action } from '@warriorjs/core';\nimport { FORWARD, RIGHT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Walk from './Walk.js';\n\ndescribe('Walk', () => {\n  let walk: Walk;\n  let unit: any;\n\n  beforeEach(() => {\n    unit = {\n      move: vi.fn(),\n      log: vi.fn(),\n    };\n    walk = new Walk(unit);\n  });\n\n  test('is an action', () => {\n    expect(walk).toBeInstanceOf(Action);\n  });\n\n  test('has a description', () => {\n    expect(walk.description).toBe(\n      `Moves one space in the given direction (\\`'${FORWARD}'\\` by default).`,\n    );\n  });\n\n  test('has meta for type generation', () => {\n    expect(walk.meta).toEqual({\n      params: [{ name: 'direction', type: 'Direction', optional: true }],\n      returns: 'void',\n    });\n  });\n\n  describe('performing', () => {\n    test('walks forward by default', () => {\n      unit.getSpaceAt = vi.fn(() => ({ isEmpty: () => true }));\n      walk.perform();\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD);\n    });\n\n    test('allows to specify direction', () => {\n      unit.getSpaceAt = vi.fn(() => ({ isEmpty: () => true }));\n      walk.perform(RIGHT);\n      expect(unit.getSpaceAt).toHaveBeenCalledWith(RIGHT);\n    });\n\n    test('keeps position if something is in the way', () => {\n      unit.getSpaceAt = () => ({\n        isEmpty: () => false,\n        toString: () => 'space',\n      });\n      walk.perform();\n      expect(unit.log).toHaveBeenCalledWith(`walks ${FORWARD} and bumps into space`);\n      expect(unit.move).not.toHaveBeenCalled();\n    });\n\n    test('moves in specified direction if space is empty', () => {\n      unit.getSpaceAt = () => ({ isEmpty: () => true });\n      walk.perform(RIGHT);\n      expect(unit.log).toHaveBeenCalledWith(`walks ${RIGHT}`);\n      expect(unit.move).toHaveBeenCalledWith(RIGHT);\n    });\n  });\n});\n"
  },
  {
    "path": "libs/abilities/src/Walk.ts",
    "content": "import { Action } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport { type AbilityMeta } from './types.js';\n\nconst defaultDirection = FORWARD;\n\nclass Walk extends Action {\n  readonly description =\n    `Moves one space in the given direction (\\`'${defaultDirection}'\\` by default).`;\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'void',\n  };\n\n  perform(direction: RelativeDirection = defaultDirection): void {\n    const space = this.unit.getSpaceAt(direction);\n    if (space.isEmpty()) {\n      this.unit.move(direction);\n      this.unit.log(`walks ${direction}`);\n    } else {\n      this.unit.log(`walks ${direction} and bumps into ${space}`);\n    }\n  }\n}\n\nexport default Walk;\n"
  },
  {
    "path": "libs/abilities/src/index.ts",
    "content": "export { default as Attack } from './Attack.js';\nexport { default as Bind } from './Bind.js';\nexport { default as Detonate } from './Detonate.js';\nexport { default as DirectionOf } from './DirectionOf.js';\nexport { default as DirectionOfStairs } from './DirectionOfStairs.js';\nexport { default as DistanceOf } from './DistanceOf.js';\nexport { default as Feel } from './Feel.js';\nexport { default as Health } from './Health.js';\nexport { default as Listen } from './Listen.js';\nexport { default as Look } from './Look.js';\nexport { default as MaxHealth } from './MaxHealth.js';\nexport { default as Pivot } from './Pivot.js';\nexport { default as Rescue } from './Rescue.js';\nexport { default as Rest } from './Rest.js';\nexport { default as Shoot } from './Shoot.js';\nexport { default as Think } from './Think.js';\nexport { default as Walk } from './Walk.js';\n"
  },
  {
    "path": "libs/abilities/src/types.ts",
    "content": "import { type SensedSpace } from '@warriorjs/core';\nimport { type AbsoluteDirection, type Location, type RelativeDirection } from '@warriorjs/spatial';\n\nexport interface Space {\n  location: Location;\n  getUnit(): Unit | null;\n  isEmpty(): boolean;\n  isStairs(): boolean;\n  isUnit(): boolean;\n  isWall(): boolean;\n}\n\nexport interface Unit {\n  health: number;\n  maxHealth: number;\n  position: {\n    location: Location;\n    orientation: AbsoluteDirection;\n  };\n  getSpaceAt(direction: RelativeDirection, forward?: number, right?: number): Space;\n  getSensedSpaceAt(direction: RelativeDirection, forward?: number, right?: number): SensedSpace;\n  getDirectionOf(space: SensedSpace): RelativeDirection;\n  getDirectionOfStairs(): RelativeDirection;\n  getDistanceOf(space: SensedSpace): number;\n  getOtherUnits(): Array<{ getSpace(): { location: Location } }>;\n  getSpace(): { location: Location };\n  move(direction: RelativeDirection): void;\n  rotate(direction: RelativeDirection): void;\n  damage(receiver: Unit, amount: number): void;\n  heal(amount: number): void;\n  release(receiver: Unit): void;\n  bind(): void;\n  isBound(): boolean;\n  isUnderEffect(effect: string): boolean;\n  triggerEffect(effect: string): void;\n  log(message: string): void;\n}\n\nexport interface AbilityParam {\n  name: string;\n  type: 'Direction' | 'Space' | 'number' | 'any';\n  optional?: boolean;\n  rest?: boolean;\n}\n\nexport interface AbilityMeta {\n  params: AbilityParam[];\n  returns: 'void' | 'number' | 'string' | 'Direction' | 'Space' | 'Space[]';\n}\n"
  },
  {
    "path": "libs/abilities/tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\"\n  },\n  \"exclude\": [\"**/*.test.ts\"]\n}\n"
  },
  {
    "path": "libs/abilities/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "libs/core/README.md",
    "content": "# @warriorjs/core\n\n> WarriorJS core.\n\n## Install\n\n```sh\nnpm install @warriorjs/core\n```\n\n## Usage\n\n```js\nconst warriorjs = require('@warriorjs/core');\nimport { runLevel } from '@warriorjs/core');\nimport * as warriorjs from '@warriorjs/core';\n```\n\n## API Reference\n\n### warriorjs.runLevel(levelConfig: Object, playerCode: string)\n\nRuns the given level config with the given player code.\n\n### warriorjs.getLevel(levelConfig: Object)\n\nReturns the level for the given level config.\n"
  },
  {
    "path": "libs/core/package.json",
    "content": "{\n  \"name\": \"@warriorjs/core\",\n  \"version\": \"0.14.0\",\n  \"description\": \"WarriorJS core\",\n  \"author\": \"Matias Olivera <hi@matiasolivera.com>\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://warrior.js.org\",\n  \"repository\": \"https://github.com/olistic/warriorjs/tree/master/libs/core\",\n  \"type\": \"module\",\n  \"main\": \"dist/index.js\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.js\",\n      \"types\": \"./dist/index.d.ts\"\n    }\n  },\n  \"keywords\": [\n    \"warriorjs\",\n    \"warriorjs-core\",\n    \"warrior\",\n    \"epic\",\n    \"battle\",\n    \"game\",\n    \"learn\",\n    \"polish\",\n    \"refine\",\n    \"test\",\n    \"js\",\n    \"javascript\",\n    \"nodejs\",\n    \"ai\",\n    \"artificial-intelligence\",\n    \"skills\"\n  ],\n  \"files\": [\n    \"dist\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"engines\": {\n    \"node\": \">=24\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc -p tsconfig.build.json\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@warriorjs/spatial\": \"workspace:^\",\n    \"esbuild\": \"^0.27.3\"\n  }\n}\n"
  },
  {
    "path": "libs/core/src/Ability.test.ts",
    "content": "import { describe, expect, test, vi } from 'vitest';\n\nimport Ability, { type AbilityMeta } from './Ability.js';\nimport Action from './Action.js';\nimport Sense from './Sense.js';\n\nclass ConcreteAction extends Action {\n  readonly description = 'test action';\n  readonly meta: AbilityMeta = { params: [], returns: 'void' };\n  perform = vi.fn();\n}\n\nclass ConcreteSense extends Sense {\n  readonly description = 'test sense';\n  readonly meta: AbilityMeta = { params: [], returns: 'number' };\n  perform = vi.fn(() => 42);\n}\n\ndescribe('Ability', () => {\n  test('stores unit reference', () => {\n    const unit = {} as any;\n    const action = new ConcreteAction(unit);\n    expect(action).toBeInstanceOf(Ability);\n  });\n\n  test('Action and Sense both extend Ability', () => {\n    expect(new ConcreteAction({} as any)).toBeInstanceOf(Ability);\n    expect(new ConcreteSense({} as any)).toBeInstanceOf(Ability);\n  });\n});\n"
  },
  {
    "path": "libs/core/src/Ability.ts",
    "content": "export interface AbilityParam {\n  name: string;\n  type: 'Direction' | 'Space' | 'number' | 'any';\n  optional?: boolean;\n  rest?: boolean;\n}\n\nexport interface AbilityMeta {\n  params: AbilityParam[];\n  returns: 'void' | 'number' | 'string' | 'Direction' | 'Space' | 'Space[]';\n}\n\nexport interface AbilityClass {\n  new (unit: any, config?: any): Ability;\n}\n\nexport type AbilityBinding = [AbilityClass, object];\n\nexport type AbilityEntry = AbilityBinding | AbilityClass;\n\nabstract class Ability {\n  protected unit: any;\n\n  abstract readonly description: string;\n  abstract readonly meta: AbilityMeta;\n\n  constructor(unit: any, _config?: Record<string, unknown>) {\n    this.unit = unit;\n  }\n\n  abstract perform(...args: unknown[]): unknown;\n}\n\nexport default Ability;\n"
  },
  {
    "path": "libs/core/src/Action.test.ts",
    "content": "import { describe, expect, test, vi } from 'vitest';\n\nimport Ability, { type AbilityMeta } from './Ability.js';\nimport Action from './Action.js';\nimport Sense from './Sense.js';\n\nclass TestAction extends Action {\n  readonly description = 'test action';\n  readonly meta: AbilityMeta = { params: [], returns: 'void' };\n  perform = vi.fn();\n\n  static with(config: { power: number }) {\n    return [TestAction, config] as const;\n  }\n}\n\ndescribe('Action', () => {\n  test('extends Ability', () => {\n    const action = new TestAction({} as any);\n    expect(action).toBeInstanceOf(Ability);\n    expect(action).toBeInstanceOf(Action);\n  });\n\n  test('is not an instance of Sense', () => {\n    const action = new TestAction({} as any);\n    expect(action).not.toBeInstanceOf(Sense);\n  });\n\n  test('has description and meta', () => {\n    const action = new TestAction({} as any);\n    expect(action.description).toBe('test action');\n    expect(action.meta).toEqual({ params: [], returns: 'void' });\n  });\n\n  test('perform can be called', () => {\n    const action = new TestAction({} as any);\n    action.perform();\n    expect(action.perform).toHaveBeenCalled();\n  });\n\n  test('.with() returns an AbilityBinding', () => {\n    const binding = TestAction.with({ power: 5 });\n    expect(binding[0]).toBe(TestAction);\n    expect(binding[1]).toEqual({ power: 5 });\n  });\n});\n"
  },
  {
    "path": "libs/core/src/Action.ts",
    "content": "import Ability from './Ability.js';\n\nabstract class Action extends Ability {\n  abstract perform(...args: unknown[]): void;\n}\n\nexport default Action;\n"
  },
  {
    "path": "libs/core/src/Effect.test.ts",
    "content": "import { describe, expect, test, vi } from 'vitest';\n\nimport Effect from './Effect.js';\n\nclass TestEffect extends Effect {\n  readonly description = 'test effect';\n  passTurn = vi.fn();\n  trigger = vi.fn();\n\n  static with(config: { time: number }) {\n    return [TestEffect, config] as const;\n  }\n}\n\ndescribe('Effect', () => {\n  test('stores unit reference', () => {\n    const unit = { log: vi.fn() };\n    const effect = new TestEffect(unit);\n    expect(effect).toBeInstanceOf(Effect);\n  });\n\n  test('has description', () => {\n    const effect = new TestEffect({});\n    expect(effect.description).toBe('test effect');\n  });\n\n  test('passTurn can be called', () => {\n    const effect = new TestEffect({});\n    effect.passTurn();\n    expect(effect.passTurn).toHaveBeenCalled();\n  });\n\n  test('trigger can be called', () => {\n    const effect = new TestEffect({});\n    effect.trigger();\n    expect(effect.trigger).toHaveBeenCalled();\n  });\n\n  test('.with() returns an EffectBinding', () => {\n    const binding = TestEffect.with({ time: 5 });\n    expect(binding[0]).toBe(TestEffect);\n    expect(binding[1]).toEqual({ time: 5 });\n  });\n});\n"
  },
  {
    "path": "libs/core/src/Effect.ts",
    "content": "export interface EffectClass {\n  new (unit: any, config?: any): Effect;\n}\n\nexport type EffectBinding = [EffectClass, object];\n\nexport type EffectEntry = EffectBinding | EffectClass;\n\nabstract class Effect {\n  protected unit: any;\n\n  abstract readonly description: string;\n\n  constructor(unit: any, _config?: Record<string, unknown>) {\n    this.unit = unit;\n  }\n\n  abstract passTurn(): void;\n  abstract trigger(): void;\n}\n\nexport default Effect;\n"
  },
  {
    "path": "libs/core/src/Floor.test.ts",
    "content": "import { NORTH } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test } from 'vitest';\n\nimport Floor from './Floor.js';\nimport Space from './Space.js';\nimport Unit from './Unit.js';\nimport Warrior from './Warrior.js';\n\ndescribe('Floor', () => {\n  let floor: Floor;\n\n  beforeEach(() => {\n    floor = new Floor(2, 3, [1, 2]);\n  });\n\n  test('returns its map', () => {\n    const unit = new Unit();\n    floor.addUnit(unit, { x: 0, y: 1, facing: NORTH });\n    const map = floor.getMap();\n    expect(map[1][1].isEmpty()).toBe(true);\n    expect(map[3][2].isStairs()).toBe(true);\n    expect(map[0][0].isWall()).toBe(true);\n    expect(map[2][1].isUnit()).toBe(true);\n  });\n\n  test(\"doesn't consider corners out of bounds\", () => {\n    expect(floor.isOutOfBounds([0, 0])).toBe(false);\n    expect(floor.isOutOfBounds([1, 0])).toBe(false);\n    expect(floor.isOutOfBounds([1, 2])).toBe(false);\n    expect(floor.isOutOfBounds([0, 2])).toBe(false);\n  });\n\n  test('considers out of bounds when going beyond sides', () => {\n    expect(floor.isOutOfBounds([-1, 0])).toBe(true);\n    expect(floor.isOutOfBounds([0, -1])).toBe(true);\n    expect(floor.isOutOfBounds([0, 3])).toBe(true);\n    expect(floor.isOutOfBounds([2, 0])).toBe(true);\n  });\n\n  test('knows where the stairs are located', () => {\n    expect(floor.isStairs([0, 0])).toBe(false);\n    expect(floor.isStairs([1, 2])).toBe(true);\n  });\n\n  test('returns the space at the stairs location', () => {\n    const stairsSpace = floor.getStairsSpace();\n    expect(stairsSpace.location).toEqual(floor.stairsLocation);\n  });\n\n  test('returns the space at the specified location', () => {\n    const space = floor.getSpaceAt([0, 0]);\n    expect(space).toBeInstanceOf(Space);\n    expect(space.location).toEqual([0, 0]);\n  });\n\n  test('adds a unit and fetches it at that position', () => {\n    const unit = new Unit();\n    floor.addUnit(unit, { x: 0, y: 1, facing: NORTH });\n    expect(floor.getUnitAt([0, 1])).toBe(unit);\n  });\n\n  test('adds the warrior and fetches it at that position', () => {\n    const warrior = new Warrior();\n    floor.addWarrior(warrior, { x: 0, y: 1, facing: NORTH });\n    expect(floor.getUnitAt([0, 1])).toBe(warrior);\n  });\n\n  test('knows which unit is the warrior after adding it', () => {\n    expect(floor.warrior).toBeNull();\n    const warrior = new Warrior();\n    floor.addWarrior(warrior, { x: 0, y: 1, facing: NORTH });\n    expect(floor.warrior).toBe(warrior);\n  });\n\n  test(\"doesn't consider a unit to be on the floor if it's not alive\", () => {\n    const unit = new Unit();\n    floor.addUnit(unit, { x: 0, y: 1, facing: NORTH });\n    unit.isAlive = () => false;\n    expect(floor.getUnits()).not.toContain(unit);\n  });\n});\n"
  },
  {
    "path": "libs/core/src/Floor.ts",
    "content": "import { type Location } from '@warriorjs/spatial';\n\nimport Position from './Position.js';\nimport Space from './Space.js';\nimport { type PositionConfig } from './types.js';\nimport type Unit from './Unit.js';\nimport type Warrior from './Warrior.js';\n\nclass Floor {\n  width: number;\n  height: number;\n  stairsLocation: Location;\n  units: Unit[];\n  warrior: Warrior | null;\n\n  constructor(width: number, height: number, stairsLocation: Location) {\n    this.width = width;\n    this.height = height;\n    this.stairsLocation = stairsLocation;\n    this.units = [];\n    this.warrior = null;\n  }\n\n  getMap(): Space[][] {\n    const map: Space[][] = [];\n    for (let y = -1; y < this.height + 1; y += 1) {\n      const row: Space[] = [];\n      for (let x = -1; x < this.width + 1; x += 1) {\n        row.push(this.getSpaceAt([x, y]));\n      }\n      map.push(row);\n    }\n    return map;\n  }\n\n  isOutOfBounds([x, y]: Location): boolean {\n    return x < 0 || y < 0 || x > this.width - 1 || y > this.height - 1;\n  }\n\n  isStairs([x, y]: Location): boolean {\n    const [stairsX, stairsY] = this.stairsLocation;\n    return x === stairsX && y === stairsY;\n  }\n\n  getStairsSpace(): Space {\n    return this.getSpaceAt(this.stairsLocation);\n  }\n\n  getSpaceAt(location: Location): Space {\n    return new Space(this, location);\n  }\n\n  addWarrior(warrior: Warrior, position: PositionConfig): void {\n    this.addUnit(warrior, position);\n    this.warrior = warrior;\n  }\n\n  addUnit(unit: Unit, { x, y, facing }: PositionConfig): void {\n    const unitWithPosition = unit;\n    const location: Location = [x, y];\n    unitWithPosition.position = new Position(this, location, facing);\n    this.units.push(unitWithPosition);\n  }\n\n  getUnitAt(location: Location): Unit | undefined {\n    return this.getUnits().find((unit) => unit.position?.isAt(location));\n  }\n\n  getUnits(): Unit[] {\n    return this.units.filter((unit) => unit.isAlive());\n  }\n}\n\nexport default Floor;\n"
  },
  {
    "path": "libs/core/src/Level.test.ts",
    "content": "import { EAST, FORWARD } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Floor from './Floor.js';\nimport Level from './Level.js';\nimport Warrior from './Warrior.js';\n\ndescribe('Level', () => {\n  let floor: Floor;\n  let level: Level;\n  let warrior: Warrior;\n\n  beforeEach(() => {\n    warrior = new Warrior('Joe', '@', '#8fbcbb', 20);\n    warrior.log = vi.fn();\n    floor = new Floor(2, 1, [1, 0]);\n    floor.addUnit(warrior, { x: 0, y: 0, facing: EAST });\n    floor.warrior = warrior;\n    level = new Level(1, 'a description', 'a tip', 'a clue', floor);\n  });\n\n  describe('playing', () => {\n    beforeEach(() => {\n      warrior.prepareTurn = vi.fn();\n      warrior.performTurn = vi.fn();\n    });\n\n    test('calls prepareTurn and playTurn on each unit once per turn', () => {\n      level.play(2);\n      expect(warrior.prepareTurn).toHaveBeenCalledTimes(2);\n      expect(warrior.performTurn).toHaveBeenCalledTimes(2);\n    });\n\n    test('plays for a max number of turns which defaults to 200', () => {\n      level.play();\n      expect(warrior.prepareTurn).toHaveBeenCalledTimes(200);\n      expect(warrior.performTurn).toHaveBeenCalledTimes(200);\n    });\n\n    test('returns immediately when passed', () => {\n      level.wasPassed = () => true;\n      level.play(2);\n      expect(warrior.performTurn).not.toHaveBeenCalled();\n    });\n\n    test('returns immediately when failed', () => {\n      level.wasFailed = () => true;\n      level.play(2);\n      expect(warrior.performTurn).not.toHaveBeenCalled();\n    });\n  });\n\n  test('considers passed when warrior is on stairs', () => {\n    warrior.move(FORWARD);\n    expect(level.wasPassed()).toBe(true);\n  });\n\n  test('considers failed when warrior is dead', () => {\n    warrior.isAlive = () => false;\n    expect(level.wasFailed()).toBe(true);\n  });\n\n  test('has a minimal JSON representation', () => {\n    expect(level.toJSON()).toEqual({\n      number: 1,\n      description: 'a description',\n      tip: 'a tip',\n      clue: 'a clue',\n      floorMap: level.floor.getMap(),\n      warriorStatus: level.floor.warrior?.getStatus(),\n      warriorAbilities: level.floor.warrior?.getAbilities(),\n    });\n  });\n});\n"
  },
  {
    "path": "libs/core/src/Level.ts",
    "content": "import type Floor from './Floor.js';\nimport Logger, { type TurnEvent } from './Logger.js';\n\nconst maxTurns = 200;\n\nclass Level {\n  number: number;\n  description: string;\n  tip: string;\n  clue: string;\n  floor: Floor;\n\n  constructor(number: number, description: string, tip: string, clue: string, floor: Floor) {\n    this.number = number;\n    this.description = description;\n    this.tip = tip;\n    this.clue = clue;\n    this.floor = floor;\n  }\n\n  play(turns: number = maxTurns): {\n    passed: boolean;\n    turns: TurnEvent[][];\n    initialState: TurnEvent | null;\n  } {\n    Logger.play(this.floor);\n\n    for (let n = 0; n < turns; n += 1) {\n      if (this.wasPassed() || this.wasFailed()) {\n        break;\n      }\n\n      Logger.turn();\n\n      this.floor.getUnits().forEach((unit) => unit.prepareTurn());\n      this.floor.getUnits().forEach((unit) => unit.performTurn());\n    }\n\n    const passed = this.wasPassed();\n\n    return {\n      passed,\n      turns: Logger.turns,\n      initialState: Logger.initialState,\n    };\n  }\n\n  wasPassed(): boolean {\n    const stairsSpace = this.floor.getStairsSpace();\n    return stairsSpace.getUnit() === this.floor.warrior;\n  }\n\n  wasFailed(): boolean {\n    return !this.floor.warrior?.isAlive();\n  }\n\n  toJSON(): any {\n    return {\n      number: this.number,\n      description: this.description,\n      tip: this.tip,\n      clue: this.clue,\n      floorMap: this.floor.getMap(),\n      warriorStatus: this.floor.warrior?.getStatus(),\n      warriorAbilities: this.floor.warrior?.getAbilities(),\n    };\n  }\n}\n\nexport default Level;\n"
  },
  {
    "path": "libs/core/src/Logger.ts",
    "content": "import type Floor from './Floor.js';\nimport type Unit from './Unit.js';\n\nexport interface TurnEvent {\n  message: string;\n  unit: { name: string; color: string } | null;\n  floorMap: { character: string; unit?: { color: string } }[][];\n  warriorStatus: { health: number; score: number } | undefined;\n}\n\nconst Logger: {\n  floor: Floor | null;\n  turns: TurnEvent[][];\n  lastTurn: TurnEvent[] | null;\n  initialState: TurnEvent | null;\n  play(floor: Floor): void;\n  turn(): void;\n  unit(unit: Unit, message: string): void;\n} = {\n  floor: null,\n  turns: [],\n  lastTurn: null,\n  initialState: null,\n\n  play(floor: Floor) {\n    Logger.floor = floor;\n    Logger.turns = [];\n    Logger.lastTurn = null;\n    Logger.initialState = {\n      message: '',\n      unit: null,\n      floorMap: JSON.parse(JSON.stringify(floor.getMap())),\n      warriorStatus: floor.warrior?.getStatus(),\n    };\n  },\n\n  turn() {\n    Logger.lastTurn = [];\n    Logger.turns.push(Logger.lastTurn);\n  },\n\n  unit(unit: Unit, message: string) {\n    Logger.lastTurn?.push({\n      message,\n      unit: JSON.parse(JSON.stringify(unit)),\n      floorMap: JSON.parse(JSON.stringify(Logger.floor?.getMap())),\n      warriorStatus: Logger.floor?.warrior?.getStatus(),\n    });\n  },\n};\n\nexport default Logger;\n"
  },
  {
    "path": "libs/core/src/Position.test.ts",
    "content": "import { BACKWARD, EAST, FORWARD, LEFT, NORTH, RIGHT, SOUTH, WEST } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test } from 'vitest';\n\nimport Floor from './Floor.js';\nimport Unit from './Unit.js';\n\ndescribe('Position', () => {\n  let floor: Floor;\n  let unit: Unit;\n  let position: any;\n\n  beforeEach(() => {\n    floor = new Floor(5, 6, [0, 0]);\n    unit = new Unit();\n    floor.addUnit(unit, { x: 1, y: 2, facing: NORTH });\n    position = unit.position;\n  });\n\n  test(\"can determine if it's at a given location\", () => {\n    expect(position.isAt([1, 1])).toBe(false);\n    expect(position.isAt([1, 2])).toBe(true);\n  });\n\n  test('returns the space at its location', () => {\n    expect(position.getSpace().location).toEqual(position.location);\n  });\n\n  test('gets relative space in front', () => {\n    floor.addUnit(new Unit(), { x: 1, y: 1, facing: NORTH });\n    expect(position.getRelativeSpace(FORWARD, [1, 0]).isEmpty()).toBe(false);\n  });\n\n  test('gets relative space in front two spaces yonder', () => {\n    floor.addUnit(new Unit(), { x: 1, y: 0, facing: NORTH });\n    expect(position.getRelativeSpace(FORWARD, [2, 0]).isEmpty()).toBe(false);\n  });\n\n  test('gets relative space in front when rotated', () => {\n    floor.addUnit(new Unit(), { x: 2, y: 2, facing: NORTH });\n    position.rotate(RIGHT);\n    expect(position.getRelativeSpace(FORWARD, [1, 0]).isEmpty()).toBe(false);\n  });\n\n  test('gets relative space diagonally', () => {\n    floor.addUnit(new Unit(), { x: 2, y: 1, facing: NORTH });\n    expect(position.getRelativeSpace(FORWARD, [1, 1]).isEmpty()).toBe(false);\n  });\n\n  test('gets relative space diagonally when rotated', () => {\n    floor.addUnit(new Unit(), { x: 2, y: 1, facing: NORTH });\n    position.rotate(BACKWARD);\n    expect(position.getRelativeSpace(FORWARD, [-1, -1]).isEmpty()).toBe(false);\n  });\n\n  test('returns distance of given space', () => {\n    expect(position.getDistanceOf(floor.getSpaceAt([5, 3]))).toBe(5);\n    expect(position.getDistanceOf(floor.getSpaceAt([4, 2]))).toBe(3);\n  });\n\n  test('returns relative direction of given space', () => {\n    expect(position.getRelativeDirectionOf(floor.getSpaceAt([5, 3]))).toEqual(RIGHT);\n    position.rotate(RIGHT);\n    expect(position.getRelativeDirectionOf(floor.getSpaceAt([1, 4]))).toEqual(RIGHT);\n  });\n\n  test('rotates position on floor relatively', () => {\n    expect(position.orientation).toEqual(NORTH);\n    [EAST, SOUTH, WEST, NORTH, EAST].forEach((direction: string) => {\n      position.rotate(RIGHT);\n      expect(position.orientation).toEqual(direction);\n    });\n  });\n\n  test('moves position on floor relatively', () => {\n    expect(floor.getUnitAt([1, 2])).toBe(unit);\n    position.move(BACKWARD, [1, 1]);\n    expect(floor.getUnitAt([1, 2])).toBeUndefined();\n    expect(floor.getUnitAt([0, 3])).toBe(unit);\n    position.rotate(LEFT);\n    position.move(RIGHT, [1, 0]);\n    expect(floor.getUnitAt([0, 3])).toBeUndefined();\n    expect(floor.getUnitAt([0, 2])).toBe(unit);\n  });\n});\n"
  },
  {
    "path": "libs/core/src/Position.ts",
    "content": "import {\n  type AbsoluteDirection,\n  getAbsoluteDirection,\n  getAbsoluteOffset,\n  getDirectionOfLocation,\n  getDistanceOfLocation,\n  getRelativeDirection,\n  type Location,\n  type RelativeDirection,\n  type RelativeOffset,\n  rotateRelativeOffset,\n  translateLocation,\n  verifyAbsoluteDirection,\n} from '@warriorjs/spatial';\n\nimport type Floor from './Floor.js';\nimport type Space from './Space.js';\n\nclass Position {\n  floor: Floor;\n  location: Location;\n  orientation: AbsoluteDirection;\n\n  constructor(floor: Floor, location: Location, orientation: string) {\n    verifyAbsoluteDirection(orientation);\n    this.floor = floor;\n    this.location = location;\n    this.orientation = orientation;\n  }\n\n  isAt([x, y]: Location): boolean {\n    const [locationX, locationY] = this.location;\n    return locationX === x && locationY === y;\n  }\n\n  getSpace(): Space {\n    return this.floor.getSpaceAt(this.location);\n  }\n\n  getRelativeSpace(direction: RelativeDirection, relativeOffset: RelativeOffset): Space {\n    const offset = getAbsoluteOffset(\n      rotateRelativeOffset(relativeOffset, direction),\n      this.orientation,\n    );\n    const spaceLocation = translateLocation(this.location, offset);\n    return this.floor.getSpaceAt(spaceLocation);\n  }\n\n  getDistanceOf(space: Space): number {\n    return getDistanceOfLocation(space.location, this.location);\n  }\n\n  getRelativeDirectionOf(space: Space): RelativeDirection {\n    return getRelativeDirection(\n      getDirectionOfLocation(space.location, this.location),\n      this.orientation,\n    );\n  }\n\n  move(direction: RelativeDirection, relativeOffset: RelativeOffset): void {\n    const offset = getAbsoluteOffset(\n      rotateRelativeOffset(relativeOffset, direction),\n      this.orientation,\n    );\n    this.location = translateLocation(this.location, offset);\n  }\n\n  rotate(direction: RelativeDirection): void {\n    this.orientation = getAbsoluteDirection(direction, this.orientation);\n  }\n}\n\nexport default Position;\n"
  },
  {
    "path": "libs/core/src/Sense.test.ts",
    "content": "import { describe, expect, test, vi } from 'vitest';\n\nimport Ability, { type AbilityMeta } from './Ability.js';\nimport Action from './Action.js';\nimport Sense from './Sense.js';\n\nclass TestSense extends Sense {\n  readonly description = 'test sense';\n  readonly meta: AbilityMeta = { params: [], returns: 'number' };\n  perform = vi.fn(() => 42);\n}\n\ndescribe('Sense', () => {\n  test('extends Ability', () => {\n    const sense = new TestSense({} as any);\n    expect(sense).toBeInstanceOf(Ability);\n    expect(sense).toBeInstanceOf(Sense);\n  });\n\n  test('is not an instance of Action', () => {\n    const sense = new TestSense({} as any);\n    expect(sense).not.toBeInstanceOf(Action);\n  });\n\n  test('has description and meta', () => {\n    const sense = new TestSense({} as any);\n    expect(sense.description).toBe('test sense');\n    expect(sense.meta).toEqual({ params: [], returns: 'number' });\n  });\n\n  test('perform returns a value', () => {\n    const sense = new TestSense({} as any);\n    expect(sense.perform()).toBe(42);\n  });\n});\n"
  },
  {
    "path": "libs/core/src/Sense.ts",
    "content": "import Ability from './Ability.js';\n\nabstract class Sense extends Ability {\n  abstract perform(...args: unknown[]): unknown;\n}\n\nexport default Sense;\n"
  },
  {
    "path": "libs/core/src/Space.test.ts",
    "content": "import { NORTH } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test } from 'vitest';\n\nimport Floor from './Floor.js';\nimport Space from './Space.js';\nimport Unit from './Unit.js';\n\ndescribe('Space', () => {\n  let floor: Floor;\n  let space: Space;\n\n  beforeEach(() => {\n    floor = new Floor(2, 3, [0, 2]);\n    space = floor.getSpaceAt([0, 0]);\n  });\n\n  describe('out of bounds', () => {\n    beforeEach(() => {\n      space = floor.getSpaceAt([-1, 1]);\n    });\n\n    test('is not empty', () => {\n      expect(space.isEmpty()).toBe(false);\n    });\n\n    test('is not stairs', () => {\n      expect(space.isStairs()).toBe(false);\n    });\n\n    test('is wall', () => {\n      expect(space.isWall()).toBe(true);\n    });\n\n    test('has name \"wall\"', () => {\n      expect(space.toString()).toEqual('wall');\n    });\n\n    describe('upper left corner', () => {\n      beforeEach(() => {\n        space = floor.getSpaceAt([-1, -1]);\n      });\n\n      test(\"appears as '\\u2554' on map\", () => {\n        expect(space.getCharacter()).toBe('\\u2554');\n      });\n    });\n\n    describe('upper right corner', () => {\n      beforeEach(() => {\n        space = floor.getSpaceAt([2, -1]);\n      });\n\n      test(\"appears as '\\u2557' on map\", () => {\n        expect(space.getCharacter()).toBe('\\u2557');\n      });\n    });\n\n    describe('lower left corner', () => {\n      beforeEach(() => {\n        space = floor.getSpaceAt([-1, 3]);\n      });\n\n      test(\"appears as '\\u255a' on map\", () => {\n        expect(space.getCharacter()).toBe('\\u255a');\n      });\n    });\n\n    describe('lower right corner', () => {\n      beforeEach(() => {\n        space = floor.getSpaceAt([2, 3]);\n      });\n\n      test(\"appears as '\\u255d' on map\", () => {\n        expect(space.getCharacter()).toBe('\\u255d');\n      });\n    });\n\n    describe('upper side', () => {\n      beforeEach(() => {\n        space = floor.getSpaceAt([1, -1]);\n      });\n\n      test(\"appears as '\\u2550' on map\", () => {\n        expect(space.getCharacter()).toBe('\\u2550');\n      });\n    });\n\n    describe('lower side', () => {\n      beforeEach(() => {\n        space = floor.getSpaceAt([1, 3]);\n      });\n\n      test(\"appears as '\\u2550' on map\", () => {\n        expect(space.getCharacter()).toBe('\\u2550');\n      });\n    });\n\n    describe('left side', () => {\n      beforeEach(() => {\n        space = floor.getSpaceAt([-1, 1]);\n      });\n\n      test(\"appears as '\\u2551' on map\", () => {\n        expect(space.getCharacter()).toBe('\\u2551');\n      });\n    });\n\n    describe('right side', () => {\n      beforeEach(() => {\n        space = floor.getSpaceAt([2, 1]);\n      });\n\n      test(\"appears as '\\u2551' on map\", () => {\n        expect(space.getCharacter()).toBe('\\u2551');\n      });\n    });\n  });\n\n  describe('with nothing on it', () => {\n    beforeEach(() => {\n      space = floor.getSpaceAt([0, 0]);\n    });\n\n    test('is empty', () => {\n      expect(space.isEmpty()).toBe(true);\n    });\n\n    test('is not stairs', () => {\n      expect(space.isStairs()).toBe(false);\n    });\n\n    test('is not wall', () => {\n      expect(space.isWall()).toBe(false);\n    });\n\n    test('is not unit', () => {\n      expect(space.isUnit()).toBe(false);\n    });\n\n    test(\"doesn't fetch a unit\", () => {\n      expect(space.getUnit()).toBeUndefined();\n    });\n\n    test('has name \"nothing\"', () => {\n      expect(space.toString()).toEqual('nothing');\n    });\n\n    test(\"appears as ' ' on map\", () => {\n      expect(space.getCharacter()).toBe(' ');\n    });\n  });\n\n  describe('with stairs', () => {\n    beforeEach(() => {\n      space = floor.getSpaceAt([0, 2]);\n    });\n\n    test('is empty', () => {\n      expect(space.isEmpty()).toBe(true);\n    });\n\n    test('is stairs', () => {\n      expect(space.isStairs()).toBe(true);\n    });\n\n    test('is not wall', () => {\n      expect(space.isWall()).toBe(false);\n    });\n\n    test('is not unit', () => {\n      expect(space.isUnit()).toBe(false);\n    });\n\n    test(\"doesn't fetch a unit\", () => {\n      expect(space.getUnit()).toBeUndefined();\n    });\n\n    test('has name \"nothing\"', () => {\n      expect(space.toString()).toEqual('nothing');\n    });\n\n    test(\"appears as '>' on map\", () => {\n      expect(space.getCharacter()).toBe('>');\n    });\n\n    describe('with unit', () => {\n      let unit: Unit;\n\n      beforeEach(() => {\n        unit = new Unit('Foo', 'f');\n        floor.addUnit(unit, { x: 0, y: 2, facing: NORTH });\n      });\n\n      test('is still stairs', () => {\n        expect(space.isStairs()).toBe(true);\n      });\n\n      test('is also unit', () => {\n        expect(space.isUnit()).toBe(true);\n      });\n\n      test('has name of unit', () => {\n        expect(space.toString()).toEqual('Foo');\n      });\n\n      test('appears as unit character on map', () => {\n        expect(space.getCharacter()).toBe('f');\n      });\n    });\n  });\n\n  describe('with unit', () => {\n    let unit: Unit;\n\n    beforeEach(() => {\n      unit = new Unit('Foo', 'f');\n      floor.addUnit(unit, { x: 0, y: 0, facing: NORTH });\n    });\n\n    test('is not empty', () => {\n      expect(space.isEmpty()).toBe(false);\n    });\n\n    test('is not stairs', () => {\n      expect(space.isStairs()).toBe(false);\n    });\n\n    test('is not wall', () => {\n      expect(space.isWall()).toBe(false);\n    });\n\n    test('is unit', () => {\n      expect(space.isUnit()).toBe(true);\n    });\n\n    test('fetches the unit', () => {\n      expect(space.getUnit()).toBe(unit);\n    });\n\n    test('has name of unit', () => {\n      expect(space.toString()).toEqual('Foo');\n    });\n\n    test('appears as its character on map', () => {\n      expect(space.getCharacter()).toBe('f');\n    });\n  });\n\n  describe('sensed space', () => {\n    let sensingUnit: Unit;\n    let sensedSpace: any;\n\n    beforeEach(() => {\n      sensingUnit = new Unit();\n      floor.addUnit(sensingUnit, { x: 1, y: 1, facing: NORTH });\n      sensedSpace = space.as(sensingUnit);\n    });\n\n    test('allows calling sensed space methods', () => {\n      const allowedApi = ['getLocation', 'getUnit', 'isEmpty', 'isStairs', 'isUnit', 'isWall'];\n      allowedApi.forEach((propertyName) => {\n        sensedSpace[propertyName]();\n      });\n    });\n\n    test(\"doesn't allow calling other space methods\", () => {\n      const forbiddenApi = ['as', 'getCharacter'];\n      forbiddenApi.forEach((propertyName: string) => {\n        expect(sensedSpace).not.toHaveProperty(propertyName);\n      });\n    });\n\n    test('has a location relative to the sensing unit', () => {\n      expect(sensedSpace.getLocation()).toEqual([1, -1]);\n    });\n\n    test('can get full space back', () => {\n      const fullSpace = Space.from(sensedSpace, sensingUnit);\n      expect(fullSpace).toBeInstanceOf(Space);\n      expect(fullSpace.floor).toBe(space.floor);\n      expect(fullSpace.location).toEqual(space.location);\n    });\n  });\n\n  test('has a minimal JSON representation', () => {\n    expect(space.toJSON()).toEqual({\n      character: space.getCharacter(),\n      unit: space.getUnit(),\n    });\n  });\n});\n"
  },
  {
    "path": "libs/core/src/Space.ts",
    "content": "import {\n  getAbsoluteOffset,\n  getRelativeOffset,\n  type Location,\n  type RelativeOffset,\n  translateLocation,\n} from '@warriorjs/spatial';\n\nimport type Floor from './Floor.js';\nimport type Unit from './Unit.js';\n\nconst upperLeftWallCharacter = '\\u2554';\nconst upperRightWallCharacter = '\\u2557';\nconst lowerLeftWallCharacter = '\\u255a';\nconst lowerRightWallCharacter = '\\u255d';\nconst verticalWallCharacter = '\\u2551';\nconst horizontalWallCharacter = '\\u2550';\nconst emptyCharacter = ' ';\nconst stairsCharacter = '>';\n\nexport interface SensedSpace {\n  getLocation(): RelativeOffset;\n  getUnit(): SensedUnit | null;\n  isEmpty(): boolean;\n  isStairs(): boolean;\n  isUnit(): boolean;\n  isWall(): boolean;\n}\n\nexport interface SensedUnit {\n  isBound(): boolean;\n  isEnemy(): boolean;\n  isUnderEffect(name: string): boolean;\n}\n\nclass Space {\n  floor: Floor;\n  location: Location;\n\n  static from(sensedSpace: SensedSpace, unit: Unit): Space {\n    const { floor, location, orientation } = unit.position!;\n    const offset = getAbsoluteOffset(sensedSpace.getLocation(), orientation);\n    const spaceLocation = translateLocation(location, offset);\n    return new Space(floor, spaceLocation);\n  }\n\n  constructor(floor: Floor, location: Location) {\n    this.floor = floor;\n    this.location = location;\n  }\n\n  getCharacter(): string {\n    if (this.isUnit()) {\n      return this.getUnit()!.character;\n    }\n\n    if (this.isWall()) {\n      const [locationX, locationY] = this.location;\n      if (locationX < 0) {\n        if (locationY < 0) {\n          return upperLeftWallCharacter;\n        }\n\n        if (locationY > this.floor.height - 1) {\n          return lowerLeftWallCharacter;\n        }\n\n        return verticalWallCharacter;\n      }\n\n      if (locationX > this.floor.width - 1) {\n        if (locationY < 0) {\n          return upperRightWallCharacter;\n        }\n\n        if (locationY > this.floor.height - 1) {\n          return lowerRightWallCharacter;\n        }\n\n        return verticalWallCharacter;\n      }\n\n      return horizontalWallCharacter;\n    }\n\n    if (this.isStairs()) {\n      return stairsCharacter;\n    }\n\n    return emptyCharacter;\n  }\n\n  isEmpty(): boolean {\n    return !this.isUnit() && !this.isWall();\n  }\n\n  isStairs(): boolean {\n    return this.floor.isStairs(this.location);\n  }\n\n  isWall(): boolean {\n    return this.floor.isOutOfBounds(this.location);\n  }\n\n  isUnit(): boolean {\n    return !!this.getUnit();\n  }\n\n  getUnit(): Unit | undefined {\n    return this.floor.getUnitAt(this.location);\n  }\n\n  as(unit: Unit): SensedSpace {\n    return {\n      getLocation: () =>\n        getRelativeOffset(this.location, unit.position!.location, unit.position!.orientation),\n      getUnit: () => {\n        const spaceUnit = this.getUnit.call(this);\n        return spaceUnit ? spaceUnit.as(unit) : null;\n      },\n      isEmpty: this.isEmpty.bind(this),\n      isStairs: this.isStairs.bind(this),\n      isUnit: this.isUnit.bind(this),\n      isWall: this.isWall.bind(this),\n    };\n  }\n\n  toString(): string {\n    if (this.isUnit()) {\n      return this.getUnit()!.toString();\n    }\n\n    if (this.isWall()) {\n      return 'wall';\n    }\n\n    return 'nothing';\n  }\n\n  toJSON(): { character: string; unit: Unit | undefined } {\n    return {\n      character: this.getCharacter(),\n      unit: this.getUnit(),\n    };\n  }\n}\n\nexport default Space;\n"
  },
  {
    "path": "libs/core/src/Unit.test.ts",
    "content": "import { BACKWARD, FORWARD, LEFT, NORTH, RIGHT, SOUTH } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Action from './Action.js';\nimport Floor from './Floor.js';\nimport Sense from './Sense.js';\nimport Unit from './Unit.js';\n\nclass MockAction extends Action {\n  readonly description = 'mock action';\n  readonly meta = { params: [], returns: 'void' as const };\n  perform = vi.fn();\n}\n\nclass MockSense extends Sense {\n  readonly description = 'mock sense';\n  readonly meta = { params: [], returns: 'void' as const };\n  perform = vi.fn();\n}\n\ndescribe('Unit', () => {\n  let unit: Unit;\n  let floor: Floor;\n\n  beforeEach(() => {\n    unit = new Unit('Joe', '@', '#8fbcbb', 20);\n    unit.log = vi.fn();\n    floor = new Floor(5, 6, [0, 0]);\n    floor.addUnit(unit, { x: 1, y: 2, facing: NORTH });\n  });\n\n  test('has a name', () => {\n    expect(unit.name).toBe('Joe');\n  });\n\n  test('has a character that represents it', () => {\n    expect(unit.character).toBe('@');\n  });\n\n  test('has a color', () => {\n    expect(unit.color).toBe('#8fbcbb');\n  });\n\n  test('has a max health', () => {\n    expect(unit.maxHealth).toBe(20);\n  });\n\n  test('has a reward which defaults to max health', () => {\n    expect(unit.reward).toBe(20);\n  });\n\n  test('allows to specify reward', () => {\n    expect(new Unit('Foo', 'f', '#fff', 20, 30).reward).toBe(30);\n  });\n\n  test('has an enemy status which defaults to true', () => {\n    expect(unit.enemy).toBe(true);\n  });\n\n  test('allows to specify enemy status', () => {\n    expect(new Unit('Foo', 'f', '#fff', 20, 30, false).enemy).toBe(false);\n  });\n\n  test('has a bound status which defaults to false', () => {\n    expect(unit.bound).toBe(false);\n  });\n\n  test('has a position which is null before adding the unit to the floor', () => {\n    expect(new Unit('Foo', 'f', '#fff', 20).position).toBeNull();\n  });\n\n  test('allows to specify bound status', () => {\n    expect(new Unit('Foo', 'f', '#fff', 20, 30, false, true).bound).toBe(true);\n  });\n\n  test('has a health which defaults to max health', () => {\n    expect(unit.health).toBe(20);\n  });\n\n  test('starts with a score of zero', () => {\n    expect(unit.score).toBe(0);\n  });\n\n  test('has a collection of abilities which starts empty', () => {\n    expect(unit.abilities).toBeInstanceOf(Map);\n    expect(unit.abilities.size).toBe(0);\n  });\n\n  test('has a collection of effects which starts empty', () => {\n    expect(unit.effects).toBeInstanceOf(Map);\n    expect(unit.effects.size).toBe(0);\n  });\n\n  test('has a turn which starts as null', () => {\n    expect(unit.turn).toBeNull();\n  });\n\n  describe('next turn', () => {\n    let turn: any;\n    let feel: MockSense;\n    let walk: MockAction;\n\n    beforeEach(() => {\n      feel = new MockSense(unit);\n      walk = new MockAction(unit);\n      unit.addAbility('feel', feel);\n      unit.addAbility('walk', walk);\n      turn = unit.getNextTurn();\n    });\n\n    test('defines a function for each ability of the unit', () => {\n      expect(turn.feel).toBeInstanceOf(Function);\n      expect(turn.walk).toBeInstanceOf(Function);\n    });\n\n    describe('with actions', () => {\n      test('has no action performed at first', () => {\n        expect(turn.action).toBeNull();\n      });\n\n      test('can call action and recall it', () => {\n        turn.walk();\n        expect(turn.action).toEqual(['walk', []]);\n      });\n\n      test('includes arguments passed to action', () => {\n        turn.walk('forward');\n        expect(turn.action).toEqual(['walk', ['forward']]);\n      });\n\n      test(\"can't call multiple actions per turn\", () => {\n        turn.walk();\n        expect(() => {\n          turn.walk();\n        }).toThrow('Only one action can be performed per turn.');\n      });\n\n      test('defers execution when calling action', () => {\n        turn.walk();\n        expect(walk.perform).not.toHaveBeenCalled();\n      });\n    });\n\n    describe('with senses', () => {\n      test('can call multiple senses per turn', () => {\n        turn.feel();\n        turn.feel();\n      });\n\n      test('executes immediately when calling sense', () => {\n        turn.feel();\n        expect(feel.perform).toHaveBeenCalled();\n      });\n    });\n  });\n\n  test('prepares turn by calling playTurn with next turn object', () => {\n    unit.getNextTurn = () => 'nextTurn' as any;\n    unit.playTurn = vi.fn();\n    unit.prepareTurn();\n    expect(unit.playTurn).toHaveBeenCalledWith('nextTurn');\n  });\n\n  test('calls passTurn once on effects when calling perform on turn', () => {\n    const ticking = { passTurn: vi.fn(), trigger: vi.fn() };\n    unit.addEffect('ticking', ticking as any);\n    unit.turn = { action: null };\n    unit.performTurn();\n    expect(ticking.passTurn).toHaveBeenCalledTimes(1);\n  });\n\n  test('performs action when calling perform on turn', () => {\n    const walk = { perform: vi.fn() };\n    unit.addAbility('walk', walk as any);\n    unit.turn = { action: ['walk', ['backward']] };\n    unit.performTurn();\n    expect(walk.perform).toHaveBeenCalledWith('backward');\n  });\n\n  test(\"doesn't throw when calling performTurn when there is no action\", () => {\n    unit.turn = { action: null };\n    unit.performTurn();\n  });\n\n  describe('when healing', () => {\n    test('adds health', () => {\n      unit.health = 5;\n      unit.heal(3);\n      expect(unit.health).toBe(8);\n      expect(unit.log).toHaveBeenCalledWith('recovers 3 HP, up to 8 HP');\n    });\n\n    test(\"doesn't go over max health\", () => {\n      unit.health = 19;\n      unit.heal(2);\n      expect(unit.health).toBe(20);\n      expect(unit.log).toHaveBeenCalledWith('recovers 2 HP, up to 20 HP');\n    });\n\n    test(\"doesn't add health when at max\", () => {\n      unit.heal(1);\n      expect(unit.health).toBe(20);\n      expect(unit.log).toHaveBeenCalledWith('recovers 1 HP, up to 20 HP');\n    });\n  });\n\n  describe('when taking damage', () => {\n    test('subtracts health', () => {\n      unit.takeDamage(3);\n      expect(unit.health).toBe(17);\n      expect(unit.log).toHaveBeenCalledWith('takes 3 damage, 17 HP left');\n    });\n\n    test(\"doesn't go under zero health\", () => {\n      unit.takeDamage(21);\n      expect(unit.health).toBe(0);\n      expect(unit.log).toHaveBeenCalledWith('takes 21 damage, 0 HP left');\n    });\n\n    test('dies when running out of health', () => {\n      unit.takeDamage(20);\n      expect(unit.isAlive()).toBe(false);\n      expect(unit.log).toHaveBeenCalledWith('takes 20 damage, 0 HP left');\n      expect(unit.log).toHaveBeenCalledWith('dies');\n    });\n  });\n\n  describe('when dead', () => {\n    beforeEach(() => {\n      unit.position = null;\n    });\n\n    test(\"doesn't perform any action\", () => {\n      const walk = { perform: vi.fn() };\n      unit.addAbility('walk', walk as any);\n      unit.turn = { action: ['walk', []] };\n      unit.performTurn();\n      expect(walk.perform).not.toHaveBeenCalled();\n    });\n  });\n\n  test('can damage another unit', () => {\n    const receiver = new Unit();\n    receiver.health = 10;\n    receiver.position = {} as any;\n    receiver.log = vi.fn();\n    unit.damage(receiver, 3);\n    expect(receiver.health).toBe(7);\n  });\n\n  describe('when dealing damage', () => {\n    let receiver: Unit;\n\n    beforeEach(() => {\n      receiver = new Unit();\n      receiver.maxHealth = 5;\n      receiver.reward = 10;\n      receiver.health = 5;\n      receiver.position = {} as any;\n      receiver.as = () => ({\n        isEnemy: () => true,\n        isBound: () => false,\n        isUnderEffect: () => false,\n      });\n      receiver.log = vi.fn();\n    });\n\n    test('earns points equal to reward when killing unit', () => {\n      unit.earnPoints = vi.fn();\n      unit.damage(receiver, 5);\n      expect(unit.earnPoints).toHaveBeenCalledWith(10);\n    });\n\n    test(\"doesn't earn points when not killing unit\", () => {\n      unit.earnPoints = vi.fn();\n      unit.damage(receiver, 3);\n      expect(unit.earnPoints).not.toHaveBeenCalled();\n    });\n\n    test('lose points equal to reward when killing a friend', () => {\n      receiver.as = () => ({\n        isEnemy: () => false,\n        isBound: () => false,\n        isUnderEffect: () => false,\n      });\n      unit.losePoints = vi.fn();\n      unit.damage(receiver, 5);\n      expect(unit.losePoints).toHaveBeenCalledWith(10);\n    });\n  });\n\n  test('considers itself alive with position', () => {\n    expect(unit.isAlive()).toBe(true);\n  });\n\n  test('considers itself dead when no position', () => {\n    unit.position = null;\n    expect(unit.isAlive()).toBe(false);\n  });\n\n  describe('when releasing', () => {\n    let receiver: Unit;\n\n    beforeEach(() => {\n      receiver = new Unit();\n      receiver.reward = 10;\n      receiver.bound = true;\n      receiver.position = {} as any;\n      receiver.as = () => ({\n        isEnemy: () => true,\n        isBound: () => false,\n        isUnderEffect: () => false,\n      });\n      receiver.log = vi.fn();\n    });\n\n    test('unbinds the unit', () => {\n      receiver.unbind = vi.fn();\n      unit.release(receiver);\n      expect(receiver.unbind).toHaveBeenCalled();\n    });\n\n    test(\"doesn't earn points\", () => {\n      unit.earnPoints = vi.fn();\n      unit.release(receiver);\n      expect(unit.earnPoints).not.toHaveBeenCalled();\n    });\n\n    describe('friendly unit', () => {\n      beforeEach(() => {\n        receiver.as = () => ({\n          isEnemy: () => false,\n          isBound: () => false,\n          isUnderEffect: () => false,\n        });\n      });\n\n      test('vanishes the unit', () => {\n        receiver.vanish = vi.fn();\n        unit.release(receiver);\n        expect(receiver.vanish).toHaveBeenCalled();\n      });\n\n      test('earns points equal to reward', () => {\n        unit.earnPoints = vi.fn();\n        unit.release(receiver);\n        expect(unit.earnPoints).toHaveBeenCalledWith(10);\n      });\n    });\n  });\n\n  test('is bound after calling bind', () => {\n    unit.bind();\n    expect(unit.isBound()).toBe(true);\n  });\n\n  describe('when bound', () => {\n    beforeEach(() => {\n      unit.bind();\n    });\n\n    test(\"doesn't perform any action\", () => {\n      const walk = { perform: vi.fn() };\n      unit.addAbility('walk', walk as any);\n      unit.turn = { action: ['walk', []] };\n      unit.performTurn();\n      expect(walk.perform).not.toHaveBeenCalled();\n    });\n\n    test('is released from bonds when calling unbind', () => {\n      unit.unbind();\n      expect(unit.isBound()).toBe(false);\n    });\n\n    test('is released from bonds when taking damage', () => {\n      unit.takeDamage(2);\n      expect(unit.isBound()).toBe(false);\n    });\n  });\n\n  test('can earn points', () => {\n    unit.earnPoints(5);\n    expect(unit.score).toBe(5);\n  });\n\n  test('can lose points', () => {\n    unit.score = 10;\n    unit.losePoints(5);\n    expect(unit.score).toBe(5);\n  });\n\n  test('can lose points under zero', () => {\n    unit.score = 3;\n    unit.losePoints(5);\n    expect(unit.score).toBe(-2);\n  });\n\n  test('allows to add abilities', () => {\n    expect(unit.abilities.has('walk')).toBe(false);\n    unit.addAbility('walk', {} as any);\n    expect(unit.abilities.has('walk')).toBe(true);\n  });\n\n  test('allows to add effects', () => {\n    expect(unit.effects.has('ticking')).toBe(false);\n    unit.addEffect('ticking', {} as any);\n    expect(unit.effects.has('ticking')).toBe(true);\n  });\n\n  test('can trigger a given effect when it has such effect', () => {\n    const ticking = { trigger: vi.fn(), passTurn: vi.fn() };\n    unit.addEffect('ticking', ticking as any);\n    const itching = { trigger: vi.fn(), passTurn: vi.fn() };\n    unit.triggerEffect('ticking');\n    unit.triggerEffect('itching');\n    expect(ticking.trigger).toHaveBeenCalled();\n    expect(itching.trigger).not.toHaveBeenCalled();\n  });\n\n  test('considers itself under an effect when it has such effect', () => {\n    unit.addEffect('ticking', {} as any);\n    expect(unit.isUnderEffect('ticking')).toBe(true);\n  });\n\n  test(\"doesn't fetch itself when fetching other units\", () => {\n    const anotherUnit = new Unit();\n    floor.addUnit(anotherUnit, { x: 3, y: 4, facing: NORTH });\n    expect(unit.getOtherUnits()).not.toContain(unit);\n    expect(unit.getOtherUnits()).toContain(anotherUnit);\n  });\n\n  test(\"returns the space where it's located\", () => {\n    const space = unit.getSpace();\n    expect(space.location).toEqual(unit.position?.location);\n  });\n\n  test('returns sensed space at a given direction and number of spaces', () => {\n    const space = { as: vi.fn() } as any;\n    unit.getSpaceAt = vi.fn(() => space);\n    unit.getSensedSpaceAt(RIGHT, 2, 1);\n    expect(unit.getSpaceAt).toHaveBeenCalledWith(RIGHT, 2, 1);\n    expect(space.as).toHaveBeenCalledWith(unit);\n  });\n\n  test('returns space at a given direction and number of spaces', () => {\n    unit.position = { getRelativeSpace: vi.fn() } as any;\n    unit.getSpaceAt(RIGHT, 2, 1);\n    expect(unit.position?.getRelativeSpace).toHaveBeenCalledWith(RIGHT, [2, 1]);\n  });\n\n  test('returns immediate space at a given direction if number of spaces is omitted', () => {\n    unit.position = { getRelativeSpace: vi.fn() } as any;\n    unit.getSpaceAt(LEFT);\n    expect(unit.position?.getRelativeSpace).toHaveBeenCalledWith(LEFT, [1, 0]);\n  });\n\n  test('returns the direction of the stairs', () => {\n    expect(unit.getDirectionOfStairs()).toEqual(FORWARD);\n  });\n\n  test('returns the direction of a given space', () => {\n    expect(unit.getDirectionOf(unit.getSensedSpaceAt(FORWARD, 1))).toEqual(FORWARD);\n    expect(unit.getDirectionOf(unit.getSensedSpaceAt(RIGHT, 1))).toEqual(RIGHT);\n    expect(unit.getDirectionOf(unit.getSensedSpaceAt(BACKWARD, 1))).toEqual(BACKWARD);\n    expect(unit.getDirectionOf(unit.getSensedSpaceAt(LEFT, 1))).toEqual(LEFT);\n  });\n\n  test('returns the distance of a given space', () => {\n    expect(unit.getDistanceOf(unit.getSensedSpaceAt(FORWARD, 2, -1))).toBe(3);\n  });\n\n  describe('when moving', () => {\n    beforeEach(() => {\n      unit.position = { move: vi.fn() } as any;\n    });\n\n    test('moves in the given direction by a given number of spaces', () => {\n      unit.move(RIGHT, 2, 1);\n      expect(unit.position?.move).toHaveBeenCalledWith(RIGHT, [2, 1]);\n    });\n\n    test('moves one space in the given direction if number of spaces is omitted', () => {\n      unit.move(LEFT);\n      expect(unit.position?.move).toHaveBeenCalledWith(LEFT, [1, 0]);\n    });\n  });\n\n  describe('when rotating', () => {\n    beforeEach(() => {\n      unit.position = { rotate: vi.fn() } as any;\n    });\n\n    test('rotates in the given direction', () => {\n      unit.rotate(RIGHT);\n      expect(unit.position?.rotate).toHaveBeenCalledWith(RIGHT);\n    });\n  });\n\n  describe('when vanishing', () => {\n    test('disappears from the floor', () => {\n      expect(unit.position).not.toBeNull();\n      unit.vanish();\n      expect(unit.position).toBeNull();\n    });\n  });\n\n  describe('sensed unit', () => {\n    let sensingUnit: Unit;\n    let sensedUnit: any;\n\n    beforeEach(() => {\n      sensingUnit = new Unit();\n      sensingUnit.enemy = false;\n      floor.addUnit(sensingUnit, { x: 0, y: 1, facing: SOUTH });\n      sensedUnit = unit.as(sensingUnit);\n    });\n\n    test('allows calling sensed unit methods', () => {\n      const allowedApi = ['isBound', 'isEnemy', 'isUnderEffect'];\n      allowedApi.forEach((propertyName) => {\n        sensedUnit[propertyName]();\n      });\n    });\n\n    test(\"is considered enemy if it doesn't fight for the same side\", () => {\n      expect(sensedUnit.isEnemy()).toBe(true);\n    });\n\n    test(\"doesn't allow calling other unit methods\", () => {\n      const forbiddenApi = [\n        'addAbility',\n        'addEffect',\n        'as',\n        'bind',\n        'damage',\n        'earnPoints',\n        'getDirectionOf',\n        'getDirectionOfStairs',\n        'getDistanceOf',\n        'getNextTurn',\n        'getOtherUnits',\n        'getSpace',\n        'getSpaceAt',\n        'heal',\n        'isAlive',\n        'log',\n        'losePoints',\n        'move',\n        'performTurn',\n        'prepareTurn',\n        'rotate',\n        'takeDamage',\n        'triggerEffect',\n        'unbind',\n        'vanish',\n      ];\n      forbiddenApi.forEach((propertyName) => {\n        expect(sensedUnit).not.toHaveProperty(propertyName);\n      });\n    });\n  });\n\n  test('has a nice string representation', () => {\n    expect(unit.toString()).toBe(unit.name);\n  });\n\n  test('has a minimal JSON representation', () => {\n    expect(unit.toJSON()).toEqual({\n      name: 'Joe',\n      color: '#8fbcbb',\n      maxHealth: 20,\n    });\n  });\n});\n"
  },
  {
    "path": "libs/core/src/Unit.ts",
    "content": "import { type RelativeDirection } from '@warriorjs/spatial';\n\nimport type Ability from './Ability.js';\nimport { type AbilityEntry } from './Ability.js';\nimport Action from './Action.js';\nimport type Effect from './Effect.js';\nimport Logger from './Logger.js';\nimport type Position from './Position.js';\nimport Space, { type SensedSpace, type SensedUnit } from './Space.js';\n\nexport type Turn = Record<string, (...args: any[]) => any>;\n\ninterface TurnState {\n  action: [string, any[]] | null;\n  [key: string]: any;\n}\n\nexport interface UnitClass {\n  new (): Unit;\n  declaredAbilities?: Record<string, AbilityEntry>;\n}\n\nclass Unit {\n  static declaredAbilities?: Record<string, AbilityEntry>;\n  name: string;\n  character: string;\n  color: string;\n  maxHealth: number;\n  reward: number;\n  enemy: boolean;\n  bound: boolean;\n  position: Position | null;\n  health: number;\n  score: number;\n  abilities: Map<string, Ability>;\n  effects: Map<string, Effect>;\n  turn: TurnState | null;\n\n  constructor(\n    name?: string,\n    character?: string,\n    color?: string,\n    maxHealth?: number,\n    reward: number | null = null,\n    enemy: boolean = true,\n    bound: boolean = false,\n  ) {\n    this.name = name!;\n    this.character = character!;\n    this.color = color!;\n    this.maxHealth = maxHealth!;\n    this.reward = reward === null ? maxHealth! : reward;\n    this.enemy = enemy;\n    this.bound = bound;\n    this.position = null;\n    this.health = maxHealth!;\n    this.score = 0;\n    this.abilities = new Map();\n    this.effects = new Map();\n    this.turn = null;\n  }\n\n  getNextTurn(): TurnState {\n    const turn: TurnState = { action: null };\n    this.abilities.forEach((ability, name) => {\n      if (ability instanceof Action) {\n        Object.defineProperty(turn, name, {\n          value: (...args: any[]) => {\n            if (turn.action) {\n              throw new Error('Only one action can be performed per turn.');\n            }\n\n            turn.action = [name, args];\n          },\n        });\n      } else {\n        Object.defineProperty(turn, name, {\n          value: (...args: any[]) => ability.perform(...args),\n        });\n      }\n    });\n    return turn;\n  }\n\n  playTurn(_turn: Turn): void {}\n\n  prepareTurn(): void {\n    this.turn = this.getNextTurn();\n    this.playTurn(this.turn);\n  }\n\n  performTurn(): void {\n    if (this.isAlive()) {\n      this.effects.forEach((effect) => effect.passTurn());\n      if (this.turn?.action && !this.isBound()) {\n        const [name, args] = this.turn.action;\n        this.abilities.get(name)?.perform(...args);\n      }\n    }\n  }\n\n  heal(amount: number): void {\n    const revisedAmount =\n      this.health + amount > this.maxHealth ? this.maxHealth - this.health : amount;\n    this.health += revisedAmount;\n    this.log(`recovers ${amount} HP, up to ${this.health} HP`);\n  }\n\n  takeDamage(amount: number): void {\n    if (this.isBound()) {\n      this.unbind();\n    }\n\n    const revisedAmount = this.health - amount < 0 ? this.health : amount;\n    this.health -= revisedAmount;\n    this.log(`takes ${amount} damage, ${this.health} HP left`);\n\n    if (this.health === 0) {\n      this.vanish();\n      this.log('dies');\n    }\n  }\n\n  damage(receiver: Unit, amount: number): void {\n    receiver.takeDamage(amount);\n    if (!receiver.isAlive()) {\n      if (receiver.as(this).isEnemy()) {\n        this.earnPoints(receiver.reward);\n      } else {\n        this.losePoints(receiver.reward);\n      }\n    }\n  }\n\n  isAlive(): boolean {\n    return this.position !== null;\n  }\n\n  release(receiver: Unit): void {\n    if (!receiver.as(this).isEnemy()) {\n      receiver.vanish();\n    }\n\n    receiver.unbind();\n    if (!receiver.isAlive()) {\n      this.earnPoints(receiver.reward);\n    }\n  }\n\n  unbind(): void {\n    this.bound = false;\n    this.log('released from bonds');\n  }\n\n  bind(): void {\n    this.bound = true;\n  }\n\n  isBound(): boolean {\n    return this.bound;\n  }\n\n  earnPoints(points: number): void {\n    this.score += points;\n  }\n\n  losePoints(points: number): void {\n    this.score -= points;\n  }\n\n  addAbility(name: string, ability: Ability): void {\n    this.abilities.set(name, ability);\n  }\n\n  addEffect(name: string, effect: Effect): void {\n    this.effects.set(name, effect);\n  }\n\n  triggerEffect(name: string): void {\n    const effect = this.effects.get(name);\n    if (effect) {\n      effect.trigger();\n    }\n  }\n\n  isUnderEffect(name: string): boolean {\n    return this.effects.has(name);\n  }\n\n  getOtherUnits(): Unit[] {\n    return this.position!.floor.getUnits().filter((unit) => unit !== this);\n  }\n\n  getSpace(): Space {\n    return this.position!.getSpace();\n  }\n\n  getSensedSpaceAt(\n    direction: RelativeDirection,\n    forward: number = 1,\n    right: number = 0,\n  ): SensedSpace {\n    return this.getSpaceAt(direction, forward, right).as(this);\n  }\n\n  getSpaceAt(direction: RelativeDirection, forward: number = 1, right: number = 0): Space {\n    return this.position!.getRelativeSpace(direction, [forward, right]);\n  }\n\n  getDirectionOfStairs(): RelativeDirection {\n    return this.position!.getRelativeDirectionOf(this.position!.floor.getStairsSpace());\n  }\n\n  getDirectionOf(sensedSpace: SensedSpace): RelativeDirection {\n    const space = Space.from(sensedSpace, this);\n    return this.position!.getRelativeDirectionOf(space);\n  }\n\n  getDistanceOf(sensedSpace: SensedSpace): number {\n    const space = Space.from(sensedSpace, this);\n    return this.position!.getDistanceOf(space);\n  }\n\n  move(direction: RelativeDirection, forward: number = 1, right: number = 0): void {\n    this.position!.move(direction, [forward, right]);\n  }\n\n  rotate(direction: RelativeDirection): void {\n    this.position?.rotate(direction);\n  }\n\n  vanish(): void {\n    this.position = null;\n  }\n\n  log(message: string): void {\n    Logger.unit(this, message);\n  }\n\n  as(unit: Unit): SensedUnit {\n    return {\n      isBound: this.isBound.bind(this),\n      isEnemy: () => this.enemy !== unit.enemy,\n      isUnderEffect: this.isUnderEffect.bind(this),\n    };\n  }\n\n  toString(): string {\n    return this.name;\n  }\n\n  toJSON(): { name: string; color: string; maxHealth: number } {\n    return {\n      name: this.name,\n      color: this.color,\n      maxHealth: this.maxHealth,\n    };\n  }\n}\n\nexport default Unit;\n"
  },
  {
    "path": "libs/core/src/Warrior.test.ts",
    "content": "import { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Action from './Action.js';\nimport Sense from './Sense.js';\nimport Warrior from './Warrior.js';\n\nclass MockAction extends Action {\n  readonly description: string;\n  readonly meta = { params: [], returns: 'void' as const };\n  constructor(unit: any, description: string) {\n    super(unit);\n    this.description = description;\n  }\n  perform = vi.fn();\n}\n\nclass MockSense extends Sense {\n  readonly description: string;\n  readonly meta = { params: [], returns: 'void' as const };\n  constructor(unit: any, description: string) {\n    super(unit);\n    this.description = description;\n  }\n  perform = vi.fn();\n}\n\ndescribe('Warrior', () => {\n  let warrior: Warrior;\n\n  beforeEach(() => {\n    warrior = new Warrior('Joe', '@', '#8fbcbb', 20);\n    warrior.addAbility('feel', new MockSense(warrior, 'a description'));\n    warrior.addAbility('walk', new MockAction(warrior, 'a description'));\n    warrior.log = vi.fn();\n  });\n\n  test('is upset for not doing anything when no action', () => {\n    warrior.turn = { action: null };\n    warrior.performTurn();\n    expect(warrior.log).toHaveBeenCalledWith('does nothing');\n  });\n\n  test('is upset for not doing anything when bound', () => {\n    warrior.bind();\n    warrior.turn = { action: ['walk', []] };\n    warrior.performTurn();\n    expect(warrior.log).toHaveBeenCalledWith('does nothing');\n  });\n\n  test('is proud of earning points', () => {\n    warrior.earnPoints(5);\n    expect(warrior.log).toHaveBeenCalledWith('earns 5 points');\n  });\n\n  test('is upset for losing points', () => {\n    warrior.losePoints(5);\n    expect(warrior.log).toHaveBeenCalledWith('loses 5 points');\n  });\n\n  test('has a grouped collection of abilities', () => {\n    expect(warrior.getAbilities()).toEqual({\n      actions: [{ name: 'walk', description: 'a description' }],\n      senses: [{ name: 'feel', description: 'a description' }],\n    });\n  });\n\n  test('has a status', () => {\n    expect(warrior.getStatus()).toEqual({\n      health: 20,\n      score: 0,\n    });\n  });\n});\n"
  },
  {
    "path": "libs/core/src/Warrior.ts",
    "content": "import Action from './Action.js';\nimport Unit from './Unit.js';\n\ninterface AbilityInfo {\n  name: string;\n  isAction: boolean;\n  description?: string;\n}\n\nclass Warrior extends Unit {\n  constructor(name?: string, character?: string, color?: string, maxHealth?: number) {\n    super(name, character, color, maxHealth, null, false);\n  }\n\n  performTurn(): void {\n    super.performTurn();\n    const turn = this.turn as { action: [string, any[]] | null };\n    if (!turn.action || this.isBound()) {\n      this.log('does nothing');\n    }\n  }\n\n  earnPoints(points: number): void {\n    super.earnPoints(points);\n    this.log(`earns ${points} points`);\n  }\n\n  losePoints(points: number): void {\n    super.losePoints(points);\n    this.log(`loses ${points} points`);\n  }\n\n  getAbilities(): {\n    actions: Omit<AbilityInfo, 'isAction'>[];\n    senses: Omit<AbilityInfo, 'isAction'>[];\n  } {\n    const abilities: AbilityInfo[] = [...this.abilities].map(([name, ability]) => ({\n      name,\n      isAction: ability instanceof Action,\n      description: ability.description,\n    }));\n    const sortedAbilities = abilities.sort((a, b) => (a.name > b.name ? 1 : -1));\n    const actions = sortedAbilities\n      .filter((ability) => ability.isAction)\n      .map(({ isAction, ...rest }) => rest);\n    const senses = sortedAbilities\n      .filter((ability) => !ability.isAction)\n      .map(({ isAction, ...rest }) => rest);\n    return {\n      actions,\n      senses,\n    };\n  }\n\n  getStatus(): { health: number; score: number } {\n    return {\n      health: this.health,\n      score: this.score,\n    };\n  }\n}\n\nexport default Warrior;\n"
  },
  {
    "path": "libs/core/src/getLevel.test.ts",
    "content": "import { EAST, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial';\nimport { expect, test } from 'vitest';\n\nimport { type AbilityMeta } from './Ability.js';\nimport Action from './Action.js';\nimport getLevel from './getLevel.js';\nimport Sense from './Sense.js';\nimport { type LevelConfig } from './types.js';\nimport Unit from './Unit.js';\n\nclass TestWalk extends Action {\n  readonly description = \"Moves one space in the given direction (`'forward'` by default).\";\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'void',\n  };\n  perform() {}\n}\n\nclass TestAttack extends Action {\n  readonly description: string;\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'void',\n  };\n  constructor(unit: any, { power }: { power: number }) {\n    super(unit);\n    this.description = `Attacks a unit in the given direction (\\`'forward'\\` by default), dealing ${power} HP of damage.`;\n  }\n  perform() {}\n  static with(config: { power: number }) {\n    return [TestAttack, config] as [new (unit: any, config: any) => TestAttack, object];\n  }\n}\n\nclass TestFeel extends Sense {\n  readonly description =\n    \"Returns the adjacent space in the given direction (`'forward'` by default).\";\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'Space',\n  };\n  perform() {}\n}\n\nclass TestSludge extends Unit {\n  static declaredAbilities = {\n    attack: TestAttack.with({ power: 3 }),\n    feel: TestFeel,\n  };\n\n  constructor() {\n    super('Sludge', 's', '#d08770', 12);\n    this.playTurn = (turn: any) => {\n      const playerDirection = RELATIVE_DIRECTIONS.find((direction) => {\n        const space = turn.feel(direction);\n        return space.isUnit() && space.getUnit().isPlayer();\n      });\n      if (playerDirection) {\n        turn.attack(playerDirection);\n      }\n    };\n  }\n}\n\nconst levelConfig = {\n  number: 2,\n  description: \"It's too dark to see anything, but you smell sludge nearby.\",\n  tip: \"Use `warrior.feel().isEmpty()` to see if there's anything in front of you, and `warrior.attack()` to fight it. Remember, you can only do one action per turn.\",\n  clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.',\n  floor: {\n    size: { width: 8, height: 1 },\n    stairs: { x: 7, y: 0 },\n    warrior: {\n      name: 'Joe',\n      character: '@',\n      color: '#8fbcbb',\n      maxHealth: 20,\n      abilities: {\n        walk: TestWalk,\n        attack: TestAttack.with({ power: 5 }),\n        feel: TestFeel,\n      },\n      position: { x: 0, y: 0, facing: EAST },\n    },\n    units: [\n      {\n        unit: TestSludge,\n        position: { x: 4, y: 0, facing: WEST },\n      },\n    ],\n  },\n} satisfies LevelConfig;\n\ntest('returns level', () => {\n  expect(getLevel(levelConfig)).toEqual({\n    number: 2,\n    description: \"It's too dark to see anything, but you smell sludge nearby.\",\n    tip: \"Use `warrior.feel().isEmpty()` to see if there's anything in front of you, and `warrior.attack()` to fight it. Remember, you can only do one action per turn.\",\n    clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.',\n    floorMap: [\n      [\n        { character: '\\u2554' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2557' },\n      ],\n      [\n        { character: '\\u2551' },\n        {\n          character: '@',\n          unit: { name: 'Joe', color: '#8fbcbb', maxHealth: 20 },\n        },\n        { character: ' ' },\n        { character: ' ' },\n        { character: ' ' },\n        {\n          character: 's',\n          unit: { name: 'Sludge', color: '#d08770', maxHealth: 12 },\n        },\n        { character: ' ' },\n        { character: ' ' },\n        { character: '>' },\n        { character: '\\u2551' },\n      ],\n      [\n        { character: '\\u255a' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u2550' },\n        { character: '\\u255d' },\n      ],\n    ],\n    warriorStatus: { health: 20, score: 0 },\n    warriorAbilities: {\n      actions: [\n        {\n          name: 'attack',\n          description:\n            \"Attacks a unit in the given direction (`'forward'` by default), dealing 5 HP of damage.\",\n        },\n        {\n          name: 'walk',\n          description: \"Moves one space in the given direction (`'forward'` by default).\",\n        },\n      ],\n      senses: [\n        {\n          name: 'feel',\n          description:\n            \"Returns the adjacent space in the given direction (`'forward'` by default).\",\n        },\n      ],\n    },\n  });\n});\n"
  },
  {
    "path": "libs/core/src/getLevel.ts",
    "content": "import loadLevel from './loadLevel.js';\nimport { type LevelConfig } from './types.js';\n\nfunction getLevel(levelConfig: LevelConfig): any {\n  const level = loadLevel(levelConfig);\n  return JSON.parse(JSON.stringify(level));\n}\n\nexport default getLevel;\n"
  },
  {
    "path": "libs/core/src/getLevelConfig.test.ts",
    "content": "import { expect, test } from 'vitest';\n\nimport getLevelConfig from './getLevelConfig.js';\n\nconst tower = {\n  name: 'Foo',\n  description: 'A test tower',\n  warrior: {\n    character: '@',\n    color: '#fff',\n    maxHealth: 20,\n  },\n  levels: [\n    {\n      floor: {\n        warrior: { abilities: { a: 1 }, position: { x: 0, y: 0, facing: 'east' } },\n        size: { width: 1, height: 1 },\n        stairs: { x: 0, y: 0 },\n        units: [],\n      },\n    },\n    {\n      floor: {\n        warrior: { abilities: { b: 2, c: 3 }, position: { x: 0, y: 0, facing: 'east' } },\n        size: { width: 1, height: 1 },\n        stairs: { x: 0, y: 0 },\n        units: [],\n      },\n    },\n    {\n      floor: {\n        warrior: { position: { x: 0, y: 0, facing: 'east' } },\n        size: { width: 1, height: 1 },\n        stairs: { x: 0, y: 0 },\n        units: [],\n      },\n    },\n    {\n      floor: {\n        warrior: { abilities: { a: 4 }, position: { x: 0, y: 0, facing: 'east' } },\n        size: { width: 1, height: 1 },\n        stairs: { x: 0, y: 0 },\n        units: [],\n      },\n    },\n  ],\n} as any;\n\ntest('merges tower warrior with level warrior', () => {\n  const config = getLevelConfig(tower, 1, 'Joe', false);\n  expect(config).not.toBeNull();\n  expect(config!.floor.warrior).toEqual({\n    character: '@',\n    color: '#fff',\n    maxHealth: 20,\n    name: 'Joe',\n    abilities: { a: 1 },\n    position: { x: 0, y: 0, facing: 'east' },\n  });\n});\n\ntest('accumulates abilities from all levels if epic', () => {\n  const config = getLevelConfig(tower, 1, 'Joe', true);\n  expect(config!.floor.warrior.abilities).toEqual({ a: 4, b: 2, c: 3 });\n});\n\ntest('accumulates abilities up to current level', () => {\n  const config = getLevelConfig(tower, 2, 'Joe', false);\n  expect(config!.floor.warrior.abilities).toEqual({ a: 1, b: 2, c: 3 });\n});\n\ntest('returns null for non-existent level', () => {\n  expect(getLevelConfig(tower, 5, 'Joe', false)).toBeNull();\n});\n\ntest('does not mutate original tower config', () => {\n  const config = getLevelConfig(tower, 1, 'Joe', false);\n  config!.floor.warrior.name = 'Modified';\n  expect(tower.warrior).not.toHaveProperty('name');\n  expect(tower.levels[0].floor.warrior).not.toHaveProperty('name');\n});\n"
  },
  {
    "path": "libs/core/src/getLevelConfig.ts",
    "content": "import { type LevelConfig, type TowerDefinition } from './types.js';\n\nfunction deepClone<T>(obj: T): T {\n  if (obj === null || typeof obj !== 'object' || obj.constructor !== Object) {\n    return obj;\n  }\n\n  if (Array.isArray(obj)) {\n    return obj.map((item) => deepClone(item)) as T;\n  }\n\n  const clone = {} as Record<string, unknown>;\n  for (const key of Object.keys(obj)) {\n    clone[key] = deepClone((obj as Record<string, unknown>)[key]);\n  }\n  return clone as T;\n}\n\n/**\n * Returns the config for the level with the given number.\n *\n * @param tower The tower.\n * @param levelNumber The number of the level.\n * @param warriorName The name of the warrior.\n * @param epic Whether the level is to be used in epic mode or not.\n *\n * @returns The level config.\n */\nfunction getLevelConfig(\n  tower: TowerDefinition,\n  levelNumber: number,\n  warriorName: string,\n  epic: boolean,\n): LevelConfig | null {\n  const level = tower.levels[levelNumber - 1];\n  if (!level) {\n    return null;\n  }\n\n  const levelConfig = deepClone(level) as unknown as LevelConfig;\n\n  const levels = epic ? tower.levels : tower.levels.slice(0, levelNumber);\n  const warriorAbilities = Object.assign(\n    {},\n    ...levels.map(\n      ({\n        floor: {\n          warrior: { abilities },\n        },\n      }) => abilities || {},\n    ),\n  );\n\n  levelConfig.number = levelNumber;\n  levelConfig.floor.warrior = {\n    ...tower.warrior,\n    ...levelConfig.floor.warrior,\n    name: warriorName,\n    abilities: warriorAbilities,\n  };\n  return levelConfig;\n}\n\nexport default getLevelConfig;\n"
  },
  {
    "path": "libs/core/src/index.ts",
    "content": "export type { AbilityBinding, AbilityEntry, AbilityMeta, AbilityParam } from './Ability.js';\nexport { default as Ability } from './Ability.js';\nexport { default as Action } from './Action.js';\nexport type { EffectBinding, EffectEntry } from './Effect.js';\nexport { default as Effect } from './Effect.js';\nexport { default as getLevel } from './getLevel.js';\nexport { default as getLevelConfig } from './getLevelConfig.js';\nexport type { TurnEvent } from './Logger.js';\nexport { default as runLevel } from './runLevel.js';\nexport { default as Sense } from './Sense.js';\nexport type { SensedSpace, SensedUnit } from './Space.js';\nexport type {\n  LevelConfig,\n  LevelDefinition,\n  TowerDefinition,\n  UnitConfig,\n  WarriorConfig,\n  WarriorDefinition,\n  WarriorOverrides,\n} from './types.js';\nexport type { Turn, UnitClass } from './Unit.js';\nexport { default as Unit } from './Unit.js';\n"
  },
  {
    "path": "libs/core/src/loadLevel.ts",
    "content": "import { type AbilityEntry } from './Ability.js';\nimport { type EffectEntry } from './Effect.js';\nimport Floor from './Floor.js';\nimport Level from './Level.js';\nimport loadPlayer from './loadPlayer.js';\nimport { type LevelConfig, type UnitConfig } from './types.js';\nimport type Unit from './Unit.js';\nimport Warrior from './Warrior.js';\n\nfunction loadAbilities(unit: Unit, abilities: Record<string, AbilityEntry> = {}): void {\n  for (const [name, entry] of Object.entries(abilities)) {\n    if (Array.isArray(entry)) {\n      const [AbilityClass, config] = entry;\n      unit.addAbility(name, new AbilityClass(unit, config));\n    } else {\n      const AbilityClass = entry;\n      unit.addAbility(name, new AbilityClass(unit));\n    }\n  }\n}\n\nfunction loadEffects(unit: Unit, effects: Record<string, EffectEntry> = {}): void {\n  for (const [name, entry] of Object.entries(effects)) {\n    if (Array.isArray(entry)) {\n      const [EffectClass, config] = entry;\n      unit.addEffect(name, new EffectClass(unit, config));\n    } else {\n      const EffectClass = entry;\n      unit.addEffect(name, new EffectClass(unit));\n    }\n  }\n}\n\nfunction loadWarrior(\n  warrior: LevelConfig['floor']['warrior'],\n  floor: Floor,\n  playerCode?: string,\n  language: 'javascript' | 'typescript' = 'javascript',\n): void {\n  const { name, character, color, maxHealth, abilities, position } = warrior;\n  const unit = new Warrior(name, character, color, maxHealth);\n  loadAbilities(unit, abilities);\n  unit.playTurn = playerCode ? loadPlayer(playerCode, language) : () => {};\n  floor.addWarrior(unit, position);\n}\n\nfunction loadUnit({ unit: UnitClass, effects, position }: UnitConfig, floor: Floor): void {\n  const unit = new UnitClass();\n  if (UnitClass.declaredAbilities) {\n    loadAbilities(unit, UnitClass.declaredAbilities);\n  }\n  if (effects) {\n    loadEffects(unit, effects);\n  }\n  floor.addUnit(unit, position);\n}\n\nfunction loadLevel(\n  { number, description, tip, clue, floor: { size, stairs, warrior, units = [] } }: LevelConfig,\n  playerCode?: string,\n  language: 'javascript' | 'typescript' = 'javascript',\n): Level {\n  const { width, height } = size;\n  const floor = new Floor(width, height, [stairs.x, stairs.y]);\n\n  loadWarrior(warrior, floor, playerCode, language);\n  for (const entry of units) {\n    loadUnit(entry as UnitConfig, floor);\n  }\n\n  return new Level(number!, description!, tip!, clue!, floor);\n}\n\nexport default loadLevel;\n"
  },
  {
    "path": "libs/core/src/loadPlayer.test.ts",
    "content": "import { describe, expect, test, vi } from 'vitest';\n\nimport loadPlayer from './loadPlayer.js';\n\ntest('runs player code and returns playTurn function', () => {\n  const playerCode = `\n    class Player {\n      playTurn(warrior) {\n        warrior.walk();\n      }\n    }\n  `;\n  const warrior = { walk: vi.fn() };\n  const playTurn = loadPlayer(playerCode);\n  playTurn(warrior);\n  expect(warrior.walk).toHaveBeenCalled();\n});\n\ntest('throws if invalid syntax', () => {\n  const playerCode = `\n    class Player {\n      playTurn() {}\n  `;\n  expect(() => {\n    loadPlayer(playerCode);\n  }).toThrow('Check your syntax and try again!');\n});\n\ntest('throws if Player class is not defined', () => {\n  const playerCode = 'function playTurn() {}';\n  expect(() => {\n    loadPlayer(playerCode);\n  }).toThrow('You must define a Player class!');\n});\n\ntest('throws if playTurn method is not defined', () => {\n  const playerCode = 'class Player {}';\n  expect(() => {\n    loadPlayer(playerCode);\n  }).toThrow('Your Player class must define a playTurn method!');\n});\n\ntest(\"throws when playing turn if there's something wrong\", () => {\n  const playerCode = `\n    class Player {\n      playTurn(warrior) {\n        warrior.walk();\n      }\n    }\n  `;\n  const playTurn = loadPlayer(playerCode);\n  const warrior = {} as any;\n  expect(() => {\n    playTurn(warrior);\n  }).toThrow('warrior.walk is not a function');\n});\n\ntest('strips export default Player in JavaScript code', () => {\n  const playerCode = `\n    class Player {\n      playTurn(warrior) {\n        warrior.walk();\n      }\n    }\n\n    export default Player;\n  `;\n  const warrior = { walk: vi.fn() };\n  const playTurn = loadPlayer(playerCode);\n  playTurn(warrior);\n  expect(warrior.walk).toHaveBeenCalled();\n});\n\ntest('strips export default Player in TypeScript code', () => {\n  const tsCode = `\n    import type { Warrior } from './types.js';\n\n    class Player {\n      playTurn(warrior: Warrior): void {\n        warrior.walk();\n      }\n    }\n\n    export default Player;\n  `;\n  const warrior = { walk: vi.fn() };\n  const playTurn = loadPlayer(tsCode, 'typescript');\n  playTurn(warrior);\n  expect(warrior.walk).toHaveBeenCalled();\n});\n\ndescribe('TypeScript support', () => {\n  test('loads TypeScript player code', () => {\n    const tsCode = `\n      class Player {\n        playTurn(warrior: any): void {}\n      }\n    `;\n    const playTurn = loadPlayer(tsCode, 'typescript');\n    expect(typeof playTurn).toBe('function');\n  });\n\n  test('executes TypeScript player code correctly', () => {\n    const tsCode = `\n      class Player {\n        playTurn(warrior: any): void {\n          warrior.walk();\n        }\n      }\n    `;\n    const warrior = { walk: vi.fn() };\n    const playTurn = loadPlayer(tsCode, 'typescript');\n    playTurn(warrior);\n    expect(warrior.walk).toHaveBeenCalled();\n  });\n\n  test('strips type-only imports from TypeScript code', () => {\n    const tsCode = `\n      import type { Warrior } from './types.js';\n      class Player {\n        playTurn(warrior: Warrior): void {}\n      }\n    `;\n    const playTurn = loadPlayer(tsCode, 'typescript');\n    expect(typeof playTurn).toBe('function');\n  });\n\n  test('handles TypeScript interfaces and type annotations', () => {\n    const tsCode = `\n      interface Turn {\n        walk(): void;\n      }\n      class Player {\n        private count: number = 0;\n        playTurn(warrior: Turn): void {\n          this.count++;\n        }\n      }\n    `;\n    const playTurn = loadPlayer(tsCode, 'typescript');\n    expect(typeof playTurn).toBe('function');\n  });\n\n  test('throws on invalid TypeScript syntax', () => {\n    const badCode = `\n      class Player {\n        playTurn(warrior: ): void {}\n      }\n    `;\n    expect(() => loadPlayer(badCode, 'typescript')).toThrow();\n  });\n\n  test('throws when Player class is not defined in TypeScript', () => {\n    const code = `const x: number = 1;`;\n    expect(() => loadPlayer(code, 'typescript')).toThrow('You must define a Player class!');\n  });\n\n  test('throws when playTurn method is missing in TypeScript', () => {\n    const code = `class Player { name: string = 'test'; }`;\n    expect(() => loadPlayer(code, 'typescript')).toThrow(\n      'Your Player class must define a playTurn method!',\n    );\n  });\n});\n"
  },
  {
    "path": "libs/core/src/loadPlayer.ts",
    "content": "import assert from 'node:assert';\nimport vm from 'node:vm';\nimport { transformSync } from 'esbuild';\n\nimport { type Turn } from './Unit.js';\n\nconst playerCodeTimeout = 3000;\n\nfunction loadPlayer(\n  playerCode: string,\n  language: 'javascript' | 'typescript' = 'javascript',\n): (turn: Turn) => void {\n  const playerCodeFilename = language === 'typescript' ? 'Player.ts' : 'Player.js';\n  const loader = language === 'typescript' ? 'ts' : 'js';\n\n  let code: string;\n  try {\n    ({ code } = transformSync(playerCode, { loader, format: 'cjs' }));\n  } catch (err: any) {\n    const error: any = new Error(`Check your syntax and try again!\\n\\n${err.message}`);\n    error.code = 'InvalidPlayerCode';\n    throw error;\n  }\n\n  const sandbox = vm.createContext({\n    module: { exports: {} },\n    exports: {},\n  });\n\n  // Do not collect stack frames for errors in the player code.\n  vm.runInContext('Error.stackTraceLimit = 0;', sandbox);\n\n  try {\n    vm.runInContext(code, sandbox, {\n      filename: playerCodeFilename,\n      timeout: playerCodeTimeout,\n    });\n  } catch (err: any) {\n    const error: any = new Error(`Check your syntax and try again!\\n\\n${err.stack}`);\n    error.code = 'InvalidPlayerCode';\n    throw error;\n  }\n\n  try {\n    const player: any = vm.runInContext('new Player();', sandbox, {\n      timeout: playerCodeTimeout,\n    });\n    assert(typeof player.playTurn === 'function', 'playTurn is not defined');\n    const playTurn = (turn: Turn): void => {\n      try {\n        player.playTurn(turn);\n      } catch (err: any) {\n        const error: any = new Error(err.message);\n        error.code = 'InvalidPlayerCode';\n        throw error;\n      }\n    };\n    return playTurn;\n  } catch (err: any) {\n    if (err.message === 'Player is not defined') {\n      const error: any = new Error('You must define a Player class!');\n      error.code = 'InvalidPlayerCode';\n      throw error;\n    } else if (err.message === 'playTurn is not defined') {\n      const error: any = new Error('Your Player class must define a playTurn method!');\n      error.code = 'InvalidPlayerCode';\n      throw error;\n    }\n\n    throw err;\n  }\n}\n\nexport default loadPlayer;\n"
  },
  {
    "path": "libs/core/src/runLevel.test.ts",
    "content": "import { BACKWARD, EAST, FORWARD, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial';\nimport { expect, test } from 'vitest';\n\nimport { type AbilityMeta } from './Ability.js';\nimport Action from './Action.js';\nimport runLevel from './runLevel.js';\nimport Sense from './Sense.js';\nimport { type LevelConfig } from './types.js';\nimport Unit from './Unit.js';\n\nclass TestWalk extends Action {\n  readonly description = 'Walks forward';\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'void',\n  };\n  perform(direction = FORWARD) {\n    const space = this.unit.getSpaceAt(direction);\n    if (space.isEmpty()) {\n      this.unit.move(direction);\n      this.unit.log(`walks ${direction}`);\n    } else {\n      this.unit.log(`walks ${direction} and bumps into ${space}`);\n    }\n  }\n}\n\nclass TestAttack extends Action {\n  readonly description: string;\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'void',\n  };\n  private power: number;\n  constructor(unit: any, { power }: { power: number }) {\n    super(unit);\n    this.description = `Attacks dealing ${power} HP`;\n    this.power = power;\n  }\n  perform(direction = FORWARD) {\n    const receiver = this.unit.getSpaceAt(direction).getUnit();\n    if (receiver) {\n      this.unit.log(`attacks ${direction} and hits ${receiver}`);\n      const amount = direction === BACKWARD ? Math.ceil(this.power / 2.0) : this.power;\n      this.unit.damage(receiver, amount);\n    } else {\n      this.unit.log(`attacks ${direction} and hits nothing`);\n    }\n  }\n  static with(config: { power: number }) {\n    return [TestAttack, config] as [new (unit: any, config: any) => TestAttack, object];\n  }\n}\n\nclass TestFeel extends Sense {\n  readonly description = 'Feels ahead';\n  readonly meta: AbilityMeta = {\n    params: [{ name: 'direction', type: 'Direction', optional: true }],\n    returns: 'Space',\n  };\n  perform(direction = FORWARD) {\n    return this.unit.getSensedSpaceAt(direction);\n  }\n}\n\nclass TestSludge extends Unit {\n  static declaredAbilities = {\n    attack: TestAttack.with({ power: 3 }),\n    feel: TestFeel,\n  };\n\n  constructor() {\n    super('Sludge', 's', '#d08770', 12);\n    this.playTurn = (turn: any) => {\n      const threatDirection = RELATIVE_DIRECTIONS.find((direction) => {\n        const unit = turn.feel(direction).getUnit();\n        return unit?.isEnemy() && !unit.isBound();\n      });\n      if (threatDirection) {\n        turn.attack(threatDirection);\n      }\n    };\n  }\n}\n\nconst levelConfig = {\n  floor: {\n    size: { width: 8, height: 1 },\n    stairs: { x: 7, y: 0 },\n    warrior: {\n      name: 'Joe',\n      character: '@',\n      color: '#8fbcbb',\n      maxHealth: 20,\n      abilities: {\n        walk: TestWalk,\n        attack: TestAttack.with({ power: 5 }),\n        feel: TestFeel,\n      },\n      position: { x: 0, y: 0, facing: EAST },\n    },\n    units: [\n      {\n        unit: TestSludge,\n        position: { x: 4, y: 0, facing: WEST },\n      },\n    ],\n  },\n} satisfies LevelConfig;\n\ntest('passes level with a winner player code', () => {\n  const playerCode = `\n    class Player {\n      playTurn(warrior) {\n        const spaceAhead = warrior.feel();\n        if (spaceAhead.isUnit() && spaceAhead.getUnit().isEnemy()) {\n          warrior.attack();\n        } else {\n          warrior.walk();\n        }\n      }\n    }\n  `;\n  const { passed } = runLevel(levelConfig, playerCode);\n  expect(passed).toBe(true);\n});\n\ntest('fails level with a loser player code', () => {\n  const playerCode = `\n    class Player {\n      playTurn(warrior) {}\n    }\n  `;\n  const { passed } = runLevel(levelConfig, playerCode);\n  expect(passed).toBe(false);\n});\n"
  },
  {
    "path": "libs/core/src/runLevel.ts",
    "content": "import { type TurnEvent } from './Logger.js';\nimport loadLevel from './loadLevel.js';\nimport { type LevelConfig } from './types.js';\n\nfunction runLevel(\n  levelConfig: LevelConfig,\n  playerCode: string,\n  language: 'javascript' | 'typescript' = 'javascript',\n): { passed: boolean; turns: TurnEvent[][]; initialState: TurnEvent | null } {\n  return loadLevel(levelConfig, playerCode, language).play();\n}\n\nexport default runLevel;\n"
  },
  {
    "path": "libs/core/src/types.ts",
    "content": "import { type AbsoluteDirection } from '@warriorjs/spatial';\n\nimport { type AbilityEntry } from './Ability.js';\nimport { type EffectEntry } from './Effect.js';\nimport { type UnitClass } from './Unit.js';\n\n/** Dimensions. */\nexport type Size = { width: number; height: number };\n\n/** A location as an object with named coordinates. */\nexport type LocationConfig = { x: number; y: number };\n\n/** A position (location + facing direction). */\nexport type PositionConfig = LocationConfig & { facing: AbsoluteDirection };\n\nexport interface UnitConfig {\n  unit: UnitClass;\n  position: PositionConfig;\n  effects?: Record<string, EffectEntry>;\n}\n\nexport interface WarriorConfig {\n  name?: string;\n  character: string;\n  color: string;\n  maxHealth: number;\n  position: PositionConfig;\n  abilities?: Record<string, AbilityEntry>;\n}\n\nexport interface LevelConfig {\n  number?: number;\n  description?: string;\n  tip?: string;\n  clue?: string;\n  timeBonus?: number;\n  aceScore?: number;\n  floor: {\n    size: Size;\n    stairs: LocationConfig;\n    warrior: WarriorConfig;\n    units?: UnitConfig[];\n  };\n}\n\nexport interface WarriorDefinition {\n  character: string;\n  color: string;\n  maxHealth: number;\n}\n\nexport interface WarriorOverrides {\n  position: PositionConfig;\n  abilities?: Record<string, AbilityEntry>;\n  maxHealth?: number;\n}\n\nexport interface LevelDefinition {\n  description: string;\n  tip: string;\n  clue?: string;\n  timeBonus: number;\n  aceScore: number;\n  floor: {\n    size: Size;\n    stairs: LocationConfig;\n    warrior: WarriorOverrides;\n    units: UnitConfig[];\n  };\n}\n\nexport interface TowerDefinition {\n  name: string;\n  description: string;\n  warrior: WarriorDefinition;\n  levels: LevelDefinition[];\n}\n"
  },
  {
    "path": "libs/core/tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\"\n  },\n  \"exclude\": [\"**/*.test.ts\"]\n}\n"
  },
  {
    "path": "libs/core/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "libs/effects/README.md",
    "content": "# @warriorjs/effects\n\n> WarriorJS official effects.\n\n## [Effects](https://warrior.js.org/docs/player/effects)\n\n### ticking\n\nKills you and all surrounding units when time reaches zero.\n"
  },
  {
    "path": "libs/effects/package.json",
    "content": "{\n  \"name\": \"@warriorjs/effects\",\n  \"version\": \"0.12.2\",\n  \"description\": \"WarriorJS base effects\",\n  \"author\": \"Matias Olivera <hi@matiasolivera.com>\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://warrior.js.org\",\n  \"repository\": \"https://github.com/olistic/warriorjs/tree/master/libs/effects\",\n  \"type\": \"module\",\n  \"main\": \"dist/index.js\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.js\",\n      \"types\": \"./dist/index.d.ts\"\n    }\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"engines\": {\n    \"node\": \">=24\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc -p tsconfig.build.json\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@warriorjs/core\": \"workspace:^\"\n  }\n}\n"
  },
  {
    "path": "libs/effects/src/Ticking.test.ts",
    "content": "import { Effect } from '@warriorjs/core';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Ticking from './Ticking.js';\n\ndescribe('Ticking', () => {\n  let ticking: Ticking;\n  let unit: {\n    health: number;\n    takeDamage: ReturnType<typeof vi.fn>;\n    log: ReturnType<typeof vi.fn>;\n    getOtherUnits?: () => { health: number; takeDamage: ReturnType<typeof vi.fn> }[];\n  };\n\n  beforeEach(() => {\n    unit = {\n      health: 20,\n      takeDamage: vi.fn(),\n      log: vi.fn(),\n    };\n    ticking = new Ticking(unit, { time: 3 });\n  });\n\n  test('extends Effect', () => {\n    expect(ticking).toBeInstanceOf(Effect);\n  });\n\n  test('has a description', () => {\n    expect(ticking.description).toBe('Kills you and all surrounding units when time reaches zero.');\n  });\n\n  test('.with() returns a binding', () => {\n    const binding = Ticking.with({ time: 5 });\n    expect(binding[0]).toBe(Ticking);\n    expect(binding[1]).toEqual({ time: 5 });\n  });\n\n  describe('passing turn', () => {\n    test('counts down bomb timer once', () => {\n      ticking.passTurn();\n      expect(ticking.time).toBe(2);\n      expect(unit.log).toHaveBeenCalledWith('is ticking');\n    });\n\n    test(\"doesn't count down bomb timer below zero\", () => {\n      ticking.trigger = () => {};\n      ticking.time = 0;\n      ticking.passTurn();\n      expect(ticking.time).toBe(0);\n    });\n\n    test('triggers when bomb time reaches zero', () => {\n      ticking.trigger = vi.fn();\n      ticking.time = 2;\n      ticking.passTurn();\n      expect(ticking.trigger).not.toHaveBeenCalled();\n      ticking.passTurn();\n      expect(ticking.trigger).toHaveBeenCalled();\n    });\n  });\n\n  describe('triggering', () => {\n    test('kills each unit on the floor', () => {\n      const anotherUnit = {\n        health: 10,\n        takeDamage: vi.fn(),\n      };\n      unit.getOtherUnits = () => [anotherUnit as never];\n      ticking.trigger();\n      expect(unit.log).toHaveBeenCalledWith(\n        'explodes, collapsing the ceiling and killing every unit',\n      );\n      expect(anotherUnit.takeDamage).toHaveBeenCalledWith(10);\n      expect(unit.takeDamage).toHaveBeenCalledWith(20);\n    });\n  });\n});\n"
  },
  {
    "path": "libs/effects/src/Ticking.ts",
    "content": "import { Effect, type EffectBinding } from '@warriorjs/core';\n\ninterface TickingConfig {\n  time: number;\n}\n\nclass Ticking extends Effect {\n  readonly description = 'Kills you and all surrounding units when time reaches zero.';\n\n  time: number;\n\n  constructor(unit: any, { time }: TickingConfig) {\n    super(unit);\n    this.time = time;\n  }\n\n  passTurn(): void {\n    if (this.time) {\n      this.time -= 1;\n    }\n\n    this.unit.log('is ticking');\n\n    if (!this.time) {\n      this.trigger();\n    }\n  }\n\n  trigger(): void {\n    this.unit.log('explodes, collapsing the ceiling and killing every unit');\n    [...this.unit.getOtherUnits(), this.unit].forEach((anotherUnit: any) =>\n      anotherUnit.takeDamage(anotherUnit.health),\n    );\n  }\n\n  static with(config: TickingConfig): EffectBinding {\n    return [Ticking, config];\n  }\n}\n\nexport default Ticking;\n"
  },
  {
    "path": "libs/effects/src/index.ts",
    "content": "export { default as Ticking } from './Ticking.js';\n"
  },
  {
    "path": "libs/effects/tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\"\n  },\n  \"exclude\": [\"**/*.test.ts\"]\n}\n"
  },
  {
    "path": "libs/effects/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "libs/scoring/package.json",
    "content": "{\n  \"name\": \"@warriorjs/scoring\",\n  \"version\": \"0.14.0\",\n  \"description\": \"WarriorJS scoring utilities\",\n  \"author\": \"Matias Olivera <hi@matiasolivera.com>\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://warrior.js.org\",\n  \"repository\": \"https://github.com/olistic/warriorjs/tree/master/libs/scoring\",\n  \"type\": \"module\",\n  \"main\": \"dist/index.js\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.js\",\n      \"types\": \"./dist/index.d.ts\"\n    }\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"engines\": {\n    \"node\": \">=24\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc -p tsconfig.build.json\",\n    \"typecheck\": \"tsc --noEmit\"\n  }\n}\n"
  },
  {
    "path": "libs/scoring/src/getClearBonus.test.ts",
    "content": "import { expect, test, vi } from 'vitest';\n\nimport getClearBonus from './getClearBonus.js';\nimport getLastEvent from './getLastEvent.js';\nimport isFloorClear from './isFloorClear.js';\n\nvi.mock('./getLastEvent.js');\nvi.mock('./isFloorClear.js');\n\ntest('returns the 20% of the sum of the warrior score and the time bonus with clear level', () => {\n  vi.mocked(getLastEvent).mockReturnValue({ floorMap: 'map' });\n  vi.mocked(isFloorClear).mockReturnValue(true);\n  expect(getClearBonus([['turn-events']] as unknown[][], 3, 2)).toBe(1);\n  expect(getLastEvent).toHaveBeenCalledWith([['turn-events']]);\n  expect(isFloorClear).toHaveBeenCalledWith('map');\n});\n\ntest('returns zero if the level is not clear', () => {\n  vi.mocked(getLastEvent).mockReturnValue({ floorMap: 'map' });\n  vi.mocked(isFloorClear).mockReturnValue(false);\n  expect(getClearBonus([['turn-events']] as unknown[][], 3, 2)).toBe(0);\n  expect(getLastEvent).toHaveBeenCalledWith([['turn-events']]);\n  expect(isFloorClear).toHaveBeenCalledWith('map');\n});\n"
  },
  {
    "path": "libs/scoring/src/getClearBonus.ts",
    "content": "import getLastEvent from './getLastEvent.js';\nimport isFloorClear from './isFloorClear.js';\n\n/**\n * Returns the bonus for clearing the level.\n *\n * @param turns The turns that happened during the play.\n * @param warriorScore The score of the warrior.\n * @param timeBonus The time bonus.\n * @returns The clear bonus.\n */\nfunction getClearBonus(turns: unknown[][], warriorScore: number, timeBonus: number): number {\n  const lastEvent = getLastEvent(turns);\n  if (!isFloorClear(lastEvent.floorMap as { unit?: unknown }[][])) {\n    return 0;\n  }\n\n  return Math.round((warriorScore + timeBonus) * 0.2);\n}\n\nexport default getClearBonus;\n"
  },
  {
    "path": "libs/scoring/src/getGradeLetter.test.ts",
    "content": "import { expect, test } from 'vitest';\n\nimport getGradeLetter from './getGradeLetter.js';\n\ntest('returns letter based on grade', () => {\n  expect(getGradeLetter(1.0)).toBe('S');\n  expect(getGradeLetter(0.99)).toBe('A');\n  expect(getGradeLetter(0.9)).toBe('A');\n  expect(getGradeLetter(0.89)).toBe('B');\n  expect(getGradeLetter(0.8)).toBe('B');\n  expect(getGradeLetter(0.79)).toBe('C');\n  expect(getGradeLetter(0.7)).toBe('C');\n  expect(getGradeLetter(0.69)).toBe('D');\n  expect(getGradeLetter(0.6)).toBe('D');\n  expect(getGradeLetter(0.59)).toBe('F');\n  expect(getGradeLetter(0)).toBe('F');\n});\n"
  },
  {
    "path": "libs/scoring/src/getGradeLetter.ts",
    "content": "/**\n * Returns the letter for the given grade.\n *\n * @param grade The grade.\n * @returns The grade letter.\n */\nfunction getGradeLetter(grade: number): string {\n  if (grade >= 1.0) {\n    return 'S';\n  }\n\n  if (grade >= 0.9) {\n    return 'A';\n  }\n\n  if (grade >= 0.8) {\n    return 'B';\n  }\n\n  if (grade >= 0.7) {\n    return 'C';\n  }\n\n  if (grade >= 0.6) {\n    return 'D';\n  }\n\n  return 'F';\n}\n\nexport default getGradeLetter;\n"
  },
  {
    "path": "libs/scoring/src/getLastEvent.test.ts",
    "content": "import { expect, test } from 'vitest';\n\nimport getLastEvent from './getLastEvent.js';\n\ntest('returns the last event of the play', () => {\n  const turns = [['turn1'], ['turn2'], ['event1', 'event2', 'event3']];\n  expect(getLastEvent(turns)).toBe('event3');\n});\n"
  },
  {
    "path": "libs/scoring/src/getLastEvent.ts",
    "content": "/**\n * Returns the last event of the play.\n *\n * @param turns The turns that happened during the play.\n * @returns The last event.\n */\nfunction getLastEvent(turns: unknown[][]): Record<string, unknown> {\n  const lastTurnEvents = turns.at(-1)!;\n  return lastTurnEvents.at(-1) as Record<string, unknown>;\n}\n\nexport default getLastEvent;\n"
  },
  {
    "path": "libs/scoring/src/getLevelScore.test.ts",
    "content": "import { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport getClearBonus from './getClearBonus.js';\nimport getLevelScore from './getLevelScore.js';\nimport getRemainingTimeBonus from './getRemainingTimeBonus.js';\nimport getWarriorScore from './getWarriorScore.js';\n\nvi.mock('./getClearBonus.js');\nvi.mock('./getRemainingTimeBonus.js');\nvi.mock('./getWarriorScore.js');\n\nconst levelConfig = { timeBonus: 16 };\n\ntest('returns null when level failed', () => {\n  expect(getLevelScore({ passed: false, turns: [] }, levelConfig)).toBeNull();\n});\n\ndescribe('level passed', () => {\n  let levelResult: { passed: boolean; turns: unknown[][] };\n\n  beforeEach(() => {\n    levelResult = {\n      passed: true,\n      turns: [['turn-events']] as unknown[][],\n    };\n  });\n\n  test('has warrior score part', () => {\n    vi.mocked(getWarriorScore).mockReturnValue(8);\n    expect(getLevelScore(levelResult, levelConfig)?.warrior).toBe(8);\n  });\n\n  test('has time bonus part', () => {\n    vi.mocked(getRemainingTimeBonus).mockReturnValue(10);\n    expect(getLevelScore(levelResult, levelConfig)?.timeBonus).toBe(10);\n    expect(getRemainingTimeBonus).toHaveBeenCalledWith([['turn-events']], 16);\n  });\n\n  test('has clear bonus part', () => {\n    vi.mocked(getWarriorScore).mockReturnValue(8);\n    vi.mocked(getRemainingTimeBonus).mockReturnValue(12);\n    vi.mocked(getClearBonus).mockReturnValue(4);\n    expect(getLevelScore(levelResult, levelConfig)?.clearBonus).toBe(4);\n    expect(getClearBonus).toHaveBeenCalledWith([['turn-events']], 8, 12);\n  });\n});\n"
  },
  {
    "path": "libs/scoring/src/getLevelScore.ts",
    "content": "import getClearBonus from './getClearBonus.js';\nimport getRemainingTimeBonus from './getRemainingTimeBonus.js';\nimport getWarriorScore from './getWarriorScore.js';\n\ninterface LevelResult {\n  passed: boolean;\n  turns: unknown[][];\n}\n\ninterface LevelConfig {\n  timeBonus: number;\n}\n\ninterface LevelScore {\n  clearBonus: number;\n  timeBonus: number;\n  warrior: number;\n}\n\n/**\n * Returns the score for the given level.\n *\n * @param result The level result.\n * @param levelConfig The level config.\n * @returns The score of the level, broken down into its components.\n */\nfunction getLevelScore(\n  { passed, turns }: LevelResult,\n  { timeBonus }: LevelConfig,\n): LevelScore | null {\n  if (!passed) {\n    return null;\n  }\n\n  const warriorScore = getWarriorScore(turns);\n  const remainingTimeBonus = getRemainingTimeBonus(turns, timeBonus);\n  const clearBonus = getClearBonus(turns, warriorScore, remainingTimeBonus);\n  return {\n    clearBonus,\n    timeBonus: remainingTimeBonus,\n    warrior: warriorScore,\n  };\n}\n\nexport default getLevelScore;\n"
  },
  {
    "path": "libs/scoring/src/getRemainingTimeBonus.test.ts",
    "content": "import { expect, test, vi } from 'vitest';\n\nimport getRemainingTimeBonus from './getRemainingTimeBonus.js';\nimport getTurnCount from './getTurnCount.js';\n\nvi.mock('./getTurnCount.js');\n\ntest('subtracts the number of turns played from the initial time bonus', () => {\n  vi.mocked(getTurnCount).mockReturnValue(3);\n  expect(getRemainingTimeBonus([['turn-events']] as unknown[][], 10)).toBe(7);\n  expect(getTurnCount).toHaveBeenCalledWith([['turn-events']]);\n});\n\ntest(\"doesn't go below zero\", () => {\n  vi.mocked(getTurnCount).mockReturnValue(11);\n  expect(getRemainingTimeBonus([['turn-events']] as unknown[][], 10)).toBe(0);\n  expect(getTurnCount).toHaveBeenCalledWith([['turn-events']]);\n});\n"
  },
  {
    "path": "libs/scoring/src/getRemainingTimeBonus.ts",
    "content": "import getTurnCount from './getTurnCount.js';\n\n/**\n * Returns the remaining time bonus.\n *\n * @param turns The turns that happened during the play.\n * @param timeBonus The initial time bonus.\n * @returns The time bonus.\n */\nfunction getRemainingTimeBonus(turns: unknown[][], timeBonus: number): number {\n  const turnCount = getTurnCount(turns);\n  const remainingTimeBonus = timeBonus - turnCount;\n  return Math.max(remainingTimeBonus, 0);\n}\n\nexport default getRemainingTimeBonus;\n"
  },
  {
    "path": "libs/scoring/src/getTurnCount.test.ts",
    "content": "import { expect, test } from 'vitest';\n\nimport getTurnCount from './getTurnCount.js';\n\ntest('returns the number of turns played', () => {\n  const turns = [['turn1'], ['turn2'], ['turn3']];\n  expect(getTurnCount(turns)).toBe(3);\n});\n"
  },
  {
    "path": "libs/scoring/src/getTurnCount.ts",
    "content": "/**\n * Returns the number of turns played.\n *\n * @param turns The turns that happened during the play.\n * @returns The turn count.\n */\nfunction getTurnCount(turns: unknown[][]): number {\n  return turns.length;\n}\n\nexport default getTurnCount;\n"
  },
  {
    "path": "libs/scoring/src/getWarriorScore.test.ts",
    "content": "import { expect, test, vi } from 'vitest';\n\nimport getLastEvent from './getLastEvent.js';\nimport getWarriorScore from './getWarriorScore.js';\n\nvi.mock('./getLastEvent.js');\n\ntest('returns the score of the warrior at the end of the play', () => {\n  vi.mocked(getLastEvent).mockReturnValue({ warriorStatus: { score: 42 } });\n  expect(getWarriorScore([['turn-events']] as unknown[][])).toBe(42);\n  expect(getLastEvent).toHaveBeenCalledWith([['turn-events']]);\n});\n"
  },
  {
    "path": "libs/scoring/src/getWarriorScore.ts",
    "content": "import getLastEvent from './getLastEvent.js';\n\n/**\n * Returns the score of the warrior.\n *\n * @param turns The turns that happened during the play.\n * @returns The score of the warrior.\n */\nfunction getWarriorScore(turns: unknown[][]): number {\n  const lastEvent = getLastEvent(turns);\n  return (lastEvent as { warriorStatus: { score: number } }).warriorStatus.score;\n}\n\nexport default getWarriorScore;\n"
  },
  {
    "path": "libs/scoring/src/index.ts",
    "content": "export { default as getGradeLetter } from './getGradeLetter.js';\nexport { default as getLevelScore } from './getLevelScore.js';\n"
  },
  {
    "path": "libs/scoring/src/isFloorClear.test.ts",
    "content": "import { expect, test } from 'vitest';\n\nimport isFloorClear from './isFloorClear.js';\n\ntest('considers clear when there are no units other than the warrior', () => {\n  const floorMap = [\n    [{}, {}],\n    [{ unit: 'warrior' }, {}],\n  ];\n  expect(isFloorClear(floorMap)).toBe(true);\n});\n\ntest(\"doesn't consider clear when there are other units apart from the warrior\", () => {\n  const floorMap = [\n    [{}, {}],\n    [{ unit: 'warrior' }, { unit: 'foo' }],\n  ];\n  expect(isFloorClear(floorMap)).toBe(false);\n});\n"
  },
  {
    "path": "libs/scoring/src/isFloorClear.ts",
    "content": "interface Space {\n  unit?: unknown;\n}\n\n/**\n * Checks if the floor is clear.\n *\n * The floor is clear when there are no units other than the warrior.\n *\n * @param floorMap The floor map.\n * @returns Whether the floor is clear or not.\n */\nfunction isFloorClear(floorMap: Space[][]): boolean {\n  const spaces = floorMap.reduce<Space[]>((acc, val) => acc.concat(val), []);\n  const unitCount = spaces.filter((space: Space) => !!space.unit).length;\n  return unitCount <= 1;\n}\n\nexport default isFloorClear;\n"
  },
  {
    "path": "libs/scoring/tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\"\n  },\n  \"exclude\": [\"**/*.test.ts\"]\n}\n"
  },
  {
    "path": "libs/scoring/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "libs/spatial/README.md",
    "content": "# @warriorjs/spatial\n\n> WarriorJS directioning.\n\n## Install\n\n```sh\nnpm install @warriorjs/spatial\n```\n\n## Usage\n\n```js\nimport { FORWARD, getAbsoluteDirection } from '@warriorjs/spatial');\n```\n\n## Top Level Exports\n\nAll methods in the API Reference below, plus the following constants:\n\n### `NORTH` _(string)_\n\nA constant representing absolute direction north.\n\n### `EAST` _(string)_\n\nA constant representing absolute direction east.\n\n### `SOUTH` _(string)_\n\nA constant representing absolute direction south.\n\n### `WEST` _(string)_\n\nA constant representing absolute direction west.\n\n### `ABSOLUTE_DIRECTIONS` _(string[])_\n\nThe absolute directions in clockwise order.\n\n### `FORWARD` _(string)_\n\nA constant representing relative direction forward.\n\n### `RIGHT` _(string)_\n\nA constant representing relative direction right.\n\n### `BACKWARD` _(string)_\n\nA constant representing relative direction backward.\n\n### `LEFT` _(string)_\n\nA constant representing relative direction left.\n\n### `RELATIVE_DIRECTIONS` _(string[])_\n\nThe relative directions in clockwise order.\n\n## API Reference\n\n### `getAbsoluteDirection(direction: string, referenceDirection: string)`\n\nReturns the absolute direction for a given direction, with reference to another\ndirection (reference direction).\n\n### `getAbsoluteOffset(relativeOffset: number[], referenceDirection: string)`\n\nReturns the absolute offset for a given relative offset with reference to a\ngiven direction (reference direction).\n\n### `getDirectionOfLocation(location: number[], referenceLocation: number[])`\n\nReturns the direction of a location from another location (reference location).\n\n### `getDistanceOfLocation(location: number[], referenceLocation: number[])`\n\nReturns the Manhattan distance of a location from another location (reference\nlocation).\n\n### `getRelativeDirection(direction: string, referenceDirection: string)`\n\nReturns the relative direction for a given direction, with reference to a\nanother direction (reference direction).\n\n### `getRelativeOffset(location: number[], referenceLocation: number[], referenceDirection: string)`\n\nReturns the relative offset for a given location, with reference to another\nlocation (reference location) and direction (reference direction).\n\n### `rotateRelativeOffset(offset: number[], direction: string)`\n\nRotates the given relative offset in the given direction.\n\n### `translateLocation(location: number[], offset: number[])`\n\nTranslates the given location by a given offset.\n\n### `verifyAbsoluteDirection(direction: string)`\n\nChecks if the given direction is a valid absolute direction.\n\n### `verifyRelativeDirection(direction: string)`\n\nChecks if the given direction is a valid relative direction.\n"
  },
  {
    "path": "libs/spatial/package.json",
    "content": "{\n  \"name\": \"@warriorjs/spatial\",\n  \"version\": \"0.7.0\",\n  \"description\": \"WarriorJS directioning\",\n  \"author\": \"Matias Olivera <hi@matiasolivera.com>\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://warrior.js.org\",\n  \"repository\": \"https://github.com/olistic/warriorjs/tree/master/libs/spatial\",\n  \"type\": \"module\",\n  \"main\": \"dist/index.js\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.js\",\n      \"types\": \"./dist/index.d.ts\"\n    }\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"engines\": {\n    \"node\": \">=24\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc -p tsconfig.build.json\",\n    \"typecheck\": \"tsc --noEmit\"\n  }\n}\n"
  },
  {
    "path": "libs/spatial/src/absoluteDirections.test.ts",
    "content": "import { beforeEach, describe, expect, test } from 'vitest';\n\nimport {\n  ABSOLUTE_DIRECTIONS,\n  EAST,\n  getAbsoluteDirection,\n  getAbsoluteOffset,\n  NORTH,\n  SOUTH,\n  verifyAbsoluteDirection,\n  WEST,\n} from './absoluteDirections.js';\nimport { BACKWARD, FORWARD, LEFT, type RelativeDirection, RIGHT } from './relativeDirections.js';\n\ntest(\"exports a NORTH constant whose value is 'north'\", () => {\n  expect(NORTH).toBe('north');\n});\n\ntest(\"exports a EAST constant whose value is 'east'\", () => {\n  expect(EAST).toBe('east');\n});\n\ntest(\"exports a SOUTH constant whose value is 'south'\", () => {\n  expect(SOUTH).toBe('south');\n});\n\ntest(\"exports a WEST constant whose value is 'west'\", () => {\n  expect(WEST).toBe('west');\n});\n\ntest('exports an array with the absolute directions in clockwise order', () => {\n  expect(ABSOLUTE_DIRECTIONS).toEqual([NORTH, EAST, SOUTH, WEST]);\n});\n\ndescribe('verifyAbsoluteDirection', () => {\n  test(\"doesn't throw if direction is valid\", () => {\n    const validDirections = ABSOLUTE_DIRECTIONS;\n    validDirections.forEach((validDirection) => verifyAbsoluteDirection(validDirection));\n  });\n\n  test('throws an error if direction is not valid', () => {\n    const invalidDirections = ['', 'foo', 'north\\n', 'North', 'southern'];\n    invalidDirections.forEach((invalidDirection) => {\n      expect(() => {\n        verifyAbsoluteDirection(invalidDirection);\n      }).toThrow(\n        `Unknown direction: '${invalidDirection}'. Should be one of: '${NORTH}', '${EAST}', '${SOUTH}' or '${WEST}'.`,\n      );\n    });\n  });\n});\n\ndescribe('getAbsoluteDirection', () => {\n  let direction: RelativeDirection;\n\n  describe('forward', () => {\n    beforeEach(() => {\n      direction = FORWARD;\n    });\n\n    test('is to the north when facing north', () => {\n      expect(getAbsoluteDirection(direction, NORTH)).toBe(NORTH);\n    });\n\n    test('is to the east when facing east', () => {\n      expect(getAbsoluteDirection(direction, EAST)).toBe(EAST);\n    });\n\n    test('is to the south when facing south', () => {\n      expect(getAbsoluteDirection(direction, SOUTH)).toBe(SOUTH);\n    });\n\n    test('is to the west when facing west', () => {\n      expect(getAbsoluteDirection(direction, WEST)).toBe(WEST);\n    });\n  });\n\n  describe('right', () => {\n    beforeEach(() => {\n      direction = RIGHT;\n    });\n\n    test('is to the east when facing north', () => {\n      expect(getAbsoluteDirection(direction, NORTH)).toBe(EAST);\n    });\n\n    test('is to the south when facing east', () => {\n      expect(getAbsoluteDirection(direction, EAST)).toBe(SOUTH);\n    });\n\n    test('is to the west when facing south', () => {\n      expect(getAbsoluteDirection(direction, SOUTH)).toBe(WEST);\n    });\n\n    test('is to the north when facing west', () => {\n      expect(getAbsoluteDirection(direction, WEST)).toBe(NORTH);\n    });\n  });\n\n  describe('backward', () => {\n    beforeEach(() => {\n      direction = BACKWARD;\n    });\n\n    test('is to the south when facing north', () => {\n      expect(getAbsoluteDirection(direction, NORTH)).toBe(SOUTH);\n    });\n\n    test('is to the west when facing east', () => {\n      expect(getAbsoluteDirection(direction, EAST)).toBe(WEST);\n    });\n\n    test('is to the north when facing south', () => {\n      expect(getAbsoluteDirection(direction, SOUTH)).toBe(NORTH);\n    });\n\n    test('is to the east when facing west', () => {\n      expect(getAbsoluteDirection(direction, WEST)).toBe(EAST);\n    });\n  });\n\n  describe('left', () => {\n    beforeEach(() => {\n      direction = LEFT;\n    });\n\n    test('is to the west when facing north', () => {\n      expect(getAbsoluteDirection(direction, NORTH)).toBe(WEST);\n    });\n\n    test('is to the north when facing east', () => {\n      expect(getAbsoluteDirection(direction, EAST)).toBe(NORTH);\n    });\n\n    test('is to the east when facing south', () => {\n      expect(getAbsoluteDirection(direction, SOUTH)).toBe(EAST);\n    });\n\n    test('is to the south when facing west', () => {\n      expect(getAbsoluteDirection(direction, WEST)).toBe(SOUTH);\n    });\n  });\n});\n\ndescribe('getAbsoluteOffset', () => {\n  test('returns the absolute offset based on direction', () => {\n    expect(getAbsoluteOffset([1, 2], NORTH)).toEqual([2, -1]);\n    expect(getAbsoluteOffset([1, 2], EAST)).toEqual([1, 2]);\n    expect(getAbsoluteOffset([1, 2], SOUTH)).toEqual([-2, 1]);\n    expect(getAbsoluteOffset([1, 2], WEST)).toEqual([-1, -2]);\n  });\n});\n"
  },
  {
    "path": "libs/spatial/src/absoluteDirections.ts",
    "content": "import { type AbsoluteOffset, type RelativeOffset } from './location.js';\nimport { RELATIVE_DIRECTIONS, type RelativeDirection } from './relativeDirections.js';\n\nexport const NORTH = 'north';\nexport const EAST = 'east';\nexport const SOUTH = 'south';\nexport const WEST = 'west';\n\n/**\n * The absolute directions in clockwise order.\n */\nexport const ABSOLUTE_DIRECTIONS = [NORTH, EAST, SOUTH, WEST] as const;\n\n/** An absolute direction. */\nexport type AbsoluteDirection = (typeof ABSOLUTE_DIRECTIONS)[number];\n\n/**\n * Checks if the given direction is a valid absolute direction.\n *\n * @param direction The direction.\n *\n * @throws Will throw if the direction is not valid.\n */\nexport function verifyAbsoluteDirection(direction: string): asserts direction is AbsoluteDirection {\n  if (!(ABSOLUTE_DIRECTIONS as readonly string[]).includes(direction)) {\n    throw new Error(\n      `Unknown direction: '${direction}'. Should be one of: '${NORTH}', '${EAST}', '${SOUTH}' or '${WEST}'.`,\n    );\n  }\n}\n\n/**\n * Returns the absolute direction for a given direction, with reference to\n * another direction (reference direction).\n *\n * @param direction The direction (relative).\n * @param referenceDirection The reference direction (absolute).\n *\n * @returns The absolute direction.\n */\nexport function getAbsoluteDirection(\n  direction: RelativeDirection,\n  referenceDirection: AbsoluteDirection,\n): AbsoluteDirection {\n  const index =\n    (ABSOLUTE_DIRECTIONS.indexOf(referenceDirection) + RELATIVE_DIRECTIONS.indexOf(direction)) % 4;\n  return ABSOLUTE_DIRECTIONS[index];\n}\n\n/**\n * Returns the absolute offset for a given relative offset with reference\n * to a given direction (reference direction).\n *\n * @param offset The relative offset as [forward, right].\n * @param referenceDirection The reference direction (absolute).\n *\n * @returns The absolute offset as [deltaX, deltaY].\n */\nexport function getAbsoluteOffset(\n  [forward, right]: RelativeOffset,\n  referenceDirection: AbsoluteDirection,\n): AbsoluteOffset {\n  if (referenceDirection === NORTH) {\n    return [right, -forward];\n  }\n\n  if (referenceDirection === EAST) {\n    return [forward, right];\n  }\n\n  if (referenceDirection === SOUTH) {\n    return [-right, forward];\n  }\n\n  return [-forward, -right];\n}\n"
  },
  {
    "path": "libs/spatial/src/index.ts",
    "content": "export type { AbsoluteDirection } from './absoluteDirections.js';\nexport {\n  ABSOLUTE_DIRECTIONS,\n  EAST,\n  getAbsoluteDirection,\n  getAbsoluteOffset,\n  NORTH,\n  SOUTH,\n  verifyAbsoluteDirection,\n  WEST,\n} from './absoluteDirections.js';\nexport type { AbsoluteOffset, Location, RelativeOffset } from './location.js';\nexport {\n  getDirectionOfLocation,\n  getDistanceOfLocation,\n  translateLocation,\n} from './location.js';\nexport type { RelativeDirection } from './relativeDirections.js';\nexport {\n  BACKWARD,\n  FORWARD,\n  getRelativeDirection,\n  getRelativeOffset,\n  LEFT,\n  RELATIVE_DIRECTIONS,\n  RIGHT,\n  rotateRelativeOffset,\n  verifyRelativeDirection,\n} from './relativeDirections.js';\n"
  },
  {
    "path": "libs/spatial/src/location.test.ts",
    "content": "import { describe, expect, test } from 'vitest';\n\nimport { EAST, NORTH, SOUTH, WEST } from './absoluteDirections.js';\nimport { getDirectionOfLocation, getDistanceOfLocation, translateLocation } from './location.js';\n\ndescribe('translateLocation', () => {\n  test('translates the given location by the given offset', () => {\n    expect(translateLocation([1, 2], [2, -1])).toEqual([3, 1]);\n  });\n});\n\ndescribe('getDirectionOfLocation', () => {\n  test('returns the direction from a given location to another given location', () => {\n    expect(getDirectionOfLocation([1, 1], [1, 2])).toEqual(NORTH);\n    expect(getDirectionOfLocation([2, 2], [1, 2])).toEqual(EAST);\n    expect(getDirectionOfLocation([1, 3], [1, 2])).toEqual(SOUTH);\n    expect(getDirectionOfLocation([0, 2], [1, 2])).toEqual(WEST);\n  });\n});\n\ndescribe('getDistanceOfLocation', () => {\n  test('returns the distance between the two given locations', () => {\n    expect(getDistanceOfLocation([5, 3], [1, 2])).toBe(5);\n    expect(getDistanceOfLocation([4, 2], [1, 2])).toBe(3);\n  });\n});\n"
  },
  {
    "path": "libs/spatial/src/location.ts",
    "content": "import { type AbsoluteDirection, EAST, NORTH, SOUTH, WEST } from './absoluteDirections.js';\n\n/** A location as [x, y]. */\nexport type Location = [number, number];\n\n/** An absolute offset as [deltaX, deltaY]. */\nexport type AbsoluteOffset = [number, number];\n\n/** A relative offset as [forward, right]. */\nexport type RelativeOffset = [number, number];\n\n/**\n * Translates the given location by a given offset.\n *\n * @param location The location as [x, y].\n * @param offset The offset as [deltaX, deltaY].\n *\n * @returns The translated location.\n */\nexport function translateLocation([x, y]: Location, [deltaX, deltaY]: AbsoluteOffset): Location {\n  return [x + deltaX, y + deltaY];\n}\n\n/**\n * Returns the direction of a location from another location (reference\n * location).\n *\n * @param location The location as [x, y].\n * @param referenceLocation The reference location as [x, y].\n *\n * @returns The direction.\n */\nexport function getDirectionOfLocation([x1, y1]: Location, [x2, y2]: Location): AbsoluteDirection {\n  if (Math.abs(x2 - x1) > Math.abs(y2 - y1)) {\n    if (x1 > x2) {\n      return EAST;\n    }\n\n    return WEST;\n  }\n\n  if (y1 > y2) {\n    return SOUTH;\n  }\n\n  return NORTH;\n}\n\n/**\n * Returns the Manhattan distance of a location from another location (reference\n * location).\n *\n * @param location The location as [x, y].\n * @param referenceLocation The reference location as [x, y].\n *\n * @returns The distance between the locations.\n */\nexport function getDistanceOfLocation([x1, y1]: Location, [x2, y2]: Location): number {\n  return Math.abs(x2 - x1) + Math.abs(y2 - y1);\n}\n"
  },
  {
    "path": "libs/spatial/src/relativeDirections.test.ts",
    "content": "import { beforeEach, describe, expect, test } from 'vitest';\n\nimport { type AbsoluteDirection, EAST, NORTH, SOUTH, WEST } from './absoluteDirections.js';\nimport {\n  BACKWARD,\n  FORWARD,\n  getRelativeDirection,\n  getRelativeOffset,\n  LEFT,\n  RELATIVE_DIRECTIONS,\n  RIGHT,\n  rotateRelativeOffset,\n  verifyRelativeDirection,\n} from './relativeDirections.js';\n\ntest(\"exports a FORWARD constant whose value is 'forward'\", () => {\n  expect(FORWARD).toBe('forward');\n});\n\ntest(\"exports a RIGHT constant whose value is 'right'\", () => {\n  expect(RIGHT).toBe('right');\n});\n\ntest(\"exports a BACKWARD constant whose value is 'backward'\", () => {\n  expect(BACKWARD).toBe('backward');\n});\n\ntest(\"exports a LEFT constant whose value is 'left'\", () => {\n  expect(LEFT).toBe('left');\n});\n\ntest('exports an array with the relative directions in clockwise order', () => {\n  expect(RELATIVE_DIRECTIONS).toEqual([FORWARD, RIGHT, BACKWARD, LEFT]);\n});\n\ndescribe('verifyRelativeDirection', () => {\n  test(\"doesn't throw if direction is valid\", () => {\n    const validDirections = RELATIVE_DIRECTIONS;\n    validDirections.forEach((validDirection) => verifyRelativeDirection(validDirection));\n  });\n\n  test('throws an error if direction is not valid', () => {\n    const invalidDirections = ['', 'foo', 'forward\\n', 'Forward', 'backwards'];\n    invalidDirections.forEach((invalidDirection) => {\n      expect(() => {\n        verifyRelativeDirection(invalidDirection);\n      }).toThrow(\n        `Unknown direction: '${invalidDirection}'. Should be one of: '${FORWARD}', '${RIGHT}', '${BACKWARD}' or '${LEFT}'.`,\n      );\n    });\n  });\n});\n\ndescribe('getRelativeDirection', () => {\n  let direction: AbsoluteDirection;\n\n  describe('north', () => {\n    beforeEach(() => {\n      direction = NORTH;\n    });\n\n    test('is forward when facing north', () => {\n      expect(getRelativeDirection(direction, NORTH)).toBe(FORWARD);\n    });\n\n    test('is to the left when facing east', () => {\n      expect(getRelativeDirection(direction, EAST)).toBe(LEFT);\n    });\n\n    test('is backward when facing south', () => {\n      expect(getRelativeDirection(direction, SOUTH)).toBe(BACKWARD);\n    });\n\n    test('is to the right when facing west', () => {\n      expect(getRelativeDirection(direction, WEST)).toBe(RIGHT);\n    });\n  });\n\n  describe('east', () => {\n    beforeEach(() => {\n      direction = EAST;\n    });\n\n    test('is to the right when facing north', () => {\n      expect(getRelativeDirection(direction, NORTH)).toBe(RIGHT);\n    });\n\n    test('is forward when facing east', () => {\n      expect(getRelativeDirection(direction, EAST)).toBe(FORWARD);\n    });\n\n    test('is to the left when facing south', () => {\n      expect(getRelativeDirection(direction, SOUTH)).toBe(LEFT);\n    });\n\n    test('is backward when facing west', () => {\n      expect(getRelativeDirection(direction, WEST)).toBe(BACKWARD);\n    });\n  });\n\n  describe('south', () => {\n    beforeEach(() => {\n      direction = SOUTH;\n    });\n\n    test('is backward when facing north', () => {\n      expect(getRelativeDirection(direction, NORTH)).toBe(BACKWARD);\n    });\n\n    test('is to the right when facing east', () => {\n      expect(getRelativeDirection(direction, EAST)).toBe(RIGHT);\n    });\n\n    test('is forward when facing south', () => {\n      expect(getRelativeDirection(direction, SOUTH)).toBe(FORWARD);\n    });\n\n    test('is to the left when facing west', () => {\n      expect(getRelativeDirection(direction, WEST)).toBe(LEFT);\n    });\n  });\n\n  describe('west', () => {\n    beforeEach(() => {\n      direction = WEST;\n    });\n\n    test('is to the left when facing north', () => {\n      expect(getRelativeDirection(direction, NORTH)).toBe(LEFT);\n    });\n\n    test('is backward when facing east', () => {\n      expect(getRelativeDirection(direction, EAST)).toBe(BACKWARD);\n    });\n\n    test('is to the right when facing south', () => {\n      expect(getRelativeDirection(direction, SOUTH)).toBe(RIGHT);\n    });\n\n    test('is forward when facing west', () => {\n      expect(getRelativeDirection(direction, WEST)).toBe(FORWARD);\n    });\n  });\n});\n\ndescribe('getRelativeOffset', () => {\n  test('returns the relative offset based on location and direction', () => {\n    expect(getRelativeOffset([3, 3], [1, 2], NORTH)).toEqual([-1, 2]);\n    expect(getRelativeOffset([3, 3], [1, 2], EAST)).toEqual([2, 1]);\n    expect(getRelativeOffset([3, 3], [1, 2], SOUTH)).toEqual([1, -2]);\n    expect(getRelativeOffset([3, 3], [1, 2], WEST)).toEqual([-2, -1]);\n    expect(getRelativeOffset([0, 0], [1, 2], NORTH)).toEqual([2, -1]);\n    expect(getRelativeOffset([0, 0], [1, 2], EAST)).toEqual([-1, -2]);\n    expect(getRelativeOffset([0, 0], [1, 2], SOUTH)).toEqual([-2, 1]);\n    expect(getRelativeOffset([0, 0], [1, 2], WEST)).toEqual([1, 2]);\n  });\n});\n\ndescribe('rotateRelativeOffset', () => {\n  test('rotates the relative offset in the given direction', () => {\n    expect(rotateRelativeOffset([1, 2], FORWARD)).toEqual([1, 2]);\n    expect(rotateRelativeOffset([1, 2], RIGHT)).toEqual([-2, 1]);\n    expect(rotateRelativeOffset([1, 2], BACKWARD)).toEqual([-1, -2]);\n    expect(rotateRelativeOffset([1, 2], LEFT)).toEqual([2, -1]);\n  });\n});\n"
  },
  {
    "path": "libs/spatial/src/relativeDirections.ts",
    "content": "import { ABSOLUTE_DIRECTIONS, type AbsoluteDirection } from './absoluteDirections.js';\nimport { type Location, type RelativeOffset } from './location.js';\n\nexport const FORWARD = 'forward';\nexport const RIGHT = 'right';\nexport const BACKWARD = 'backward';\nexport const LEFT = 'left';\n\n/**\n * The relative directions in clockwise order.\n */\nexport const RELATIVE_DIRECTIONS = [FORWARD, RIGHT, BACKWARD, LEFT] as const;\n\n/** A relative direction. */\nexport type RelativeDirection = (typeof RELATIVE_DIRECTIONS)[number];\n\n/**\n * Checks if the given direction is a valid relative direction.\n *\n * @param direction The direction.\n *\n * @throws Will throw if the direction is not valid.\n */\nexport function verifyRelativeDirection(direction: string): asserts direction is RelativeDirection {\n  if (!(RELATIVE_DIRECTIONS as readonly string[]).includes(direction)) {\n    throw new Error(\n      `Unknown direction: '${direction}'. Should be one of: '${FORWARD}', '${RIGHT}', '${BACKWARD}' or '${LEFT}'.`,\n    );\n  }\n}\n\n/**\n * Returns the relative direction for a given direction, with reference to a\n * another direction (reference direction).\n *\n * @param direction The direction (absolute).\n * @param referenceDirection The reference direction (absolute).\n *\n * @returns The relative direction.\n */\nexport function getRelativeDirection(\n  direction: AbsoluteDirection,\n  referenceDirection: AbsoluteDirection,\n): RelativeDirection {\n  const index =\n    (ABSOLUTE_DIRECTIONS.indexOf(direction) -\n      ABSOLUTE_DIRECTIONS.indexOf(referenceDirection) +\n      RELATIVE_DIRECTIONS.length) %\n    RELATIVE_DIRECTIONS.length;\n  return RELATIVE_DIRECTIONS[index];\n}\n\n/**\n * Returns the relative offset for a given location, with reference to another\n * location (reference location) and direction (reference direction).\n *\n * @param location The location.\n * @param referenceLocation The reference location.\n * @param referenceDirection The reference direction (absolute).\n *\n * @returns The relative offset as [forward, right].\n */\nexport function getRelativeOffset(\n  [x1, y1]: Location,\n  [x2, y2]: Location,\n  referenceDirection: AbsoluteDirection,\n): RelativeOffset {\n  const [deltaX, deltaY] = [x1 - x2, y1 - y2];\n\n  if (referenceDirection === 'north') {\n    return [-deltaY, deltaX];\n  }\n\n  if (referenceDirection === 'east') {\n    return [deltaX, deltaY];\n  }\n\n  if (referenceDirection === 'south') {\n    return [deltaY, -deltaX];\n  }\n\n  return [-deltaX, -deltaY];\n}\n\n/**\n * Rotates the given relative offset in the given direction.\n *\n * @param offset The relative offset as [forward, right].\n * @param direction The direction (relative direction).\n *\n * @returns The rotated offset as [forward, right].\n */\nexport function rotateRelativeOffset(\n  [forward, right]: RelativeOffset,\n  direction: RelativeDirection,\n): RelativeOffset {\n  if (direction === FORWARD) {\n    return [forward, right];\n  }\n\n  if (direction === RIGHT) {\n    return [-right, forward];\n  }\n\n  if (direction === BACKWARD) {\n    return [-forward, -right];\n  }\n\n  return [right, -forward];\n}\n"
  },
  {
    "path": "libs/spatial/tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\"\n  },\n  \"exclude\": [\"**/*.test.ts\"]\n}\n"
  },
  {
    "path": "libs/spatial/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "libs/units/README.md",
    "content": "# @warriorjs/units\n\n> WarriorJS official units.\n\n### Archer\n\n- **Character:** a\n- **Max Health:** 7 HP\n- **Abilities:**\n  - look (3 range)\n  - shoot (3 range, 3 power)\n- **AI:** Attack first enemy in line of sight in any direction.\n\n### Captive\n\n- **Character:** C\n- **Max Health:** 1 HP\n- **Abilities:** None.\n- **AI:** Wait to be rescued.\n\n### Sludge\n\n- **Character:** s\n- **Max Health:** 12 HP\n- **Abilities:**\n  - feel\n  - attack (3 power)\n- **AI:** Attack first adjacent enemy in any direction.\n\n### Thick Sludge\n\n- **Character:** S\n- **Max Health:** 24 HP\n- **Abilities:**\n  - feel\n  - attack (3 power)\n- **AI:** Attack first adjacent enemy in any direction.\n\n### Warrior\n\n- **Character:** @\n- **Max Health:** 20 HP\n- **Abilities:** Determined by the level.\n- **AI:** Provided by the player.\n\n### Wizard\n\n- **Character:** w\n- **Max Health:** 3 HP\n- **Abilities:**\n  - look (3 range)\n  - shoot (3 range, 11 power)\n- **AI:** Attack first enemy in line of sight in any direction.\n"
  },
  {
    "path": "libs/units/package.json",
    "content": "{\n  \"name\": \"@warriorjs/units\",\n  \"version\": \"0.13.0\",\n  \"description\": \"WarriorJS base units\",\n  \"author\": \"Matias Olivera <hi@matiasolivera.com>\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://warrior.js.org\",\n  \"repository\": \"https://github.com/olistic/warriorjs/tree/master/libs/units\",\n  \"type\": \"module\",\n  \"main\": \"dist/index.js\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.js\",\n      \"types\": \"./dist/index.d.ts\"\n    }\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"engines\": {\n    \"node\": \">=24\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc -p tsconfig.build.json\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@warriorjs/abilities\": \"workspace:^\",\n    \"@warriorjs/core\": \"workspace:^\",\n    \"@warriorjs/spatial\": \"workspace:^\"\n  }\n}\n"
  },
  {
    "path": "libs/units/src/Archer.test.ts",
    "content": "import { beforeEach, describe, expect, test } from 'vitest';\n\nimport Archer from './Archer.js';\nimport RangedUnit from './RangedUnit.js';\n\ndescribe('Archer', () => {\n  let archer: Archer;\n\n  beforeEach(() => {\n    archer = new Archer();\n  });\n\n  test('extends RangedUnit', () => {\n    expect(archer).toBeInstanceOf(RangedUnit);\n  });\n\n  test(\"appears as 'a' on map\", () => {\n    expect(archer.character).toBe('a');\n  });\n\n  test('has #ebcb8b color', () => {\n    expect(archer.color).toBe('#ebcb8b');\n  });\n\n  test('has 7 max health', () => {\n    expect(archer.maxHealth).toBe(7);\n  });\n\n  test('has shoot ability', () => {\n    expect(Archer.declaredAbilities).toHaveProperty('shoot');\n  });\n\n  test('has look ability', () => {\n    expect(Archer.declaredAbilities).toHaveProperty('look');\n  });\n});\n"
  },
  {
    "path": "libs/units/src/Archer.ts",
    "content": "import { Look, Shoot } from '@warriorjs/abilities';\n\nimport RangedUnit from './RangedUnit.js';\n\nclass Archer extends RangedUnit {\n  static declaredAbilities = {\n    look: Look.with({ range: 3 }),\n    shoot: Shoot.with({ range: 3, power: 3 }),\n  };\n\n  constructor() {\n    super('Archer', 'a', '#ebcb8b', 7);\n  }\n}\n\nexport default Archer;\n"
  },
  {
    "path": "libs/units/src/Captive.test.ts",
    "content": "import { Unit } from '@warriorjs/core';\nimport { beforeEach, describe, expect, test } from 'vitest';\n\nimport Captive from './Captive.js';\n\ndescribe('Captive', () => {\n  let captive: Captive;\n\n  beforeEach(() => {\n    captive = new Captive();\n  });\n\n  test('extends Unit', () => {\n    expect(captive).toBeInstanceOf(Unit);\n  });\n\n  test(\"appears as 'C' on map\", () => {\n    expect(captive.character).toBe('C');\n  });\n\n  test('has #81a1c1 color', () => {\n    expect(captive.color).toBe('#81a1c1');\n  });\n\n  test('has 1 max health', () => {\n    expect(captive.maxHealth).toBe(1);\n  });\n\n  test('has a reward of 20 points', () => {\n    expect(captive.reward).toBe(20);\n  });\n\n  test('is not an enemy', () => {\n    expect(captive.enemy).toBe(false);\n  });\n\n  test('is bound', () => {\n    expect(captive.bound).toBe(true);\n  });\n});\n"
  },
  {
    "path": "libs/units/src/Captive.ts",
    "content": "import { Unit } from '@warriorjs/core';\n\nclass Captive extends Unit {\n  constructor() {\n    super('Captive', 'C', '#81a1c1', 1, 20, false, true);\n  }\n}\n\nexport default Captive;\n"
  },
  {
    "path": "libs/units/src/MeleeUnit.test.ts",
    "content": "import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport MeleeUnit from './MeleeUnit.js';\n\nclass TestMeleeUnit extends MeleeUnit {\n  constructor() {\n    super('Melee', 'm', '#aaa', 10);\n  }\n}\n\ndescribe('MeleeUnit', () => {\n  let unit: TestMeleeUnit;\n  let turn: any;\n  let space: any;\n\n  beforeEach(() => {\n    unit = new TestMeleeUnit();\n    space = { getUnit: () => undefined };\n    turn = {\n      attack: vi.fn(),\n      feel: vi.fn(() => space),\n    };\n  });\n\n  test('feels in all directions looking for threats', () => {\n    unit.playTurn(turn);\n    expect(turn.feel).toHaveBeenCalledWith(FORWARD);\n    expect(turn.feel).toHaveBeenCalledWith(RIGHT);\n    expect(turn.feel).toHaveBeenCalledWith(BACKWARD);\n    expect(turn.feel).toHaveBeenCalledWith(LEFT);\n  });\n\n  test('attacks the first enemy it finds', () => {\n    turn.feel.mockReturnValueOnce({\n      getUnit: () => ({ isEnemy: () => true, isBound: () => false }),\n    });\n    unit.playTurn(turn);\n    expect(turn.attack).toHaveBeenCalledWith(FORWARD);\n  });\n\n  test('does not attack if no enemies found', () => {\n    unit.playTurn(turn);\n    expect(turn.attack).not.toHaveBeenCalled();\n  });\n\n  test('does not attack bound enemies', () => {\n    turn.feel.mockReturnValue({\n      getUnit: () => ({ isEnemy: () => true, isBound: () => true }),\n    });\n    unit.playTurn(turn);\n    expect(turn.attack).not.toHaveBeenCalled();\n  });\n\n  test('does not attack non-enemies', () => {\n    turn.feel.mockReturnValue({\n      getUnit: () => ({ isEnemy: () => false, isBound: () => false }),\n    });\n    unit.playTurn(turn);\n    expect(turn.attack).not.toHaveBeenCalled();\n  });\n\n  test('stops looking once it finds a threat', () => {\n    turn.feel.mockReturnValueOnce({ getUnit: () => undefined }).mockReturnValueOnce({\n      getUnit: () => ({ isEnemy: () => true, isBound: () => false }),\n    });\n    unit.playTurn(turn);\n    expect(turn.feel).toHaveBeenCalledTimes(2);\n    expect(turn.attack).toHaveBeenCalledWith(RIGHT);\n  });\n});\n"
  },
  {
    "path": "libs/units/src/MeleeUnit.ts",
    "content": "import { type Turn, Unit } from '@warriorjs/core';\nimport { RELATIVE_DIRECTIONS } from '@warriorjs/spatial';\n\nabstract class MeleeUnit extends Unit {\n  playTurn(turn: Turn) {\n    const threatDirection = RELATIVE_DIRECTIONS.find((direction) => {\n      const unit = turn.feel(direction).getUnit();\n      return unit?.isEnemy() && !unit.isBound();\n    });\n    if (threatDirection) {\n      turn.attack(threatDirection);\n    }\n  }\n}\n\nexport default MeleeUnit;\n"
  },
  {
    "path": "libs/units/src/RangedUnit.test.ts",
    "content": "import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport RangedUnit from './RangedUnit.js';\n\nclass TestRangedUnit extends RangedUnit {\n  constructor() {\n    super('Ranged', 'r', '#bbb', 8);\n  }\n}\n\ndescribe('RangedUnit', () => {\n  let unit: TestRangedUnit;\n  let turn: any;\n  let emptySpaces: any[];\n\n  beforeEach(() => {\n    unit = new TestRangedUnit();\n    emptySpaces = [{ isUnit: () => false }, { isUnit: () => false }];\n    turn = {\n      shoot: vi.fn(),\n      look: vi.fn(() => emptySpaces),\n    };\n  });\n\n  test('looks in all directions for threats', () => {\n    unit.playTurn(turn);\n    expect(turn.look).toHaveBeenCalledWith(FORWARD);\n    expect(turn.look).toHaveBeenCalledWith(RIGHT);\n    expect(turn.look).toHaveBeenCalledWith(BACKWARD);\n    expect(turn.look).toHaveBeenCalledWith(LEFT);\n  });\n\n  test('shoots the first direction with an enemy', () => {\n    turn.look.mockReturnValueOnce([\n      { isUnit: () => false },\n      { isUnit: () => true, getUnit: () => ({ isEnemy: () => true, isBound: () => false }) },\n    ]);\n    unit.playTurn(turn);\n    expect(turn.shoot).toHaveBeenCalledWith(FORWARD);\n  });\n\n  test('does not shoot if no enemies found', () => {\n    unit.playTurn(turn);\n    expect(turn.shoot).not.toHaveBeenCalled();\n  });\n\n  test('does not shoot bound enemies', () => {\n    turn.look.mockReturnValue([\n      { isUnit: () => true, getUnit: () => ({ isEnemy: () => true, isBound: () => true }) },\n    ]);\n    unit.playTurn(turn);\n    expect(turn.shoot).not.toHaveBeenCalled();\n  });\n\n  test('does not shoot non-enemies', () => {\n    turn.look.mockReturnValue([\n      { isUnit: () => true, getUnit: () => ({ isEnemy: () => false, isBound: () => false }) },\n    ]);\n    unit.playTurn(turn);\n    expect(turn.shoot).not.toHaveBeenCalled();\n  });\n\n  test('stops looking once it finds a threat', () => {\n    turn.look\n      .mockReturnValueOnce([{ isUnit: () => false }])\n      .mockReturnValueOnce([\n        { isUnit: () => true, getUnit: () => ({ isEnemy: () => true, isBound: () => false }) },\n      ]);\n    unit.playTurn(turn);\n    expect(turn.look).toHaveBeenCalledTimes(2);\n    expect(turn.shoot).toHaveBeenCalledWith(RIGHT);\n  });\n});\n"
  },
  {
    "path": "libs/units/src/RangedUnit.ts",
    "content": "import { type Turn, Unit } from '@warriorjs/core';\nimport { RELATIVE_DIRECTIONS } from '@warriorjs/spatial';\n\nabstract class RangedUnit extends Unit {\n  playTurn(turn: Turn) {\n    const threatDirection = RELATIVE_DIRECTIONS.find((direction) => {\n      const spaceWithUnit = turn.look(direction).find((space: any) => space.isUnit());\n      return spaceWithUnit?.getUnit().isEnemy() && !spaceWithUnit.getUnit().isBound();\n    });\n    if (threatDirection) {\n      turn.shoot(threatDirection);\n    }\n  }\n}\n\nexport default RangedUnit;\n"
  },
  {
    "path": "libs/units/src/Sludge.test.ts",
    "content": "import { beforeEach, describe, expect, test } from 'vitest';\n\nimport MeleeUnit from './MeleeUnit.js';\nimport Sludge from './Sludge.js';\n\ndescribe('Sludge', () => {\n  let sludge: Sludge;\n\n  beforeEach(() => {\n    sludge = new Sludge();\n  });\n\n  test('extends MeleeUnit', () => {\n    expect(sludge).toBeInstanceOf(MeleeUnit);\n  });\n\n  test(\"appears as 's' on map\", () => {\n    expect(sludge.character).toBe('s');\n  });\n\n  test('has #d08770 color', () => {\n    expect(sludge.color).toBe('#d08770');\n  });\n\n  test('has 12 max health', () => {\n    expect(sludge.maxHealth).toBe(12);\n  });\n\n  test('has attack ability', () => {\n    expect(Sludge.declaredAbilities).toHaveProperty('attack');\n  });\n\n  test('has feel ability', () => {\n    expect(Sludge.declaredAbilities).toHaveProperty('feel');\n  });\n});\n"
  },
  {
    "path": "libs/units/src/Sludge.ts",
    "content": "import { Attack, Feel } from '@warriorjs/abilities';\n\nimport MeleeUnit from './MeleeUnit.js';\n\nclass Sludge extends MeleeUnit {\n  static declaredAbilities = {\n    attack: Attack.with({ power: 3 }),\n    feel: Feel,\n  };\n\n  constructor() {\n    super('Sludge', 's', '#d08770', 12);\n  }\n}\n\nexport default Sludge;\n"
  },
  {
    "path": "libs/units/src/ThickSludge.test.ts",
    "content": "import { beforeEach, describe, expect, test } from 'vitest';\n\nimport MeleeUnit from './MeleeUnit.js';\nimport ThickSludge from './ThickSludge.js';\n\ndescribe('ThickSludge', () => {\n  let thickSludge: ThickSludge;\n\n  beforeEach(() => {\n    thickSludge = new ThickSludge();\n  });\n\n  test('extends MeleeUnit', () => {\n    expect(thickSludge).toBeInstanceOf(MeleeUnit);\n  });\n\n  test(\"appears as 'S' on map\", () => {\n    expect(thickSludge.character).toBe('S');\n  });\n\n  test('has #bf616a color', () => {\n    expect(thickSludge.color).toBe('#bf616a');\n  });\n\n  test('has 24 max health', () => {\n    expect(thickSludge.maxHealth).toBe(24);\n  });\n\n  test('has attack ability', () => {\n    expect(ThickSludge.declaredAbilities).toHaveProperty('attack');\n  });\n\n  test('has feel ability', () => {\n    expect(ThickSludge.declaredAbilities).toHaveProperty('feel');\n  });\n});\n"
  },
  {
    "path": "libs/units/src/ThickSludge.ts",
    "content": "import { Attack, Feel } from '@warriorjs/abilities';\n\nimport MeleeUnit from './MeleeUnit.js';\n\nclass ThickSludge extends MeleeUnit {\n  static declaredAbilities = {\n    attack: Attack.with({ power: 3 }),\n    feel: Feel,\n  };\n\n  constructor() {\n    super('Thick Sludge', 'S', '#bf616a', 24);\n  }\n}\n\nexport default ThickSludge;\n"
  },
  {
    "path": "libs/units/src/Wizard.test.ts",
    "content": "import { beforeEach, describe, expect, test } from 'vitest';\n\nimport RangedUnit from './RangedUnit.js';\nimport Wizard from './Wizard.js';\n\ndescribe('Wizard', () => {\n  let wizard: Wizard;\n\n  beforeEach(() => {\n    wizard = new Wizard();\n  });\n\n  test('extends RangedUnit', () => {\n    expect(wizard).toBeInstanceOf(RangedUnit);\n  });\n\n  test(\"appears as 'w' on map\", () => {\n    expect(wizard.character).toBe('w');\n  });\n\n  test('has #b48ead color', () => {\n    expect(wizard.color).toBe('#b48ead');\n  });\n\n  test('has 3 max health', () => {\n    expect(wizard.maxHealth).toBe(3);\n  });\n\n  test('has shoot ability', () => {\n    expect(Wizard.declaredAbilities).toHaveProperty('shoot');\n  });\n\n  test('has look ability', () => {\n    expect(Wizard.declaredAbilities).toHaveProperty('look');\n  });\n});\n"
  },
  {
    "path": "libs/units/src/Wizard.ts",
    "content": "import { Look, Shoot } from '@warriorjs/abilities';\n\nimport RangedUnit from './RangedUnit.js';\n\nclass Wizard extends RangedUnit {\n  static declaredAbilities = {\n    look: Look.with({ range: 3 }),\n    shoot: Shoot.with({ range: 3, power: 11 }),\n  };\n\n  constructor() {\n    super('Wizard', 'w', '#b48ead', 3);\n  }\n}\n\nexport default Wizard;\n"
  },
  {
    "path": "libs/units/src/index.ts",
    "content": "export { default as Archer } from './Archer.js';\nexport { default as Captive } from './Captive.js';\nexport { default as Sludge } from './Sludge.js';\nexport { default as ThickSludge } from './ThickSludge.js';\nexport { default as Wizard } from './Wizard.js';\n"
  },
  {
    "path": "libs/units/tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\"\n  },\n  \"exclude\": [\"**/*.test.ts\"]\n}\n"
  },
  {
    "path": "libs/units/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "logo/LICENSE",
    "content": "# Attribution 4.0 International\n\nCreative Commons Corporation (“Creative Commons”) is not a law firm and does not provide legal services or legal advice. Distribution of Creative Commons public licenses does not create a lawyer-client or other relationship. Creative Commons makes its licenses and related information available on an “as-is” basis. Creative Commons gives no warranties regarding its licenses, any material licensed under their terms and conditions, or any related information. Creative Commons disclaims all liability for damages resulting from their use to the fullest extent possible.\n\n### Using Creative Commons Public Licenses\n\nCreative Commons public licenses provide a standard set of terms and conditions that creators and other rights holders may use to share original works of authorship and other material subject to copyright and certain other rights specified in the public license below. The following considerations are for informational purposes only, are not exhaustive, and do not form part of our licenses.\n\n* **Considerations for licensors:** Our public licenses are intended for use by those authorized to give the public permission to use material in ways otherwise restricted by copyright and certain other rights. Our licenses are irrevocable. Licensors should read and understand the terms and conditions of the license they choose before applying it. Licensors should also secure all rights necessary before applying our licenses so that the public can reuse the material as expected. Licensors should clearly mark any material not subject to the license. This includes other CC-licensed material, or material used under an exception or limitation to copyright. [More considerations for licensors](https://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensors).\n\n* **Considerations for the public:** By using one of our public licenses, a licensor grants the public permission to use the licensed material under specified terms and conditions. If the licensor’s permission is not necessary for any reason–for example, because of any applicable exception or limitation to copyright–then that use is not regulated by the license. Our licenses grant only permissions under copyright and certain other rights that a licensor has authority to grant. Use of the licensed material may still be restricted for other reasons, including because others have copyright or other rights in the material. A licensor may make special requests, such as asking that all changes be marked or described. Although not required by our licenses, you are encouraged to respect those requests where reasonable. [More considerations for the public](https://wiki.creativecommons.org/Considerations_for_licensors_and_licensees#Considerations_for_licensees).\n\n## Creative Commons Attribution 4.0 International Public License\n\nBy exercising the Licensed Rights (defined below), You accept and agree to be bound by the terms and conditions of this Creative Commons Attribution 4.0 International Public License (\"Public License\"). To the extent this Public License may be interpreted as a contract, You are granted the Licensed Rights in consideration of Your acceptance of these terms and conditions, and the Licensor grants You such rights in consideration of benefits the Licensor receives from making the Licensed Material available under these terms and conditions.\n\n### Section 1 – Definitions.\n\na. **Adapted Material** means material subject to Copyright and Similar Rights that is derived from or based upon the Licensed Material and in which the Licensed Material is translated, altered, arranged, transformed, or otherwise modified in a manner requiring permission under the Copyright and Similar Rights held by the Licensor. For purposes of this Public License, where the Licensed Material is a musical work, performance, or sound recording, Adapted Material is always produced where the Licensed Material is synched in timed relation with a moving image.\n\nb. **Adapter's License** means the license You apply to Your Copyright and Similar Rights in Your contributions to Adapted Material in accordance with the terms and conditions of this Public License.\n\nc. **Copyright and Similar Rights** means copyright and/or similar rights closely related to copyright including, without limitation, performance, broadcast, sound recording, and Sui Generis Database Rights, without regard to how the rights are labeled or categorized. For purposes of this Public License, the rights specified in Section 2(b)(1)-(2) are not Copyright and Similar Rights.\n\nd. **Effective Technological Measures** means those measures that, in the absence of proper authority, may not be circumvented under laws fulfilling obligations under Article 11 of the WIPO Copyright Treaty adopted on December 20, 1996, and/or similar international agreements.\n\ne. **Exceptions and Limitations** means fair use, fair dealing, and/or any other exception or limitation to Copyright and Similar Rights that applies to Your use of the Licensed Material.\n\nf. **Licensed Material** means the artistic or literary work, database, or other material to which the Licensor applied this Public License.\n\ng. **Licensed Rights** means the rights granted to You subject to the terms and conditions of this Public License, which are limited to all Copyright and Similar Rights that apply to Your use of the Licensed Material and that the Licensor has authority to license.\n\nh. **Licensor** means the individual(s) or entity(ies) granting rights under this Public License.\n\ni. **Share** means to provide material to the public by any means or process that requires permission under the Licensed Rights, such as reproduction, public display, public performance, distribution, dissemination, communication, or importation, and to make material available to the public including in ways that members of the public may access the material from a place and at a time individually chosen by them.\n\nj. **Sui Generis Database Rights** means rights other than copyright resulting from Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, as amended and/or succeeded, as well as other essentially equivalent rights anywhere in the world.\n\nk. **You** means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning.\n\n### Section 2 – Scope.\n\na. **_License grant._**\n\n1.  Subject to the terms and conditions of this Public License, the Licensor hereby grants You a worldwide, royalty-free, non-sublicensable, non-exclusive, irrevocable license to exercise the Licensed Rights in the Licensed Material to:\n\n    A. reproduce and Share the Licensed Material, in whole or in part; and\n\n    B. produce, reproduce, and Share Adapted Material.\n\n2.  **Exceptions and Limitations.** For the avoidance of doubt, where Exceptions and Limitations apply to Your use, this Public License does not apply, and You do not need to comply with its terms and conditions.\n\n3.  **Term.** The term of this Public License is specified in Section 6(a).\n\n4.  **Media and formats; technical modifications allowed.** The Licensor authorizes You to exercise the Licensed Rights in all media and formats whether now known or hereafter created, and to make technical modifications necessary to do so. The Licensor waives and/or agrees not to assert any right or authority to forbid You from making technical modifications necessary to exercise the Licensed Rights, including technical modifications necessary to circumvent Effective Technological Measures. For purposes of this Public License, simply making modifications authorized by this Section 2(a)(4) never produces Adapted Material.\n\n5.  **Downstream recipients.**\n\n    A. **Offer from the Licensor – Licensed Material.** Every recipient of the Licensed Material automatically receives an offer from the Licensor to exercise the Licensed Rights under the terms and conditions of this Public License.\n\n    B. **No downstream restrictions.** You may not offer or impose any additional or different terms or conditions on, or apply any Effective Technological Measures to, the Licensed Material if doing so restricts exercise of the Licensed Rights by any recipient of the Licensed Material.\n\n6.  **No endorsement.** Nothing in this Public License constitutes or may be construed as permission to assert or imply that You are, or that Your use of the Licensed Material is, connected with, or sponsored, endorsed, or granted official status by, the Licensor or others designated to receive attribution as provided in Section 3(a)(1)(A)(i).\n\nb. **_Other rights._**\n\n1.  Moral rights, such as the right of integrity, are not licensed under this Public License, nor are publicity, privacy, and/or other similar personality rights; however, to the extent possible, the Licensor waives and/or agrees not to assert any such rights held by the Licensor to the limited extent necessary to allow You to exercise the Licensed Rights, but not otherwise.\n\n2.  Patent and trademark rights are not licensed under this Public License.\n\n3.  To the extent possible, the Licensor waives any right to collect royalties from You for the exercise of the Licensed Rights, whether directly or through a collecting society under any voluntary or waivable statutory or compulsory licensing scheme. In all other cases the Licensor expressly reserves any right to collect such royalties.\n\n### Section 3 – License Conditions.\n\nYour exercise of the Licensed Rights is expressly made subject to the following conditions.\n\na. **_Attribution._**\n\n1.  If You Share the Licensed Material (including in modified form), You must:\n\n    A. retain the following if it is supplied by the Licensor with the Licensed Material:\n\n    i. identification of the creator(s) of the Licensed Material and any others designated to receive attribution, in any reasonable manner requested by the Licensor (including by pseudonym if designated);\n\n    ii. a copyright notice;\n\n    iii. a notice that refers to this Public License;\n\n    iv. a notice that refers to the disclaimer of warranties;\n\n    v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable;\n\n    B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and\n\n    C. indicate the Licensed Material is licensed under this Public License, and include the text of, or the URI or hyperlink to, this Public License.\n\n2.  You may satisfy the conditions in Section 3(a)(1) in any reasonable manner based on the medium, means, and context in which You Share the Licensed Material. For example, it may be reasonable to satisfy the conditions by providing a URI or hyperlink to a resource that includes the required information.\n\n3.  If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable.\n\n4.  If You Share Adapted Material You produce, the Adapter's License You apply must not prevent recipients of the Adapted Material from complying with this Public License.\n\n### Section 4 – Sui Generis Database Rights.\n\nWhere the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material:\n\na. for the avoidance of doubt, Section 2(a)(1) grants You the right to extract, reuse, reproduce, and Share all or a substantial portion of the contents of the database;\n\nb. if You include all or a substantial portion of the database contents in a database in which You have Sui Generis Database Rights, then the database in which You have Sui Generis Database Rights (but not its individual contents) is Adapted Material; and\n\nc. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database.\n\nFor the avoidance of doubt, this Section 4 supplements and does not replace Your obligations under this Public License where the Licensed Rights include other Copyright and Similar Rights.\n\n### Section 5 – Disclaimer of Warranties and Limitation of Liability.\n\na. **Unless otherwise separately undertaken by the Licensor, to the extent possible, the Licensor offers the Licensed Material as-is and as-available, and makes no representations or warranties of any kind concerning the Licensed Material, whether express, implied, statutory, or other. This includes, without limitation, warranties of title, merchantability, fitness for a particular purpose, non-infringement, absence of latent or other defects, accuracy, or the presence or absence of errors, whether or not known or discoverable. Where disclaimers of warranties are not allowed in full or in part, this disclaimer may not apply to You.**\n\nb. **To the extent possible, in no event will the Licensor be liable to You on any legal theory (including, without limitation, negligence) or otherwise for any direct, special, indirect, incidental, consequential, punitive, exemplary, or other losses, costs, expenses, or damages arising out of this Public License or use of the Licensed Material, even if the Licensor has been advised of the possibility of such losses, costs, expenses, or damages. Where a limitation of liability is not allowed in full or in part, this limitation may not apply to You.**\n\nc. The disclaimer of warranties and limitation of liability provided above shall be interpreted in a manner that, to the extent possible, most closely approximates an absolute disclaimer and waiver of all liability.\n\n### Section 6 – Term and Termination.\n\na. This Public License applies for the term of the Copyright and Similar Rights licensed here. However, if You fail to comply with this Public License, then Your rights under this Public License terminate automatically.\n\nb. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates:\n\n1.  automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or\n\n2.  upon express reinstatement by the Licensor.\n\nFor the avoidance of doubt, this Section 6(b) does not affect any right the Licensor may have to seek remedies for Your violations of this Public License.\n\nc. For the avoidance of doubt, the Licensor may also offer the Licensed Material under separate terms or conditions or stop distributing the Licensed Material at any time; however, doing so will not terminate this Public License.\n\nd. Sections 1, 5, 6, 7, and 8 survive termination of this Public License.\n\n### Section 7 – Other Terms and Conditions.\n\na. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed.\n\nb. Any arrangements, understandings, or agreements regarding the Licensed Material not stated herein are separate from and independent of the terms and conditions of this Public License.\n\n### Section 8 – Interpretation.\n\na. For the avoidance of doubt, this Public License does not, and shall not be interpreted to, reduce, limit, restrict, or impose conditions on any use of the Licensed Material that could lawfully be made without permission under this Public License.\n\nb. To the extent possible, if any provision of this Public License is deemed unenforceable, it shall be automatically reformed to the minimum extent necessary to make it enforceable. If the provision cannot be reformed, it shall be severed from this Public License without affecting the enforceability of the remaining terms and conditions.\n\nc. No term or condition of this Public License will be waived and no failure to comply consented to unless expressly agreed to by the Licensor.\n\nd. Nothing in this Public License constitutes or may be interpreted as a limitation upon, or waiver of, any privileges and immunities that apply to the Licensor or You, including from the legal processes of any jurisdiction or authority.\n\n> Creative Commons is not a party to its public licenses. Notwithstanding, Creative Commons may elect to apply one of its public licenses to material it publishes and in those instances will be considered the “Licensor.” Except for the limited purpose of indicating that material is shared under a Creative Commons public license or as otherwise permitted by the Creative Commons policies published at [creativecommons.org/policies](https://creativecommons.org/policies), Creative Commons does not authorize the use of the trademark “Creative Commons” or any other trademark or logo of Creative Commons without its prior written consent including, without limitation, in connection with any unauthorized modifications to any of its public licenses or any other arrangements, understandings, or agreements concerning use of licensed material. For the avoidance of doubt, this paragraph does not form part of the public licenses.\n>\n> Creative Commons may be contacted at [creativecommons.org](https://creativecommons.org).\n"
  },
  {
    "path": "logo/README.md",
    "content": "<p align=\"center\">\n  <img\n    alt=\"The WarriorJS logo concept, in dark.\"\n    src=\"warriorjs-banner-dark.png?raw=true\"\n  />\n</p>\n<p align=\"center\">\n  <img\n    alt=\"The WarriorJS logo concept, in light.\"\n    src=\"warriorjs-banner-light.png?raw=true\"\n  />\n</p>\n"
  },
  {
    "path": "package.json",
    "content": "{\n  \"private\": true,\n  \"type\": \"module\",\n  \"packageManager\": \"pnpm@10.11.0\",\n  \"engines\": {\n    \"node\": \">=24\"\n  },\n  \"homepage\": \"https://warrior.js.org\",\n  \"repository\": \"https://github.com/olistic/warriorjs\",\n  \"scripts\": {\n    \"build\": \"turbo run build --filter='!warriorjs-website'\",\n    \"clean\": \"rm -rf {apps,libs,towers}/*/dist\",\n    \"lint\": \"biome check\",\n    \"lint:fix\": \"biome check --write\",\n    \"typecheck\": \"turbo run typecheck --filter='!warriorjs-website'\",\n    \"test\": \"vitest run\",\n    \"test:coverage\": \"vitest run --coverage\",\n    \"test:watch\": \"vitest\",\n    \"prepare\": \"lefthook install\"\n  },\n  \"devDependencies\": {\n    \"@biomejs/biome\": \"^2.4.6\",\n    \"@types/node\": \"^25.4.0\",\n    \"@vitest/coverage-v8\": \"^4.0.18\",\n    \"lefthook\": \"^2.1.4\",\n    \"turbo\": \"^2.4.4\",\n    \"typescript\": \"^5.9.3\",\n    \"vitest\": \"^4.0.18\"\n  },\n  \"pnpm\": {\n    \"onlyBuiltDependencies\": [\"lefthook\"]\n  }\n}\n"
  },
  {
    "path": "pnpm-workspace.yaml",
    "content": "onlyBuiltDependencies:\n  - lefthook\n\npackages:\n  - 'apps/*'\n  - 'libs/*'\n  - 'towers/*'\n\nsettings:\n  strictPeerDependencies: false\n"
  },
  {
    "path": "towers/README.md",
    "content": "# [Towers](https://warrior.js.org/docs/player/towers)\n\nThe towers available in WarriorJS are independent packages that add new\nuniverses (levels, abilities and units) to the game.\n\n| Package                                                                        | Version                                                                                        |\n| ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- |\n| [`@warriorjs/tower-the-narrow-path`][warriorjs-tower-the-narrow-path]          | [![npm][warriorjs-tower-the-narrow-path-badge]][warriorjs-tower-the-narrow-path-npm]           |\n| [`@warriorjs/tower-the-powder-keep`][warriorjs-tower-the-powder-keep]          | [![npm][warriorjs-tower-the-powder-keep-badge]][warriorjs-tower-the-powder-keep-npm]           |\n\n- [`@warriorjs/tower-the-narrow-path`][warriorjs-tower-the-narrow-path] is \"The Narrow\n  Path\", the entry-level tower. You should play this first.\n- [`@warriorjs/tower-the-powder-keep`][warriorjs-tower-the-powder-keep] is \"The\n  Powder Keep\", a more challenging tower.\n\n> You can find community maintained towers in [npm][community-towers-npm].\n\n[warriorjs-tower-the-narrow-path]: /towers/the-narrow-path\n[warriorjs-tower-the-narrow-path-badge]:\n  https://img.shields.io/npm/v/@warriorjs/tower-the-narrow-path.svg?style=flat-square\n[warriorjs-tower-the-narrow-path-npm]:\n  https://www.npmjs.com/package/@warriorjs/tower-the-narrow-path\n[warriorjs-tower-the-powder-keep]: /towers/the-powder-keep\n[warriorjs-tower-the-powder-keep-badge]:\n  https://img.shields.io/npm/v/@warriorjs/tower-the-powder-keep.svg?style=flat-square\n[warriorjs-tower-the-powder-keep-npm]:\n  https://www.npmjs.com/package/@warriorjs/tower-the-powder-keep\n[community-towers-npm]: https://www.npmjs.com/search?q=warriorjs-tower\n"
  },
  {
    "path": "towers/the-narrow-path/README.md",
    "content": "# @warriorjs/tower-the-narrow-path\n\n> A corridor of stone where the only way out is forward.\n\nThe walls press close. Torchlight flickers against wet stone, casting long\nshadows down a passage that stretches beyond sight. Whatever waits ahead, there\nis no turning back — only forward, one step at a time.\n\n**9 levels.** Walk, fight, rest, rescue, pivot, shoot — each floor teaches one\nnew ability and asks you to combine it with everything you've learned so far.\nStart here.\n\n## Install\n\n`@warriorjs/cli` ships with this tower built-in. Just run:\n\n```sh\nwarriorjs\n```\n\nTo install it separately:\n\n```sh\nnpm install @warriorjs/tower-the-narrow-path\n```\n\nSee the [Towers](https://warrior.js.org/docs/player/towers) docs for more.\n"
  },
  {
    "path": "towers/the-narrow-path/package.json",
    "content": "{\n  \"name\": \"@warriorjs/tower-the-narrow-path\",\n  \"version\": \"0.13.0\",\n  \"description\": \"The Narrow Path\",\n  \"author\": \"Matias Olivera <hi@matiasolivera.com>\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://warrior.js.org\",\n  \"repository\": \"https://github.com/olistic/warriorjs/tree/master/towers/the-narrow-path\",\n  \"keywords\": [\n    \"warriorjs-tower\"\n  ],\n  \"type\": \"module\",\n  \"main\": \"dist/index.js\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.js\",\n      \"require\": \"./dist/index.js\",\n      \"types\": \"./dist/index.d.ts\"\n    }\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"engines\": {\n    \"node\": \">=24\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc -p tsconfig.build.json\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@warriorjs/abilities\": \"workspace:^\",\n    \"@warriorjs/core\": \"workspace:^\",\n    \"@warriorjs/spatial\": \"workspace:^\",\n    \"@warriorjs/units\": \"workspace:^\"\n  }\n}\n"
  },
  {
    "path": "towers/the-narrow-path/src/index.ts",
    "content": "import {\n  Attack,\n  Feel,\n  Health,\n  Look,\n  MaxHealth,\n  Pivot,\n  Rescue,\n  Rest,\n  Shoot,\n  Think,\n  Walk,\n} from '@warriorjs/abilities';\nimport { type TowerDefinition } from '@warriorjs/core';\nimport { EAST, WEST } from '@warriorjs/spatial';\nimport { Archer, Captive, Sludge, ThickSludge, Wizard } from '@warriorjs/units';\n\nconst tower: TowerDefinition = {\n  name: 'The Narrow Path',\n  description: 'A corridor of stone where the only way out is forward',\n  warrior: {\n    character: '@',\n    color: '#8fbcbb',\n    maxHealth: 20,\n  },\n  levels: [\n    {\n      description:\n        'A long hallway stretches before you, torchlight glinting off stairs at the far end. The air is still. Nothing stirs.',\n      tip: \"The path is clear. Call `warrior.walk()` to walk forward in the Player's `playTurn` method.\",\n      timeBonus: 15,\n      aceScore: 10,\n      floor: {\n        size: {\n          width: 8,\n          height: 1,\n        },\n        stairs: {\n          x: 7,\n          y: 0,\n        },\n        warrior: {\n          abilities: {\n            think: Think,\n            walk: Walk,\n          },\n          position: {\n            x: 0,\n            y: 0,\n            facing: EAST,\n          },\n        },\n        units: [],\n      },\n    },\n    {\n      description:\n        'The torches have gone out. Darkness swallows the corridor, but the stench of sludge hangs thick in the air.',\n      tip: \"Something lurks ahead. Use `warrior.feel().isEmpty()` to check if the space is clear. If not, `warrior.attack()` will fight whatever's there. Remember: one action per turn.\",\n      clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.',\n      timeBonus: 20,\n      aceScore: 26,\n      floor: {\n        size: {\n          width: 8,\n          height: 1,\n        },\n        stairs: {\n          x: 7,\n          y: 0,\n        },\n        warrior: {\n          abilities: {\n            attack: Attack.with({ power: 5 }),\n            feel: Feel,\n          },\n          position: {\n            x: 0,\n            y: 0,\n            facing: EAST,\n          },\n        },\n        units: [\n          {\n            unit: Sludge,\n            position: {\n              x: 4,\n              y: 0,\n              facing: WEST,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description:\n        'The air is heavy and wet, almost hard to breathe. The stench is overwhelming — there must be a horde of them.',\n      tip: 'These walls will wear you down. Use `warrior.health()` and `warrior.maxHealth()` to keep watch over your health, and `warrior.rest()` to recover 10% of your max health.',\n      clue: \"When there's no enemy ahead of you, call `warrior.rest()` until your health is full before walking forward.\",\n      timeBonus: 35,\n      aceScore: 71,\n      floor: {\n        size: {\n          width: 9,\n          height: 1,\n        },\n        stairs: {\n          x: 8,\n          y: 0,\n        },\n        warrior: {\n          abilities: {\n            health: Health,\n            maxHealth: MaxHealth,\n            rest: Rest.with({ healthGain: 0.1 }),\n          },\n          position: {\n            x: 0,\n            y: 0,\n            facing: EAST,\n          },\n        },\n        units: [\n          {\n            unit: Sludge,\n            position: {\n              x: 2,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 4,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 5,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 7,\n              y: 0,\n              facing: WEST,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description:\n        'A faint creak echoes off the walls. Somewhere ahead, bow strings are being drawn.',\n      tip: \"No new abilities — but arrows fly whether you're ready or not. Save a `this.health` variable and compare it on each turn to see if you're taking damage. Don't rest under fire.\",\n      clue: \"Set `this.health` to your current health at the end of `playTurn`. If this is greater than your current health next turn, then you know you're taking damage and shouldn't rest.\",\n      timeBonus: 45,\n      aceScore: 90,\n      floor: {\n        size: {\n          width: 7,\n          height: 1,\n        },\n        stairs: {\n          x: 6,\n          y: 0,\n        },\n        warrior: {\n          position: {\n            x: 0,\n            y: 0,\n            facing: EAST,\n          },\n        },\n        units: [\n          {\n            unit: ThickSludge,\n            position: {\n              x: 2,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Archer,\n            position: {\n              x: 3,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: ThickSludge,\n            position: {\n              x: 5,\n              y: 0,\n              facing: WEST,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description: 'Muffled cries echo through the stone. Someone is alive down here — and bound.',\n      tip: 'Not every figure in the dark is a foe. Use `warrior.feel().getUnit()?.isEnemy()` and `warrior.feel().getUnit()?.isBound()` to identify captives, and `warrior.rescue()` to free them.',\n      clue: \"Don't forget to constantly check if you are being attacked. Rest until your health is full if you're not taking damage.\",\n      timeBonus: 45,\n      aceScore: 123,\n      floor: {\n        size: {\n          width: 7,\n          height: 1,\n        },\n        stairs: {\n          x: 6,\n          y: 0,\n        },\n        warrior: {\n          abilities: {\n            rescue: Rescue,\n          },\n          position: {\n            x: 0,\n            y: 0,\n            facing: EAST,\n          },\n        },\n        units: [\n          {\n            unit: Captive,\n            position: {\n              x: 2,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Archer,\n            position: {\n              x: 3,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Archer,\n            position: {\n              x: 4,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: ThickSludge,\n            position: {\n              x: 5,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Captive,\n            position: {\n              x: 6,\n              y: 0,\n              facing: WEST,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description:\n        'The corridor opens wider than before. Cries reach you from both ends — ahead and behind.',\n      tip: \"Danger on two fronts. Pass `'backward'` to `walk()`, `feel()`, `rescue()`, and `attack()` to act behind you. Archers have a limited attack distance.\",\n      clue: \"Walk backward if you're taking damage from afar and don't have enough health to attack. You may also want to consider walking backward until you hit a wall. Use `warrior.feel().isWall()` to see if there's a wall.\",\n      timeBonus: 55,\n      aceScore: 105,\n      floor: {\n        size: {\n          width: 8,\n          height: 1,\n        },\n        stairs: {\n          x: 7,\n          y: 0,\n        },\n        warrior: {\n          position: {\n            x: 2,\n            y: 0,\n            facing: EAST,\n          },\n        },\n        units: [\n          {\n            unit: Captive,\n            position: {\n              x: 0,\n              y: 0,\n              facing: EAST,\n            },\n          },\n          {\n            unit: ThickSludge,\n            position: {\n              x: 4,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Archer,\n            position: {\n              x: 6,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Archer,\n            position: {\n              x: 7,\n              y: 0,\n              facing: WEST,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description:\n        'Cold stone meets your outstretched hand. A dead end — but a draft at your back tells you the way lies behind.',\n      tip: \"Fighting backward dulls your blade. Use `warrior.feel().isWall()` to detect the wall, and `warrior.pivot()` to turn and face what's coming.\",\n      timeBonus: 30,\n      aceScore: 50,\n      floor: {\n        size: {\n          width: 6,\n          height: 1,\n        },\n        stairs: {\n          x: 0,\n          y: 0,\n        },\n        warrior: {\n          abilities: {\n            pivot: Pivot,\n          },\n          position: {\n            x: 5,\n            y: 0,\n            facing: EAST,\n          },\n        },\n        units: [\n          {\n            unit: Archer,\n            position: {\n              x: 1,\n              y: 0,\n              facing: EAST,\n            },\n          },\n          {\n            unit: ThickSludge,\n            position: {\n              x: 3,\n              y: 0,\n              facing: EAST,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description:\n        'Low chanting reverberates through the passage. Wizards. Your hand finds a bow left behind by some less fortunate soul.',\n      tip: 'Their spells are deadly, but wizards are frail. Use `warrior.look()` to see ahead, and `warrior.shoot()` to loose an arrow before they strike.',\n      clue: \"Wizards are deadly but low in health. Kill them before they've time to attack.\",\n      timeBonus: 20,\n      aceScore: 46,\n      floor: {\n        size: {\n          width: 6,\n          height: 1,\n        },\n        stairs: {\n          x: 5,\n          y: 0,\n        },\n        warrior: {\n          abilities: {\n            look: Look.with({ range: 3 }),\n            shoot: Shoot.with({ power: 3, range: 3 }),\n          },\n          position: {\n            x: 0,\n            y: 0,\n            facing: EAST,\n          },\n        },\n        units: [\n          {\n            unit: Captive,\n            position: {\n              x: 2,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Wizard,\n            position: {\n              x: 3,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Wizard,\n            position: {\n              x: 4,\n              y: 0,\n              facing: WEST,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description:\n        \"The passage splits open into a long chamber. Enemies ahead, enemies behind. Everything you've survived has led to this.\",\n      tip: 'Trust your instincts. Watch your back.',\n      clue: \"Don't just keep shooting the bow while you're being attacked from behind.\",\n      timeBonus: 40,\n      aceScore: 100,\n      floor: {\n        size: {\n          width: 11,\n          height: 1,\n        },\n        stairs: {\n          x: 0,\n          y: 0,\n        },\n        warrior: {\n          position: {\n            x: 5,\n            y: 0,\n            facing: EAST,\n          },\n        },\n        units: [\n          {\n            unit: Captive,\n            position: {\n              x: 1,\n              y: 0,\n              facing: EAST,\n            },\n          },\n          {\n            unit: Archer,\n            position: {\n              x: 2,\n              y: 0,\n              facing: EAST,\n            },\n          },\n          {\n            unit: ThickSludge,\n            position: {\n              x: 7,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Wizard,\n            position: {\n              x: 9,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Captive,\n            position: {\n              x: 10,\n              y: 0,\n              facing: WEST,\n            },\n          },\n        ],\n      },\n    },\n  ],\n};\n\nexport default tower;\n"
  },
  {
    "path": "towers/the-narrow-path/tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\"\n  },\n  \"exclude\": [\"**/*.test.ts\"]\n}\n"
  },
  {
    "path": "towers/the-narrow-path/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "towers/the-powder-keep/README.md",
    "content": "# @warriorjs/tower-the-powder-keep\n\n> An old fortress where something ticks beneath the floor.\n\nThe keep was abandoned long ago, but not emptied. Its rooms are vast and dark,\nand somewhere deep within, a faint ticking pulses through the walls like a\nsecond heartbeat. Move carefully. Not everything here can be fought — some\nthings can only be outrun.\n\n**9 levels.** Navigate open rooms, listen for sounds, bind enemies, defuse\nticking bombs. Floors are two-dimensional — threats come from every direction.\n\n## Install\n\nInstall alongside `@warriorjs/cli`:\n\n```sh\nnpm install --global @warriorjs/tower-the-powder-keep\n```\n\nOr locally:\n\n```sh\nnpm install @warriorjs/tower-the-powder-keep\n```\n\nThen run `warriorjs` and select The Powder Keep.\n\nSee the [Towers](https://warrior.js.org/docs/player/towers) docs for more.\n"
  },
  {
    "path": "towers/the-powder-keep/package.json",
    "content": "{\n  \"name\": \"@warriorjs/tower-the-powder-keep\",\n  \"version\": \"0.13.0\",\n  \"description\": \"The Powder Keep\",\n  \"author\": \"Matias Olivera <hi@matiasolivera.com>\",\n  \"license\": \"MIT\",\n  \"homepage\": \"https://warrior.js.org\",\n  \"repository\": \"https://github.com/olistic/warriorjs/tree/master/towers/the-powder-keep\",\n  \"keywords\": [\n    \"warriorjs-tower\"\n  ],\n  \"type\": \"module\",\n  \"main\": \"dist/index.js\",\n  \"exports\": {\n    \".\": {\n      \"import\": \"./dist/index.js\",\n      \"require\": \"./dist/index.js\",\n      \"types\": \"./dist/index.d.ts\"\n    }\n  },\n  \"files\": [\n    \"dist\"\n  ],\n  \"publishConfig\": {\n    \"access\": \"public\"\n  },\n  \"engines\": {\n    \"node\": \">=24\"\n  },\n  \"scripts\": {\n    \"build\": \"tsc -p tsconfig.build.json\",\n    \"typecheck\": \"tsc --noEmit\"\n  },\n  \"dependencies\": {\n    \"@warriorjs/abilities\": \"workspace:^\",\n    \"@warriorjs/core\": \"workspace:^\",\n    \"@warriorjs/effects\": \"workspace:^\",\n    \"@warriorjs/spatial\": \"workspace:^\",\n    \"@warriorjs/units\": \"workspace:^\"\n  }\n}\n"
  },
  {
    "path": "towers/the-powder-keep/src/index.ts",
    "content": "import {\n  Attack,\n  Bind,\n  Detonate,\n  DirectionOf,\n  DirectionOfStairs,\n  DistanceOf,\n  Feel,\n  Health,\n  Listen,\n  Look,\n  MaxHealth,\n  Rescue,\n  Rest,\n  Think,\n  Walk,\n} from '@warriorjs/abilities';\nimport { type TowerDefinition } from '@warriorjs/core';\nimport { Ticking } from '@warriorjs/effects';\nimport { EAST, NORTH, SOUTH, WEST } from '@warriorjs/spatial';\nimport { Captive, Sludge, ThickSludge } from '@warriorjs/units';\n\nconst tower: TowerDefinition = {\n  name: 'The Powder Keep',\n  description: 'An old fortress where something ticks beneath the floor',\n  warrior: {\n    character: '@',\n    color: '#8fbcbb',\n    maxHealth: 20,\n  },\n  levels: [\n    {\n      description:\n        'Silence. The room stretches wide and empty, your footsteps swallowed by the dark. A crumpled map in your hand marks the way to the stairs.',\n      tip: \"The dark won't guide you, but the map will. Use `warrior.directionOfStairs()` to find the stairs, and pass the result to `warrior.walk()` to move toward them.\",\n      timeBonus: 20,\n      aceScore: 19,\n      floor: {\n        size: {\n          width: 6,\n          height: 4,\n        },\n        stairs: {\n          x: 2,\n          y: 3,\n        },\n        warrior: {\n          abilities: {\n            directionOfStairs: DirectionOfStairs,\n            think: Think,\n            walk: Walk,\n          },\n          position: {\n            x: 0,\n            y: 1,\n            facing: EAST,\n          },\n        },\n        units: [],\n      },\n    },\n    {\n      description:\n        'The next chamber is not empty. Shapes shift in the darkness on all sides, between you and the stairs.',\n      tip: 'Threats can come from any direction now. You can attack and feel forward, left, right, and backward.',\n      clue: \"Call `warrior.feel().getUnit()?.isEnemy()` in each direction to make sure there isn't an enemy beside you (attack if there is). Call `warrior.rest()` if you're low in health when there are no enemies around.\",\n      timeBonus: 40,\n      aceScore: 84,\n      floor: {\n        size: {\n          width: 4,\n          height: 2,\n        },\n        stairs: {\n          x: 3,\n          y: 1,\n        },\n        warrior: {\n          abilities: {\n            attack: Attack.with({ power: 5 }),\n            feel: Feel,\n            health: Health,\n            maxHealth: MaxHealth,\n            rest: Rest.with({ healthGain: 0.1 }),\n          },\n          position: {\n            x: 0,\n            y: 0,\n            facing: EAST,\n          },\n        },\n        units: [\n          {\n            unit: Sludge,\n            position: {\n              x: 1,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: ThickSludge,\n            position: {\n              x: 2,\n              y: 1,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 1,\n              y: 1,\n              facing: NORTH,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description: 'Slime presses against you from every direction. You are surrounded.',\n      tip: 'Too many to fight at once. Call `warrior.bind()` to hold an enemy in place while you deal with the others.',\n      clue: 'Count the number of unbound enemies around you. Bind an enemy if there are two or more.',\n      timeBonus: 50,\n      aceScore: 101,\n      floor: {\n        size: {\n          width: 3,\n          height: 3,\n        },\n        stairs: {\n          x: 0,\n          y: 0,\n        },\n        warrior: {\n          position: {\n            x: 1,\n            y: 1,\n            facing: EAST,\n          },\n          abilities: {\n            bind: Bind,\n            rescue: Rescue,\n          },\n        },\n        units: [\n          {\n            unit: Sludge,\n            position: {\n              x: 1,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Captive,\n            position: {\n              x: 1,\n              y: 2,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 0,\n              y: 1,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 2,\n              y: 1,\n              facing: WEST,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description:\n        'Your eyes are useless here, but your ears sharpen. Breathing. Struggling. Faint sounds scattered across the room.',\n      tip: 'Listen for what you cannot see. Use `warrior.listen()` to find spaces with other units, and `warrior.directionOf()` to determine which way they are.',\n      clue: 'Walk towards a unit with `warrior.walk(warrior.directionOf(warrior.listen()[0]))`. Once `warrior.listen().length === 0`, head for the stairs.',\n      timeBonus: 55,\n      aceScore: 144,\n      floor: {\n        size: {\n          width: 4,\n          height: 3,\n        },\n        stairs: {\n          x: 3,\n          y: 2,\n        },\n        warrior: {\n          position: {\n            x: 1,\n            y: 1,\n            facing: EAST,\n          },\n          abilities: {\n            directionOf: DirectionOf,\n            listen: Listen,\n          },\n        },\n        units: [\n          {\n            unit: Captive,\n            position: {\n              x: 0,\n              y: 0,\n              facing: EAST,\n            },\n          },\n          {\n            unit: Captive,\n            position: {\n              x: 0,\n              y: 2,\n              facing: EAST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 2,\n              y: 0,\n              facing: SOUTH,\n            },\n          },\n          {\n            unit: ThickSludge,\n            position: {\n              x: 3,\n              y: 1,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 2,\n              y: 2,\n              facing: NORTH,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description:\n        'The stairs are right beside you — you could leave now. But the room beyond is not empty, and neither is your conscience.',\n      tip: 'Leaving is easy. Clearing the floor is worth more. Use `warrior.feel().isStairs()` and `warrior.feel().isEmpty()` to choose your path.',\n      clue: 'If going towards a unit is the same direction as the stairs, try moving in another empty direction until you can safely move toward the enemies.',\n      timeBonus: 45,\n      aceScore: 107,\n      floor: {\n        size: {\n          width: 5,\n          height: 2,\n        },\n        stairs: {\n          x: 1,\n          y: 1,\n        },\n        warrior: {\n          position: {\n            x: 0,\n            y: 1,\n            facing: EAST,\n          },\n        },\n        units: [\n          {\n            unit: ThickSludge,\n            position: {\n              x: 4,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: ThickSludge,\n            position: {\n              x: 3,\n              y: 1,\n              facing: NORTH,\n            },\n          },\n          {\n            unit: Captive,\n            position: {\n              x: 4,\n              y: 1,\n              facing: WEST,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description:\n        'A rhythmic ticking cuts through the silence. Somewhere in the dark, a captive kneels over a bomb that will not wait.',\n      tip: \"Time is short. Rescue captives with `space.getUnit().isUnderEffect('ticking')` first — they won't last long.\",\n      clue: \"Avoid fighting enemies at first. Use `warrior.listen()` and `space.getUnit().isUnderEffect('ticking')` and quickly rescue those captives.\",\n      timeBonus: 50,\n      aceScore: 108,\n      floor: {\n        size: {\n          width: 6,\n          height: 2,\n        },\n        stairs: {\n          x: 5,\n          y: 0,\n        },\n        warrior: {\n          position: {\n            x: 0,\n            y: 1,\n            facing: EAST,\n          },\n        },\n        units: [\n          {\n            unit: Sludge,\n            position: {\n              x: 1,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 3,\n              y: 1,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Captive,\n            position: {\n              x: 0,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Captive,\n            effects: {\n              ticking: Ticking.with({ time: 7 }),\n            },\n            position: {\n              x: 4,\n              y: 1,\n              facing: WEST,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description:\n        'The ticking again. Faster now. But the sludge between you and the captive does not intend to move.',\n      tip: 'No way around — only through. Kill the sludge and reach the captive before the bomb goes off.',\n      clue: 'Determine the direction of the ticking captive and kill any enemies blocking that path. You may need to bind surrounding enemies first.',\n      timeBonus: 70,\n      aceScore: 134,\n      floor: {\n        size: {\n          width: 5,\n          height: 3,\n        },\n        stairs: {\n          x: 4,\n          y: 0,\n        },\n        warrior: {\n          position: {\n            x: 0,\n            y: 1,\n            facing: EAST,\n          },\n        },\n        units: [\n          {\n            unit: Sludge,\n            position: {\n              x: 1,\n              y: 0,\n              facing: SOUTH,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 1,\n              y: 2,\n              facing: NORTH,\n            },\n          },\n          {\n            unit: Captive,\n            position: {\n              x: 2,\n              y: 1,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Captive,\n            effects: {\n              ticking: Ticking.with({ time: 10 }),\n            },\n            position: {\n              x: 4,\n              y: 1,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Captive,\n            position: {\n              x: 2,\n              y: 0,\n              facing: WEST,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description:\n        \"Your boot catches a leather satchel half-buried in dust. Bombs. The keep's former garrison left something useful behind.\",\n      tip: 'Fire answers numbers. Use `warrior.look()` to spot clustered enemies, and `warrior.detonate()` to thin the herd. Mind your health.',\n      clue: 'Calling `warrior.look()` will return an array of spaces. If the first two contain enemies, detonate a bomb with `warrior.detonate()`.',\n      timeBonus: 30,\n      aceScore: 91,\n      floor: {\n        size: {\n          width: 7,\n          height: 1,\n        },\n        stairs: {\n          x: 6,\n          y: 0,\n        },\n        warrior: {\n          position: {\n            x: 0,\n            y: 0,\n            facing: EAST,\n          },\n          abilities: {\n            detonate: Detonate.with({ targetPower: 8, surroundingPower: 4 }),\n            look: Look.with({ range: 3 }),\n          },\n        },\n        units: [\n          {\n            unit: Captive,\n            effects: {\n              ticking: Ticking.with({ time: 9 }),\n            },\n            position: {\n              x: 5,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: ThickSludge,\n            position: {\n              x: 2,\n              y: 0,\n              facing: WEST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 3,\n              y: 0,\n              facing: WEST,\n            },\n          },\n        ],\n      },\n    },\n    {\n      description:\n        'The final chamber writhes with sludge — more than you have ever seen. The ticking beneath the floor has not stopped.',\n      tip: 'One wrong blast and the captive dies with the rest. Use `warrior.distanceOf()` to keep the flames clear of those you came to save.',\n      clue: 'Be sure to bind the surrounding enemies before fighting. Check your health before detonating explosives.',\n      timeBonus: 70,\n      aceScore: 176,\n      floor: {\n        size: {\n          width: 4,\n          height: 3,\n        },\n        stairs: {\n          x: 3,\n          y: 0,\n        },\n        warrior: {\n          position: {\n            x: 0,\n            y: 1,\n            facing: EAST,\n          },\n          abilities: {\n            distanceOf: DistanceOf,\n          },\n        },\n        units: [\n          {\n            unit: Captive,\n            effects: {\n              ticking: Ticking.with({ time: 20 }),\n            },\n            position: {\n              x: 2,\n              y: 0,\n              facing: SOUTH,\n            },\n          },\n          {\n            unit: Captive,\n            position: {\n              x: 2,\n              y: 2,\n              facing: NORTH,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 0,\n              y: 0,\n              facing: SOUTH,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 1,\n              y: 0,\n              facing: SOUTH,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 1,\n              y: 1,\n              facing: EAST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 2,\n              y: 1,\n              facing: EAST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 3,\n              y: 1,\n              facing: EAST,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 0,\n              y: 2,\n              facing: NORTH,\n            },\n          },\n          {\n            unit: Sludge,\n            position: {\n              x: 1,\n              y: 2,\n              facing: NORTH,\n            },\n          },\n        ],\n      },\n    },\n  ],\n};\n\nexport default tower;\n"
  },
  {
    "path": "towers/the-powder-keep/tsconfig.build.json",
    "content": "{\n  \"extends\": \"./tsconfig.json\",\n  \"compilerOptions\": {\n    \"outDir\": \"dist\"\n  },\n  \"exclude\": [\"**/*.test.ts\"]\n}\n"
  },
  {
    "path": "towers/the-powder-keep/tsconfig.json",
    "content": "{\n  \"extends\": \"../../tsconfig.json\",\n  \"compilerOptions\": {\n    \"rootDir\": \"src\"\n  },\n  \"include\": [\"src\"]\n}\n"
  },
  {
    "path": "tsconfig.json",
    "content": "{\n  \"compilerOptions\": {\n    \"target\": \"ES2022\",\n    \"module\": \"NodeNext\",\n    \"moduleResolution\": \"NodeNext\",\n    \"declaration\": true,\n    \"declarationMap\": true,\n    \"sourceMap\": true,\n    \"strict\": true,\n    \"esModuleInterop\": true,\n    \"skipLibCheck\": true,\n    \"forceConsistentCasingInFileNames\": true,\n    \"resolveJsonModule\": true,\n    \"isolatedModules\": true\n  }\n}\n"
  },
  {
    "path": "turbo.json",
    "content": "{\n  \"$schema\": \"https://turbo.build/schema.json\",\n  \"tasks\": {\n    \"build\": {\n      \"dependsOn\": [\"^build\"],\n      \"outputs\": [\"dist/**\"]\n    },\n    \"test\": {\n      \"dependsOn\": [\"build\"]\n    },\n    \"lint\": {},\n    \"typecheck\": {\n      \"dependsOn\": [\"^build\"]\n    }\n  }\n}\n"
  },
  {
    "path": "vitest.config.ts",
    "content": "import { defineConfig } from 'vitest/config';\n\nexport default defineConfig({\n  test: {\n    globals: true,\n    environment: 'node',\n    clearMocks: true,\n    include: ['libs/**/src/**/*.test.ts', 'apps/**/src/**/*.test.{ts,tsx}'],\n    exclude: ['**/node_modules/**', '**/dist/**'],\n    coverage: {\n      include: ['libs/**/src/**/*.ts', 'apps/**/src/**/*.{ts,tsx}'],\n      exclude: ['libs/warriorjs-tower-**/src/**', '**/*.test.{ts,tsx}'],\n      thresholds: {\n        lines: 80,\n        functions: 80,\n        branches: 80,\n        statements: 80,\n      },\n    },\n  },\n});\n"
  }
]