Repository: olistic/warriorjs Branch: master Commit: 704a68810e9a Files: 305 Total size: 550.6 KB Directory structure: gitextract_4ifgu9p8/ ├── .editorconfig ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE.md │ └── workflows/ │ └── ci.yml ├── .gitignore ├── CHANGELOG.md ├── CLAUDE.md ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── apps/ │ ├── README.md │ ├── cli/ │ │ ├── README.md │ │ ├── bin/ │ │ │ └── warriorjs.js │ │ ├── declarations.d.ts │ │ ├── package.json │ │ ├── src/ │ │ │ ├── Game.test.ts │ │ │ ├── Game.ts │ │ │ ├── GameError.ts │ │ │ ├── Profile.test.ts │ │ │ ├── Profile.ts │ │ │ ├── ProfileGenerator.test.ts │ │ │ ├── ProfileGenerator.ts │ │ │ ├── Tower.test.ts │ │ │ ├── Tower.ts │ │ │ ├── cli.test.ts │ │ │ ├── cli.ts │ │ │ ├── loadTowers.test.ts │ │ │ ├── loadTowers.ts │ │ │ ├── parseArgs.test.ts │ │ │ ├── parseArgs.ts │ │ │ ├── ui/ │ │ │ │ ├── components/ │ │ │ │ │ ├── App.tsx │ │ │ │ │ ├── ConfirmPrompt.test.tsx │ │ │ │ │ ├── ConfirmPrompt.tsx │ │ │ │ │ ├── Divider.test.tsx │ │ │ │ │ ├── Divider.tsx │ │ │ │ │ ├── ErrorMessage.test.tsx │ │ │ │ │ ├── ErrorMessage.tsx │ │ │ │ │ ├── FloorMap.test.tsx │ │ │ │ │ ├── FloorMap.tsx │ │ │ │ │ ├── GameMenu.test.tsx │ │ │ │ │ ├── GameMenu.tsx │ │ │ │ │ ├── Header.test.tsx │ │ │ │ │ ├── Header.tsx │ │ │ │ │ ├── LevelCompleteScreen.test.tsx │ │ │ │ │ ├── LevelCompleteScreen.tsx │ │ │ │ │ ├── LogArea.test.tsx │ │ │ │ │ ├── LogArea.tsx │ │ │ │ │ ├── PlayLayout.test.tsx │ │ │ │ │ ├── PlayLayout.tsx │ │ │ │ │ ├── PlayScreen.test.tsx │ │ │ │ │ ├── PlayScreen.tsx │ │ │ │ │ ├── PlaySession.test.tsx │ │ │ │ │ ├── PlaySession.tsx │ │ │ │ │ ├── ProfileWizard.test.tsx │ │ │ │ │ ├── ProfileWizard.tsx │ │ │ │ │ ├── ResultScreen.test.tsx │ │ │ │ │ ├── ResultScreen.tsx │ │ │ │ │ ├── Scrubber.test.tsx │ │ │ │ │ ├── Scrubber.tsx │ │ │ │ │ ├── SelectPrompt.test.tsx │ │ │ │ │ ├── SelectPrompt.tsx │ │ │ │ │ ├── TextPrompt.test.tsx │ │ │ │ │ ├── TextPrompt.tsx │ │ │ │ │ ├── TowerCompleteScreen.test.tsx │ │ │ │ │ ├── TowerCompleteScreen.tsx │ │ │ │ │ ├── WarriorArt.test.tsx │ │ │ │ │ ├── WarriorArt.tsx │ │ │ │ │ ├── WarriorStatus.test.tsx │ │ │ │ │ ├── WarriorStatus.tsx │ │ │ │ │ ├── WelcomeScreen.test.tsx │ │ │ │ │ └── WelcomeScreen.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ ├── usePlaySession.test.ts │ │ │ │ │ ├── usePlaySession.ts │ │ │ │ │ ├── usePlayback.test.ts │ │ │ │ │ └── usePlayback.ts │ │ │ │ ├── testing.ts │ │ │ │ ├── theme.ts │ │ │ │ ├── types.ts │ │ │ │ └── utils/ │ │ │ │ ├── buildLevelReport.test.ts │ │ │ │ └── buildLevelReport.ts │ │ │ └── utils/ │ │ │ ├── formatDirectory.test.ts │ │ │ ├── formatDirectory.ts │ │ │ ├── getFloorMap.test.ts │ │ │ ├── getFloorMap.ts │ │ │ ├── getFloorMapKey.test.ts │ │ │ ├── getFloorMapKey.ts │ │ │ ├── getTowerId.test.ts │ │ │ ├── getTowerId.ts │ │ │ ├── getWarriorNameSuggestions.test.ts │ │ │ ├── getWarriorNameSuggestions.ts │ │ │ ├── renderPlayerCode.test.ts │ │ │ ├── renderPlayerCode.ts │ │ │ ├── renderReadme.test.ts │ │ │ ├── renderReadme.ts │ │ │ ├── renderTypes.test.ts │ │ │ └── renderTypes.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ └── website/ │ ├── core/ │ │ ├── Footer.js │ │ ├── GitHubButton.js │ │ └── TwitterButton.js │ ├── crowdin.yaml │ ├── data/ │ │ └── sponsors.json │ ├── i18n/ │ │ └── en.json │ ├── languages.js │ ├── package.json │ ├── pages/ │ │ └── en/ │ │ └── index.js │ ├── sidebars.json │ ├── siteConfig.js │ ├── static/ │ │ ├── .circleci/ │ │ │ └── config.yml │ │ ├── css/ │ │ │ ├── custom.css │ │ │ └── nord.css │ │ └── googlee0ff7b5bc8d30f78.html │ └── utils/ │ ├── getDocUrl.js │ └── getImgUrl.js ├── biome.json ├── docs/ │ ├── community/ │ │ ├── ecosystem.md │ │ ├── roadmap.md │ │ └── socialize.md │ ├── maker/ │ │ ├── adding-levels.md │ │ ├── creating-tower.md │ │ ├── defining-abilities.md │ │ ├── defining-units.md │ │ ├── introduction.md │ │ ├── publishing.md │ │ ├── refactoring.md │ │ ├── space-api.md │ │ ├── testing.md │ │ └── unit-api.md │ └── player/ │ ├── abilities.md │ ├── ai-tips.md │ ├── cli-tips.md │ ├── effects.md │ ├── epic-mode.md │ ├── gameplay.md │ ├── general-tips.md │ ├── install.md │ ├── js-tips.md │ ├── object.md │ ├── options.md │ ├── overview.md │ ├── perspective.md │ ├── scoring.md │ ├── space-api.md │ ├── spaces.md │ ├── towers.md │ ├── turn-api.md │ ├── unit-api.md │ ├── units.md │ └── warrior.md ├── lefthook.yml ├── libs/ │ ├── README.md │ ├── abilities/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── Attack.test.ts │ │ │ ├── Attack.ts │ │ │ ├── Bind.test.ts │ │ │ ├── Bind.ts │ │ │ ├── Detonate.test.ts │ │ │ ├── Detonate.ts │ │ │ ├── DirectionOf.test.ts │ │ │ ├── DirectionOf.ts │ │ │ ├── DirectionOfStairs.test.ts │ │ │ ├── DirectionOfStairs.ts │ │ │ ├── DistanceOf.test.ts │ │ │ ├── DistanceOf.ts │ │ │ ├── Feel.test.ts │ │ │ ├── Feel.ts │ │ │ ├── Health.test.ts │ │ │ ├── Health.ts │ │ │ ├── Listen.test.ts │ │ │ ├── Listen.ts │ │ │ ├── Look.test.ts │ │ │ ├── Look.ts │ │ │ ├── MaxHealth.test.ts │ │ │ ├── MaxHealth.ts │ │ │ ├── Pivot.test.ts │ │ │ ├── Pivot.ts │ │ │ ├── Rescue.test.ts │ │ │ ├── Rescue.ts │ │ │ ├── Rest.test.ts │ │ │ ├── Rest.ts │ │ │ ├── Shoot.test.ts │ │ │ ├── Shoot.ts │ │ │ ├── Think.test.ts │ │ │ ├── Think.ts │ │ │ ├── Walk.test.ts │ │ │ ├── Walk.ts │ │ │ ├── index.ts │ │ │ └── types.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── core/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── Ability.test.ts │ │ │ ├── Ability.ts │ │ │ ├── Action.test.ts │ │ │ ├── Action.ts │ │ │ ├── Effect.test.ts │ │ │ ├── Effect.ts │ │ │ ├── Floor.test.ts │ │ │ ├── Floor.ts │ │ │ ├── Level.test.ts │ │ │ ├── Level.ts │ │ │ ├── Logger.ts │ │ │ ├── Position.test.ts │ │ │ ├── Position.ts │ │ │ ├── Sense.test.ts │ │ │ ├── Sense.ts │ │ │ ├── Space.test.ts │ │ │ ├── Space.ts │ │ │ ├── Unit.test.ts │ │ │ ├── Unit.ts │ │ │ ├── Warrior.test.ts │ │ │ ├── Warrior.ts │ │ │ ├── getLevel.test.ts │ │ │ ├── getLevel.ts │ │ │ ├── getLevelConfig.test.ts │ │ │ ├── getLevelConfig.ts │ │ │ ├── index.ts │ │ │ ├── loadLevel.ts │ │ │ ├── loadPlayer.test.ts │ │ │ ├── loadPlayer.ts │ │ │ ├── runLevel.test.ts │ │ │ ├── runLevel.ts │ │ │ └── types.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── effects/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── Ticking.test.ts │ │ │ ├── Ticking.ts │ │ │ └── index.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── scoring/ │ │ ├── package.json │ │ ├── src/ │ │ │ ├── getClearBonus.test.ts │ │ │ ├── getClearBonus.ts │ │ │ ├── getGradeLetter.test.ts │ │ │ ├── getGradeLetter.ts │ │ │ ├── getLastEvent.test.ts │ │ │ ├── getLastEvent.ts │ │ │ ├── getLevelScore.test.ts │ │ │ ├── getLevelScore.ts │ │ │ ├── getRemainingTimeBonus.test.ts │ │ │ ├── getRemainingTimeBonus.ts │ │ │ ├── getTurnCount.test.ts │ │ │ ├── getTurnCount.ts │ │ │ ├── getWarriorScore.test.ts │ │ │ ├── getWarriorScore.ts │ │ │ ├── index.ts │ │ │ ├── isFloorClear.test.ts │ │ │ └── isFloorClear.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ ├── spatial/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ ├── absoluteDirections.test.ts │ │ │ ├── absoluteDirections.ts │ │ │ ├── index.ts │ │ │ ├── location.test.ts │ │ │ ├── location.ts │ │ │ ├── relativeDirections.test.ts │ │ │ └── relativeDirections.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ └── units/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ ├── Archer.test.ts │ │ ├── Archer.ts │ │ ├── Captive.test.ts │ │ ├── Captive.ts │ │ ├── MeleeUnit.test.ts │ │ ├── MeleeUnit.ts │ │ ├── RangedUnit.test.ts │ │ ├── RangedUnit.ts │ │ ├── Sludge.test.ts │ │ ├── Sludge.ts │ │ ├── ThickSludge.test.ts │ │ ├── ThickSludge.ts │ │ ├── Wizard.test.ts │ │ ├── Wizard.ts │ │ └── index.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── logo/ │ ├── LICENSE │ ├── README.md │ └── warriorjs.sketch ├── package.json ├── pnpm-workspace.yaml ├── towers/ │ ├── README.md │ ├── the-narrow-path/ │ │ ├── README.md │ │ ├── package.json │ │ ├── src/ │ │ │ └── index.ts │ │ ├── tsconfig.build.json │ │ └── tsconfig.json │ └── the-powder-keep/ │ ├── README.md │ ├── package.json │ ├── src/ │ │ └── index.ts │ ├── tsconfig.build.json │ └── tsconfig.json ├── tsconfig.json ├── turbo.json └── vitest.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .editorconfig ================================================ root = true [*] charset = utf-8 end_of_line = lf insert_final_newline = true trim_trailing_whitespace = true [*.{js,json}] indent_size = 2 indent_style = space [*.md] trim_trailing_whitespace = false ================================================ FILE: .gitattributes ================================================ * text=auto ================================================ FILE: .github/ISSUE_TEMPLATE.md ================================================ # Environment # Steps to reproduce # Expected Behavior # Actual Behavior ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [master] pull_request: branches: [master] jobs: lint: name: Lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint test: name: Test runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build - run: pnpm test:coverage - uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} build: name: Build runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v6 with: node-version: 24 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build ================================================ FILE: .gitignore ================================================ # Logs logs *.log turbo-debug.log* npm-debug.log* yarn-debug.log* yarn-error.log* # Runtime data pids *.pid *.seed *.pid.lock # Directory for instrumented libs generated by jscoverage/JSCover lib-cov # Coverage directory used by tools like istanbul coverage # nyc test coverage .nyc_output # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) .grunt # Bower dependency directory (https://bower.io/) bower_components # node-waf configuration .lock-wscript # Compiled binary addons (http://nodejs.org/api/addons.html) build/Release # Dependency directories node_modules/ jspm_packages/ # Typescript v1 declaration files typings/ # Optional npm cache directory .npm # Optional eslint cache .eslintcache # Optional REPL history .node_repl_history # Output of 'npm pack' *.tgz # Yarn Integrity file .yarn-integrity # dotenv environment variables file .env # Turborepo cache .turbo # Build directories apps/*/dist/ apps/website/build/ libs/*/dist/ towers/*/dist/ # Website apps/website/translated_docs apps/website/i18n/* !apps/website/i18n/en.json ================================================ FILE: CHANGELOG.md ================================================ # Changelog All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ## [0.14.0] - 2018-10-17 ### Changed - Simplify `@warriorjs/helper-get-level-score` ([@olistic][] in [#247][]) - Rename `@warriorjs/helper-get-play-score` package to `@warriorjs/helper-get-level-score` ([@olistic][] in [#246][]) ## [0.13.0] - 2018-10-09 ### Added - Add `maxHealth` ability ([@jseed][] in [#238][]) ### Changed - Improve abilities descriptions ([@olistic][] in [#240][]) ## [0.12.3] - 2018-10-04 ### Fixed - Fix `getLevelConfig` (make it pure for real) ([@olistic][] in [#236][]) ## [0.12.2] - 2018-10-04 ### Fixed - Make `getLevelConfig` a pure function ([@olistic][] in [#234][]) ## [0.12.1] - 2018-07-31 ### Fixed - Fix `tick-tick-boom` tower description ## [0.12.0] - 2018-07-30 ### Changed - Official towers names and descriptions ([@olistic][] in [#221][]) ### Fixed - Think ability with complex thoughts ([@olistic][] in [#220][]) ## [0.11.3] - 2018-07-24 ### Added - Warrior status to level JSON ([@olistic][] in [#219][]) ## [0.11.2] - 2018-07-17 ### Changed - Update `superheroes` dependency to version that doesn't include a CLI ([@wtgtybhertgeghgtwtg][] in [#217][]) ## [0.11.1] - 2018-07-14 Force publish of helper packages. ## [0.11.0] - 2018-07-14 [YANKED] ### Changed - Calling `.default` after `require('@warriorjs/helper-**')` is no longer needed (nor supported) ([@olistic][] in [#216][]). ## [0.10.0] - 2018-07-14 This release modularizes the codebase even more, adding new helper packages whose logic can be reused by different flavors of the game. ### Changed - Remove play score from play result ([@olistic][] in [#215][]) ## [0.9.0] - 2018-07-09 ### Changed - Use less fragile method to check for player code errors ([@olistic][] in [#213][]) ## [0.8.0] - 2018-07-06 ### Added - RGB colors to units ([@olistic][] in [#165][]) ### Changed - Optimize level and events payload ([@olistic][] in [#210][]) ## [0.7.0] - 2018-07-06 ### Changed - Reduce `unit.log()` calls making play reproduction ~50% faster ([@olistic][] in [#208][]) - Improve play log ([@olistic][] in [#209][]) ## [0.6.0] - 2018-06-17 ### Changed - Enhance CLI welcome message ([@olistic][] in [#191][]) - Sort actions and senses alphabetically in README ([@djohalo2][] in [#194][]) - Reference tower by ID in levels ([@olistic][] in [#202][]) ## [0.5.1] - 2018-06-01 ### Fixed - External towers discovery ([@olistic][] in [#188][]) ## [0.5.0] - 2018-06-01 ### Added - Load towers dynamically (support for external towers) ([@olistic][] in [#169][]) - Add tower description to README if available ([@olistic][] in [#185][]) ### Changed - Prevent seeing through walls with "look" ability ([@pigalot][] in [#162][]) - Optimize profile file ([@olistic][] in [#170][] and [#180][]) - No longer ask for confirmation before creating game directory ([@olistic][] in [#177][]) ## [0.4.2] - 2018-05-23 ### Added - Warrior name suggestions ([@olistic][] in [#152][]) ## [0.4.1] - 2018-05-22 ### Added - `--silent` flag to CLI ([@xaviserrag][] in [#82][]) ### Changed - Print level independently of play log ([@olistic][] in [#145][]) - UI tweaks ([@olistic][] in [#147][]) - Improve think ability description ([@olistic][] in [#149][]) - Improve level tips ([@olistic][] in [#150][]) ## [0.4.0] - 2018-05-19 ### Added - Make warrior upset when losing points ([@skywalker212][] in [#107][]) - `getRelativeOffset` function (`@warriorjs/geography`) ([@olistic][] in [#138][]) - `unit.release()` method ([@olistic][] in [#140][]) ### Changed - Rename `unit.say()` to `unit.log()` ([@olistic][] in [#131][]) - Improve profile directory detection ([@glneto][] in [#133][]) - Rename `unit.isHostile()` to `unit.isEnemy()` ([@olistic][] in [#129][]) - `space.getLocation()` returns the relative location of the space ([@olistic][] in [#129][]) - `unit.isEnemy()` returns whether the unit is considered an enemy by the unit that sensed it ([@olistic][] in [#129][]) ### Removed - `unit.isFriendly()` ([@olistic][] in [#129][]) - `unit.isWarrior()` and `unit.isPlayer()` ([@olistic][] in [#129][]) ### Fixed - Line breaks on CLI output on Windows ([@olistic][] in [#120][]) - Diamond symbol on Windows ([@glneto][] in [#121][]) ## [0.3.0] - 2018-05-16 ### Added - Subtract reward points when killing a friendly unit ([@Terseus][] in [#87][]) - Think ability (`console.log` replacement) ([@olistic][] in [#102][]) ### Changed - Distinguish between hostile and friendly units ([@xFloki][] in [#101][]) - Move unit methods out of the Space API and to the Unit API ([@olistic][] in [#113][]) ### Fixed - Enforce [Player API](https://warrior.js.org/docs/player/space-api) ([@olistic][] in [#114][]) ## [0.2.0] - 2018-05-14 ### Added - Reward property to Unit class ([@RascalTwo][] in [#67][]) - Warrior score next to health in play log ([@RascalTwo][] in [#70][]) - Support for Node 9 and 10 ([@olistic][] in [#81][]) - `--yes` flag to CLI ([@olistic][] in [#93][] and [#98][]) ### Changed - Exclude abilities from play log except warrior's ([@olistic][] in [#83][]) - Rescue ability awards Unit's reward ([@RascalTwo][] in [#86][], [@olistic][] in [#90][]) - CLI default answers ([@olistic][] in [#97][]) ### Removed - `--skip` flag from CLI ([@olistic][] in [#93][]) ### Fixed - Path normalization in tests ([@jakehamilton][] in [#77][]) ## [0.1.1] - 2018-05-03 ### Fixed - Missing `bin` directory in `@warriorjs/cli` package files ## 0.1.0 - 2018-05-03 [YANKED] Initial version. [unreleased]: https://github.com/olistic/warriorjs/compare/v0.14.0...HEAD [0.14.0]: https://github.com/olistic/warriorjs/compare/v0.13.0...v0.14.0 [0.13.0]: https://github.com/olistic/warriorjs/compare/v0.12.3...v0.13.0 [0.12.3]: https://github.com/olistic/warriorjs/compare/v0.12.2...v0.12.3 [0.12.2]: https://github.com/olistic/warriorjs/compare/v0.12.1...v0.12.2 [0.12.1]: https://github.com/olistic/warriorjs/compare/v0.12.0...v0.12.1 [0.12.0]: https://github.com/olistic/warriorjs/compare/v0.11.3...v0.12.0 [0.11.3]: https://github.com/olistic/warriorjs/compare/v0.11.2...v0.11.3 [0.11.2]: https://github.com/olistic/warriorjs/compare/v0.11.1...v0.11.2 [0.11.1]: https://github.com/olistic/warriorjs/compare/v0.11.0...v0.11.1 [0.11.0]: https://github.com/olistic/warriorjs/compare/v0.10.0...v0.11.0 [0.10.0]: https://github.com/olistic/warriorjs/compare/v0.9.0...v0.10.0 [0.9.0]: https://github.com/olistic/warriorjs/compare/v0.8.0...v0.9.0 [0.8.0]: https://github.com/olistic/warriorjs/compare/v0.7.0...v0.8.0 [0.7.0]: https://github.com/olistic/warriorjs/compare/v0.6.0...v0.7.0 [0.6.0]: https://github.com/olistic/warriorjs/compare/v0.5.1...v0.6.0 [0.5.1]: https://github.com/olistic/warriorjs/compare/v0.5.0...v0.5.1 [0.5.0]: https://github.com/olistic/warriorjs/compare/v0.4.2...v0.5.0 [0.4.2]: https://github.com/olistic/warriorjs/compare/v0.4.1...v0.4.2 [0.4.1]: https://github.com/olistic/warriorjs/compare/v0.4.0...v0.4.1 [0.4.0]: https://github.com/olistic/warriorjs/compare/v0.3.0...v0.4.0 [0.3.0]: https://github.com/olistic/warriorjs/compare/v0.2.0...v0.3.0 [0.2.0]: https://github.com/olistic/warriorjs/compare/v0.1.1...v0.2.0 [0.1.1]: https://github.com/olistic/warriorjs/compare/v0.1.0...v0.1.1 [@olistic]: https://github.com/olistic [@rascaltwo]: https://github.com/RascalTwo [@jakehamilton]: https://github.com/jakehamilton [@terseus]: https://github.com/Terseus [@xfloki]: https://github.com/xFloki [@skywalker212]: https://github.com/skywalker212 [@glneto]: https://github.com/glneto [@xaviserrag]: https://github.com/xaviserrag [@pigalot]: https://github.com/pigalot [@djohalo2]: https://github.com/djohalo2 [@wtgtybhertgeghgtwtg]: https://github.com/wtgtybhertgeghgtwtg [@jseed]: https://github.com/JSeed [#67]: https://github.com/olistic/warriorjs/pull/67 [#70]: https://github.com/olistic/warriorjs/pull/70 [#77]: https://github.com/olistic/warriorjs/pull/77 [#81]: https://github.com/olistic/warriorjs/pull/81 [#82]: https://github.com/olistic/warriorjs/pull/82 [#83]: https://github.com/olistic/warriorjs/pull/83 [#86]: https://github.com/olistic/warriorjs/pull/86 [#87]: https://github.com/olistic/warriorjs/pull/87 [#90]: https://github.com/olistic/warriorjs/pull/90 [#93]: https://github.com/olistic/warriorjs/pull/93 [#97]: https://github.com/olistic/warriorjs/pull/97 [#98]: https://github.com/olistic/warriorjs/pull/98 [#101]: https://github.com/olistic/warriorjs/pull/101 [#102]: https://github.com/olistic/warriorjs/pull/102 [#107]: https://github.com/olistic/warriorjs/pull/107 [#113]: https://github.com/olistic/warriorjs/pull/113 [#114]: https://github.com/olistic/warriorjs/pull/114 [#120]: https://github.com/olistic/warriorjs/pull/120 [#121]: https://github.com/olistic/warriorjs/pull/121 [#129]: https://github.com/olistic/warriorjs/pull/129 [#131]: https://github.com/olistic/warriorjs/pull/131 [#133]: https://github.com/olistic/warriorjs/pull/133 [#138]: https://github.com/olistic/warriorjs/pull/138 [#140]: https://github.com/olistic/warriorjs/pull/140 [#145]: https://github.com/olistic/warriorjs/pull/145 [#147]: https://github.com/olistic/warriorjs/pull/147 [#149]: https://github.com/olistic/warriorjs/pull/149 [#150]: https://github.com/olistic/warriorjs/pull/150 [#152]: https://github.com/olistic/warriorjs/pull/152 [#162]: https://github.com/olistic/warriorjs/pull/162 [#165]: https://github.com/olistic/warriorjs/pull/165 [#169]: https://github.com/olistic/warriorjs/pull/169 [#170]: https://github.com/olistic/warriorjs/pull/170 [#177]: https://github.com/olistic/warriorjs/pull/177 [#180]: https://github.com/olistic/warriorjs/pull/180 [#185]: https://github.com/olistic/warriorjs/pull/185 [#188]: https://github.com/olistic/warriorjs/pull/188 [#191]: https://github.com/olistic/warriorjs/pull/191 [#194]: https://github.com/olistic/warriorjs/pull/194 [#202]: https://github.com/olistic/warriorjs/pull/202 [#208]: https://github.com/olistic/warriorjs/pull/208 [#209]: https://github.com/olistic/warriorjs/pull/209 [#210]: https://github.com/olistic/warriorjs/pull/210 [#213]: https://github.com/olistic/warriorjs/pull/213 [#215]: https://github.com/olistic/warriorjs/pull/215 [#216]: https://github.com/olistic/warriorjs/pull/216 [#217]: https://github.com/olistic/warriorjs/pull/217 [#219]: https://github.com/olistic/warriorjs/pull/219 [#220]: https://github.com/olistic/warriorjs/pull/220 [#221]: https://github.com/olistic/warriorjs/pull/221 [#234]: https://github.com/olistic/warriorjs/pull/234 [#236]: https://github.com/olistic/warriorjs/pull/236 [#238]: https://github.com/olistic/warriorjs/pull/238 [#240]: https://github.com/olistic/warriorjs/pull/240 [#246]: https://github.com/olistic/warriorjs/pull/246 [#247]: https://github.com/olistic/warriorjs/pull/247 ================================================ FILE: CLAUDE.md ================================================ # WarriorJS A game that teaches JavaScript and TypeScript through interactive coding challenges. Players write code to control a warrior navigating through towers full of enemies. ## Commands ```bash pnpm install # Install dependencies pnpm build # Build all packages (Turborepo handles ordering) pnpm test # Run all tests pnpm lint # Check linting/formatting (Biome) pnpm lint:fix # Auto-fix linting/formatting ``` Run a single package's tests: `npx vitest run apps/cli/` ## Architecture pnpm monorepo with Turborepo. Code is organized into three top-level directories: - **`apps/`** — End-user applications - **@warriorjs/cli** — CLI for offline play - **`libs/`** — Shared libraries - **@warriorjs/core** — Game engine, level runner, player code loader - **@warriorjs/abilities** — Warrior abilities (walk, attack, feel, etc.) - **@warriorjs/units** — Game units/enemies - **@warriorjs/effects** — Status effects system - **@warriorjs/spatial** — Spatial/direction utilities (foundational, no deps) - **@warriorjs/scoring** — Score calculation and grade letters - **`towers/`** — Built-in tower definitions - **@warriorjs/tower-the-narrow-path** / **tower-the-powder-keep** — The Narrow Path and The Powder Keep Dependency flow: spatial → abilities → units → towers, spatial → core → scoring → cli. Each package compiles with `tsc` to `dist/`. ## Conventions - **Biome** enforces formatting and linting — don't manually fix style, run `pnpm lint:fix` - **Lefthook** pre-commit hook auto-formats staged files - All imports use `.js` extensions (ES modules with NodeNext resolution) - Tests live next to source: `src/Foo.test.ts` - Coverage thresholds: 80% (lines, functions, branches, statements) - Conventional Commits with scope: `feat(cli): add language choice`, `fix(core): handle edge case` ================================================ FILE: CODE_OF_CONDUCT.md ================================================ # Contributor Covenant Code of Conduct ## Our Pledge We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, caste, color, religion, or sexual identity and orientation. We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. ## Our Standards Examples of behavior that contributes to a positive environment for our community include: - Demonstrating empathy and kindness toward other people - Being respectful of differing opinions, viewpoints, and experiences - Giving and gracefully accepting constructive feedback - Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience - Focusing on what is best not just for us as individuals, but for the overall community Examples of unacceptable behavior include: - The use of sexualized language or imagery, and sexual attention or advances of any kind - Trolling, insulting or derogatory comments, and personal or political attacks - Public or private harassment - Publishing others' private information, such as a physical or email address, without their explicit permission - Other conduct which could reasonably be considered inappropriate in a professional setting ## Enforcement Responsibilities Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. ## Scope This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. Examples of representing our community include using an official e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. ## Enforcement Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders responsible for enforcement at hi@matiasolivera.com. All complaints will be reviewed and investigated promptly and fairly. All community leaders are obligated to respect the privacy and security of the reporter of any incident. ## Enforcement Guidelines Community leaders will follow these Community Impact Guidelines in determining the consequences for any action they deem in violation of this Code of Conduct: ### 1. Correction **Community Impact**: Use of inappropriate language or other behavior deemed unprofessional or unwelcome in the community. **Consequence**: A private, written warning from community leaders, providing clarity around the nature of the violation and an explanation of why the behavior was inappropriate. A public apology may be requested. ### 2. Warning **Community Impact**: A violation through a single incident or series of actions. **Consequence**: A warning with consequences for continued behavior. No interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, for a specified period of time. This includes avoiding interactions in community spaces as well as external channels like social media. Violating these terms may lead to a temporary or permanent ban. ### 3. Temporary Ban **Community Impact**: A serious violation of community standards, including sustained inappropriate behavior. **Consequence**: A temporary ban from any sort of interaction or public communication with the community for a specified period of time. No public or private interaction with the people involved, including unsolicited interaction with those enforcing the Code of Conduct, is allowed during this period. Violating these terms may lead to a permanent ban. ### 4. Permanent Ban **Community Impact**: Demonstrating a pattern of violation of community standards, including sustained inappropriate behavior, harassment of an individual, or aggression toward or disparagement of classes of individuals. **Consequence**: A permanent ban from any sort of public interaction within the community. ## Attribution This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 2.1, available at [https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. Community Impact Guidelines were inspired by [Mozilla's code of conduct enforcement ladder][Mozilla CoC]. For answers to common questions about this code of conduct, see the FAQ at [https://www.contributor-covenant.org/faq][FAQ]. Translations are available at [https://www.contributor-covenant.org/translations][translations]. [homepage]: https://www.contributor-covenant.org [v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html [Mozilla CoC]: https://github.com/mozilla/diversity [FAQ]: https://www.contributor-covenant.org/faq [translations]: https://www.contributor-covenant.org/translations ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing ## Code of Conduct This project follows a [Code of Conduct](CODE_OF_CONDUCT.md). Please read it. ## Getting Started 1. Fork and clone the repo. 2. Install dependencies: `pnpm install` 3. Build: `pnpm build` 4. Run tests: `pnpm test` 5. Lint: `pnpm lint` Edit code in `libs/*/src/`. Tests live next to source files (`Foo.test.ts`). ## Making Changes - **Bug fix or feature?** Open an issue first to discuss scope. - **Building a tower?** See the [tower guide](https://warrior.js.org/docs/player/towers). - Create a branch, make your changes, and open a PR. - Use [Conventional Commits](https://www.conventionalcommits.org) for commit messages: `feat(cli): add language choice`, `fix(core): handle edge case`. New to open source? GitHub's [guide to contributing](https://docs.github.com/en/get-started/exploring-projects-on-github/contributing-to-a-project) is a good starting point. ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2015-present Matías Olivera Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
WarriorJS Banner

Learn JavaScript and TypeScript by writing code that fights

CI Codecov

In WarriorJS, you write JavaScript or TypeScript to guide a warrior through towers full of enemies. Each floor is a puzzle: battle sludge, dodge archers, rescue captives, and reach the stairs alive. The code you write _is_ the strategy — there's no clicking, no dragging, just logic and sharp thinking. **Whether you're writing your first `if` statement or refactoring for a perfect score, every floor will test you.** ## Quick Start 1. Install [Node.js](https://nodejs.org) 22 or later. 2. Install the CLI: ```sh npm install --global @warriorjs/cli ``` 3. Launch the game: ```sh warriorjs ``` The game walks you through creating a warrior and choosing a tower. Open the generated `README.md` for your first level's instructions, write your solution in `Player.js`, then run `warriorjs` again to see how your warrior fares. You can also play from your browser at [warriorjs.com](https://warriorjs.com/?ref=gh). ## Documentation The [official docs](https://warrior.js.org) cover everything from first steps to building your own towers: - [Gameplay](https://warrior.js.org/docs/player/gameplay) - [Towers](https://warrior.js.org/docs/player/towers) - [Player API](https://warrior.js.org/docs/player/space-api) ## Contributing The best way to contribute is to build a [tower](https://warrior.js.org/docs/player/towers) — a set of levels that other players can install and play. You can also fix bugs, improve the docs, or add new abilities and units. See the [contribution guide](CONTRIBUTING.md) and [Code of Conduct](CODE_OF_CONDUCT.md). ## Acknowledgments This project was born as a port of [ruby-warrior](https://github.com/ryanb/ruby-warrior). Credits for the original idea go to [Ryan Bates](https://github.com/ryanb). Special thanks to [Guillermo Cura](https://github.com/guillecura) for designing a wonderful [logo](logo). ## License WarriorJS is licensed under a [MIT License](LICENSE). ================================================ FILE: apps/README.md ================================================ # Apps End-user applications built on the WarriorJS packages. | Package | Version | | ------------------------------------------------- | ---------------------------------------------------------------- | | [`@warriorjs/cli`][warriorjs-cli] | [![npm][warriorjs-cli-badge]][warriorjs-cli-npm] | - [`@warriorjs/cli`][warriorjs-cli] is the original version of WarriorJS, playable from the terminal. [warriorjs-cli]: /apps/cli [warriorjs-cli-badge]: https://img.shields.io/npm/v/@warriorjs/cli.svg?style=flat-square [warriorjs-cli-npm]: https://www.npmjs.com/package/@warriorjs/cli ================================================ FILE: apps/cli/README.md ================================================ # @warriorjs/cli > WarriorJS command line. ## Install ```sh npm install --global @warriorjs/cli ``` ## Usage ```sh warriorjs ``` For more in depth documentation see: https://warrior.js.org/docs/player/options. ================================================ FILE: apps/cli/bin/warriorjs.js ================================================ #!/usr/bin/env node import { hideBin } from 'yargs/helpers'; import('../dist/cli.js').then(({ run }) => run(hideBin(process.argv))); ================================================ FILE: apps/cli/declarations.d.ts ================================================ declare module 'yargs' { function yargs(args?: string[]): any; export default yargs; } declare module 'yargs/helpers' { function hideBin(args: string[]): string[]; export type { hideBin }; } declare module 'mock-fs' { function mock(config: Record): void; namespace mock { function restore(): void; } export default mock; } declare module 'array-shuffle' { function arrayShuffle(array: T[]): T[]; export default arrayShuffle; } ================================================ FILE: apps/cli/package.json ================================================ { "name": "@warriorjs/cli", "version": "0.14.0", "description": "WarriorJS command line", "author": "Matias Olivera ", "license": "MIT", "homepage": "https://warrior.js.org", "repository": "https://github.com/olistic/warriorjs/tree/master/apps/cli", "type": "module", "keywords": [ "warriorjs", "warriorjs-cli", "warrior", "epic", "battle", "game", "learn", "polish", "refine", "test", "js", "javascript", "nodejs", "ai", "artificial-intelligence", "skills" ], "bin": { "warriorjs": "./bin/warriorjs.js" }, "engines": { "node": ">=24" }, "main": "dist/cli.js", "exports": { ".": { "import": "./dist/cli.js", "types": "./dist/cli.d.ts" } }, "files": [ "bin", "dist" ], "publishConfig": { "access": "public" }, "scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit" }, "devDependencies": { "@types/react": "^19.2.14", "ink-testing-library": "^4.0.0", "mock-fs": "^5.5.0" }, "dependencies": { "@warriorjs/abilities": "workspace:^", "@warriorjs/core": "workspace:^", "@warriorjs/scoring": "workspace:^", "@warriorjs/tower-the-narrow-path": "workspace:^", "array-shuffle": "^3.0.0", "find-up": "^7.0.0", "globby": "^14.0.0", "ink": "^6.8.0", "ink-link": "^5.0.0", "react": "^19.2.4", "yargs": "^17.0.0" } } ================================================ FILE: apps/cli/src/Game.test.ts ================================================ import fs from 'node:fs'; import path from 'node:path'; import { getLevelConfig } from '@warriorjs/core'; import mock from 'mock-fs'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import Game from './Game.js'; import GameError from './GameError.js'; import loadTowers from './loadTowers.js'; import Profile from './Profile.js'; import ProfileGenerator from './ProfileGenerator.js'; vi.mock('@warriorjs/core'); vi.mock('./ProfileGenerator.js', () => { const MockProfileGenerator = vi.fn(function (this: any) {}); return { default: MockProfileGenerator, __esModule: true }; }); vi.mock('./loadTowers.js', () => ({ default: vi.fn(() => [{ id: 'tower1', name: 'Tower 1' }]), })); describe('Game', () => { let game: any; beforeEach(() => { game = new Game('/path/to/game', undefined, false); }); test('has a run directory path', () => { expect(game.runDirectoryPath).toBe('/path/to/game'); }); test('has a game directory path', () => { expect(game.gameDirectoryPath).toBe(path.normalize('/path/to/game/warriorjs')); }); describe('buildContext', () => { beforeEach(() => { vi.spyOn(Profile, 'load').mockReturnValue(null); game.getProfiles = vi.fn().mockReturnValue([]); }); afterEach(() => { vi.restoreAllMocks(); }); test('loads towers', () => { const context = game.buildContext(); expect(context.towers).toEqual([{ id: 'tower1', name: 'Tower 1' }]); }); test('sets needsProfileSetup when no profile found', () => { const context = game.buildContext(); expect(context.needsProfileSetup).toBe(true); expect(context.profile).toBeNull(); }); test('sets profile when found', () => { const mockProfile = { isEpic: () => false }; vi.spyOn(Profile, 'load').mockReturnValue(mockProfile as any); const context = game.buildContext(); expect(context.needsProfileSetup).toBe(false); expect(context.profile).toBe(mockProfile); }); test('sets error when tower loading fails', () => { vi.mocked(loadTowers).mockImplementationOnce(() => { throw new GameError('Tower load failed'); }); const context = game.buildContext(); expect(context.error).toBe('Tower load failed'); }); test('provides callbacks', () => { const context = game.buildContext(); expect(typeof context.onCreateProfile).toBe('function'); expect(typeof context.onIsExistingProfile).toBe('function'); expect(typeof context.onPrepareNextLevel).toBe('function'); expect(typeof context.onPrepareEpicMode).toBe('function'); expect(typeof context.onGenerateProfileFiles).toBe('function'); expect(typeof context.onProfileSelected).toBe('function'); }); }); describe('createProfile', () => { test('creates a profile with the correct directory path', () => { const tower = { id: 'the-narrow-path' }; const profile = game.createProfile('Olric', 'typescript', tower); expect(profile).toBeInstanceOf(Profile); expect(profile.warriorName).toBe('Olric'); expect(profile.language).toBe('typescript'); expect(profile.directoryPath).toBe( path.normalize('/path/to/game/warriorjs/olric-the-narrow-path'), ); }); }); test('returns profiles', () => { const originalLoad = Profile.load; game.towers = ['tower1', 'tower2']; game.getProfileDirectoriesPaths = () => [ '/path/to/game/warriorjs/profile1', '/path/to/game/warriorjs/profile2', ]; Profile.load = vi.fn() as any; game.getProfiles(); expect(Profile.load).toHaveBeenCalledWith('/path/to/game/warriorjs/profile1', [ 'tower1', 'tower2', ]); expect(Profile.load).toHaveBeenCalledWith('/path/to/game/warriorjs/profile2', [ 'tower1', 'tower2', ]); Profile.load = originalLoad; }); test('knows if profile exists', () => { const nonExistingProfile = { directoryPath: '/path/to/nonexisting-profile', }; const existentProfile = { directoryPath: '/path/to/profile' }; game.getProfileDirectoriesPaths = () => ['/path/to/profile']; expect(game.isExistingProfile(nonExistingProfile)).toBe(false); expect(game.isExistingProfile(existentProfile)).toBe(true); }); test('returns paths to profile directories', async () => { game.ensureGameDirectory = vi.fn(); mock({ '/path/to/game/warriorjs': { profile1: {}, profile2: {}, 'other-file': '', }, }); const profileDirectoriesPaths = await game.getProfileDirectoriesPaths(); mock.restore(); expect(profileDirectoriesPaths).toEqual([ '/path/to/game/warriorjs/profile1', '/path/to/game/warriorjs/profile2', ]); expect(game.ensureGameDirectory).toHaveBeenCalled(); }); describe('ensuring game directory', () => { test("creates directory if it doesn't exist", () => { mock({ '/path/to/game': {} }); game.ensureGameDirectory(); expect(fs.statSync('/path/to/game/warriorjs').isDirectory()).toBe(true); mock.restore(); }); test('does nothing if directory already exists', () => { mock({ '/path/to/game/warriorjs': {} }); expect(fs.statSync('/path/to/game/warriorjs').isDirectory()).toBe(true); game.ensureGameDirectory(); expect(fs.statSync('/path/to/game/warriorjs').isDirectory()).toBe(true); mock.restore(); }); test('throws if a warriorjs file exists', () => { mock({ '/path/to/game/warriorjs': '' }); expect(() => { game.ensureGameDirectory(); }).toThrow( new GameError( 'A file named warriorjs exists at this location. Please change the directory under which you are running warriorjs.', ), ); mock.restore(); }); }); test('prepares next level', () => { game.profile = { goToNextLevel: vi.fn() }; game.generateProfileFiles = vi.fn(); game.prepareNextLevel(); expect(game.profile.goToNextLevel).toHaveBeenCalled(); expect(game.generateProfileFiles).toHaveBeenCalled(); }); test('generates player', () => { game.profile = { tower: 'tower', levelNumber: 1, warriorName: 'Joe', epic: false, getReadmeFilePath: () => '/path/to/profile/readme', }; (getLevelConfig as any).mockReturnValue('config'); const mockGenerate = vi.fn(); (ProfileGenerator as any).mockImplementation(function (this: any) { this.generate = mockGenerate; }); game.generateProfileFiles(); expect(getLevelConfig).toHaveBeenCalledWith('tower', 1, 'Joe', false); expect(ProfileGenerator).toHaveBeenCalledWith(game.profile, 'config'); expect(mockGenerate).toHaveBeenCalled(); }); test('prepares epic mode', () => { game.profile = { enableEpicMode: vi.fn() }; game.prepareEpicMode(); expect(game.profile.enableEpicMode).toHaveBeenCalled(); }); }); ================================================ FILE: apps/cli/src/Game.ts ================================================ import fs from 'node:fs'; import { createRequire } from 'node:module'; import path from 'node:path'; import { getLevelConfig } from '@warriorjs/core'; import { globbySync } from 'globby'; import GameError from './GameError.js'; import loadTowers from './loadTowers.js'; import Profile from './Profile.js'; import ProfileGenerator from './ProfileGenerator.js'; import type Tower from './Tower.js'; const require = createRequire(import.meta.url); const { version: cliVersion } = require('../package.json'); const gameDirectory = 'warriorjs'; export interface GameContext { version: string; runDirectoryPath: string; practiceLevel: number | undefined; silencePlay: boolean; towers: Tower[]; profile: Profile | null; profiles: Profile[]; needsProfileSetup: boolean; error?: string; onCreateProfile: ( warriorName: string, language: 'javascript' | 'typescript', tower: Tower, ) => Profile; onIsExistingProfile: (profile: Profile) => boolean; onPrepareNextLevel: () => void; onPrepareEpicMode: () => void; onGenerateProfileFiles: () => void; onProfileSelected: (profile: Profile) => void; } class Game { runDirectoryPath: string; practiceLevel: number | undefined; silencePlay: boolean; gameDirectoryPath: string; towers: Tower[] = []; profile: Profile | null = null; constructor(runDirectoryPath: string, practiceLevel: number | undefined, silencePlay: boolean) { this.runDirectoryPath = runDirectoryPath; this.practiceLevel = practiceLevel; this.silencePlay = silencePlay; this.gameDirectoryPath = path.join(this.runDirectoryPath, gameDirectory); } buildContext(): GameContext { let error: string | undefined; try { this.towers = loadTowers(); } catch (err: any) { error = err instanceof GameError || err.code === 'InvalidPlayerCode' ? err.message : String(err); } let profiles: Profile[] = []; let needsProfileSetup = false; if (!error) { this.profile = Profile.load(this.runDirectoryPath, this.towers); if (!this.profile) { try { profiles = this.getProfiles(); needsProfileSetup = true; } catch (err: any) { error = err instanceof GameError ? err.message : String(err); } } } return { version: `v${cliVersion}`, runDirectoryPath: this.runDirectoryPath, practiceLevel: this.practiceLevel, silencePlay: this.silencePlay, towers: this.towers, profile: this.profile, profiles, needsProfileSetup, error, onCreateProfile: (warriorName, language, tower) => this.createProfile(warriorName, language, tower), onIsExistingProfile: (profile) => this.isExistingProfile(profile), onPrepareNextLevel: () => this.prepareNextLevel(), onPrepareEpicMode: () => this.prepareEpicMode(), onGenerateProfileFiles: () => this.generateProfileFiles(), onProfileSelected: (profile) => { this.profile = profile; }, }; } createProfile(warriorName: string, language: 'javascript' | 'typescript', tower: Tower): Profile { const profileDirectoryPath = path.join( this.gameDirectoryPath, `${warriorName}-${tower.id}`.toLowerCase().replace(/[^a-z0-9]+/g, '-'), ); return new Profile(warriorName, tower, profileDirectoryPath, language); } isExistingProfile(profile: Profile): boolean { const profileDirectoriesPaths = this.getProfileDirectoriesPaths(); return profileDirectoriesPaths.some( (profileDirectoryPath) => profileDirectoryPath === profile.directoryPath, ); } getProfiles(): Profile[] { const profileDirectoriesPaths = this.getProfileDirectoriesPaths(); return profileDirectoriesPaths .map((profileDirectoryPath) => Profile.load(profileDirectoryPath, this.towers)) .filter((p): p is Profile => p !== null); } getProfileDirectoriesPaths(): string[] { this.ensureGameDirectory(); const profileDirectoryPattern = path.join(this.gameDirectoryPath, '*'); return globbySync(profileDirectoryPattern, { onlyDirectories: true }); } ensureGameDirectory(): void { try { if (!fs.statSync(this.gameDirectoryPath).isDirectory()) { throw new GameError( 'A file named warriorjs exists at this location. Please change the directory under which you are running warriorjs.', ); } } catch (err: any) { if (err.code !== 'ENOENT') { throw err; } fs.mkdirSync(this.gameDirectoryPath); } } prepareNextLevel(): void { this.profile!.goToNextLevel(); this.generateProfileFiles(); } generateProfileFiles(): void { const { tower, levelNumber, warriorName, epic } = this.profile!; const levelConfig = getLevelConfig(tower, levelNumber, warriorName, epic); new ProfileGenerator(this.profile!, levelConfig!).generate(); } prepareEpicMode(): void { this.profile!.enableEpicMode(); } } export default Game; ================================================ FILE: apps/cli/src/GameError.ts ================================================ class GameError extends Error { constructor(message: string) { super(message); Error.captureStackTrace(this, GameError); } } export default GameError; ================================================ FILE: apps/cli/src/Profile.test.ts ================================================ import fs from 'node:fs'; import path from 'node:path'; import mock from 'mock-fs'; import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import GameError from './GameError.js'; import Profile from './Profile.js'; import type Tower from './Tower.js'; describe('Profile.load', () => { const originalRead = Profile.read; const originalIsProfileDirectory = Profile.isProfileDirectory; const profileTower = { id: 'foo', name: 'Foo' } as any as Tower; const towers = [profileTower, { id: 'bar', name: 'Bar' } as any as Tower]; afterEach(() => { Profile.read = originalRead; Profile.isProfileDirectory = originalIsProfileDirectory; }); test('instances Profile with contents of profile file', () => { Profile.isProfileDirectory = () => true; Profile.read = () => 'eyJ3YXJyaW9yTmFtZSI6ICJKb2UiLCAidG93ZXJJZCI6ICJmb28iLCAiYW5vdGhlcktleSI6IDQyfQ=='; const profile = Profile.load('/path/to/profile', towers)!; expect(profile).toBeInstanceOf(Profile); expect(profile.warriorName).toBe('Joe'); expect(profile.tower).toBe(profileTower); expect((profile as any).anotherKey).toBe(42); }); test('sets the directory path to the path from where the profile is being loaded', () => { Profile.isProfileDirectory = () => true; Profile.read = () => 'eyJ3YXJyaW9yTmFtZSI6ICJKb2UiLCAidG93ZXJJZCI6ICJmb28ifQ=='; const profile = Profile.load('/path/to/profile', towers)!; expect(profile.directoryPath).toBe('/path/to/profile'); }); test('ignores keys that were once part of the encoded profile', () => { Profile.isProfileDirectory = () => true; Profile.read = () => 'eyJ3YXJyaW9yTmFtZSI6ICJKb2UiLCAidG93ZXJJZCI6ICJmb28iLCAiZGlyZWN0b3J5UGF0aCI6ICJsZWdhY3kiLCAidG93ZXJOYW1lIjogImxlZ2FjeSIsICJjdXJyZW50RXBpY1Njb3JlIjogImxlZ2FjeSIsICJjdXJyZW50RXBpY0dyYWRlcyI6ICJsZWdhY3kifQ=='; const profile = Profile.load('/path/to/profile', towers)!; expect(profile).not.toHaveProperty('towerName'); expect(profile.directoryPath).toBe('/path/to/profile'); expect(profile.currentEpicScore).toBe(0); expect(profile.currentEpicGrades).toEqual({}); }); test('returns null if not a profile directory', () => { Profile.isProfileDirectory = () => false; const profile = Profile.load('/path/to/profile', towers); expect(profile).toBeNull(); }); test('returns null if no encoded profile', () => { Profile.isProfileDirectory = () => true; Profile.read = () => null; const profile = Profile.load('/path/to/profile', towers); expect(profile).toBeNull(); }); test('throws if profile tower is not available', () => { Profile.isProfileDirectory = () => true; Profile.read = () => 'eyJ3YXJyaW9yTmFtZSI6ICJKb2UiLCAidG93ZXJJZCI6ICJmb28iLCAiYW5vdGhlcktleSI6IDQyfQ=='; expect(() => { Profile.load('/path/to/profile', []); }).toThrow(new GameError(`Unable to find tower 'foo', make sure it is available.`)); }); }); describe('Profile.isProfileDirectory', () => { test('returns false if only the profile file exists', () => { mock({ '/path/to/profile/.profile': 'encoded profile' }); expect(Profile.isProfileDirectory('/path/to/profile')).toBe(false); mock.restore(); }); test('returns false if only the player code file exists', () => { mock({ '/path/to/profile/Player.js': 'player code' }); expect(Profile.isProfileDirectory('/path/to/profile')).toBe(false); mock.restore(); }); test('returns false if neither file exists', () => { expect(Profile.isProfileDirectory('/path/to/profile')).toBe(false); }); test('returns true if both files exist', () => { mock({ '/path/to/profile/.profile': 'encoded profile', '/path/to/profile/Player.js': 'player code', }); expect(Profile.isProfileDirectory('/path/to/profile')).toBe(true); mock.restore(); }); }); describe('Profile.read', () => { test('returns contents of profile file', () => { mock({ '/path/to/profile/file': 'encoded profile' }); expect(Profile.read('/path/to/profile/file')).toBe('encoded profile'); mock.restore(); }); test("returns null if profile file doesn't exist", () => { mock({ '/path/to/profile': {} }); expect(Profile.read('/path/to/profile/file')).toBeNull(); mock.restore(); }); }); describe('Profile.decode', () => { test('decodes from JSON + base64', () => { expect( Profile.decode( 'eyJ3YXJyaW9yTmFtZSI6IkpvZSIsInRvd2VySWQiOiJmb28iLCJsZXZlbE51bWJlciI6MCwiY2x1ZSI6ZmFsc2UsImVwaWMiOmZhbHNlLCJzY29yZSI6MCwiZXBpY1Njb3JlIjowLCJhdmVyYWdlR3JhZGUiOm51bGx9', ), ).toEqual({ warriorName: 'Joe', towerId: 'foo', levelNumber: 0, clue: false, epic: false, score: 0, epicScore: 0, averageGrade: null, }); }); test('throws if invalid encoded profile', () => { expect(() => { Profile.decode('invalid encoded profile'); }).toThrow( new GameError( 'Corrupted .profile file. Run warriorjs from a directory with a valid profile.', ), ); }); }); describe('Profile', () => { let profile: Profile; let tower: any; beforeEach(() => { tower = { id: 'foo', name: 'Foo' }; profile = new Profile('Joe', tower, '/path/to/profile'); }); test('has a warrior name', () => { expect(profile.warriorName).toBe('Joe'); }); test('has a tower', () => { expect(profile.tower).toBe(tower); }); test('has a directory path', () => { expect(profile.directoryPath).toBe('/path/to/profile'); }); test('starts level number at zero', () => { expect(profile.levelNumber).toBe(0); }); test('starts score at zero', () => { expect(profile.score).toBe(0); expect(profile.epicScore).toBe(0); expect(profile.currentEpicScore).toBe(0); expect(profile.currentEpicGrades).toEqual({}); }); test('starts in normal mode', () => { expect(profile.epic).toBe(false); }); test("doesn't show clue at the beginning", () => { expect(profile.clue).toBe(false); }); test('makes directory', () => { mock({ '/path/to': {} }); profile.makeProfileDirectory(); expect(fs.statSync('/path/to/profile').isDirectory()).toBe(true); mock.restore(); }); describe('when reading player code', () => { beforeEach(() => { profile.getPlayerCodeFilePath = () => '/path/to/profile/player-code'; }); test('returns contents of player code file', () => { mock({ '/path/to/profile/player-code': 'class Player {}' }); expect(profile.readPlayerCode()).toBe('class Player {}'); mock.restore(); }); test("returns null if player code file doesn't exist", () => { mock({ '/path/to/profile': {} }); expect(profile.readPlayerCode()).toBeNull(); mock.restore(); }); }); test('knows the path to the player code file', () => { expect(profile.getPlayerCodeFilePath()).toBe(path.normalize('/path/to/profile/Player.js')); }); test('knows the path to the README file', () => { expect(profile.getReadmeFilePath()).toBe(path.normalize('/path/to/profile/README.md')); }); describe('when going to the next level', () => { beforeEach(() => { profile.save = vi.fn(); }); test('increments the level number', () => { profile.levelNumber = 0; profile.goToNextLevel(); expect(profile.levelNumber).toBe(1); }); test('resets the clue status', () => { profile.clue = true; profile.goToNextLevel(); expect(profile.clue).toBe(false); }); test('saves the profile', () => { profile.goToNextLevel(); expect(profile.save).toHaveBeenCalled(); }); }); describe('when requesting the clue', () => { beforeEach(() => { profile.save = vi.fn(); }); test('sets the clue status', () => { profile.clue = false; profile.requestClue(); expect(profile.clue).toBe(true); }); test('saves the profile', () => { profile.requestClue(); expect(profile.save).toHaveBeenCalled(); }); }); test('knows if clue is being shown', () => { expect(profile.isShowingClue()).toBe(false); profile.clue = true; expect(profile.isShowingClue()).toBe(true); }); describe('enabling epic mode', () => { beforeEach(() => { profile.save = vi.fn(); }); test('sets the epic status', () => { profile.epic = false; profile.enableEpicMode(); expect(profile.epic).toBe(true); }); test('saves the profile', () => { profile.enableEpicMode(); expect(profile.save).toHaveBeenCalled(); }); }); test("knows if it's epic", () => { expect(profile.isEpic()).toBe(false); profile.epic = true; expect(profile.isEpic()).toBe(true); }); test('tallies the points by adding to the score', () => { profile.score = 0; profile.tallyPoints(1, 123); expect(profile.score).toBe(123); }); test('writes the encoded profile to the profile file when saving', () => { profile.getProfileFilePath = () => '/path/to/profile/file'; profile.encode = () => 'encoded'; mock({ '/path/to/profile': {} }); profile.save(); expect(fs.readFileSync('/path/to/profile/file', 'utf8')).toBe('encoded'); mock.restore(); }); test('knows the path to the profile file', () => { expect(profile.getProfileFilePath()).toBe(path.normalize('/path/to/profile/.profile')); }); test('encodes with JSON + base64', () => { expect(profile.encode()).toBe( 'eyJ3YXJyaW9yTmFtZSI6IkpvZSIsInRvd2VySWQiOiJmb28iLCJsYW5ndWFnZSI6ImphdmFzY3JpcHQiLCJsZXZlbE51bWJlciI6MCwiY2x1ZSI6ZmFsc2UsImVwaWMiOmZhbHNlLCJzY29yZSI6MCwiZXBpY1Njb3JlIjowLCJhdmVyYWdlR3JhZGUiOm51bGx9', ); }); test('serializes to JSON ignoring properties', () => { (profile as any).currentEpicScore = 'ignored'; (profile as any).currentEpicGrades = 'ignored'; (profile as any).directoryPath = 'ignored'; (profile as any).tower = 'ignored'; const serializedProfile = JSON.parse(JSON.stringify(profile)); expect(serializedProfile).not.toHaveProperty('currentEpicScore'); expect(serializedProfile).not.toHaveProperty('currentEpicGrades'); expect(serializedProfile).not.toHaveProperty('directoryPath'); expect(serializedProfile).not.toHaveProperty('tower'); }); test('has a nice string representation', () => { profile.tower.toString = () => 'Foo'; profile.levelNumber = 4; profile.score = 123; expect(profile.toString()).toBe('Joe - JavaScript - Foo - level 4 - score 123'); }); describe('epic mode', () => { beforeEach(() => { profile.epic = true; }); test('tallies the points by adding to the current epic score and grades', () => { profile.epicScore = 0; profile.currentEpicGrades = {}; profile.tallyPoints(1, 124, 0.8); expect(profile.currentEpicScore).toBe(124); expect(profile.currentEpicGrades[1]).toBe(0.8); }); test('calculates average grade as the average of the current epic grades', () => { profile.currentEpicGrades = { 1: 0.7, 2: 0.9 }; expect(profile.calculateAverageGrade()).toBe(0.8); }); test("doesn't calculate average grade if no grades are present", () => { expect(profile.calculateAverageGrade()).toBeNull(); }); test('returns only epic score if no average grade', () => { profile.epicScore = 124; expect(profile.getEpicScoreWithGrade()).toBe('124'); }); test('returns epic score with grade if average grade', () => { profile.epicScore = 124; profile.averageGrade = 0.7; expect(profile.getEpicScoreWithGrade()).toBe('124 (C)'); }); describe('updating epic score', () => { beforeEach(() => { profile.save = vi.fn(); }); test('should override epic score and average grade with current ones if current epic score is higher', () => { profile.currentEpicScore = 123; profile.currentEpicGrades = { 1: 0.7, 2: 0.9 }; profile.updateEpicScore(); expect(profile.epicScore).toBe(123); expect(profile.averageGrade).toBe(0.8); }); test('should not override epic score and average grade if it is lower', () => { profile.epicScore = 124; profile.averageGrade = 0.9; profile.currentEpicScore = 123; profile.currentEpicGrades = { 1: 0.7, 2: 0.9 }; profile.updateEpicScore(); expect(profile.epicScore).toBe(124); expect(profile.averageGrade).toBe(0.9); }); test('saves the profile', () => { profile.updateEpicScore(); expect(profile.save).toHaveBeenCalled(); }); }); test('includes epic score in string representation', () => { profile.tower.toString = () => 'Foo'; profile.score = 123; profile.getEpicScoreWithGrade = () => '124 (C)'; expect(profile.toString()).toBe( 'Joe - JavaScript - Foo - first score 123 - epic score 124 (C)', ); }); }); }); ================================================ FILE: apps/cli/src/Profile.ts ================================================ import fs from 'node:fs'; import path from 'node:path'; import { getGradeLetter } from '@warriorjs/scoring'; import GameError from './GameError.js'; import type Tower from './Tower.js'; const profileFile = '.profile'; const playerCodeFileJs = 'Player.js'; const playerCodeFileTs = 'Player.ts'; const readmeFile = 'README.md'; class Profile { warriorName: string; tower: Tower; directoryPath: string; language: 'javascript' | 'typescript'; levelNumber: number; score: number; clue: boolean; epic: boolean; epicScore: number; averageGrade: number | null; currentEpicScore: number; currentEpicGrades: Record; [key: string]: unknown; static load(profileDirectoryPath: string, towers: Tower[]): Profile | null { if (!Profile.isProfileDirectory(profileDirectoryPath)) { return null; } const profileFilePath = path.join(profileDirectoryPath, profileFile); const encodedProfile = Profile.read(profileFilePath); if (!encodedProfile) { return null; } const decodedProfile = Profile.decode(encodedProfile); const { warriorName, towerId, towerName, // TODO: Remove before v1.0.0. directoryPath, // TODO: Remove before v1.0.0. currentEpicScore, // TODO: Remove before v1.0.0. currentEpicGrades, // TODO: Remove before v1.0.0. ...profileData } = decodedProfile; const towerKey = towerId || towerName; // Support legacy profiles. const profileTower = towers.find((tower) => tower.id === towerKey); if (!profileTower) { throw new GameError(`Unable to find tower '${towerKey}', make sure it is available.`); } const profile = new Profile(warriorName as string, profileTower, profileDirectoryPath); return Object.assign(profile, profileData); } static isProfileDirectory(profileDirectoryPath: string): boolean { const profileFilePath = path.join(profileDirectoryPath, profileFile); const playerCodeFilePathJs = path.join(profileDirectoryPath, playerCodeFileJs); const playerCodeFilePathTs = path.join(profileDirectoryPath, playerCodeFileTs); const fileExists = (p: string) => { try { return fs.statSync(p).isFile(); } catch { return false; } }; return ( fileExists(profileFilePath) && (fileExists(playerCodeFilePathJs) || fileExists(playerCodeFilePathTs)) ); } static read(profileFilePath: string): string | null { try { return fs.readFileSync(profileFilePath, 'utf8'); } catch (err: any) { if (err.code === 'ENOENT') { return null; } throw err; } } static decode(encodedProfile: string): Record { try { return JSON.parse(Buffer.from(encodedProfile, 'base64').toString()); } catch (err) { if (err instanceof SyntaxError) { throw new GameError( 'Corrupted .profile file. Run warriorjs from a directory with a valid profile.', ); } throw err; } } constructor( warriorName: string, tower: Tower, directoryPath: string, language: 'javascript' | 'typescript' = 'javascript', ) { this.warriorName = warriorName; this.tower = tower; this.directoryPath = directoryPath; this.language = language; this.levelNumber = 0; this.score = 0; this.clue = false; this.epic = false; this.epicScore = 0; this.averageGrade = null; this.currentEpicScore = 0; this.currentEpicGrades = {}; } makeProfileDirectory(): void { fs.mkdirSync(this.directoryPath); } readPlayerCode(): string | null { try { return fs.readFileSync(this.getPlayerCodeFilePath(), 'utf8'); } catch (err: any) { if (err.code === 'ENOENT') { return null; } throw err; } } getPlayerCodeFilePath(): string { const playerCodeFile = this.language === 'typescript' ? playerCodeFileTs : playerCodeFileJs; return path.join(this.directoryPath, playerCodeFile); } getReadmeFilePath(): string { return path.join(this.directoryPath, readmeFile); } goToNextLevel(): void { this.levelNumber += 1; this.clue = false; this.save(); } requestClue(): void { this.clue = true; this.save(); } isShowingClue(): boolean { return this.clue; } enableEpicMode(): void { this.epic = true; this.save(); } isEpic(): boolean { return this.epic; } tallyPoints(levelNumber: number, totalScore: number, grade?: number): void { if (this.isEpic()) { this.currentEpicGrades[levelNumber] = grade!; this.currentEpicScore += totalScore; } else { this.score += totalScore; } } getEpicScoreWithGrade(): string { if (this.averageGrade) { return `${this.epicScore} (${getGradeLetter(this.averageGrade)})`; } return this.epicScore.toString(); } updateEpicScore(): void { if (this.currentEpicScore > this.epicScore) { this.epicScore = this.currentEpicScore; this.averageGrade = this.calculateAverageGrade(); } this.save(); } calculateAverageGrade(): number | null { const grades = Object.values(this.currentEpicGrades); if (!grades.length) { return null; } return grades.reduce((sum, value) => sum + value) / grades.length; } save(): void { fs.writeFileSync(this.getProfileFilePath(), this.encode()); } getProfileFilePath(): string { return path.join(this.directoryPath, profileFile); } encode(): string { return Buffer.from(JSON.stringify(this)).toString('base64'); } toJSON(): Record { return { warriorName: this.warriorName, towerId: this.tower.id, language: this.language, levelNumber: this.levelNumber, clue: this.clue, epic: this.epic, score: this.score, epicScore: this.epicScore, averageGrade: this.averageGrade, }; } toString(): string { const languageLabel = this.language === 'typescript' ? 'TypeScript' : 'JavaScript'; let result = `${this.warriorName} - ${languageLabel} - ${this.tower}`; if (this.isEpic()) { result += ` - first score ${this.score} - epic score ${this.getEpicScoreWithGrade()}`; } else { result += ` - level ${this.levelNumber} - score ${this.score}`; } return result; } } export default Profile; ================================================ FILE: apps/cli/src/ProfileGenerator.test.ts ================================================ import fs from 'node:fs'; import mock from 'mock-fs'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import ProfileGenerator from './ProfileGenerator.js'; import renderPlayerCode from './utils/renderPlayerCode.js'; import renderReadme from './utils/renderReadme.js'; import renderTypes from './utils/renderTypes.js'; vi.mock('./utils/renderReadme.js'); vi.mock('./utils/renderPlayerCode.js'); vi.mock('./utils/renderTypes.js'); describe('ProfileGenerator', () => { let profileGenerator: ProfileGenerator; let profile: any; let levelConfig: any; beforeEach(() => { profile = { getPlayerCodeFilePath: () => '/path/to/profile/player-code', getReadmeFilePath: () => '/path/to/profile/readme', directoryPath: '/path/to/profile', }; levelConfig = { floor: { warrior: { abilities: {} }, }, }; profileGenerator = new ProfileGenerator(profile, levelConfig); }); test('has a profile', () => { expect(profileGenerator.profile).toBe(profile); }); test('has a level config', () => { expect(profileGenerator.levelConfig).toBe(levelConfig); }); describe('when generating', () => { beforeEach(() => { profileGenerator.generateReadmeFile = vi.fn(); profileGenerator.generatePlayerCodeFile = vi.fn(); }); test('generates readme file', () => { profileGenerator.generate(); expect(profileGenerator.generateReadmeFile).toHaveBeenCalled(); expect(profileGenerator.generatePlayerCodeFile).not.toHaveBeenCalled(); }); test('generates player code file if first level', () => { profile.levelNumber = 1; profileGenerator.generate(); expect(profileGenerator.generatePlayerCodeFile).toHaveBeenCalled(); }); test('generates types file for typescript profiles', () => { profile.language = 'typescript'; profileGenerator.generateTypesFile = vi.fn(); profileGenerator.generate(); expect(profileGenerator.generateTypesFile).toHaveBeenCalled(); }); test('does not generate types file for javascript profiles', () => { profile.language = 'javascript'; profileGenerator.generateTypesFile = vi.fn(); profileGenerator.generate(); expect(profileGenerator.generateTypesFile).not.toHaveBeenCalled(); }); }); test('generates readme file', () => { (renderReadme as any).mockReturnValue('rendered readme'); mock({ '/path/to/profile': {} }); profileGenerator.generateReadmeFile(); expect(renderReadme).toHaveBeenCalledWith(profile, levelConfig); expect(fs.readFileSync('/path/to/profile/readme', 'utf8')).toBe('rendered readme'); mock.restore(); }); test('generates player code file', () => { (renderPlayerCode as any).mockReturnValue('rendered player code'); mock({ '/path/to/profile': {} }); profileGenerator.generatePlayerCodeFile(); expect(renderPlayerCode).toHaveBeenCalledWith(profile, levelConfig); expect(fs.readFileSync('/path/to/profile/player-code', 'utf8')).toBe('rendered player code'); mock.restore(); }); test('generates types file', () => { (renderTypes as any).mockReturnValue('rendered types'); mock({ '/path/to/profile': {} }); profileGenerator.generateTypesFile(); expect(renderTypes).toHaveBeenCalledWith(profile, levelConfig); expect(fs.readFileSync('/path/to/profile/types.ts', 'utf8')).toBe('rendered types'); mock.restore(); }); }); ================================================ FILE: apps/cli/src/ProfileGenerator.ts ================================================ import fs from 'node:fs'; import path from 'node:path'; import { type LevelConfig } from '@warriorjs/core'; import type Profile from './Profile.js'; import renderPlayerCode from './utils/renderPlayerCode.js'; import renderReadme from './utils/renderReadme.js'; import renderTypes from './utils/renderTypes.js'; class ProfileGenerator { profile: Profile; levelConfig: LevelConfig; constructor(profile: Profile, levelConfig: LevelConfig) { this.profile = profile; this.levelConfig = levelConfig; } generate(): void { this.generateReadmeFile(); if (this.profile.levelNumber === 1) { this.generatePlayerCodeFile(); } if (this.profile.language === 'typescript') { this.generateTypesFile(); } } generateReadmeFile(): void { const readme = renderReadme(this.profile, this.levelConfig); fs.writeFileSync(this.profile.getReadmeFilePath(), readme); } generatePlayerCodeFile(): void { const code = renderPlayerCode(this.profile, this.levelConfig); fs.writeFileSync(this.profile.getPlayerCodeFilePath(), code); } generateTypesFile(): void { const rendered = renderTypes(this.profile, this.levelConfig); const typesFilePath = path.join(this.profile.directoryPath, 'types.ts'); fs.writeFileSync(typesFilePath, rendered); } } export default ProfileGenerator; ================================================ FILE: apps/cli/src/Tower.test.ts ================================================ import { beforeEach, describe, expect, test } from 'vitest'; import Tower from './Tower.js'; describe('Tower', () => { let tower: Tower; beforeEach(() => { tower = new Tower('foo', 'Foo', 'bar baz', 'warrior' as any, ['level1', 'level2'] as any); }); test('has an id', () => { expect(tower.id).toBe('foo'); }); test('has a name', () => { expect(tower.name).toBe('Foo'); }); test('has a description', () => { expect(tower.description).toBe('bar baz'); }); test('has a warrior', () => { expect(tower.warrior).toEqual('warrior'); }); test('has some levels', () => { expect(tower.levels).toEqual(['level1', 'level2']); }); test('knows if it has a given level', () => { expect(tower.hasLevel(1)).toBe(true); expect(tower.hasLevel(3)).toBe(false); }); test('returns the level with the given number', () => { expect(tower.getLevel(1)).toBe('level1'); }); test('has a nice string representation', () => { expect(tower.toString()).toBe('Foo'); }); }); ================================================ FILE: apps/cli/src/Tower.ts ================================================ import { type LevelDefinition, type WarriorDefinition } from '@warriorjs/core'; class Tower { id: string; name: string; description: string; warrior: WarriorDefinition; levels: LevelDefinition[]; constructor( id: string, name: string, description: string, warrior: WarriorDefinition, levels: LevelDefinition[], ) { this.id = id; this.name = name; this.description = description; this.warrior = warrior; this.levels = levels; } hasLevel(levelNumber: number): boolean { return !!this.getLevel(levelNumber); } getLevel(levelNumber: number): LevelDefinition | undefined { return this.levels[levelNumber - 1]; } toString(): string { return this.name; } } export default Tower; ================================================ FILE: apps/cli/src/cli.test.ts ================================================ import { expect, test, vi } from 'vitest'; import { run } from './cli.js'; import Game from './Game.js'; vi.mock('ink', () => ({ render: vi.fn(() => ({ waitUntilExit: () => Promise.resolve(), })), })); vi.mock('./ui/components/App.js', () => ({ default: vi.fn(), })); vi.mock('./Game.js', () => { const MockGame = vi.fn(function (this: any) {}); return { default: MockGame, __esModule: true }; }); test('builds context and renders the app', async () => { const mockContext = { towers: [] }; const mockBuildContext = vi.fn(() => mockContext); (Game as any).mockImplementation(function (this: any) { this.buildContext = mockBuildContext; }); await run(['-d', '/path/to/game', '-l', '2', '-s']); expect(Game).toHaveBeenCalledWith('/path/to/game', 2, true); expect(mockBuildContext).toHaveBeenCalled(); const { render } = await import('ink'); expect(render).toHaveBeenCalled(); }); ================================================ FILE: apps/cli/src/cli.ts ================================================ import { render } from 'ink'; import React from 'react'; import Game from './Game.js'; import parseArgs from './parseArgs.js'; import App from './ui/components/App.js'; /** * Starts the game. * * @param args The command line arguments. */ async function run(args: string[]): Promise { const { directory, level, silent } = parseArgs(args); const game = new Game(directory, level, silent); const context = game.buildContext(); const { waitUntilExit } = render(React.createElement(App, { context })); await waitUntilExit(); } export { run }; ================================================ FILE: apps/cli/src/loadTowers.test.ts ================================================ import mock from 'mock-fs'; import { expect, test, vi } from 'vitest'; import loadTowers from './loadTowers.js'; import Tower from './Tower.js'; const { mockRequire } = vi.hoisted(() => { const mockRequire = vi.fn(); return { mockRequire }; }); vi.mock('module', async (importOriginal) => { const original = (await importOriginal()) as any; return { ...original, createRequire: () => mockRequire, }; }); vi.mock('find-up', () => ({ findUpSync: vi.fn().mockReturnValue('/path/to/node_modules'), })); vi.mock('./Tower.js'); test('loads internal towers', () => { mockRequire.mockReturnValue({ name: 'The Narrow Path', description: 'A corridor of stone where the only way out is forward', warrior: 'warrior', levels: ['level1', 'level2'], }); mock({ '/path/to/node_modules/@warriorjs/cli': {} }); loadTowers(); mock.restore(); expect(Tower).toHaveBeenCalledWith( 'the-narrow-path', 'The Narrow Path', 'A corridor of stone where the only way out is forward', 'warrior', ['level1', 'level2'], ); }); test('loads external official towers', () => { mockRequire.mockImplementation((path: string) => { if (path.includes('tower-foo')) { return { name: 'Foo', description: 'bar', warrior: 'warrior', levels: ['level1', 'level2'], }; } return { name: 'The Narrow Path', description: 'A corridor of stone where the only way out is forward', warrior: 'warrior', levels: ['level1', 'level2'], }; }); mock({ '/path/to/node_modules': { '@warriorjs': { cli: {}, 'tower-foo': { 'package.json': '', 'index.js': "module.exports = { name: 'Foo', description: 'bar', warrior: 'warrior, levels: ['level1', 'level2'] }", }, }, }, }); loadTowers(); mock.restore(); expect(Tower).toHaveBeenCalledWith('foo', 'Foo', 'bar', 'warrior', ['level1', 'level2']); }); test('loads external community towers', () => { mockRequire.mockImplementation((path: string) => { if (path.includes('warriorjs-tower-foo')) { return { name: 'Foo', description: 'bar', warrior: 'warrior', levels: ['level1', 'level2'], }; } return { name: 'The Narrow Path', description: 'A corridor of stone where the only way out is forward', warrior: 'warrior', levels: ['level1', 'level2'], }; }); mock({ '/path/to/node_modules': { '@warriorjs': { cli: {}, }, 'warriorjs-tower-foo': { 'package.json': '', 'index.js': "module.exports = { name: 'Foo', description: 'bar', warrior: 'warrior, levels: ['level1', 'level2'] }", }, }, }); loadTowers(); mock.restore(); expect(Tower).toHaveBeenCalledWith('foo', 'Foo', 'bar', 'warrior', ['level1', 'level2']); }); test("ignores directories that are seemingly towers but don't have a package.json", () => { mockRequire.mockReturnValue({ name: 'The Narrow Path', description: 'A corridor of stone where the only way out is forward', warrior: 'warrior', levels: ['level1', 'level2'], }); mock({ '/path/to/node_modules': { '@warriorjs': { cli: {}, 'tower-foo': { 'index.js': "module.exports = { name: 'Foo', description: 'baz', warrior: 'warrior, levels: ['level1', 'level2'] }", }, }, 'warriorjs-tower-bar': { 'index.js': "module.exports = { name: 'Bar', description: 'baz', warrior: 'warrior, levels: ['level1', 'level2'] }", }, }, }); loadTowers(); mock.restore(); expect(Tower).not.toHaveBeenCalledWith('foo', 'Foo', 'baz', 'warrior', ['level1', 'level2']); expect(Tower).not.toHaveBeenCalledWith('bar', 'Bar', 'baz', 'warrior', ['level1', 'level2']); }); test("doesn't throw when @warriorjs/cli doesn't exist", async () => { mockRequire.mockReturnValue({ name: 'The Narrow Path', description: 'A corridor of stone where the only way out is forward', warrior: 'warrior', levels: ['level1', 'level2'], }); const { findUpSync } = await import('find-up'); (findUpSync as any).mockReturnValue(null); loadTowers(); }); ================================================ FILE: apps/cli/src/loadTowers.ts ================================================ import { createRequire } from 'node:module'; import path from 'node:path'; import { findUpSync } from 'find-up'; import { globbySync } from 'globby'; import Tower from './Tower.js'; import getTowerId from './utils/getTowerId.js'; const require = createRequire(import.meta.url); const internalTowerPackageNames = ['@warriorjs/tower-the-narrow-path']; const officialTowerPackageJsonPattern = '@warriorjs/tower-*/package.json'; const communityTowerPackageJsonPattern = 'warriorjs-tower-*/package.json'; interface TowerInfo { id: string; requirePath: string; } function getInternalTowersInfo(): TowerInfo[] { return internalTowerPackageNames.map((towerPackageName) => ({ id: getTowerId(towerPackageName), requirePath: towerPackageName, })); } function getExternalTowersInfo(): TowerInfo[] { const cliDir = findUpSync('@warriorjs/cli', { cwd: import.meta.dirname, type: 'directory' }); if (!cliDir) { return []; } const cliParentDir = path.resolve(cliDir, '..'); const towerSearchDir = findUpSync('node_modules', { cwd: cliParentDir, type: 'directory' }); if (!towerSearchDir) { return []; } const towerPackageJsonPaths = globbySync( [officialTowerPackageJsonPattern, communityTowerPackageJsonPattern], { cwd: towerSearchDir }, ); const towerPackageNames = towerPackageJsonPaths.map((p: string) => path.dirname(p)); const seen = new Map(); for (const towerPackageName of towerPackageNames) { const id = getTowerId(towerPackageName); if (!seen.has(id)) { seen.set(id, { id, requirePath: path.resolve(towerSearchDir, towerPackageName), }); } } return [...seen.values()]; } function loadTowers(): Tower[] { const internalTowersInfo = getInternalTowersInfo(); const externalTowersInfo = getExternalTowersInfo(); const allInfo = internalTowersInfo.concat(externalTowersInfo); const uniqueInfo = [...new Map(allInfo.map((item) => [item.id, item])).values()]; return uniqueInfo.map(({ id, requirePath }) => { const mod = require(requirePath); const { name, description, warrior, levels } = mod.default || mod; return new Tower(id, name, description, warrior, levels); }); } export default loadTowers; ================================================ FILE: apps/cli/src/parseArgs.test.ts ================================================ import { describe, expect, test, vi } from 'vitest'; import parseArgs from './parseArgs.js'; test("doesn't fail when no args are supplied", () => { parseArgs([]); }); describe('-d', () => { test('parses correctly', () => { expect(parseArgs(['-d', '/path/to/run']).d).toBe('/path/to/run'); }); test('has alias --directory', () => { expect(parseArgs(['--directory', '/path/to/run']).directory).toBe('/path/to/run'); }); test("defaults to '.'", () => { expect(parseArgs([]).d).toBe('.'); }); }); describe('-l', () => { test('parses correctly', () => { expect(parseArgs(['-l', '4']).l).toBe(4); }); test('has alias --level', () => { expect(parseArgs(['--level', '4']).level).toBe(4); }); test('exits with error if not a number', () => { const originalExit = process.exit; const originalError = console.error; process.exit = vi.fn() as any; console.error = vi.fn(); try { parseArgs(['-l', 'invalid']); } catch { // yargs may throw after calling process.exit in test environments } expect(process.exit).toHaveBeenCalledWith(1); expect(console.error).toHaveBeenCalledWith('Invalid argument: level must be a number'); process.exit = originalExit; console.error = originalError; }); }); test('exits with error on unknown option', () => { const originalExit = process.exit; const originalError = console.error; process.exit = vi.fn() as any; console.error = vi.fn(); try { parseArgs(['--unknown']); } catch { // yargs may throw after calling process.exit in test environments } expect(process.exit).toHaveBeenCalledWith(1); expect(console.error).toHaveBeenCalledWith(expect.stringContaining('Unknown')); process.exit = originalExit; console.error = originalError; }); describe('-s', () => { test('parses correctly', () => { expect(parseArgs(['-s']).s).toBe(true); }); test('has alias --silent', () => { expect(parseArgs(['--silent']).silent).toBe(true); }); test('defaults to false', () => { expect(parseArgs([]).s).toBe(false); }); }); ================================================ FILE: apps/cli/src/parseArgs.ts ================================================ import yargs from 'yargs'; interface ParsedArgs { directory: string; level: number | undefined; silent: boolean; d: string; l: number | undefined; s: boolean; [key: string]: unknown; } function parseArgs(args: string[]): ParsedArgs { return yargs(args) .usage('Usage: $0 [options]') .options({ d: { alias: 'directory', default: '.', describe: 'Run under given directory', type: 'string' as const, }, l: { alias: 'level', coerce: (arg: string) => { const parsed = Number.parseInt(arg, 10); if (Number.isNaN(parsed)) { throw new Error('Invalid argument: level must be a number'); } return parsed; }, describe: 'Practice level (epic mode only)', type: 'number' as const, }, s: { alias: 'silent', default: false, describe: 'Suppress play log', type: 'boolean' as const, }, }) .version() .help() .strict() .fail((msg: string, err: Error | undefined) => { if (err) { console.error(err.message); } else if (msg) { console.error(msg); } process.exit(1); }) .parseSync() as unknown as ParsedArgs; } export default parseArgs; ================================================ FILE: apps/cli/src/ui/components/App.tsx ================================================ import type React from 'react'; import { useState } from 'react'; import { type GameContext } from '../../Game.js'; import type Profile from '../../Profile.js'; import GameMenu from './GameMenu.js'; import PlaySession from './PlaySession.js'; interface AppProps { context: GameContext; } export default function App({ context }: AppProps): React.ReactElement { const [session, setSession] = useState<{ profile: Profile; initialLevel: number; } | null>(null); if (session) { return ( ); } return ( setSession({ profile, initialLevel: level })} /> ); } ================================================ FILE: apps/cli/src/ui/components/ConfirmPrompt.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test, vi } from 'vitest'; import { waitForRender } from '../testing.js'; import ConfirmPrompt from './ConfirmPrompt.js'; describe('ConfirmPrompt', () => { test('renders message with y/N hint by default', () => { const { lastFrame } = render(); const output = lastFrame()!; expect(output).toContain('Continue?'); expect(output).toContain('(y/N)'); }); test('shows Y/n hint when defaultValue is true', () => { const { lastFrame } = render( , ); expect(lastFrame()!).toContain('(Y/n)'); }); test('confirms with true on y', () => { const onConfirm = vi.fn(); const { stdin } = render(); stdin.write('y'); expect(onConfirm).toHaveBeenCalledWith(true); }); test('confirms with false on n', () => { const onConfirm = vi.fn(); const { stdin } = render(); stdin.write('n'); expect(onConfirm).toHaveBeenCalledWith(false); }); test('uses default value on enter', () => { const onConfirm = vi.fn(); const { stdin } = render( , ); stdin.write('\r'); expect(onConfirm).toHaveBeenCalledWith(true); }); test('shows Yes in submitted state', async () => { const { stdin, lastFrame } = render(); stdin.write('y'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Continue?'); expect(output).toContain('Yes'); }); test('shows No in submitted state', async () => { const { stdin, lastFrame } = render(); stdin.write('n'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('No'); }); test('ignores input after submission', async () => { const onConfirm = vi.fn(); const { stdin } = render(); stdin.write('y'); await waitForRender(); stdin.write('n'); expect(onConfirm).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: apps/cli/src/ui/components/ConfirmPrompt.tsx ================================================ import { Box, Text, useInput } from 'ink'; import type React from 'react'; import { useState } from 'react'; interface ConfirmPromptProps { message: string; defaultValue?: boolean; onConfirm: (value: boolean) => void; } export default function ConfirmPrompt({ message, defaultValue = false, onConfirm, }: ConfirmPromptProps): React.ReactElement { const [submitted, setSubmitted] = useState(false); const [result, setResult] = useState(defaultValue); useInput((input, key) => { if (submitted) return; if (input === 'y' || input === 'Y') { setResult(true); setSubmitted(true); onConfirm(true); return; } if (input === 'n' || input === 'N') { setResult(false); setSubmitted(true); onConfirm(false); return; } if (key.return) { setSubmitted(true); onConfirm(defaultValue); } }); if (submitted) { return ( {message} {result ? 'Yes' : 'No'} ); } const hint = defaultValue === true ? 'Y/n' : 'y/N'; return ( {message} {`(${hint})`} ); } ================================================ FILE: apps/cli/src/ui/components/Divider.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test } from 'vitest'; import Divider from './Divider.js'; describe('Divider', () => { test('renders a line of dashes matching stdout columns', () => { const { lastFrame } = render(); const output = lastFrame()!; // ink-testing-library stdout has 100 columns. expect(output).toBe('─'.repeat(100)); }); }); ================================================ FILE: apps/cli/src/ui/components/Divider.tsx ================================================ import { Text, useStdout } from 'ink'; import type React from 'react'; export default function Divider(): React.ReactElement { const { stdout } = useStdout(); return {'─'.repeat(stdout.columns || 80)}; } ================================================ FILE: apps/cli/src/ui/components/ErrorMessage.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test, vi } from 'vitest'; import { waitForRender } from '../testing.js'; import ErrorMessage from './ErrorMessage.js'; describe('ErrorMessage', () => { test('renders error message in output', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('Something went wrong'); expect(output).toContain('Press any key to continue...'); }); test('calls onDismiss on any key press', () => { const onDismiss = vi.fn(); const { stdin } = render(); stdin.write('x'); expect(onDismiss).toHaveBeenCalled(); }); test('renders null after dismissal', async () => { const { stdin, lastFrame } = render(); stdin.write('x'); await waitForRender(); expect(lastFrame()!).toBe(''); }); test('ignores subsequent key presses', async () => { const onDismiss = vi.fn(); const { stdin } = render(); stdin.write('x'); await waitForRender(); stdin.write('y'); expect(onDismiss).toHaveBeenCalledTimes(1); }); test('renders without dismiss hint when onDismiss is not provided', () => { const { lastFrame } = render(); const output = lastFrame()!; expect(output).toContain('Fatal error'); expect(output).not.toContain('Press any key to continue...'); }); test('does not respond to key presses when onDismiss is not provided', async () => { const { stdin, lastFrame } = render(); stdin.write('x'); await waitForRender(); expect(lastFrame()!).toContain('Fatal error'); }); }); ================================================ FILE: apps/cli/src/ui/components/ErrorMessage.tsx ================================================ import { Box, Text, useInput } from 'ink'; import type React from 'react'; import { useState } from 'react'; interface ErrorMessageProps { message: string; onDismiss?: () => void; } export default function ErrorMessage({ message, onDismiss, }: ErrorMessageProps): React.ReactElement | null { const [dismissed, setDismissed] = useState(false); const dismissable = !!onDismiss; useInput( () => { if (dismissed) return; setDismissed(true); onDismiss!(); }, { isActive: dismissable }, ); if (dismissed) return null; return ( {message} {dismissable && Press any key to continue...} ); } ================================================ FILE: apps/cli/src/ui/components/FloorMap.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test } from 'vitest'; import FloorMap from './FloorMap.js'; describe('FloorMap', () => { test('renders floor map characters', () => { const floorMap = [ [{ character: '╔' }, { character: '═' }, { character: '╗' }], [{ character: '║' }, { character: '@', unit: { color: '#00ff00' } }, { character: '║' }], [{ character: '╚' }, { character: '═' }, { character: '╝' }], ]; const { lastFrame } = render(); const output = lastFrame()!; expect(output).toContain('╔'); expect(output).toContain('@'); expect(output).toContain('╝'); }); test('renders empty spaces without unit styling', () => { const floorMap = [[{ character: ' ' }, { character: '>' }]]; const { lastFrame } = render(); const output = lastFrame()!; expect(output).toContain('>'); }); }); ================================================ FILE: apps/cli/src/ui/components/FloorMap.tsx ================================================ import { Box, Text } from 'ink'; import type React from 'react'; interface FloorSpace { character: string; unit?: { color: string }; } interface FloorMapProps { floorMap: FloorSpace[][]; } const STAIR_CHAR = '>'; function getSpaceColor(space: FloorSpace): string | undefined { if (space.unit) return space.unit.color; if (space.character === STAIR_CHAR) return 'yellow'; if (space.character !== ' ') return 'gray'; return undefined; } export default function FloorMap({ floorMap }: FloorMapProps): React.ReactElement { return ( {floorMap.map((row, rowIndex) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static grid {row.map((space, colIndex) => ( // biome-ignore lint/suspicious/noArrayIndexKey: static grid {space.character} ))} ))} ); } ================================================ FILE: apps/cli/src/ui/components/GameMenu.test.tsx ================================================ import { render } from 'ink-testing-library'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { type GameContext } from '../../Game.js'; import type Profile from '../../Profile.js'; import type Tower from '../../Tower.js'; import { getLastContentFrame, waitForRender } from '../testing.js'; import GameMenu from './GameMenu.js'; vi.mock('../../utils/getWarriorNameSuggestions.js', () => ({ default: () => ['TestHero'], })); function createMockTower(name: string, overrides: Partial = {}): Tower { return { id: name.toLowerCase().replace(/\s+/g, '-'), name, description: '', levels: [{}, {}, {}], hasLevel: (n: number) => n >= 1 && n <= 3, getLevel: (n: number) => (n >= 1 && n <= 3 ? {} : undefined), toString: () => name, ...overrides, } as unknown as Tower; } function createMockProfile(name: string, overrides: Partial = {}): Profile { return { warriorName: name, tower: createMockTower('The Narrow Path'), directoryPath: `/tmp/warriorjs/${name.toLowerCase()}`, language: 'javascript', levelNumber: 1, score: 0, epic: false, isEpic: () => false, getReadmeFilePath: () => `/tmp/warriorjs/${name.toLowerCase()}/README.md`, makeProfileDirectory: vi.fn(), toString: () => name, ...overrides, } as unknown as Profile; } function createMockContext(overrides: Partial = {}): GameContext { return { version: 'v1.0.0', runDirectoryPath: '/tmp', practiceLevel: undefined, silencePlay: false, towers: [createMockTower('The Narrow Path')], profile: null, profiles: [], needsProfileSetup: true, onCreateProfile: vi.fn(() => createMockProfile('TestHero')), onIsExistingProfile: vi.fn(() => false), onPrepareNextLevel: vi.fn(), onPrepareEpicMode: vi.fn(), onGenerateProfileFiles: vi.fn(), onProfileSelected: vi.fn(), ...overrides, }; } describe('GameMenu', () => { let onStart: ReturnType void>>; beforeEach(() => { onStart = vi.fn<(profile: Profile, levelNumber: number) => void>(); }); test('renders error message when context has error', async () => { const context = createMockContext({ error: 'Tower not found', needsProfileSetup: false, }); const { frames } = render(); await waitForRender(); const output = getLastContentFrame(frames); expect(output).toContain('Tower not found'); expect(onStart).not.toHaveBeenCalled(); }); test('calls onStart for returning player with existing profile', async () => { const profile = createMockProfile('Warrior', { levelNumber: 3 }); const context = createMockContext({ profile, needsProfileSetup: false, }); render(); await waitForRender(); expect(context.onProfileSelected).toHaveBeenCalledWith(profile); expect(onStart).toHaveBeenCalledWith(profile, 3); }); test('renders first-level message for returning player at level 0', async () => { const profile = createMockProfile('Warrior', { levelNumber: 0 }); const context = createMockContext({ profile, needsProfileSetup: false, }); const { frames } = render(); await waitForRender(); const output = getLastContentFrame(frames); expect(output).toContain('Level 1 is ready'); expect(output).toContain('README.md'); expect(context.onPrepareNextLevel).toHaveBeenCalled(); expect(onStart).not.toHaveBeenCalled(); }); test('renders start prompt for new player', async () => { const context = createMockContext({ needsProfileSetup: true, profiles: [] }); const { lastFrame } = render(); await waitForRender(); const output = lastFrame()!; expect(output).toContain('A tower of enemies awaits'); expect(output).toContain('Venture forth'); expect(output).toContain('Retreat'); }); test('selecting "Venture forth" transitions to wizard', async () => { const context = createMockContext({ needsProfileSetup: true, profiles: [] }); const { stdin, lastFrame } = render(); await waitForRender(); // Select "Venture forth" (first item, press enter). stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Venture forth'); expect(output).toContain('Enter one for your warrior'); }); test('selecting "Retreat" shows exit message', async () => { const context = createMockContext({ needsProfileSetup: true, profiles: [] }); const { stdin, frames } = render(); await waitForRender(); // Move down to "Retreat" and select. stdin.write('\x1B[B'); await waitForRender(); stdin.write('\r'); await waitForRender(); const output = getLastContentFrame(frames); expect(output).toContain('Even the bravest need a moment to prepare'); }); test('renders wizard with choose-profile when profiles exist', async () => { const profile = createMockProfile('Warrior1'); const context = createMockContext({ needsProfileSetup: true, profiles: [profile], }); const { lastFrame } = render(); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Which warrior answers the call?'); expect(output).toContain('Warrior1'); }); test('renders error for practice level in normal mode', async () => { const profile = createMockProfile('Warrior', { levelNumber: 2 }); const context = createMockContext({ profile, needsProfileSetup: false, practiceLevel: 1, }); const { frames } = render(); await waitForRender(); const output = getLastContentFrame(frames); expect(output).toContain('The -l option is only available in epic mode'); expect(onStart).not.toHaveBeenCalled(); }); test('calls onStart with level 1 for epic mode', async () => { const profile = createMockProfile('Warrior', { epic: true, isEpic: () => true, }); const context = createMockContext({ profile, needsProfileSetup: false, }); render(); await waitForRender(); expect(onStart).toHaveBeenCalledWith(profile, 1); }); test('calls onStart with practice level for epic mode', async () => { const profile = createMockProfile('Warrior', { epic: true, isEpic: () => true, }); const context = createMockContext({ profile, needsProfileSetup: false, practiceLevel: 2, }); render(); await waitForRender(); expect(onStart).toHaveBeenCalledWith(profile, 2); }); test('renders error for invalid practice level in epic mode', async () => { const tower = createMockTower('The Narrow Path', { levels: [{}, {}, {}] as Tower['levels'], hasLevel: (n: number) => n >= 1 && n <= 3, }); const profile = createMockProfile('Warrior', { epic: true, isEpic: () => true, tower, }); const context = createMockContext({ profile, needsProfileSetup: false, practiceLevel: 10, }); const { frames } = render(); await waitForRender(); const output = getLastContentFrame(frames); expect(output).toContain("Level 10 doesn't exist"); expect(output).toContain('3 levels'); expect(onStart).not.toHaveBeenCalled(); }); }); ================================================ FILE: apps/cli/src/ui/components/GameMenu.tsx ================================================ import path from 'node:path'; import { Box, Text, useApp } from 'ink'; import Link from 'ink-link'; import type React from 'react'; import { useEffect, useRef, useState } from 'react'; import { type GameContext } from '../../Game.js'; import type Profile from '../../Profile.js'; import getWarriorNameSuggestions from '../../utils/getWarriorNameSuggestions.js'; import { type GameMenuStep } from '../types.js'; import Divider from './Divider.js'; import ErrorMessage from './ErrorMessage.js'; import ProfileWizard from './ProfileWizard.js'; import SelectPrompt from './SelectPrompt.js'; import WelcomeScreen from './WelcomeScreen.js'; interface GameMenuProps { context: GameContext; onStart: (profile: Profile, levelNumber: number) => void; } export default function GameMenu({ context, onStart }: GameMenuProps): React.ReactElement { const { exit } = useApp(); const [step, setStep] = useState(() => { if (context.error || context.profile) return null; // handled in mount effect if (context.needsProfileSetup) { return context.profiles.length > 0 ? { type: 'wizard', initialStep: 'choose-profile' as const } : { type: 'start' as const }; } return null; }); const [history, setHistory] = useState([]); const [finalMessage, setFinalMessage] = useState(null); const [suggestedName] = useState(() => getWarriorNameSuggestions()[0]); const initialized = useRef(false); const pushHistory = (element: React.ReactElement) => { setHistory((prev) => [...prev, element]); }; const popHistory = () => { setHistory((prev) => prev.slice(0, -1)); }; function showFinalMessage(element: React.ReactElement) { setFinalMessage(element); } useEffect(() => { if (finalMessage) { exit(); } }, [finalMessage, exit]); function handleProfileReady(selectedProfile: Profile) { context.onProfileSelected(selectedProfile); try { if (selectedProfile.isEpic()) { if (context.practiceLevel) { if (!selectedProfile.tower.hasLevel(context.practiceLevel)) { showFinalMessage( , ); return; } onStart(selectedProfile, context.practiceLevel); } else { onStart(selectedProfile, 1); } } else { if (context.practiceLevel) { showFinalMessage( , ); return; } if (selectedProfile.levelNumber === 0) { context.onPrepareNextLevel(); const readmePath = selectedProfile.getReadmeFilePath(); showFinalMessage( {'Level 1 is ready. See '} {readmePath} {' for instructions.'} , ); return; } onStart(selectedProfile, selectedProfile.levelNumber); } } catch (err: unknown) { showFinalMessage(); } } useEffect(() => { if (initialized.current) return; initialized.current = true; if (context.error) { showFinalMessage(); } else if (context.profile && !context.needsProfileSetup) { handleProfileReady(context.profile); } }); const renderStep = (): React.ReactElement | null => { if (!step) return null; switch (step.type) { case 'start': { const items = [ { label: 'Venture forth', value: 'start' }, { label: 'Retreat', value: 'quit' }, ]; return ( { if (value === 'start') { pushHistory( A tower of enemies awaits. Do you dare enter? Venture forth , ); setStep({ type: 'wizard', initialStep: 'new' }); } else { showFinalMessage(Even the bravest need a moment to prepare.); } }} /> ); } case 'wizard': return ( handleProfileReady(selectedProfile)} onCancel={() => { popHistory(); setStep({ type: 'start' }); }} onError={(message) => showFinalMessage()} /> ); } }; return ( {history} {renderStep()} {finalMessage} ); } ================================================ FILE: apps/cli/src/ui/components/Header.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test } from 'vitest'; import Header from './Header.js'; describe('Header', () => { test('renders warrior name and tower info', () => { const { lastFrame } = render(
, ); const output = lastFrame()!; expect(output).toContain('WarriorJS'); expect(output).toContain('Olric'); expect(output).toContain('The Narrow Path'); expect(output).toContain('Level 3'); expect(output).toContain('125'); }); test('renders without level/score when not provided', () => { const { lastFrame } = render(
); const output = lastFrame()!; expect(output).toContain('Olric'); expect(output).not.toContain('Level'); }); }); ================================================ FILE: apps/cli/src/ui/components/Header.tsx ================================================ import { Box, Text } from 'ink'; import type React from 'react'; interface HeaderProps { warriorName: string; towerName: string; levelNumber?: number; score?: number; } export default function Header({ warriorName, towerName, levelNumber, score, }: HeaderProps): React.ReactElement { return ( {'0={==> WarriorJS'} {warriorName} {'·'} {towerName} {levelNumber !== undefined && ( <> {'·'} {`Level ${levelNumber}`} )} {score !== undefined && ( <> {'·'} {`Score ${score}`} )} ); } ================================================ FILE: apps/cli/src/ui/components/LevelCompleteScreen.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test, vi } from 'vitest'; import { makeLevelReport, makeLevelRun, waitForRender } from '../testing.js'; import LevelCompleteScreen from './LevelCompleteScreen.js'; describe('LevelCompleteScreen', () => { test('shows passed menu with Next level when hasNextLevel', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('Next level'); expect(output).toContain('Review turns'); expect(output).toContain('Stay and hone'); }); test('shows Enter epic mode when no next level', () => { const { lastFrame } = render( , ); expect(lastFrame()!).toContain('Enter epic mode'); }); test('shows failed menu with Try again', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('Try again'); expect(output).toContain('Review turns'); }); test('shows Reveal clues when clue available and not showing', () => { const { lastFrame } = render( , ); expect(lastFrame()!).toContain('Reveal clues'); }); test('hides Reveal clues when already showing', () => { const { lastFrame } = render( , ); expect(lastFrame()!).not.toContain('Reveal clues'); }); test('renders next-level action message', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('path/to/README'); expect(output).toContain('instructions'); }); test('renders clue action message', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('path/to/README'); expect(output).toContain('clues'); }); test('renders stay action message', () => { const { lastFrame } = render( , ); expect(lastFrame()!).toContain('stayed on the current level'); }); test('renders epic-mode action message', () => { const { lastFrame } = render( , ); expect(lastFrame()!).toContain('Run warriorjs again to play epic mode'); }); test('calls onSelect when menu item is chosen', async () => { const onSelect = vi.fn(); const { stdin } = render( , ); stdin.write('\r'); await waitForRender(); expect(onSelect).toHaveBeenCalledWith('next-level'); }); }); ================================================ FILE: apps/cli/src/ui/components/LevelCompleteScreen.tsx ================================================ import path from 'node:path'; import { Text } from 'ink'; import Link from 'ink-link'; import type React from 'react'; import { useMemo } from 'react'; import { type LevelCompleteAction, type LevelCompleteChoice, type LevelReport, type LevelRun, } from '../types.js'; import Divider from './Divider.js'; import PlayLayout from './PlayLayout.js'; import ResultScreen from './ResultScreen.js'; import SelectPrompt from './SelectPrompt.js'; function buildMenuItems(levelReport: LevelReport): { label: string; value: LevelCompleteChoice }[] { if (levelReport.passed) { return [ levelReport.hasNextLevel ? { label: 'Next level', value: 'next-level' } : { label: 'Enter epic mode', value: 'epic-mode' }, { label: 'Review turns', value: 'review' }, { label: 'Stay and hone', value: 'stay' }, ]; } return [ { label: 'Try again', value: 'try-again' }, { label: 'Review turns', value: 'review' }, ...(levelReport.hasClue && !levelReport.isShowingClue ? [{ label: 'Reveal clues', value: 'clue' as const }] : []), ]; } interface LevelCompleteScreenProps { levelReport: LevelReport; levelRun: LevelRun; action: LevelCompleteAction; onSelect: (value: LevelCompleteChoice) => void; } export default function LevelCompleteScreen({ levelReport, levelRun, action, onSelect, }: LevelCompleteScreenProps): React.ReactElement { const menuItems = useMemo(() => buildMenuItems(levelReport), [levelReport]); return ( {action.type === 'prompt' && ( )} {action.type === 'next-level' && ( {'See '} {action.readmePath} {' for instructions.'} )} {action.type === 'clue' && ( {'See '} {action.readmePath} {' for the clues.'} )} {action.type === 'stay' && ( You stayed on the current level. Aim for more points next time. )} {action.type === 'epic-mode' && Run warriorjs again to play epic mode.} ); } ================================================ FILE: apps/cli/src/ui/components/LogArea.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test } from 'vitest'; import LogArea from './LogArea.js'; describe('LogArea', () => { const turns = [ [ { message: '', unit: { name: 'Warrior', color: '#ffffff' }, floorMap: [], warriorStatus: { health: 20, score: 0 }, }, ], [ { message: 'walks forward', unit: { name: 'Warrior', color: '#ffffff' }, floorMap: [], warriorStatus: { health: 20, score: 0 }, }, ], [ { message: 'attacks forward', unit: { name: 'Warrior', color: '#ffffff' }, floorMap: [], warriorStatus: { health: 18, score: 5 }, }, { message: 'takes 5 damage, dies', unit: { name: 'Sludge', color: '#00ff00' }, floorMap: [], warriorStatus: { health: 18, score: 10 }, }, ], ]; test('renders log messages up to current turn', () => { const { lastFrame } = render(); const output = lastFrame()!; expect(output).toContain('Turn 2'); expect(output).toContain('attacks forward'); expect(output).toContain('Sludge'); expect(output).toContain('Turn 1'); expect(output).toContain('walks forward'); }); test('truncates to maxLines', () => { const { lastFrame } = render(); const output = lastFrame()!; expect(output).toContain('Turn 2'); expect(output).toContain('attacks forward'); expect(output).toContain('Sludge'); expect(output).not.toContain('Turn 1'); }); test('shows nothing on turn zero', () => { const { lastFrame } = render(); const output = lastFrame()!; expect(output).not.toContain('Turn'); }); test('shows only turns up to currentTurn', () => { const { lastFrame } = render(); const output = lastFrame()!; expect(output).toContain('Turn 1'); expect(output).toContain('walks forward'); expect(output).not.toContain('Turn 2'); }); }); ================================================ FILE: apps/cli/src/ui/components/LogArea.tsx ================================================ import { Box, Text } from 'ink'; import type React from 'react'; import { useMemo } from 'react'; import { type TurnEvent } from '../types.js'; interface LogAreaProps { turns: TurnEvent[][]; currentTurn: number; maxLines?: number; } interface LogLine { type: 'turn' | 'event'; turnNumber: number; event?: TurnEvent; } function buildUnitColorMap(turns: TurnEvent[][]): Map { const map = new Map(); for (const turn of turns) { for (const event of turn) { if (event.unit) { map.set(event.unit.name, event.unit.color); } } } return map; } const STAT_RE = /\d+ damage|\d+ HP/; function buildColorizeRegex(unitColors: Map): RegExp { const unitNames = Array.from(unitColors.keys()).map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), ); const alternatives = [STAT_RE.source, ...unitNames]; return new RegExp(`(${alternatives.join('|')})`); } function colorizeMessage( message: string, unitColors: Map, regex: RegExp, ): React.ReactNode[] { const parts = message.split(regex); return parts.map((part, i) => { const unitColor = unitColors.get(part); if (unitColor) { return ( // biome-ignore lint/suspicious/noArrayIndexKey: split parts are positional {part} ); } if (STAT_RE.test(part)) { return ( // biome-ignore lint/suspicious/noArrayIndexKey: split parts are positional {part} ); } // biome-ignore lint/suspicious/noArrayIndexKey: split parts are positional return {part}; }); } export default function LogArea({ turns, currentTurn, maxLines = 10, }: LogAreaProps): React.ReactElement { const unitColors = useMemo(() => buildUnitColorMap(turns), [turns]); const colorizeRegex = useMemo(() => buildColorizeRegex(unitColors), [unitColors]); // Build a flat list of lines (turn headers + events), newest first. const visibleLines = useMemo(() => { const lines: LogLine[] = []; const visibleTurns = turns.slice(0, currentTurn + 1); for (let i = visibleTurns.length - 1; i >= 0; i--) { const turnEvents = visibleTurns[i]!; const hasMessages = turnEvents.some((e) => e.message); if (!hasMessages) continue; const turnNumber = i; lines.push({ type: 'turn', turnNumber }); for (const event of turnEvents) { if (event.message) { lines.push({ type: 'event', turnNumber, event }); } } } return lines.slice(0, maxLines); }, [turns, currentTurn, maxLines]); return ( {visibleLines.map((line, index) => { if (line.type === 'turn') { return ( {`Turn ${line.turnNumber}`} ); } const event = line.event!; const text = event.unit ? `${event.unit.name} ${event.message}` : event.message; return ( // biome-ignore lint/suspicious/noArrayIndexKey: unique with turnNumber {'>'} {colorizeMessage(text, unitColors, colorizeRegex)} ); })} ); } ================================================ FILE: apps/cli/src/ui/components/PlayLayout.test.tsx ================================================ import { Text } from 'ink'; import { render } from 'ink-testing-library'; import { describe, expect, test } from 'vitest'; import { type TurnEvent } from '../types.js'; import PlayLayout from './PlayLayout.js'; function makeEvent(overrides: Partial = {}): TurnEvent { return { message: 'test', unit: null, floorMap: [[{ character: '║' }, { character: '@' }, { character: '║' }]], warriorStatus: { health: 20, score: 0 }, ...overrides, }; } describe('PlayLayout', () => { test('renders header, floor map, divider, and children', () => { const turns = [[makeEvent()]]; const { lastFrame } = render( child content , ); const output = lastFrame()!; expect(output).toContain('Olric'); expect(output).toContain('The Narrow Path'); expect(output).toContain('3'); expect(output).toContain('@'); expect(output).toContain('child content'); expect(output).toContain('─'); }); test('renders without floor map when turns are empty', () => { const { lastFrame } = render( content , ); const output = lastFrame()!; expect(output).toContain('content'); expect(output).not.toContain('@'); }); }); ================================================ FILE: apps/cli/src/ui/components/PlayLayout.tsx ================================================ import { Box } from 'ink'; import type React from 'react'; import { type TurnEvent } from '../types.js'; import Divider from './Divider.js'; import FloorMap from './FloorMap.js'; import Header from './Header.js'; interface PlayLayoutProps { turns: TurnEvent[][]; warriorName: string; towerName: string; levelNumber: number; totalScore: number; children: React.ReactNode; } export default function PlayLayout({ turns, warriorName, towerName, levelNumber, totalScore, children, }: PlayLayoutProps): React.ReactElement { const lastTurnEvents = turns[turns.length - 1]; const lastEvent = lastTurnEvents?.[lastTurnEvents.length - 1]; return (
{lastEvent && } {children} ); } ================================================ FILE: apps/cli/src/ui/components/PlayScreen.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test, vi } from 'vitest'; import PlayScreen from './PlayScreen.js'; describe('PlayScreen', () => { const initialState = { message: '', unit: null, floorMap: [ [{ character: '╔' }, { character: '═' }, { character: '╗' }], [{ character: '║' }, { character: '@', unit: { color: '#ffffff' } }, { character: '║' }], [{ character: '╚' }, { character: '═' }, { character: '╝' }], ], warriorStatus: { health: 20, score: 0 }, }; const turns = [ [ { message: 'walks forward', unit: { name: 'Warrior', color: '#ffffff' }, floorMap: [ [{ character: '╔' }, { character: '═' }, { character: '╗' }], [{ character: '║' }, { character: ' ' }, { character: '║' }], [{ character: '╚' }, { character: '═' }, { character: '╝' }], ], warriorStatus: { health: 20, score: 0 }, }, ], ]; test('renders initial state on turn zero', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('WarriorJS'); expect(output).toContain('Olric'); expect(output).toContain('@'); expect(output).toContain('❤'); expect(output).toContain('Turn 0/1'); }); }); ================================================ FILE: apps/cli/src/ui/components/PlayScreen.tsx ================================================ import { Box } from 'ink'; import type React from 'react'; import { useMemo } from 'react'; import { usePlayback } from '../hooks/usePlayback.js'; import { type TurnEvent } from '../types.js'; import Divider from './Divider.js'; import FloorMap from './FloorMap.js'; import Header from './Header.js'; import LogArea from './LogArea.js'; import Scrubber from './Scrubber.js'; import WarriorStatus from './WarriorStatus.js'; interface PlayScreenProps { turns: TurnEvent[][]; initialState: TurnEvent; warriorName: string; towerName: string; levelNumber: number; totalScore: number; maxHealth: number; reviewMode?: boolean; onPlaybackComplete: () => void; } export default function PlayScreen({ turns, initialState, warriorName, towerName, levelNumber, totalScore, maxHealth, reviewMode, onPlaybackComplete, }: PlayScreenProps): React.ReactElement { const turnsWithInitial = useMemo(() => [[initialState], ...turns], [initialState, turns]); const { state } = usePlayback(turnsWithInitial.length, onPlaybackComplete, reviewMode); const currentTurnEvents = turnsWithInitial[state.currentTurn]; const lastEvent = currentTurnEvents?.[currentTurnEvents.length - 1]; return (
{lastEvent && ( <> )} ); } ================================================ FILE: apps/cli/src/ui/components/PlaySession.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test, vi } from 'vitest'; import { type GameContext } from '../../Game.js'; import type Profile from '../../Profile.js'; import { usePlaySession } from '../hooks/usePlaySession.js'; import { getLastContentFrame, makeLevelReport, makeLevelRun, waitForRender } from '../testing.js'; import { type PlaySessionState } from '../types.js'; import PlaySession from './PlaySession.js'; vi.mock('../hooks/usePlaySession.js', () => ({ usePlaySession: vi.fn(), })); const mockUsePlaySession = vi.mocked(usePlaySession); const mockContext = {} as GameContext; const mockProfile = { calculateAverageGrade: vi.fn(() => 0.9), currentEpicGrades: { '1': 1.0, '2': 0.8 } as Record, } as unknown as Profile; describe('PlaySession', () => { test('renders PlayScreen when state is playing', () => { const levelRun = makeLevelRun(); mockUsePlaySession.mockReturnValue({ state: { type: 'playing', levelRun }, handlePlayComplete: vi.fn(), handleLevelCompleteChoice: vi.fn(), }); const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('Olric'); expect(output).toContain('@'); }); test('renders LevelCompleteScreen when state is levelComplete with prompt action', () => { const levelRun = makeLevelRun(); const levelReport = makeLevelReport({ passed: true, hasNextLevel: true }); mockUsePlaySession.mockReturnValue({ state: { type: 'levelComplete', levelRun, levelReport, action: { type: 'prompt' } }, handlePlayComplete: vi.fn(), handleLevelCompleteChoice: vi.fn(), }); const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('Success'); expect(output).toContain('Next level'); }); test('renders TowerCompleteScreen when state is towerComplete', () => { mockUsePlaySession.mockReturnValue({ state: { type: 'towerComplete' }, handlePlayComplete: vi.fn(), handleLevelCompleteChoice: vi.fn(), }); const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('average grade'); expect(output).toContain('Level 1'); expect(output).toContain('Level 2'); }); test('renders error message when state is error', () => { mockUsePlaySession.mockReturnValue({ state: { type: 'error', message: 'Something went wrong' }, handlePlayComplete: vi.fn(), handleLevelCompleteChoice: vi.fn(), }); const { lastFrame } = render( , ); expect(lastFrame()!).toContain('Something went wrong'); }); test('renders nothing when state is null', () => { mockUsePlaySession.mockReturnValue({ state: null as unknown as PlaySessionState, handlePlayComplete: vi.fn(), handleLevelCompleteChoice: vi.fn(), }); const { lastFrame } = render( , ); expect(lastFrame()!).toBe(''); }); test('renders LevelCompleteScreen for terminal actions', async () => { const levelRun = makeLevelRun(); const levelReport = makeLevelReport(); mockUsePlaySession.mockReturnValue({ state: { type: 'levelComplete', levelRun, levelReport, action: { type: 'next-level', readmePath: 'path/to/README' }, }, handlePlayComplete: vi.fn(), handleLevelCompleteChoice: vi.fn(), }); const { frames } = render( , ); await waitForRender(); const output = getLastContentFrame(frames); expect(output).toContain('path/to/README'); expect(output).toContain('instructions'); }); }); ================================================ FILE: apps/cli/src/ui/components/PlaySession.tsx ================================================ import { useApp } from 'ink'; import type React from 'react'; import { useEffect } from 'react'; import { type GameContext } from '../../Game.js'; import type Profile from '../../Profile.js'; import { usePlaySession } from '../hooks/usePlaySession.js'; import ErrorMessage from './ErrorMessage.js'; import LevelCompleteScreen from './LevelCompleteScreen.js'; import PlayScreen from './PlayScreen.js'; import TowerCompleteScreen from './TowerCompleteScreen.js'; interface PlaySessionProps { context: GameContext; profile: Profile; initialLevel: number; } export default function PlaySession({ context, profile, initialLevel, }: PlaySessionProps): React.ReactElement | null { const { exit } = useApp(); const { state, handlePlayComplete, handleLevelCompleteChoice } = usePlaySession({ context, profile, initialLevel, exit, }); // Exit when a terminal level-complete action is selected. useEffect(() => { if (state?.type === 'levelComplete' && state.action.type !== 'prompt') { exit(); } }, [state, exit]); if (!state) return null; switch (state.type) { case 'playing': return ( ); case 'levelComplete': return ( ); case 'towerComplete': return ( } /> ); case 'error': return ; } } ================================================ FILE: apps/cli/src/ui/components/ProfileWizard.test.tsx ================================================ import { render } from 'ink-testing-library'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { type GameContext } from '../../Game.js'; import type Profile from '../../Profile.js'; import type Tower from '../../Tower.js'; import { waitForRender } from '../testing.js'; import ProfileWizard from './ProfileWizard.js'; function createMockTower(name: string): Tower { return { toString: () => name, } as unknown as Tower; } function createMockProfile(name: string): Profile { return { toString: () => name, makeProfileDirectory: vi.fn(), } as unknown as Profile; } function createMockContext(overrides: Partial = {}): GameContext { return { version: '1.0.0', runDirectoryPath: '/tmp', practiceLevel: undefined, silencePlay: false, towers: [createMockTower('The Narrow Path')], profile: null, profiles: [], needsProfileSetup: true, onCreateProfile: vi.fn(() => createMockProfile('TestWarrior - The Narrow Path')), onIsExistingProfile: vi.fn(() => false), onPrepareNextLevel: vi.fn(), onPrepareEpicMode: vi.fn(), onGenerateProfileFiles: vi.fn(), onProfileSelected: vi.fn(), ...overrides, }; } describe('ProfileWizard', () => { let onComplete: ReturnType void>>; let onCancel: ReturnType void>>; let onError: ReturnType void>>; beforeEach(() => { onComplete = vi.fn<(profile: Profile) => void>(); onCancel = vi.fn<() => void>(); onError = vi.fn<(message: string) => void>(); }); describe('choose-profile step', () => { test('renders existing profiles and "New profile" option', () => { const profile1 = createMockProfile('Warrior1 - Tower1'); const profile2 = createMockProfile('Warrior2 - Tower2'); const context = createMockContext({ profiles: [profile1, profile2] }); const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('Warrior1 - Tower1'); expect(output).toContain('Warrior2 - Tower2'); expect(output).toContain('New profile'); expect(output).toContain('Which warrior answers the call?'); }); test('selecting an existing profile calls onComplete', async () => { const profile = createMockProfile('Warrior1 - Tower1'); const context = createMockContext({ profiles: [profile] }); const { stdin, lastFrame } = render( , ); // First item is selected by default, press enter. await waitForRender(); stdin.write('\r'); await waitForRender(); expect(lastFrame()!).toContain('Warrior1 - Tower1'); expect(onComplete).toHaveBeenCalledWith(profile); }); test('selecting "New profile" transitions to name input', async () => { const profile = createMockProfile('Warrior1 - Tower1'); const context = createMockContext({ profiles: [profile] }); const { stdin, lastFrame } = render( , ); // Move down past the profile and separator to "New profile". await waitForRender(); stdin.write('\x1B[B'); await waitForRender(); stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Enter one for your warrior'); expect(output).toContain('New profile'); }); }); describe('create-name step', () => { test('renders name input with suggested name as default', () => { const context = createMockContext(); const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('Enter one for your warrior'); expect(output).toContain('Hero'); }); test('typing a name and submitting transitions to language selection', async () => { const context = createMockContext(); const { stdin, lastFrame } = render( , ); await waitForRender(); stdin.write('Braveheart'); await waitForRender(); stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Choose your language'); expect(output).toContain('Braveheart'); }); test('submitting with default name uses suggested name', async () => { const context = createMockContext(); const { stdin, lastFrame } = render( , ); await waitForRender(); stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Choose your language'); expect(output).toContain('Hero'); }); test('submitting empty name with no default shows error', async () => { const context = createMockContext(); const { stdin, lastFrame } = render( , ); await waitForRender(); stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('A warrior without a name is just a shadow'); }); test('escape from name step when from "new" calls onCancel', async () => { const context = createMockContext(); const { stdin } = render( , ); await waitForRender(); stdin.write('\x1B'); await waitForRender(); expect(onCancel).toHaveBeenCalled(); }); test('escape from name step when from "choose-profile" goes back to choose-profile', async () => { const profile = createMockProfile('Warrior1 - Tower1'); const context = createMockContext({ profiles: [profile] }); const { stdin, lastFrame } = render( , ); // Navigate to "New profile" and select it. await waitForRender(); stdin.write('\x1B[B'); await waitForRender(); stdin.write('\r'); await waitForRender(); // Verify we transitioned to name step (history entry for the selection was added). expect(lastFrame()!).toContain('Enter one for your warrior'); // Now we should be on the name step, press escape to go back. stdin.write('\x1B'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Which warrior answers the call?'); }); }); describe('create-name-error step', () => { test('dismissing error goes back to name input', async () => { const context = createMockContext(); const { stdin, lastFrame } = render( , ); // Submit empty name to trigger error. await waitForRender(); stdin.write('\r'); await waitForRender(); expect(lastFrame()!).toContain('A warrior without a name is just a shadow'); // Press any key to dismiss. stdin.write(' '); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Enter one for your warrior'); }); }); describe('create-language step', () => { test('renders language options', async () => { const context = createMockContext(); const { stdin, lastFrame } = render( , ); // Submit default name. await waitForRender(); stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Choose your language'); expect(output).toContain('TypeScript (recommended)'); expect(output).toContain('JavaScript'); }); test('selecting TypeScript transitions to tower selection', async () => { const context = createMockContext(); const { stdin, lastFrame } = render( , ); // Submit name. await waitForRender(); stdin.write('\r'); await waitForRender(); // Select TypeScript (default). stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Choose a tower'); expect(output).toContain('TypeScript'); }); test('selecting JavaScript transitions to tower selection', async () => { const context = createMockContext(); const { stdin, lastFrame } = render( , ); // Submit name. await waitForRender(); stdin.write('\r'); await waitForRender(); // Move to JavaScript and select. stdin.write('\x1B[B'); await waitForRender(); stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Choose a tower'); expect(output).toContain('JavaScript'); }); test('escape from language goes back to name input', async () => { const context = createMockContext(); const { stdin, lastFrame } = render( , ); // Submit name. await waitForRender(); stdin.write('\r'); await waitForRender(); // Verify history entry was added. expect(lastFrame()!).toContain('Hero'); // Escape from language. stdin.write('\x1B'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Enter one for your warrior'); }); }); describe('create-tower step', () => { test('renders tower options', async () => { const tower1 = createMockTower('The Narrow Path'); const tower2 = createMockTower('The Powder Keep'); const context = createMockContext({ towers: [tower1, tower2] }); const { stdin, lastFrame } = render( , ); // Submit name, then select language. await waitForRender(); stdin.write('\r'); await waitForRender(); stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Choose a tower'); expect(output).toContain('The Narrow Path'); expect(output).toContain('The Powder Keep'); }); test('selecting tower with no existing profile completes wizard', async () => { const tower = createMockTower('The Narrow Path'); const mockProfile = createMockProfile('Hero - The Narrow Path'); const context = createMockContext({ towers: [tower], onCreateProfile: vi.fn(() => mockProfile), onIsExistingProfile: vi.fn(() => false), }); const { stdin } = render( , ); // Submit name, select language, select tower. await waitForRender(); stdin.write('\r'); await waitForRender(); stdin.write('\r'); await waitForRender(); stdin.write('\r'); await waitForRender(); expect(context.onCreateProfile).toHaveBeenCalledWith('Hero', 'typescript', tower); expect(context.onIsExistingProfile).toHaveBeenCalledWith(mockProfile); expect(mockProfile.makeProfileDirectory).toHaveBeenCalled(); expect(onComplete).toHaveBeenCalledWith(mockProfile); }); test('selecting tower with existing profile shows confirm-replace', async () => { const tower = createMockTower('The Narrow Path'); const mockProfile = createMockProfile('Hero - The Narrow Path'); const context = createMockContext({ towers: [tower], onCreateProfile: vi.fn(() => mockProfile), onIsExistingProfile: vi.fn(() => true), }); const { stdin, lastFrame } = render( , ); // Submit name, select language, select tower. await waitForRender(); stdin.write('\r'); await waitForRender(); stdin.write('\r'); await waitForRender(); stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('already climbing'); expect(output).toContain('The Narrow Path'); expect(output).toContain('Do you want to replace'); expect(onComplete).not.toHaveBeenCalled(); }); test('escape from tower goes back to language selection', async () => { const context = createMockContext(); const { stdin, lastFrame } = render( , ); // Submit name, select language. await waitForRender(); stdin.write('\r'); await waitForRender(); stdin.write('\r'); await waitForRender(); // Verify history entries exist. expect(lastFrame()!).toContain('TypeScript'); // Escape from tower. stdin.write('\x1B'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Choose your language'); }); test('escape from tower after selecting JavaScript restores language index', async () => { const context = createMockContext(); const { stdin, lastFrame } = render( , ); // Submit name. await waitForRender(); stdin.write('\r'); await waitForRender(); // Select JavaScript (index ). stdin.write('\x1B[B'); await waitForRender(); stdin.write('\r'); await waitForRender(); // Escape from tower to go back to language. stdin.write('\x1B'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Choose your language'); // JavaScript should be pre-selected (indicator on JavaScript line). expect(output).toContain('JavaScript'); }); }); describe('create-confirm-replace step', () => { test('confirming replace calls onComplete', async () => { const tower = createMockTower('The Narrow Path'); const mockProfile = createMockProfile('Hero - The Narrow Path'); const context = createMockContext({ towers: [tower], onCreateProfile: vi.fn(() => mockProfile), onIsExistingProfile: vi.fn(() => true), }); const { stdin } = render( , ); // Submit name, select language, select tower. await waitForRender(); stdin.write('\r'); await waitForRender(); stdin.write('\r'); await waitForRender(); stdin.write('\r'); await waitForRender(); // Confirm replace with 'y'. stdin.write('y'); await waitForRender(); expect(context.onCreateProfile).toHaveBeenCalledTimes(2); expect(onComplete).toHaveBeenCalledWith(mockProfile); }); test('declining replace calls onError', async () => { const tower = createMockTower('The Narrow Path'); const mockProfile = createMockProfile('Hero - The Narrow Path'); const context = createMockContext({ towers: [tower], onCreateProfile: vi.fn(() => mockProfile), onIsExistingProfile: vi.fn(() => true), }); const { stdin } = render( , ); // Submit name, select language, select tower. await waitForRender(); stdin.write('\r'); await waitForRender(); stdin.write('\r'); await waitForRender(); stdin.write('\r'); await waitForRender(); // Decline replace with 'n'. stdin.write('n'); await waitForRender(); expect(onComplete).not.toHaveBeenCalled(); expect(onError).toHaveBeenCalledWith('Unable to continue without a profile.'); }); }); describe('full flow', () => { test('complete flow from new: name -> language -> tower -> done', async () => { const tower = createMockTower('The Narrow Path'); const mockProfile = createMockProfile('Braveheart - The Narrow Path'); const context = createMockContext({ towers: [tower], onCreateProfile: vi.fn(() => mockProfile), onIsExistingProfile: vi.fn(() => false), }); const { stdin, lastFrame } = render( , ); // Type name. await waitForRender(); stdin.write('Braveheart'); await waitForRender(); stdin.write('\r'); await waitForRender(); // Select TypeScript. stdin.write('\r'); await waitForRender(); // Select tower. stdin.write('\r'); await waitForRender(); expect(context.onCreateProfile).toHaveBeenCalledWith('Braveheart', 'typescript', tower); expect(mockProfile.makeProfileDirectory).toHaveBeenCalled(); expect(onComplete).toHaveBeenCalledWith(mockProfile); // Verify history entries are rendered. const output = lastFrame()!; expect(output).toContain('Braveheart'); expect(output).toContain('TypeScript'); expect(output).toContain('The Narrow Path'); }); test('complete flow from choose-profile: select existing -> done', async () => { const profile = createMockProfile('Warrior1 - Tower1'); const context = createMockContext({ profiles: [profile] }); const { stdin, lastFrame } = render( , ); await waitForRender(); stdin.write('\r'); await waitForRender(); expect(onComplete).toHaveBeenCalledWith(profile); // Verify history entry is rendered. expect(lastFrame()!).toContain('Warrior1 - Tower1'); }); }); }); ================================================ FILE: apps/cli/src/ui/components/ProfileWizard.tsx ================================================ import { Box, Text } from 'ink'; import type React from 'react'; import { useState } from 'react'; import { type GameContext } from '../../Game.js'; import type Profile from '../../Profile.js'; import type Tower from '../../Tower.js'; import ConfirmPrompt from './ConfirmPrompt.js'; import ErrorMessage from './ErrorMessage.js'; import SelectPrompt from './SelectPrompt.js'; import TextPrompt from './TextPrompt.js'; type WizardStep = | { type: 'choose-profile' } | { type: 'create-name'; from?: 'new' | 'choose-profile'; initialValue?: string } | { type: 'create-name-error' } | { type: 'create-language'; warriorName: string; initialIndex?: number } | { type: 'create-tower'; warriorName: string; language: 'javascript' | 'typescript'; initialIndex?: number; } | { type: 'create-confirm-replace'; warriorName: string; language: 'javascript' | 'typescript'; tower: Tower; }; interface ProfileWizardProps { context: GameContext; suggestedName: string; initialStep: 'new' | 'choose-profile'; onComplete: (profile: Profile) => void; onCancel: () => void; onError: (message: string) => void; } export default function ProfileWizard({ context, suggestedName, initialStep, onComplete, onCancel, onError, }: ProfileWizardProps): React.ReactElement | null { const [step, setStep] = useState( initialStep === 'choose-profile' ? { type: 'choose-profile' } : { type: 'create-name', from: 'new' }, ); const [history, setHistory] = useState([]); const pushHistory = (question: string, answer: string) => { setHistory((prev) => [ ...prev, {question} {answer} , ]); }; const popHistory = () => { setHistory((prev) => prev.slice(0, -1)); }; const renderStep = (): React.ReactElement | null => { switch (step.type) { case 'choose-profile': { const items: ({ label: string; value: Profile | 'new' } | { separator: true })[] = [ ...context.profiles.map((p) => ({ label: p.toString(), value: p as Profile | 'new' })), { separator: true as const }, { label: 'New profile', value: 'new' as const }, ]; return ( { if (value === 'new') { pushHistory('Your sword awaits. Which warrior answers the call?', 'New profile'); setStep({ type: 'create-name', from: 'choose-profile' }); } else { onComplete(value); } }} /> ); } case 'create-name': return ( { if (!name) { setStep({ type: 'create-name-error' }); return; } pushHistory('Every legend needs a name. Enter one for your warrior:', name); setStep({ type: 'create-language', warriorName: name }); }} onCancel={() => { popHistory(); if (step.from === 'choose-profile') { setStep({ type: 'choose-profile' }); } else { onCancel(); } }} /> ); case 'create-name-error': return ( setStep({ type: 'create-name' })} /> ); case 'create-language': { const items: { label: string; value: 'typescript' | 'javascript' }[] = [ { label: 'TypeScript (recommended)', value: 'typescript' }, { label: 'JavaScript', value: 'javascript' }, ]; return ( { const label = value === 'typescript' ? 'TypeScript' : 'JavaScript'; pushHistory('Choose your language:', label); setStep({ type: 'create-tower', warriorName: step.warriorName, language: value, }); }} onCancel={() => { popHistory(); setStep({ type: 'create-name', initialValue: step.warriorName }); }} /> ); } case 'create-tower': { const items = context.towers.map((t) => ({ label: t.toString(), value: t })); return ( { const profile = context.onCreateProfile(step.warriorName, step.language, tower); if (context.onIsExistingProfile(profile)) { pushHistory('Choose a tower:', tower.toString()); setStep({ type: 'create-confirm-replace', warriorName: step.warriorName, language: step.language, tower, }); } else { profile.makeProfileDirectory(); onComplete(profile); } }} onCancel={() => { popHistory(); const languageIndex = step.language === 'typescript' ? 0 : 1; setStep({ type: 'create-language', warriorName: step.warriorName, initialIndex: languageIndex, }); }} /> ); } case 'create-confirm-replace': return ( {`A warrior named ${step.warriorName} is already climbing ${step.tower}.`} { if (!yes) { onError('Unable to continue without a profile.'); return; } const profile = context.onCreateProfile( step.warriorName, step.language, step.tower, ); onComplete(profile); }} /> ); } }; return ( {history} {renderStep()} ); } ================================================ FILE: apps/cli/src/ui/components/ResultScreen.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test } from 'vitest'; import ResultScreen from './ResultScreen.js'; describe('ResultScreen', () => { test('renders success message when passed', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('Success'); expect(output).toContain('Warrior Score: 10'); expect(output).toContain('Time Bonus: 5'); expect(output).toContain('Clear Bonus: 3'); }); test('renders failure message when not passed', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('failed'); }); test('renders congratulations when tower complete', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('CONGRATULATIONS'); }); }); ================================================ FILE: apps/cli/src/ui/components/ResultScreen.tsx ================================================ import { getGradeLetter } from '@warriorjs/scoring'; import { Box, Text } from 'ink'; import type React from 'react'; interface ResultScreenProps { passed: boolean; levelNumber: number; hasNextLevel: boolean; scoreParts: { warrior: number; timeBonus: number; clearBonus: number }; totalScore: number; grade: number; isEpic: boolean; previousScore: number; } export default function ResultScreen({ passed, levelNumber, hasNextLevel, scoreParts, totalScore, grade, isEpic, previousScore, }: ResultScreenProps): React.ReactElement { if (!passed) { return ( {`You failed level ${levelNumber}. Update your code and try again.`} ); } const successMessage = hasNextLevel ? 'Success! You have found the stairs.' : 'CONGRATULATIONS! You have climbed to the top of the tower.'; return ( {successMessage} {'Warrior Score: '} {scoreParts.warrior} {'Time Bonus: '} {scoreParts.timeBonus} {'Clear Bonus: '} {scoreParts.clearBonus} {isEpic && ( {'Level Grade: '} {getGradeLetter(grade)} )} {'Total Score: '} {`${previousScore} + ${totalScore} = ${previousScore + totalScore}`} ); } ================================================ FILE: apps/cli/src/ui/components/Scrubber.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test } from 'vitest'; import Scrubber from './Scrubber.js'; describe('Scrubber', () => { test('renders turn position in playback mode', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('Turn 4/14'); expect(output).toContain('[space] pause'); }); test('shows play hint when paused', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('[space] play'); }); test('highlights active speed', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('2x'); }); test('shows review mode controls', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('[←/→] step'); expect(output).toContain('[esc] go back'); }); }); ================================================ FILE: apps/cli/src/ui/components/Scrubber.tsx ================================================ import { Box, Text } from 'ink'; import type React from 'react'; interface ScrubberProps { currentTurn: number; totalTurns: number; speed: number; mode: 'playback' | 'review'; isPlaying: boolean; } export default function Scrubber({ currentTurn, totalTurns, speed, mode, isPlaying, }: ScrubberProps): React.ReactElement { const turnDisplay = `Turn ${currentTurn}/${totalTurns - 1}`; if (mode === 'playback') { return ( {turnDisplay} {'[tab]'} {[1, 2, 4].map((s) => ( {`${s}x`} ))} {isPlaying ? '[space] pause' : '[space] play'} {'[s] skip'} ); } return ( {turnDisplay} {'[←/→] step'} {'[esc] go back'} ); } ================================================ FILE: apps/cli/src/ui/components/SelectPrompt.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test, vi } from 'vitest'; import { waitForRender } from '../testing.js'; import SelectPrompt from './SelectPrompt.js'; const items = [ { label: 'Option A', value: 'a' }, { label: 'Option B', value: 'b' }, { label: 'Option C', value: 'c' }, ]; describe('SelectPrompt', () => { test('renders message and items', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('Pick one:'); expect(output).toContain('Option A'); expect(output).toContain('Option B'); expect(output).toContain('Option C'); }); test('first item is selected by default', () => { const { lastFrame } = render(); const output = lastFrame()!; expect(output).toContain('❯'); }); test('calls onSelect with value on enter', () => { const onSelect = vi.fn(); const { stdin } = render(); stdin.write('\r'); expect(onSelect).toHaveBeenCalledWith('a'); }); test('navigates down and selects second item', async () => { const onSelect = vi.fn(); const { stdin } = render(); stdin.write('\x1B[B'); // down arrow await waitForRender(); stdin.write('\r'); expect(onSelect).toHaveBeenCalledWith('b'); }); test('wraps around when navigating past last item', async () => { const onSelect = vi.fn(); const { stdin } = render(); stdin.write('\x1B[A'); // up arrow wraps to last await waitForRender(); stdin.write('\r'); expect(onSelect).toHaveBeenCalledWith('c'); }); test('shows submitted state after selection', async () => { const { stdin, lastFrame } = render( , ); stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Pick:'); expect(output).toContain('Option A'); expect(output).not.toContain('❯'); }); test('renders without message', async () => { const { stdin, lastFrame } = render( , ); stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Option A'); }); test('calls onCancel on escape', async () => { const onCancel = vi.fn(); const { stdin } = render( , ); stdin.write('\x1B'); await waitForRender(); expect(onCancel).toHaveBeenCalled(); }); test('supports separator items', () => { const itemsWithSep = [ { label: 'Option A', value: 'a' }, { separator: true as const }, { label: 'Option B', value: 'b' }, ]; const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('───'); }); test('respects initialIndex', () => { const onSelect = vi.fn(); const { stdin } = render( , ); stdin.write('\r'); expect(onSelect).toHaveBeenCalledWith('c'); }); test('ignores input after submission', async () => { const onSelect = vi.fn(); const { stdin } = render(); stdin.write('\r'); await waitForRender(); stdin.write('\x1B[B'); await waitForRender(); stdin.write('\r'); expect(onSelect).toHaveBeenCalledTimes(1); }); }); ================================================ FILE: apps/cli/src/ui/components/SelectPrompt.tsx ================================================ import { Box, Text, useInput } from 'ink'; import type React from 'react'; import { useState } from 'react'; interface SelectChoice { label: string; value: T; separator?: false; } interface SelectSeparator { separator: true; } type SelectItem = SelectChoice | SelectSeparator; interface SelectPromptProps { message: string; items: SelectItem[]; initialIndex?: number; onSelect: (value: T) => void; onCancel?: () => void; } export default function SelectPrompt({ message, items, initialIndex = 0, onSelect, onCancel, }: SelectPromptProps): React.ReactElement { const choices = items.filter((item): item is SelectChoice => !item.separator); const [selectedIndex, setSelectedIndex] = useState(initialIndex); const [submitted, setSubmitted] = useState(false); useInput((_input, key) => { if (submitted) return; if (key.escape && onCancel) { onCancel(); return; } if (key.return) { setSubmitted(true); onSelect(choices[selectedIndex]!.value); return; } if (key.upArrow) { setSelectedIndex((prev) => (prev > 0 ? prev - 1 : choices.length - 1)); } if (key.downArrow) { setSelectedIndex((prev) => (prev < choices.length - 1 ? prev + 1 : 0)); } }); if (submitted) { const answer = choices[selectedIndex]!.label; return message ? ( {message} {answer} ) : ( {answer} ); } let choiceIndex = 0; return ( {message ? {message} : null} {items.map((item, index) => { if (item.separator) { return ( // biome-ignore lint/suspicious/noArrayIndexKey: static item list {'───'} ); } const currentChoiceIndex = choiceIndex++; const isSelected = currentChoiceIndex === selectedIndex; return ( // biome-ignore lint/suspicious/noArrayIndexKey: static item list {isSelected ? '❯' : ' '} {item.label} ); })} ); } ================================================ FILE: apps/cli/src/ui/components/TextPrompt.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test, vi } from 'vitest'; import { waitForRender } from '../testing.js'; import TextPrompt from './TextPrompt.js'; describe('TextPrompt', () => { test('renders message', () => { const { lastFrame } = render(); expect(lastFrame()!).toContain('Enter name:'); }); test('shows default value as hint', () => { const { lastFrame } = render( , ); expect(lastFrame()!).toContain('(Olric)'); }); test('submits typed value on enter', async () => { const onSubmit = vi.fn(); const { stdin } = render(); stdin.write('Corwin'); await waitForRender(); stdin.write('\r'); expect(onSubmit).toHaveBeenCalledWith('Corwin'); }); test('submits default value when empty', () => { const onSubmit = vi.fn(); const { stdin } = render( , ); stdin.write('\r'); expect(onSubmit).toHaveBeenCalledWith('Olric'); }); test('backspace removes last character', async () => { const onSubmit = vi.fn(); const { stdin } = render(); stdin.write('abc'); await waitForRender(); stdin.write('\x7F'); // backspace await waitForRender(); stdin.write('\r'); expect(onSubmit).toHaveBeenCalledWith('ab'); }); test('shows submitted state', async () => { const { stdin, lastFrame } = render(); stdin.write('Corwin'); await waitForRender(); stdin.write('\r'); await waitForRender(); const output = lastFrame()!; expect(output).toContain('Name:'); expect(output).toContain('Corwin'); }); test('calls onCancel on escape', async () => { const onCancel = vi.fn(); const { stdin } = render(); stdin.write('\x1B'); await waitForRender(); expect(onCancel).toHaveBeenCalled(); }); test('ignores input after submission', async () => { const onSubmit = vi.fn(); const { stdin } = render(); stdin.write('a'); await waitForRender(); stdin.write('\r'); await waitForRender(); stdin.write('b'); await waitForRender(); stdin.write('\r'); expect(onSubmit).toHaveBeenCalledTimes(1); }); test('uses initialValue as starting text', () => { const onSubmit = vi.fn(); const { stdin } = render(); stdin.write('\r'); expect(onSubmit).toHaveBeenCalledWith('pre'); }); }); ================================================ FILE: apps/cli/src/ui/components/TextPrompt.tsx ================================================ import { Box, Text, useInput } from 'ink'; import type React from 'react'; import { useState } from 'react'; interface TextPromptProps { message: string; defaultValue?: string; initialValue?: string; onSubmit: (value: string) => void; onCancel?: () => void; } export default function TextPrompt({ message, defaultValue = '', initialValue = '', onSubmit, onCancel, }: TextPromptProps): React.ReactElement { const [value, setValue] = useState(initialValue); const [submitted, setSubmitted] = useState(false); useInput((input, key) => { if (submitted) return; if (key.return) { const result = value || defaultValue; setSubmitted(true); onSubmit(result); return; } if (key.backspace || key.delete) { setValue((prev) => prev.slice(0, -1)); return; } if (key.escape && onCancel) { onCancel(); return; } // Ignore control characters. if (key.ctrl || key.meta || key.escape) return; if (key.upArrow || key.downArrow || key.leftArrow || key.rightArrow) return; if (key.tab) return; setValue((prev) => prev + input); }); if (submitted) { const display = value || defaultValue; return ( {message} {display} ); } return ( {message} {value ? ( {value} ) : defaultValue ? ( {`(${defaultValue})`} ) : null} ); } ================================================ FILE: apps/cli/src/ui/components/TowerCompleteScreen.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test } from 'vitest'; import TowerCompleteScreen from './TowerCompleteScreen.js'; describe('TowerCompleteScreen', () => { test('renders average grade and per-level grades', () => { const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('average grade'); expect(output).toContain('Level 1'); expect(output).toContain('Level 2'); }); }); ================================================ FILE: apps/cli/src/ui/components/TowerCompleteScreen.tsx ================================================ import { getGradeLetter } from '@warriorjs/scoring'; import { Box, Text } from 'ink'; import type React from 'react'; interface TowerCompleteScreenProps { averageGrade: number; levelGrades: Record; } export default function TowerCompleteScreen({ averageGrade, levelGrades, }: TowerCompleteScreenProps): React.ReactElement { return ( {'Your average grade for this tower is: '} {getGradeLetter(averageGrade)} {Object.keys(levelGrades) .sort() .map((levelNumber) => ( {' Level '} {levelNumber} {': '} {getGradeLetter(levelGrades[levelNumber]!)} ))} {'\nTo practice a level, use the -l option.'} ); } ================================================ FILE: apps/cli/src/ui/components/WarriorArt.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test } from 'vitest'; import WarriorArt from './WarriorArt.js'; describe('WarriorArt', () => { test('renders warrior art', () => { const { lastFrame } = render(); const output = lastFrame()!; expect(output.split('\n').length).toBeGreaterThan(1); }); }); ================================================ FILE: apps/cli/src/ui/components/WarriorArt.tsx ================================================ import { Text } from 'ink'; import type React from 'react'; import { brandColor } from '../theme.js'; const art = [ // A warrior holding a sword. ' ▌', ' ▐▛██▜▌▌', '▝▜████▛▛', ' ▘▘▝▝', ].join('\n'); export default function WarriorArt(): React.ReactElement { return {art}; } ================================================ FILE: apps/cli/src/ui/components/WarriorStatus.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test } from 'vitest'; import WarriorStatus from './WarriorStatus.js'; describe('WarriorStatus', () => { test('renders health and score', () => { const { lastFrame } = render(); const output = lastFrame()!; expect(output).toContain('❤'); expect(output).toContain('12/20'); expect(output).toContain('◆'); expect(output).toContain('25'); }); }); ================================================ FILE: apps/cli/src/ui/components/WarriorStatus.tsx ================================================ import { Box, Text } from 'ink'; import type React from 'react'; interface WarriorStatusProps { health: number; maxHealth: number; score: number; } export default function WarriorStatus({ health, maxHealth, score, }: WarriorStatusProps): React.ReactElement { return ( {`❤ ${health}/${maxHealth}`} {`◆ ${score}`} ); } ================================================ FILE: apps/cli/src/ui/components/WelcomeScreen.test.tsx ================================================ import { render } from 'ink-testing-library'; import { describe, expect, test } from 'vitest'; // @ts-expect-error -- JSON import import { version } from '../../../package.json'; import WelcomeScreen from './WelcomeScreen.js'; describe('WelcomeScreen', () => { test('renders warrior art, title, version, and directory', () => { const versionString = `v${version}`; const { lastFrame } = render( , ); const output = lastFrame()!; expect(output).toContain('WarriorJS'); expect(output).toContain(versionString); expect(output).toContain('~/Projects/warriorjs'); }); }); ================================================ FILE: apps/cli/src/ui/components/WelcomeScreen.tsx ================================================ import { Box, Text } from 'ink'; import type React from 'react'; import formatDirectory from '../../utils/formatDirectory.js'; import WarriorArt from './WarriorArt.js'; interface WelcomeScreenProps { version: string; directory: string; } export default function WelcomeScreen({ version, directory, }: WelcomeScreenProps): React.ReactElement { return ( WarriorJS {version} Write code. Fight enemies. Climb the tower. {formatDirectory(directory)} ); } ================================================ FILE: apps/cli/src/ui/hooks/usePlaySession.test.ts ================================================ import { render } from 'ink-testing-library'; import React, { act } from 'react'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import { type GameContext } from '../../Game.js'; import type Profile from '../../Profile.js'; import { type LevelCompleteChoice, type LevelConfig, type LevelReport, type LevelResult, type PlaySessionState, } from '../types.js'; vi.mock('@warriorjs/core', () => ({ getLevelConfig: vi.fn(), runLevel: vi.fn(), })); vi.mock('../utils/buildLevelReport.js', () => ({ buildLevelReport: vi.fn(), })); // Re-import after mocks are set up const { getLevelConfig, runLevel } = await import('@warriorjs/core'); const { buildLevelReport } = await import('../utils/buildLevelReport.js'); const { usePlaySession } = await import('./usePlaySession.js'); const mockedGetLevelConfig = vi.mocked(getLevelConfig); const mockedRunLevel = vi.mocked(runLevel); const mockedBuildLevelReport = vi.mocked(buildLevelReport); // --- Helpers --- function createMockProfile(overrides: Partial> = {}): Profile { return { tower: { name: 'The Narrow Path', hasLevel: vi.fn().mockReturnValue(true), levels: [1, 2, 3], }, warriorName: 'Olric', epic: false, levelNumber: 1, language: 'javascript', score: 100, currentEpicScore: 0, currentEpicGrades: [], isEpic: vi.fn().mockReturnValue(false), readPlayerCode: vi.fn().mockReturnValue('warrior.walk();'), isShowingClue: vi.fn().mockReturnValue(false), tallyPoints: vi.fn(), updateEpicScore: vi.fn(), requestClue: vi.fn(), getReadmeFilePath: vi.fn().mockReturnValue('/path/to/README.md'), calculateAverageGrade: vi.fn().mockReturnValue(0.8), makeProfileDirectory: vi.fn(), ...overrides, } as unknown as Profile; } function createMockContext(overrides: Partial = {}): GameContext { return { version: 'v1.0.0', runDirectoryPath: '/tmp/warriorjs', practiceLevel: undefined, silencePlay: false, towers: [], profile: null, profiles: [], needsProfileSetup: false, onCreateProfile: vi.fn(), onIsExistingProfile: vi.fn(), onPrepareNextLevel: vi.fn(), onPrepareEpicMode: vi.fn(), onGenerateProfileFiles: vi.fn(), onProfileSelected: vi.fn(), ...overrides, }; } const defaultLevelConfig: LevelConfig = { clue: 'Try walking', timeBonus: 10, aceScore: 30, floor: { warrior: { maxHealth: 20 } }, }; const defaultLevelResult: LevelResult = { passed: true, turns: [ [ { message: 'walks forward', unit: { name: 'Warrior', color: '#fff' }, floorMap: [[{ character: '@', unit: { color: '#fff' } }]], warriorStatus: { health: 20, score: 10 }, }, ], ], initialState: { message: '', unit: null, floorMap: [[{ character: '@', unit: { color: '#fff' } }]], warriorStatus: { health: 20, score: 0 }, }, }; const defaultLevelReport: LevelReport = { passed: true, levelNumber: 1, hasNextLevel: true, scoreParts: { warrior: 10, timeBonus: 5, clearBonus: 0 }, totalScore: 15, grade: 0.5, isEpic: false, previousScore: 100, hasClue: true, isShowingClue: false, }; interface HookRef { state: PlaySessionState; handlePlayComplete: () => void; handleLevelCompleteChoice: (v: LevelCompleteChoice) => void; } function renderHook(params: { context: GameContext; profile: Profile; initialLevel: number; exit: () => void; }) { const ref = React.createRef(); function TestComponent() { const hook = usePlaySession(params); (ref as React.MutableRefObject).current = hook; return null; } const instance = render(React.createElement(TestComponent)); return { ref: ref as React.RefObject, ...instance }; } // --- Tests --- describe('usePlaySession', () => { let exit: ReturnType void>>; let context: GameContext; let mockProfile: Profile; beforeEach(() => { vi.clearAllMocks(); exit = vi.fn(); context = createMockContext(); mockProfile = createMockProfile(); mockedGetLevelConfig.mockReturnValue(defaultLevelConfig as ReturnType); mockedRunLevel.mockReturnValue(defaultLevelResult as ReturnType); mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport }); }); describe('playLevel (via mount)', () => { test('sets state to playing on mount', () => { const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); expect(ref.current!.state).toEqual(expect.objectContaining({ type: 'playing' })); expect(mockedGetLevelConfig).toHaveBeenCalledWith(mockProfile.tower, 1, 'Olric', false); expect(mockedRunLevel).toHaveBeenCalled(); }); test('sets error state when playerCode is null', () => { const noCodeProfile = createMockProfile({ readPlayerCode: vi.fn().mockReturnValue(null), }); const { ref } = renderHook({ context, profile: noCodeProfile, initialLevel: 1, exit, }); expect(ref.current!.state).toEqual({ type: 'error', message: 'No player code found. Check your profile directory.', }); }); test('sets error state when getLevelConfig throws', () => { mockedGetLevelConfig.mockImplementation(() => { throw new Error('Config not found'); }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); expect(ref.current!.state).toEqual({ type: 'error', message: 'Config not found', }); }); test('sets error state when runLevel throws', () => { mockedRunLevel.mockImplementation(() => { throw new Error('Syntax error in player code'); }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); expect(ref.current!.state).toEqual({ type: 'error', message: 'Syntax error in player code', }); }); test('handles non-Error thrown values', () => { mockedRunLevel.mockImplementation(() => { // biome-ignore lint/style/useThrowOnlyError: testing non-Error throw handling throw 'string error'; }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); expect(ref.current!.state).toEqual({ type: 'error', message: 'string error', }); }); test('defaults language to javascript when profile language is falsy', () => { const noLangProfile = createMockProfile({ language: '' }); renderHook({ context, profile: noLangProfile, initialLevel: 1, exit, }); expect(mockedRunLevel).toHaveBeenCalledWith( defaultLevelConfig, 'warrior.walk();', 'javascript', ); }); test('skips playing state when silencePlay is true', () => { context = createMockContext({ silencePlay: true }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); // Should go directly to levelComplete (evaluateLevel), not playing. expect(mockedBuildLevelReport).toHaveBeenCalled(); expect(ref.current!.state).toEqual(expect.objectContaining({ type: 'levelComplete' })); }); test('uses currentEpicScore for epic profiles in levelRun', () => { const epicProfile = createMockProfile({ isEpic: vi.fn().mockReturnValue(true), currentEpicScore: 42, }); const { ref } = renderHook({ context, profile: epicProfile, initialLevel: 1, exit, }); expect(ref.current!.state).toEqual(expect.objectContaining({ type: 'playing' })); const playingState = ref.current!.state as { type: 'playing'; levelRun: { totalScore: number }; }; expect(playingState.levelRun.totalScore).toBe(42); }); test('uses profile.score for non-epic profiles in levelRun', () => { const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); const playingState = ref.current!.state as { type: 'playing'; levelRun: { totalScore: number }; }; expect(playingState.levelRun.totalScore).toBe(100); }); }); describe('evaluateLevel (via handlePlayComplete)', () => { test('tallies points when result is passed', () => { mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport, passed: true, totalScore: 15, grade: 0.5, }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); act(() => { ref.current!.handlePlayComplete(); }); expect(mockProfile.tallyPoints).toHaveBeenCalledWith(1, 15, 0.5); }); test('does not tally points when result failed', () => { mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport, passed: false, }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); act(() => { ref.current!.handlePlayComplete(); }); expect(mockProfile.tallyPoints).not.toHaveBeenCalled(); }); test('epic auto-advance: plays next level instead of showing result', () => { mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport, passed: true, isEpic: true, hasNextLevel: true, }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); mockedGetLevelConfig.mockClear(); act(() => { ref.current!.handlePlayComplete(); }); // Should have called getLevelConfig for level 2. expect(mockedGetLevelConfig).toHaveBeenCalledWith(mockProfile.tower, 2, 'Olric', false); }); test('epic auto-advance does not happen when practiceLevel is set', () => { context = createMockContext({ practiceLevel: 1 }); mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport, passed: true, isEpic: true, hasNextLevel: true, }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); act(() => { ref.current!.handlePlayComplete(); }); // Should show levelComplete instead of auto-advancing. expect(ref.current!.state).toEqual( expect.objectContaining({ type: 'levelComplete', action: { type: 'prompt' }, }), ); }); test('epic tower complete: updates epic score and shows towerComplete', () => { mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport, passed: true, isEpic: true, hasNextLevel: false, }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 3, exit, }); act(() => { ref.current!.handlePlayComplete(); }); expect(mockProfile.updateEpicScore).toHaveBeenCalled(); expect(ref.current!.state).toEqual({ type: 'towerComplete' }); }); test('epic tower complete does not happen when practiceLevel is set', () => { context = createMockContext({ practiceLevel: 3 }); mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport, passed: true, isEpic: true, hasNextLevel: false, }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 3, exit, }); act(() => { ref.current!.handlePlayComplete(); }); // Should show levelComplete instead of towerComplete. expect(ref.current!.state).toEqual( expect.objectContaining({ type: 'levelComplete', action: { type: 'prompt' }, }), ); }); test('shows levelComplete state for normal passed result', () => { mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport, passed: true, isEpic: false, }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); act(() => { ref.current!.handlePlayComplete(); }); expect(ref.current!.state).toEqual( expect.objectContaining({ type: 'levelComplete', action: { type: 'prompt' }, }), ); }); test('shows levelComplete state for failed result', () => { mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport, passed: false, }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); act(() => { ref.current!.handlePlayComplete(); }); expect(ref.current!.state).toEqual( expect.objectContaining({ type: 'levelComplete', action: { type: 'prompt' }, }), ); }); test('passes isShowingClue false when level passed', () => { context = createMockContext({ silencePlay: true }); mockedRunLevel.mockReturnValue({ ...defaultLevelResult, passed: true, } as ReturnType); renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); expect(mockedBuildLevelReport).toHaveBeenCalledWith( expect.objectContaining({ isShowingClue: false }), ); }); test('passes profile isShowingClue when level failed', () => { context = createMockContext({ silencePlay: true }); mockedRunLevel.mockReturnValue({ ...defaultLevelResult, passed: false, } as ReturnType); (mockProfile.isShowingClue as ReturnType).mockReturnValue(true); renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); expect(mockedBuildLevelReport).toHaveBeenCalledWith( expect.objectContaining({ isShowingClue: true }), ); }); test('uses currentEpicScore for epic profiles in buildLevelReport', () => { const epicProfile = createMockProfile({ isEpic: vi.fn().mockReturnValue(true), currentEpicScore: 200, }); context = createMockContext({ silencePlay: true }); renderHook({ context, profile: epicProfile, initialLevel: 1, exit, }); expect(mockedBuildLevelReport).toHaveBeenCalledWith( expect.objectContaining({ isEpic: true, currentScore: 200, }), ); }); }); describe('handlePlayComplete', () => { test('processes pending result when present', () => { const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); // State is 'playing' after mount, pending result is stored. act(() => { ref.current!.handlePlayComplete(); }); expect(mockedBuildLevelReport).toHaveBeenCalled(); }); test('returns to levelComplete state after review', () => { mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport, passed: true, isEpic: false, }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); // Complete playback to get to levelComplete. act(() => { ref.current!.handlePlayComplete(); }); // Select review to set reviewReturnRef. act(() => { ref.current!.handleLevelCompleteChoice('review'); }); // handlePlayComplete should now return to levelComplete. act(() => { ref.current!.handlePlayComplete(); }); expect(ref.current!.state).toEqual( expect.objectContaining({ type: 'levelComplete', action: { type: 'prompt' }, }), ); }); test('calls exit when no pending result or review return', () => { // Use silencePlay so playLevel goes directly to evaluateLevel (no pending). context = createMockContext({ silencePlay: true }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); // State is levelComplete after mount (silencePlay skipped playing), // so handlePlayComplete with no pending and no review should exit. act(() => { ref.current!.handlePlayComplete(); }); expect(exit).toHaveBeenCalled(); }); }); describe('handleLevelCompleteChoice', () => { test('returns early when currentReportRef is null', () => { // Use silencePlay to make evaluateLevel run immediately on mount. // When the result is towerComplete, currentReportRef won't be set. mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport, passed: true, isEpic: true, hasNextLevel: false, }); context = createMockContext({ silencePlay: true }); const { ref } = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); // State is towerComplete; currentReportRef was not set. const stateBeforeSelect = ref.current!.state; act(() => { ref.current!.handleLevelCompleteChoice('review'); }); // State should be unchanged (no-op). expect(ref.current!.state).toEqual(stateBeforeSelect); }); // Helper to get the hook into a state where currentReportRef is set. function setupWithResultScreen() { mockedBuildLevelReport.mockReturnValue({ ...defaultLevelReport, passed: true, isEpic: false, }); const hookResult = renderHook({ context, profile: mockProfile, initialLevel: 1, exit, }); // Complete playback to trigger evaluateLevel, which sets currentReportRef. act(() => { hookResult.ref.current!.handlePlayComplete(); }); return hookResult; } test('review: sets playing state with reviewMode', () => { const { ref } = setupWithResultScreen(); act(() => { ref.current!.handleLevelCompleteChoice('review'); }); expect(ref.current!.state).toEqual( expect.objectContaining({ type: 'playing', reviewMode: true, }), ); }); test('next-level: calls onPrepareNextLevel and shows next-level action', () => { const { ref } = setupWithResultScreen(); act(() => { ref.current!.handleLevelCompleteChoice('next-level'); }); expect(context.onPrepareNextLevel).toHaveBeenCalled(); expect(ref.current!.state).toEqual( expect.objectContaining({ type: 'levelComplete', action: { type: 'next-level', readmePath: '/path/to/README.md', }, }), ); }); test('clue: calls requestClue, onGenerateProfileFiles, and shows clue action', () => { const { ref } = setupWithResultScreen(); act(() => { ref.current!.handleLevelCompleteChoice('clue'); }); expect(mockProfile.requestClue).toHaveBeenCalled(); expect(context.onGenerateProfileFiles).toHaveBeenCalled(); expect(ref.current!.state).toEqual( expect.objectContaining({ type: 'levelComplete', action: { type: 'clue', readmePath: '/path/to/README.md', }, }), ); }); test('stay: shows levelComplete state with stay action', () => { const { ref } = setupWithResultScreen(); act(() => { ref.current!.handleLevelCompleteChoice('stay'); }); expect(ref.current!.state).toEqual( expect.objectContaining({ type: 'levelComplete', action: { type: 'stay' }, }), ); }); test('epic-mode: calls onPrepareEpicMode and shows epic-mode action', () => { const { ref } = setupWithResultScreen(); act(() => { ref.current!.handleLevelCompleteChoice('epic-mode'); }); expect(context.onPrepareEpicMode).toHaveBeenCalled(); expect(ref.current!.state).toEqual( expect.objectContaining({ type: 'levelComplete', action: { type: 'epic-mode' }, }), ); }); test('try-again: replays the same level', () => { const { ref } = setupWithResultScreen(); mockedGetLevelConfig.mockClear(); mockedRunLevel.mockClear(); act(() => { ref.current!.handleLevelCompleteChoice('try-again'); }); // Should call playLevel with the same level number from levelReport. expect(mockedGetLevelConfig).toHaveBeenCalledWith( mockProfile.tower, defaultLevelReport.levelNumber, 'Olric', false, ); expect(mockedRunLevel).toHaveBeenCalled(); }); }); }); ================================================ FILE: apps/cli/src/ui/hooks/usePlaySession.ts ================================================ import { getLevelConfig, runLevel } from '@warriorjs/core'; import { useCallback, useRef, useState } from 'react'; import { type GameContext } from '../../Game.js'; import type Profile from '../../Profile.js'; import { type LevelCompleteChoice, type LevelConfig, type LevelEvaluation, type LevelReport, type LevelResult, type LevelRun, type PlaySessionState, } from '../types.js'; import { buildLevelReport } from '../utils/buildLevelReport.js'; interface UsePlaySessionParams { context: GameContext; profile: Profile; initialLevel: number; exit: () => void; } interface UsePlaySessionReturn { state: PlaySessionState; handlePlayComplete: () => void; handleLevelCompleteChoice: (value: LevelCompleteChoice) => void; } interface LevelOutcome { state: PlaySessionState; evaluation: LevelEvaluation | null; currentReport: { levelReport: LevelReport; levelRun: LevelRun } | null; } function executeLevel(profile: Profile, levelNumber: number, context: GameContext): LevelOutcome { try { const { tower, warriorName, epic } = profile; const levelConfig = getLevelConfig(tower, levelNumber, warriorName, epic)!; const playerCode = profile.readPlayerCode(); if (!playerCode) { return { state: { type: 'error', message: 'No player code found. Check your profile directory.' }, evaluation: null, currentReport: null, }; } const language = profile.language || 'javascript'; const rawResult = runLevel(levelConfig, playerCode, language); if (!rawResult.initialState) { return { state: { type: 'error', message: 'Level produced no initial state.' }, evaluation: null, currentReport: null, }; } const levelResult = rawResult as LevelResult; const levelRun: LevelRun = { turns: levelResult.turns, initialState: levelResult.initialState, warriorName, towerName: tower.name, levelNumber, totalScore: profile.isEpic() ? profile.currentEpicScore : profile.score, maxHealth: levelConfig.floor.warrior.maxHealth, }; if (context.silencePlay) { return evaluateLevel(profile, levelNumber, levelResult, levelConfig, levelRun, context); } return { state: { type: 'playing', levelRun }, evaluation: { profile, levelNumber, levelResult, levelConfig, levelRun }, currentReport: null, }; } catch (err: unknown) { return { state: { type: 'error', message: err instanceof Error ? err.message : String(err) }, evaluation: null, currentReport: null, }; } } function evaluateLevel( profile: Profile, levelNumber: number, levelResult: LevelResult, levelConfig: LevelConfig, levelRun: LevelRun, context: GameContext, ): LevelOutcome { const levelReport = buildLevelReport({ levelResult, levelConfig, levelNumber, hasNextLevel: profile.tower.hasLevel(levelNumber + 1), isEpic: profile.isEpic(), currentScore: profile.isEpic() ? profile.currentEpicScore : profile.score, isShowingClue: levelResult.passed ? false : profile.isShowingClue(), }); if (levelReport.passed) { profile.tallyPoints(levelNumber, levelReport.totalScore, levelReport.grade); } // Epic auto-advance: skip result screen and play next level. if ( levelReport.passed && levelReport.isEpic && levelReport.hasNextLevel && !context.practiceLevel ) { return executeLevel(profile, levelNumber + 1, context); } // Epic tower complete: update score and show tower-complete screen. if ( levelReport.passed && levelReport.isEpic && !levelReport.hasNextLevel && !context.practiceLevel ) { profile.updateEpicScore(); return { state: { type: 'towerComplete' }, evaluation: null, currentReport: null, }; } return { state: { type: 'levelComplete', levelRun, levelReport, action: { type: 'prompt' } }, evaluation: null, currentReport: { levelReport, levelRun }, }; } export function usePlaySession({ context, profile, initialLevel, exit, }: UsePlaySessionParams): UsePlaySessionReturn { const evaluationRef = useRef(null); const reviewReturnRef = useRef<{ levelReport: LevelReport; levelRun: LevelRun } | null>(null); const currentReportRef = useRef<{ levelReport: LevelReport; levelRun: LevelRun } | null>(null); const [state, setState] = useState(() => { const outcome = executeLevel(profile, initialLevel, context); evaluationRef.current = outcome.evaluation; if (outcome.currentReport) { currentReportRef.current = outcome.currentReport; } return outcome.state; }); const playLevel = useCallback( (levelNumber: number) => { const outcome = executeLevel(profile, levelNumber, context); evaluationRef.current = outcome.evaluation; if (outcome.currentReport) { currentReportRef.current = outcome.currentReport; } setState(outcome.state); }, [profile, context], ); const handlePlayComplete = useCallback(() => { const pending = evaluationRef.current; if (pending) { const outcome = evaluateLevel( profile, pending.levelNumber, pending.levelResult, pending.levelConfig, pending.levelRun, context, ); evaluationRef.current = outcome.evaluation; if (outcome.currentReport) { currentReportRef.current = outcome.currentReport; } setState(outcome.state); } else if (reviewReturnRef.current) { const { levelReport, levelRun } = reviewReturnRef.current; reviewReturnRef.current = null; setState({ type: 'levelComplete', levelReport, levelRun, action: { type: 'prompt' } }); } else { exit(); } }, [profile, context, exit]); const handleLevelCompleteChoice = useCallback( (value: LevelCompleteChoice) => { const current = currentReportRef.current; if (!current) { return; } const { levelReport, levelRun } = current; switch (value) { case 'review': reviewReturnRef.current = { levelReport, levelRun }; setState({ type: 'playing', levelRun, reviewMode: true }); evaluationRef.current = null; break; case 'stay': setState({ type: 'levelComplete', levelReport, levelRun, action: { type: 'stay' }, }); break; case 'next-level': context.onPrepareNextLevel(); setState({ type: 'levelComplete', levelReport, levelRun, action: { type: 'next-level', readmePath: profile.getReadmeFilePath(), }, }); break; case 'epic-mode': context.onPrepareEpicMode(); setState({ type: 'levelComplete', levelReport, levelRun, action: { type: 'epic-mode' }, }); break; case 'clue': profile.requestClue(); context.onGenerateProfileFiles(); setState({ type: 'levelComplete', levelReport, levelRun, action: { type: 'clue', readmePath: profile.getReadmeFilePath(), }, }); break; case 'try-again': playLevel(levelReport.levelNumber); break; } }, [profile, context, playLevel], ); return { state, handlePlayComplete, handleLevelCompleteChoice }; } ================================================ FILE: apps/cli/src/ui/hooks/usePlayback.test.ts ================================================ import { describe, expect, test } from 'vitest'; import { type PlaybackState, playbackReducer } from './usePlayback.js'; describe('playbackReducer', () => { const initial: PlaybackState = { currentTurn: 0, totalTurns: 10, isPlaying: true, speed: 1, mode: 'playback', }; test('toggles pause/resume during playback', () => { const paused = playbackReducer(initial, { type: 'TOGGLE_PLAY_PAUSE' }); expect(paused.isPlaying).toBe(false); const resumed = playbackReducer(paused, { type: 'TOGGLE_PLAY_PAUSE' }); expect(resumed.isPlaying).toBe(true); }); test('cycles speed forward: 1 -> 2 -> 4 -> 1', () => { const s2 = playbackReducer(initial, { type: 'CYCLE_SPEED' }); expect(s2.speed).toBe(2); const s4 = playbackReducer(s2, { type: 'CYCLE_SPEED' }); expect(s4.speed).toBe(4); const s1 = playbackReducer(s4, { type: 'CYCLE_SPEED' }); expect(s1.speed).toBe(1); }); test('cycles speed backward: 1 -> 4 -> 2 -> 1', () => { const s4 = playbackReducer(initial, { type: 'CYCLE_SPEED_BACK' }); expect(s4.speed).toBe(4); const s2 = playbackReducer(s4, { type: 'CYCLE_SPEED_BACK' }); expect(s2.speed).toBe(2); const s1 = playbackReducer(s2, { type: 'CYCLE_SPEED_BACK' }); expect(s1.speed).toBe(1); }); test('skip goes to last turn and enters review mode', () => { const skipped = playbackReducer(initial, { type: 'SKIP' }); expect(skipped.currentTurn).toBe(9); expect(skipped.isPlaying).toBe(false); expect(skipped.mode).toBe('review'); }); test('advance_turn moves forward during playback', () => { const next = playbackReducer(initial, { type: 'ADVANCE_TURN' }); expect(next.currentTurn).toBe(1); }); test('advance_turn at last turn transitions to review mode', () => { const atEnd = { ...initial, currentTurn: 9 }; const result = playbackReducer(atEnd, { type: 'ADVANCE_TURN' }); expect(result.mode).toBe('review'); expect(result.isPlaying).toBe(false); }); test('step_forward in review mode', () => { const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false, currentTurn: 5 }; const next = playbackReducer(review, { type: 'STEP_FORWARD' }); expect(next.currentTurn).toBe(6); }); test('step_forward at last turn is no-op', () => { const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false, currentTurn: 9 }; const next = playbackReducer(review, { type: 'STEP_FORWARD' }); expect(next.currentTurn).toBe(9); }); test('step_back in review mode', () => { const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false, currentTurn: 5 }; const prev = playbackReducer(review, { type: 'STEP_BACK' }); expect(prev.currentTurn).toBe(4); }); test('step_back at turn 0 is no-op', () => { const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false, currentTurn: 0 }; const prev = playbackReducer(review, { type: 'STEP_BACK' }); expect(prev.currentTurn).toBe(0); }); test('restart goes back to playback mode from turn 0', () => { const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false, currentTurn: 9 }; const restarted = playbackReducer(review, { type: 'RESTART' }); expect(restarted.currentTurn).toBe(0); expect(restarted.isPlaying).toBe(true); expect(restarted.mode).toBe('playback'); expect(restarted.speed).toBe(1); }); test('toggle_play_pause is ignored in review mode', () => { const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false }; const result = playbackReducer(review, { type: 'TOGGLE_PLAY_PAUSE' }); expect(result.isPlaying).toBe(false); }); test('cycle_speed is ignored in review mode', () => { const review: PlaybackState = { ...initial, mode: 'review', isPlaying: false }; const result = playbackReducer(review, { type: 'CYCLE_SPEED' }); expect(result.speed).toBe(review.speed); }); test('startInReview initializes at last turn in review mode', () => { const startInReview: PlaybackState = { currentTurn: 9, totalTurns: 10, isPlaying: false, speed: 1, mode: 'review', }; // Verify stepping back works from the review start position. const stepped = playbackReducer(startInReview, { type: 'STEP_BACK' }); expect(stepped.currentTurn).toBe(8); // Verify restart works. const restarted = playbackReducer(startInReview, { type: 'RESTART' }); expect(restarted.currentTurn).toBe(0); expect(restarted.isPlaying).toBe(true); expect(restarted.mode).toBe('playback'); }); }); ================================================ FILE: apps/cli/src/ui/hooks/usePlayback.ts ================================================ import { useInput } from 'ink'; import { useEffect, useReducer, useRef } from 'react'; export interface PlaybackState { currentTurn: number; totalTurns: number; isPlaying: boolean; speed: number; mode: 'playback' | 'review'; } export type PlaybackAction = | { type: 'TOGGLE_PLAY_PAUSE' } | { type: 'CYCLE_SPEED' } | { type: 'CYCLE_SPEED_BACK' } | { type: 'SKIP' } | { type: 'ADVANCE_TURN' } | { type: 'STEP_FORWARD' } | { type: 'STEP_BACK' } | { type: 'RESTART' }; const SPEEDS = [1, 2, 4]; export function playbackReducer(state: PlaybackState, action: PlaybackAction): PlaybackState { switch (action.type) { case 'TOGGLE_PLAY_PAUSE': { if (state.mode !== 'playback') return state; return { ...state, isPlaying: !state.isPlaying }; } case 'CYCLE_SPEED': { if (state.mode !== 'playback') return state; const currentIndex = SPEEDS.indexOf(state.speed); const nextSpeed = SPEEDS[(currentIndex + 1) % SPEEDS.length]!; return { ...state, speed: nextSpeed }; } case 'CYCLE_SPEED_BACK': { if (state.mode !== 'playback') return state; const currentIndex = SPEEDS.indexOf(state.speed); const prevSpeed = SPEEDS[(currentIndex - 1 + SPEEDS.length) % SPEEDS.length]!; return { ...state, speed: prevSpeed }; } case 'SKIP': { return { ...state, currentTurn: state.totalTurns - 1, isPlaying: false, mode: 'review', }; } case 'ADVANCE_TURN': { if (state.currentTurn >= state.totalTurns - 1) { return { ...state, isPlaying: false, mode: 'review' }; } return { ...state, currentTurn: state.currentTurn + 1 }; } case 'STEP_FORWARD': { if (state.mode !== 'review') return state; if (state.currentTurn >= state.totalTurns - 1) return state; return { ...state, currentTurn: state.currentTurn + 1 }; } case 'STEP_BACK': { if (state.mode !== 'review') return state; if (state.currentTurn <= 0) return state; return { ...state, currentTurn: state.currentTurn - 1 }; } case 'RESTART': { return { ...state, currentTurn: 0, isPlaying: true, mode: 'playback', speed: 1, }; } default: return state; } } export function usePlayback( totalTurns: number, onPlaybackComplete: () => void, startInReview = false, ): { state: PlaybackState; dispatch: React.Dispatch } { const [state, dispatch] = useReducer(playbackReducer, { currentTurn: startInReview ? totalTurns - 1 : 0, totalTurns, isPlaying: !startInReview, speed: 1, mode: startInReview ? 'review' : 'playback', }); const hasFiredComplete = useRef(startInReview); useEffect(() => { if (!state.isPlaying) return; const interval = setInterval(() => dispatch({ type: 'ADVANCE_TURN' }), 600 / state.speed); return () => clearInterval(interval); }, [state.isPlaying, state.speed]); useEffect(() => { if (state.mode === 'review' && !hasFiredComplete.current) { hasFiredComplete.current = true; onPlaybackComplete(); } }, [state.mode, onPlaybackComplete]); useInput((input, key) => { if (state.mode === 'playback') { if (input === ' ') dispatch({ type: 'TOGGLE_PLAY_PAUSE' }); if (key.tab && key.shift) dispatch({ type: 'CYCLE_SPEED_BACK' }); else if (key.tab) dispatch({ type: 'CYCLE_SPEED' }); if (input === 's') dispatch({ type: 'SKIP' }); } else { if (key.escape) onPlaybackComplete(); if (key.leftArrow) dispatch({ type: 'STEP_BACK' }); if (key.rightArrow) dispatch({ type: 'STEP_FORWARD' }); } }); return { state, dispatch }; } ================================================ FILE: apps/cli/src/ui/testing.ts ================================================ import { type LevelReport, type LevelRun } from './types.js'; export const waitForRender = () => new Promise((resolve) => setTimeout(resolve, 50)); /** Returns the last non-empty frame (exit() can write an empty frame after unmount). */ export function getLastContentFrame(frames: string[]): string { for (let i = frames.length - 1; i >= 0; i--) { if (frames[i]!.trim()) return frames[i]!; } return ''; } export function makeLevelRun(overrides: Partial = {}): LevelRun { return { turns: [ [ { message: 'test', unit: null, floorMap: [[{ character: '@' }]], warriorStatus: { health: 20, score: 0 }, }, ], ], initialState: { message: '', unit: null, floorMap: [[{ character: '@' }]], warriorStatus: { health: 20, score: 0 }, }, warriorName: 'Olric', towerName: 'The Narrow Path', levelNumber: 1, totalScore: 10, maxHealth: 20, ...overrides, }; } export function makeLevelReport(overrides: Partial = {}): LevelReport { return { passed: true, levelNumber: 1, hasNextLevel: true, scoreParts: { warrior: 10, timeBonus: 5, clearBonus: 0 }, totalScore: 15, grade: 0.75, isEpic: false, previousScore: 0, hasClue: false, isShowingClue: false, ...overrides, }; } ================================================ FILE: apps/cli/src/ui/theme.ts ================================================ export const brandColor = '#C5832B'; ================================================ FILE: apps/cli/src/ui/types.ts ================================================ import type Profile from '../Profile.js'; // UI-specific narrowings of @warriorjs/core types. // These pick only the fields the UI layer needs, keeping a clean boundary // between core engine types and rendering concerns. export interface LevelConfig { clue?: string; timeBonus?: number; aceScore?: number; floor: { warrior: { maxHealth: number } }; } export interface TurnEvent { message: string; unit: { name: string; color: string } | null; floorMap: { character: string; unit?: { color: string } }[][]; warriorStatus: { health: number; score: number }; } export interface LevelResult { passed: boolean; turns: TurnEvent[][]; initialState: TurnEvent; } export interface LevelRun { turns: TurnEvent[][]; initialState: TurnEvent; warriorName: string; towerName: string; levelNumber: number; totalScore: number; maxHealth: number; } export interface LevelReport { passed: boolean; levelNumber: number; hasNextLevel: boolean; scoreParts: { warrior: number; timeBonus: number; clearBonus: number }; totalScore: number; grade: number; isEpic: boolean; previousScore: number; hasClue: boolean; isShowingClue: boolean; } export interface LevelEvaluation { profile: Profile; levelNumber: number; levelResult: LevelResult; levelConfig: LevelConfig; levelRun: LevelRun; } export type LevelCompleteChoice = | 'review' | 'stay' | 'next-level' | 'epic-mode' | 'clue' | 'try-again'; export type LevelCompleteAction = | { type: 'prompt' } | { type: 'stay' } | { type: 'next-level'; readmePath: string } | { type: 'epic-mode' } | { type: 'clue'; readmePath: string }; export type PlaySessionState = | { type: 'playing'; levelRun: LevelRun; reviewMode?: boolean } | { type: 'levelComplete'; levelReport: LevelReport; levelRun: LevelRun; action: LevelCompleteAction; } | { type: 'towerComplete' } | { type: 'error'; message: string }; export type GameMenuStep = | { type: 'start' } | { type: 'wizard'; initialStep: 'new' | 'choose-profile' }; ================================================ FILE: apps/cli/src/ui/utils/buildLevelReport.test.ts ================================================ import { describe, expect, test } from 'vitest'; import { type LevelConfig, type LevelResult, type TurnEvent } from '../types.js'; import { buildLevelReport } from './buildLevelReport.js'; function makeEvent(score: number, floorMap: { unit?: unknown }[][] = [[]]): TurnEvent { return { warriorStatus: { score }, floorMap } as unknown as TurnEvent; } describe('buildLevelReport', () => { test('returns failure result when level not passed', () => { const result = buildLevelReport({ levelResult: { passed: false, turns: [] } as unknown as LevelResult, levelConfig: { clue: 'Try walking', timeBonus: 10, aceScore: 30 } as LevelConfig, levelNumber: 2, hasNextLevel: true, isEpic: false, currentScore: 50, isShowingClue: false, }); expect(result).toEqual({ passed: false, levelNumber: 2, hasNextLevel: true, scoreParts: { warrior: 0, timeBonus: 0, clearBonus: 0 }, totalScore: 0, grade: 0, isEpic: false, previousScore: 50, hasClue: true, isShowingClue: false, }); }); test('returns failure result with isShowingClue when clue already requested', () => { const result = buildLevelReport({ levelResult: { passed: false, turns: [] } as unknown as LevelResult, levelConfig: { clue: 'Try walking', timeBonus: 10, aceScore: 30 } as LevelConfig, levelNumber: 2, hasNextLevel: true, isEpic: false, currentScore: 50, isShowingClue: true, }); expect(result.hasClue).toBe(true); expect(result.isShowingClue).toBe(true); }); test('returns failure result with hasClue false when no clue exists', () => { const result = buildLevelReport({ levelResult: { passed: false, turns: [] } as unknown as LevelResult, levelConfig: { timeBonus: 10, aceScore: 30 } as LevelConfig, levelNumber: 1, hasNextLevel: true, isEpic: false, currentScore: 0, isShowingClue: false, }); expect(result.hasClue).toBe(false); }); test('returns success result with calculated scores', () => { const result = buildLevelReport({ levelResult: { passed: true, turns: [[makeEvent(5), makeEvent(10)], [makeEvent(15)]], } as unknown as LevelResult, levelConfig: { timeBonus: 10, aceScore: 30 } as LevelConfig, levelNumber: 3, hasNextLevel: true, isEpic: false, currentScore: 100, isShowingClue: false, }); expect(result.passed).toBe(true); expect(result.levelNumber).toBe(3); expect(result.previousScore).toBe(100); expect(result.hasClue).toBe(false); expect(result.isShowingClue).toBe(false); expect(result.scoreParts.warrior).toBe(15); expect(result.totalScore).toBe( result.scoreParts.warrior + result.scoreParts.timeBonus + result.scoreParts.clearBonus, ); }); test('calculates grade as totalScore / aceScore', () => { const result = buildLevelReport({ levelResult: { passed: true, turns: [[makeEvent(20)]], } as unknown as LevelResult, levelConfig: { timeBonus: 0, aceScore: 100 } as LevelConfig, levelNumber: 1, hasNextLevel: true, isEpic: true, currentScore: 0, isShowingClue: false, }); expect(result.grade).toBe(result.totalScore / 100); }); test('returns grade 0 when aceScore is missing', () => { const result = buildLevelReport({ levelResult: { passed: true, turns: [[makeEvent(20)]], } as unknown as LevelResult, levelConfig: { timeBonus: 0 } as LevelConfig, levelNumber: 1, hasNextLevel: true, isEpic: false, currentScore: 0, isShowingClue: false, }); expect(result.grade).toBe(0); }); test('defaults timeBonus to 0 when undefined', () => { const result = buildLevelReport({ levelResult: { passed: true, turns: [[makeEvent(10)]], } as unknown as LevelResult, levelConfig: { aceScore: 100 } as LevelConfig, levelNumber: 1, hasNextLevel: false, isEpic: false, currentScore: 0, isShowingClue: false, }); expect(result.passed).toBe(true); expect(result.scoreParts.timeBonus).toBe(0); }); }); ================================================ FILE: apps/cli/src/ui/utils/buildLevelReport.ts ================================================ import { getLevelScore } from '@warriorjs/scoring'; import { type LevelConfig, type LevelReport, type LevelResult } from '../types.js'; interface BuildLevelReportParams { levelResult: LevelResult; levelConfig: LevelConfig; levelNumber: number; hasNextLevel: boolean; isEpic: boolean; currentScore: number; isShowingClue: boolean; } export function buildLevelReport({ levelResult, levelConfig, levelNumber, hasNextLevel, isEpic, currentScore, isShowingClue, }: BuildLevelReportParams): LevelReport { if (!levelResult.passed) { return { passed: false, levelNumber, hasNextLevel, scoreParts: { warrior: 0, timeBonus: 0, clearBonus: 0 }, totalScore: 0, grade: 0, isEpic, previousScore: currentScore, hasClue: !!levelConfig.clue, isShowingClue, }; } const scoreParts = getLevelScore(levelResult, { timeBonus: levelConfig.timeBonus ?? 0 })!; const totalScore = scoreParts.warrior + scoreParts.timeBonus + scoreParts.clearBonus; const grade = levelConfig.aceScore ? totalScore / levelConfig.aceScore : 0; return { passed: true, levelNumber, hasNextLevel, scoreParts, totalScore, grade, isEpic, previousScore: currentScore, hasClue: false, isShowingClue: false, }; } ================================================ FILE: apps/cli/src/utils/formatDirectory.test.ts ================================================ import os from 'node:os'; import path from 'node:path'; import { describe, expect, test } from 'vitest'; import formatDirectory from './formatDirectory.js'; const home = os.homedir(); describe('formatDirectory', () => { test('replaces home directory with ~', () => { expect(formatDirectory(home)).toBe('~'); }); test('replaces home prefix with ~/', () => { expect(formatDirectory(`${home}/Projects/warriorjs`)).toBe('~/Projects/warriorjs'); }); test('resolves relative paths before replacing', () => { expect(formatDirectory('.')).toBe(path.resolve('.').replace(home, '~')); }); test('returns absolute path when not under home', () => { expect(formatDirectory('/tmp/warriorjs')).toBe('/tmp/warriorjs'); }); test('does not replace partial home match', () => { expect(formatDirectory(`${home}-extra/foo`)).toBe(`${home}-extra/foo`); }); }); ================================================ FILE: apps/cli/src/utils/formatDirectory.ts ================================================ import os from 'node:os'; import path from 'node:path'; export default function formatDirectory(directory: string): string { const resolved = path.resolve(directory); const home = os.homedir(); if (resolved === home) return '~'; if (resolved.startsWith(`${home}/`)) return `~/${resolved.slice(home.length + 1)}`; return resolved; } ================================================ FILE: apps/cli/src/utils/getFloorMap.test.ts ================================================ import { expect, test } from 'vitest'; import getFloorMap from './getFloorMap.js'; test('returns the floor map', () => { const map = [ [{ character: 'a' }, { character: 'b' }], [{ character: 'c' }, { character: 'd' }], ]; expect(getFloorMap(map)).toBe('ab\ncd'); }); ================================================ FILE: apps/cli/src/utils/getFloorMap.ts ================================================ interface FloorSpace { character: string; [key: string]: unknown; } function getFloorMap(map: FloorSpace[][]): string { return map.map((row) => row.map((space) => space.character).join('')).join('\n'); } export default getFloorMap; ================================================ FILE: apps/cli/src/utils/getFloorMapKey.test.ts ================================================ import { expect, test } from 'vitest'; import getFloorMapKey from './getFloorMapKey.js'; test('returns the floor map key', () => { const map = [ [{ character: '@', unit: { name: 'Joe', maxHealth: 20 } }, { character: 'b' }], [{ character: 'c' }], ]; expect(getFloorMapKey(map as any)).toBe('@ = Joe (20 HP)\n> = stairs'); }); ================================================ FILE: apps/cli/src/utils/getFloorMapKey.ts ================================================ interface FloorSpace { character: string; unit?: { name: string; maxHealth: number; }; } function getFloorMapKey(map: FloorSpace[][]): string { return map .reduce((acc, row) => acc.concat(row), []) .filter((space) => space.unit) .filter( (space, index, arr) => arr.findIndex((anotherSpace) => anotherSpace.character === space.character) === index, ) .map(({ character, unit }) => { const { name, maxHealth } = unit!; return `${character} = ${name} (${maxHealth} HP)`; }) .concat(['> = stairs']) .join('\n'); } export default getFloorMapKey; ================================================ FILE: apps/cli/src/utils/getTowerId.test.ts ================================================ import { expect, test } from 'vitest'; import getTowerId from './getTowerId.js'; test('returns the tower id for official towers', () => { expect(getTowerId('@warriorjs/tower-foo')).toBe('foo'); }); test('returns the tower id for community towers', () => { expect(getTowerId('warriorjs-tower-foo')).toBe('foo'); }); ================================================ FILE: apps/cli/src/utils/getTowerId.ts ================================================ const towerIdRegex = /(?:@warriorjs\/|warriorjs-)tower-(.+)/; function getTowerId(towerPackageName: string): string { return towerPackageName.match(towerIdRegex)?.[1] ?? ''; } export default getTowerId; ================================================ FILE: apps/cli/src/utils/getWarriorNameSuggestions.test.ts ================================================ import arrayShuffle from 'array-shuffle'; import { expect, test, vi } from 'vitest'; import getWarriorNameSuggestions from './getWarriorNameSuggestions.js'; vi.mock('array-shuffle'); test('returns shuffled list of warrior names', () => { getWarriorNameSuggestions(); expect(arrayShuffle).toHaveBeenCalledWith(expect.arrayContaining(['Aldric', 'Brenna', 'Cedric'])); }); ================================================ FILE: apps/cli/src/utils/getWarriorNameSuggestions.ts ================================================ import arrayShuffle from 'array-shuffle'; const warriorNames = [ 'Aldric', 'Brenna', 'Cedric', 'Dahlia', 'Elric', 'Freya', 'Gareth', 'Hilde', 'Isolde', 'Jareth', 'Kael', 'Liora', 'Maren', 'Nyx', 'Orin', 'Petra', 'Rowan', 'Sable', 'Theron', 'Ursa', 'Vesper', 'Wren', 'Xara', 'Yara', 'Zephyr', 'Ashwin', 'Briar', 'Corwin', 'Dusk', 'Ember', 'Flint', 'Gale', 'Hazel', 'Ingrid', 'Jorin', 'Kyra', 'Leander', 'Morrigan', 'Nerys', 'Onyx', 'Pike', 'Riven', 'Sage', 'Tormund', 'Ulric', 'Voss', 'Wynne', 'Xander', 'Ylva', 'Zareth', ]; function getWarriorNameSuggestions(): string[] { return arrayShuffle(warriorNames); } export default getWarriorNameSuggestions; ================================================ FILE: apps/cli/src/utils/renderPlayerCode.test.ts ================================================ import { describe, expect, test } from 'vitest'; import renderPlayerCode from './renderPlayerCode.js'; describe('renderPlayerCode', () => { const levelConfig: any = { floor: { warrior: { abilities: {} } }, }; test('renders javascript player code', () => { const profile: any = { language: 'javascript' }; expect(renderPlayerCode(profile, levelConfig)).toBe( `class Player { playTurn(warrior) { // Decide what your warrior should do. } } export default Player; `, ); }); test('renders typescript player code', () => { const profile: any = { language: 'typescript' }; expect(renderPlayerCode(profile, levelConfig)).toBe( `import type { Warrior } from './types.js'; class Player { playTurn(warrior: Warrior) { // Decide what your warrior should do. } } export default Player; `, ); }); }); ================================================ FILE: apps/cli/src/utils/renderPlayerCode.ts ================================================ import { type LevelConfig } from '@warriorjs/core'; import type Profile from '../Profile.js'; function renderPlayerCode(profile: Profile, _levelConfig: LevelConfig): string { if (profile.language === 'typescript') { return `import type { Warrior } from './types.js'; class Player { playTurn(warrior: Warrior) { // Decide what your warrior should do. } } export default Player; `; } return `class Player { playTurn(warrior) { // Decide what your warrior should do. } } export default Player; `; } export default renderPlayerCode; ================================================ FILE: apps/cli/src/utils/renderReadme.test.ts ================================================ import { beforeEach, describe, expect, test, vi } from 'vitest'; import renderReadme from './renderReadme.js'; vi.mock('@warriorjs/core', () => ({ getLevel: vi.fn(), })); import { getLevel } from '@warriorjs/core'; describe('renderReadme', () => { let profile: any; let levelConfig: any; let level: any; beforeEach(() => { profile = { warriorName: 'Aldric', tower: { name: 'The Narrow Path', description: 'A tower for beginners' }, language: 'javascript', isShowingClue: () => false, }; levelConfig = { floor: { warrior: { abilities: {} } }, }; level = { number: 1, description: 'You see a light in the distance.', tip: 'Walk towards the light.', clue: 'Use warrior.walk()', floorMap: [[{ character: '@' }, { character: ' ' }, { character: '>' }]], warriorAbilities: { actions: [{ name: 'walk', description: 'Walks forward' }], senses: [], }, }; (getLevel as any).mockReturnValue(level); }); test('calls getLevel with levelConfig', () => { renderReadme(profile, levelConfig); expect(getLevel).toHaveBeenCalledWith(levelConfig); }); test('renders readme for javascript profile', () => { expect(renderReadme(profile, levelConfig)).toBe( [ '# Aldric - The Narrow Path', '', '### _A tower for beginners_', '', '## Level 1', '', '_You see a light in the distance._', '', '> **TIP:** Walk towards the light.', '', '### Floor Map', '', '```', '@ >', '', '> = stairs', '```', '', '## Abilities', '', '### Actions (only one per turn)', '', '- `warrior.walk()`: Walks forward', '', '## Next Steps', '', "When you're done editing `Player.js`, run the `warriorjs` command again.", '', ].join('\n'), ); }); test('renders readme for typescript profile', () => { profile.language = 'typescript'; const result = renderReadme(profile, levelConfig); expect(result).toMatch(/`Player\.ts`/); expect(result).not.toMatch(/`Player\.js`/); }); test('renders readme without tower description', () => { profile.tower.description = ''; const result = renderReadme(profile, levelConfig); expect(result).toBe( [ '# Aldric - The Narrow Path', '', '## Level 1', '', '_You see a light in the distance._', '', '> **TIP:** Walk towards the light.', '', '### Floor Map', '', '```', '@ >', '', '> = stairs', '```', '', '## Abilities', '', '### Actions (only one per turn)', '', '- `warrior.walk()`: Walks forward', '', '## Next Steps', '', "When you're done editing `Player.js`, run the `warriorjs` command again.", '', ].join('\n'), ); }); test('renders readme with clue when showing', () => { profile.isShowingClue = () => true; const result = renderReadme(profile, levelConfig); expect(result).toBe( [ '# Aldric - The Narrow Path', '', '### _A tower for beginners_', '', '## Level 1', '', '_You see a light in the distance._', '', '> **TIP:** Walk towards the light.', '', '> **CLUE:** Use warrior.walk()', '', '### Floor Map', '', '```', '@ >', '', '> = stairs', '```', '', '## Abilities', '', '### Actions (only one per turn)', '', '- `warrior.walk()`: Walks forward', '', '## Next Steps', '', "When you're done editing `Player.js`, run the `warriorjs` command again.", '', ].join('\n'), ); }); test('renders readme with actions and senses', () => { level.warriorAbilities.senses = [{ name: 'feel', description: 'Feels the space ahead' }]; const result = renderReadme(profile, levelConfig); expect(result).toBe( [ '# Aldric - The Narrow Path', '', '### _A tower for beginners_', '', '## Level 1', '', '_You see a light in the distance._', '', '> **TIP:** Walk towards the light.', '', '### Floor Map', '', '```', '@ >', '', '> = stairs', '```', '', '## Abilities', '', '### Actions (only one per turn)', '', '- `warrior.walk()`: Walks forward', '', '### Senses', '', '- `warrior.feel()`: Feels the space ahead', '', '## Next Steps', '', "When you're done editing `Player.js`, run the `warriorjs` command again.", '', ].join('\n'), ); }); }); ================================================ FILE: apps/cli/src/utils/renderReadme.ts ================================================ import { getLevel, type LevelConfig } from '@warriorjs/core'; import type Profile from '../Profile.js'; import getFloorMap from './getFloorMap.js'; import getFloorMapKey from './getFloorMapKey.js'; interface Ability { name: string; description: string; } function renderHeader(profile: Profile): string { return `# ${profile.warriorName} - ${profile.tower.name}`; } function renderTowerDescription(description: string): string { if (!description) return ''; return `### _${description}_`; } function renderLevelInfo(level: any): string { return `## Level ${level.number}\n\n_${level.description}_\n\n> **TIP:** ${level.tip}`; } function renderClue(profile: Profile, clue: string): string { if (!profile.isShowingClue()) return ''; return `> **CLUE:** ${clue}`; } function renderFloorMap(level: any): string { return `### Floor Map\n\n\`\`\`\n${getFloorMap(level.floorMap)}\n\n${getFloorMapKey(level.floorMap)}\n\`\`\``; } function renderAbilityList(abilities: Ability[]): string { return abilities.map((a) => `- \`warrior.${a.name}()\`: ${a.description}`).join('\n'); } function renderActions(actions: Ability[]): string { return `### Actions (only one per turn)\n\n${renderAbilityList(actions)}`; } function renderSenses(senses: Ability[]): string { if (!senses.length) return ''; return `### Senses\n\n${renderAbilityList(senses)}`; } function renderAbilities(level: any): string { const sections = [ '## Abilities', renderActions(level.warriorAbilities.actions), renderSenses(level.warriorAbilities.senses), ].filter(Boolean); return sections.join('\n\n'); } function renderNextSteps(profile: Profile): string { const playerFile = profile.language === 'typescript' ? 'Player.ts' : 'Player.js'; return `## Next Steps\n\nWhen you're done editing \`${playerFile}\`, run the \`warriorjs\` command again.`; } function renderReadme(profile: Profile, levelConfig: LevelConfig): string { const level = getLevel(levelConfig); const sections = [ renderHeader(profile), renderTowerDescription(profile.tower.description), renderLevelInfo(level), renderClue(profile, level.clue), renderFloorMap(level), renderAbilities(level), renderNextSteps(profile), ].filter(Boolean); return `${sections.join('\n\n')}\n`; } export default renderReadme; ================================================ FILE: apps/cli/src/utils/renderTypes.test.ts ================================================ import { type AbilityMeta, Action, Sense } from '@warriorjs/core'; import { describe, expect, test } from 'vitest'; import renderTypes from './renderTypes.js'; class MockWalk extends Action { readonly description = 'Walks forward'; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; perform() {} } class MockFeel extends Sense { readonly description = 'Feels the space ahead'; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'Space', }; perform() {} } class MockHealth extends Sense { readonly description = 'Returns current health'; readonly meta: AbilityMeta = { params: [], returns: 'number', }; perform() {} } const profile: any = { language: 'typescript' }; function makeLevelConfig(abilities: Record): any { return { floor: { warrior: { abilities } } }; } describe('renderTypes', () => { test('renders types with a single action', () => { expect(renderTypes(profile, makeLevelConfig({ walk: MockWalk }))).toBe( [ '// @generated — Auto-generated each level. Do not edit.', '', "export type Direction = 'forward' | 'right' | 'backward' | 'left';", '', 'export interface Warrior {', ' /** Walks forward */', ' walk(direction?: Direction): void;', '}', '', ].join('\n'), ); }); test('renders types with actions before senses, both sorted alphabetically', () => { expect( renderTypes( profile, makeLevelConfig({ health: MockHealth, walk: MockWalk, feel: MockFeel, }), ), ).toBe( [ '// @generated — Auto-generated each level. Do not edit.', '', "export type Direction = 'forward' | 'right' | 'backward' | 'left';", '', 'export interface Unit {', ' /** Determines if the unit is bound. */', ' isBound(): boolean;', ' /** Determines if the unit is an enemy. */', ' isEnemy(): boolean;', ' /** Determines if the unit is under the given effect. */', ' isUnderEffect(name: string): boolean;', '}', '', 'export interface Space {', ' /** Returns the relative location of this space as the offset `[forward, right]`. */', ' getLocation(): [number, number];', ' /** Returns the unit located at this space, or `null` if there is none. */', ' getUnit(): Unit | null;', ' /** Determines if nothing (except maybe stairs) is at this space. */', ' isEmpty(): boolean;', ' /** Determines if the stairs are at this space. */', ' isStairs(): boolean;', ' /** Determines if there is a unit at this space. */', ' isUnit(): boolean;', ' /** Determines if this space is the edge of the level. */', ' isWall(): boolean;', '}', '', 'export interface Warrior {', ' /** Walks forward */', ' walk(direction?: Direction): void;', ' /** Feels the space ahead */', ' feel(direction?: Direction): Space;', ' /** Returns current health */', ' health(): number;', '}', '', ].join('\n'), ); }); test('omits Space and Unit interfaces when no abilities use Space', () => { expect(renderTypes(profile, makeLevelConfig({ walk: MockWalk, health: MockHealth }))).toBe( [ '// @generated — Auto-generated each level. Do not edit.', '', "export type Direction = 'forward' | 'right' | 'backward' | 'left';", '', 'export interface Warrior {', ' /** Walks forward */', ' walk(direction?: Direction): void;', ' /** Returns current health */', ' health(): number;', '}', '', ].join('\n'), ); }); test('handles rest parameters', () => { class RestAction extends Action { readonly description = 'Does something with rest params'; readonly meta: AbilityMeta = { params: [{ name: 'targets', type: 'any', rest: true }], returns: 'void', }; perform() {} } expect(renderTypes(profile, makeLevelConfig({ multi: RestAction }))).toBe( [ '// @generated — Auto-generated each level. Do not edit.', '', "export type Direction = 'forward' | 'right' | 'backward' | 'left';", '', 'export interface Warrior {', ' /** Does something with rest params */', ' multi(...targets: any[]): void;', '}', '', ].join('\n'), ); }); test('handles empty abilities', () => { expect(renderTypes(profile, makeLevelConfig({}))).toBe( [ '// @generated — Auto-generated each level. Do not edit.', '', "export type Direction = 'forward' | 'right' | 'backward' | 'left';", '', 'export interface Warrior {', '}', '', ].join('\n'), ); }); }); ================================================ FILE: apps/cli/src/utils/renderTypes.ts ================================================ import { type Ability, type AbilityEntry, Action, type LevelConfig } from '@warriorjs/core'; import type Profile from '../Profile.js'; interface MethodEntry { name: string; action: boolean; description: string; signature: string; } function renderHeader(): string { return '// @generated — Auto-generated each level. Do not edit.'; } function renderDirectionType(): string { return "export type Direction = 'forward' | 'right' | 'backward' | 'left';"; } function renderSpaceInterfaces(): string { return [ 'export interface Unit {', ' /** Determines if the unit is bound. */', ' isBound(): boolean;', ' /** Determines if the unit is an enemy. */', ' isEnemy(): boolean;', ' /** Determines if the unit is under the given effect. */', ' isUnderEffect(name: string): boolean;', '}', '', 'export interface Space {', ' /** Returns the relative location of this space as the offset `[forward, right]`. */', ' getLocation(): [number, number];', ' /** Returns the unit located at this space, or `null` if there is none. */', ' getUnit(): Unit | null;', ' /** Determines if nothing (except maybe stairs) is at this space. */', ' isEmpty(): boolean;', ' /** Determines if the stairs are at this space. */', ' isStairs(): boolean;', ' /** Determines if there is a unit at this space. */', ' isUnit(): boolean;', ' /** Determines if this space is the edge of the level. */', ' isWall(): boolean;', '}', ].join('\n'); } function renderMethod(method: MethodEntry): string { return ` /** ${method.description} */\n ${method.signature};`; } function renderWarriorInterface(methods: MethodEntry[]): string { if (!methods.length) { return 'export interface Warrior {\n}'; } const body = methods.map(renderMethod).join('\n'); return `export interface Warrior {\n${body}\n}`; } function instantiateAbility(entry: AbilityEntry): Ability { if (Array.isArray(entry)) { const [AbilityClass, config] = entry; return new AbilityClass({} as any, config); } const AbilityClass = entry; return new AbilityClass({} as any); } function renderTypes(_profile: Profile, levelConfig: LevelConfig): string { const abilities = levelConfig.floor.warrior.abilities ?? {}; const methods: MethodEntry[] = []; let needsSpace = false; for (const [name, entry] of Object.entries(abilities)) { const ability = instantiateAbility(entry); const { description, meta } = ability; const params: string[] = meta.params.map((param: any) => { const tsType = param.type; if (tsType === 'Space') { needsSpace = true; } if (param.rest) { return `...${param.name}: ${tsType}[]`; } const optional = param.optional ? '?' : ''; return `${param.name}${optional}: ${tsType}`; }); const returnType = meta.returns; if (returnType === 'Space' || returnType === 'Space[]') { needsSpace = true; } methods.push({ name, description, action: ability instanceof Action, signature: `${name}(${params.join(', ')}): ${returnType}`, }); } const actions = methods.filter((m) => m.action).sort((a, b) => a.name.localeCompare(b.name)); const senses = methods.filter((m) => !m.action).sort((a, b) => a.name.localeCompare(b.name)); const sortedMethods = [...actions, ...senses]; const sections = [ renderHeader(), renderDirectionType(), needsSpace ? renderSpaceInterfaces() : '', renderWarriorInterface(sortedMethods), ].filter(Boolean); return `${sections.join('\n\n')}\n`; } export default renderTypes; ================================================ FILE: apps/cli/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist" }, "exclude": ["**/*.test.ts"] } ================================================ FILE: apps/cli/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src", "jsx": "react-jsx" }, "include": ["src", "declarations.d.ts"] } ================================================ FILE: apps/website/core/Footer.js ================================================ const PropTypes = require('prop-types'); const React = require('react'); const GitHubButton = require('./GitHubButton'); const TwitterButton = require('./TwitterButton'); const getDocUrl = require('../utils/getDocUrl'); const Footer = ({ config, language }) => ( ); Footer.propTypes = { config: PropTypes.shape({ baseUrl: PropTypes.string.isRequired, footerIcon: PropTypes.string.isRequired, gitHubUrl: PropTypes.string.isRequired, title: PropTypes.string.isRequired, }).isRequired, language: PropTypes.string.isRequired, }; module.exports = Footer; ================================================ FILE: apps/website/core/GitHubButton.js ================================================ const PropTypes = require('prop-types'); const React = require('react'); const GitHubButton = ({ username, repo }) => ( Star ); GitHubButton.propTypes = { username: PropTypes.string.isRequired, repo: PropTypes.string.isRequired, }; module.exports = GitHubButton; ================================================ FILE: apps/website/core/TwitterButton.js ================================================ const PropTypes = require('prop-types'); const React = require('react'); const TwitterButton = ({ username }) => ( Follow WarriorJS on Twitter ); TwitterButton.propTypes = { username: PropTypes.string.isRequired, }; module.exports = TwitterButton; ================================================ FILE: apps/website/crowdin.yaml ================================================ project_identifier_env: CROWDIN_WARRIORJS_PROJECT_ID api_key_env: CROWDIN_WARRIORJS_API_KEY base_path: "../" preserve_hierarchy: true files: - source: '/docs/**/*.md' translation: '/website/translated_docs/%locale%/**/%original_file_name%' languages_mapping: &anchor locale: 'af': 'af' 'ar': 'ar' 'bs-BA': 'bs-BA' 'ca': 'ca' 'cs': 'cs' 'da': 'da' 'de': 'de' 'el': 'el' 'es-ES': 'es-ES' 'fa': 'fa-IR' 'fi': 'fi' 'fr': 'fr' 'he': 'he' 'hu': 'hu' 'id': 'id-ID' 'it': 'it' 'ja': 'ja' 'ko': 'ko' 'mr': 'mr-IN' 'nl': 'nl' 'no': 'no-NO' 'pl': 'pl' 'pt-BR': 'pt-BR' 'pt-PT': 'pt-PT' 'ro': 'ro' 'ru': 'ru' 'sk': 'sk-SK' 'sr': 'sr' 'sv-SE': 'sv-SE' 'tr': 'tr' 'uk': 'uk' 'vi': 'vi' 'zh-CN': 'zh-CN' 'zh-TW': 'zh-TW' - source: '/website/i18n/en.json' translation: '/website/i18n/%locale%.json' languages_mapping: *anchor ================================================ FILE: apps/website/data/sponsors.json ================================================ [] ================================================ FILE: apps/website/i18n/en.json ================================================ { "_comment": "This file is auto-generated by write-translations.js", "localized-strings": { "next": "Next", "previous": "Previous", "tagline": "An exciting game of programming and Artificial Intelligence", "community/ecosystem": "Ecosystem", "community/roadmap": "Roadmap & Contribution", "community/socialize": "Socialize", "maker/adding-levels": "Adding Levels", "maker/creating-tower": "Creating Your Tower", "maker/defining-abilities": "Defining Abilities", "maker/defining-units": "Defining Units", "maker/introduction": "Introduction", "maker/publishing": "Publishing", "maker/refactoring": "Refactoring", "maker/space-api": "Space API", "maker/testing": "Testing", "maker/unit-api": "Unit API", "player/abilities": "Abilities", "player/ai-tips": "AI Tips", "Artificial Intelligence": "Artificial Intelligence", "player/cli-tips": "CLI Tips", "CLI": "CLI", "player/effects": "Effects", "player/epic-mode": "Epic Mode", "player/gameplay": "Gameplay", "player/general-tips": "General Tips", "General": "General", "player/install": "Install", "player/js-tips": "JavaScript Tips", "JavaScript": "JavaScript", "player/object": "Object", "player/options": "Options", "player/overview": "Overview", "player/perspective": "Perspective", "player/scoring": "Scoring", "player/space-api": "Space API", "player/spaces": "Spaces", "player/towers": "Towers", "player/turn-api": "Turn API", "player/unit-api": "Unit API", "player/units": "Units", "player/warrior": "Warrior", "Player": "Player", "Maker": "Maker", "Community": "Community", "GitHub": "GitHub", "Game": "Game", "Concepts": "Concepts", "Player API": "Player API", "Tips & Tricks": "Tips & Tricks", "Guide": "Guide", "Maker API": "Maker API", "Play": "Play", "Make": "Make", "player/cli-options": "CLI Options", "player/set-up": "Set Up", "Introduction": "Introduction", "Configuration": "Configuration" }, "pages-strings": { "Play Now|no description given": "Play Now", "Docs|no description given": "Docs", "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.", "Code|no description given": "Code", "Run your code and check how your warrior does.|no description given": "Run your code and check how your warrior does.", "Play|no description given": "Play", "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.", "Make|no description given": "Make", "Quick Start|no description given": "Quick Start", "Install WarriorJS:|no description given": "Install WarriorJS:", "Launch the game:|no description given": "Launch the game:", "Create your warrior and begin your journey!|no description given": "Create your warrior and begin your journey!", "Sponsors|no description given": "Sponsors", "Become a sponsor|no description given": "Become a sponsor", "Help Translate|recruit community translators for your project": "Help Translate", "Edit this Doc|recruitment message asking to edit the doc source": "Edit", "Translate this Doc|recruitment message asking to translate the docs": "Translate", "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.", "Documentation|no description given": "Documentation", "Get Started|no description given": "Get Started" } } ================================================ FILE: apps/website/languages.js ================================================ const languages = [ { enabled: true, name: 'English', tag: 'en', }, { enabled: false, name: '日本語', tag: 'ja', }, { enabled: true, name: 'العربية', tag: 'ar', }, { enabled: false, name: 'Bosanski', tag: 'bs-BA', }, { enabled: true, name: 'Català', tag: 'ca', }, { enabled: true, name: 'Čeština', tag: 'cs', }, { enabled: false, name: 'Dansk', tag: 'da', }, { enabled: true, name: 'Deutsch', tag: 'de', }, { enabled: true, name: 'Ελληνικά', tag: 'el', }, { enabled: true, name: 'Español', tag: 'es-ES', }, { enabled: false, name: 'فارسی', tag: 'fa-IR', }, { enabled: false, name: 'Suomi', tag: 'fi', }, { enabled: true, name: 'Français', tag: 'fr', }, { enabled: false, name: 'עִברִית', tag: 'he', }, { enabled: false, name: 'Magyar', tag: 'hu', }, { enabled: false, name: 'Bahasa Indonesia', tag: 'id-ID', }, { enabled: true, name: 'Italiano', tag: 'it', }, { enabled: false, name: 'Afrikaans', tag: 'af', }, { enabled: false, name: '한국어', tag: 'ko', }, { enabled: false, name: 'मराठी', tag: 'mr-IN', }, { enabled: false, name: 'Nederlands', tag: 'nl', }, { enabled: false, name: 'Norsk', tag: 'no-NO', }, { enabled: true, name: 'Polskie', tag: 'pl', }, { enabled: false, name: 'Português', tag: 'pt-PT', }, { enabled: false, name: 'Português (Brasil)', tag: 'pt-BR', }, { enabled: false, name: 'Română', tag: 'ro', }, { enabled: true, name: 'Русский', tag: 'ru', }, { enabled: false, name: 'Slovenský', tag: 'sk-SK', }, { enabled: true, name: 'Српски језик (Ћирилица)', tag: 'sr', }, { enabled: true, name: 'Svenska', tag: 'sv-SE', }, { enabled: true, name: 'Türkçe', tag: 'tr', }, { enabled: false, name: 'Українська', tag: 'uk', }, { enabled: false, name: 'Tiếng Việt', tag: 'vi', }, { enabled: true, name: '中文', tag: 'zh-CN', }, { enabled: true, name: '繁體中文', tag: 'zh-TW', }, ]; module.exports = languages; ================================================ FILE: apps/website/package.json ================================================ { "name": "warriorjs-website", "version": "0.0.0", "private": true, "scripts": { "start": "docusaurus-start", "build": "docusaurus-build", "publish-gh-pages": "docusaurus-publish", "write-translations": "docusaurus-write-translations", "version": "docusaurus-version", "rename-version": "docusaurus-rename-version", "crowdin-upload": "crowdin upload sources --auto-update -b master", "crowdin-download": "crowdin download -b master" }, "devDependencies": { "docusaurus": "^1.2.0" }, "dependencies": { "prop-types": "^15.6.2" } } ================================================ FILE: apps/website/pages/en/index.js ================================================ const PropTypes = require('prop-types'); const React = require('react'); const GitHubButton = require(`${process.cwd()}/core/GitHubButton`); const getDocUrl = require(`${process.cwd()}/utils/getDocUrl`); const getImgUrl = require(`${process.cwd()}/utils/getImgUrl`); const siteConfig = require(`${process.cwd()}/siteConfig`); const translation = require('../../server/translation.js'); // eslint-disable-line import/no-unresolved const { Container, GridBlock, MarkdownBlock, } = require('../../core/CompLibrary'); // eslint-disable-line import/no-unresolved const { translate } = require('../../server/translate'); // eslint-disable-line import/no-unresolved const PromoSection = ({ children }) => (
{children}
); PromoSection.propTypes = { children: PropTypes.node.isRequired, }; const Button = ({ children, href, primary, target }) => ( ); Button.propTypes = { children: PropTypes.node.isRequired, href: PropTypes.string.isRequired, primary: PropTypes.bool, target: PropTypes.string, }; Button.defaultProps = { primary: false, target: '_self', }; const HomeSplash = ({ language }) => (

WarriorJS Banner {translation[language]['localized-strings'].tagline}

); HomeSplash.propTypes = { language: PropTypes.string.isRequired, }; const Code = () => ( Write JavaScript to teach your warrior what to do depending on the situation. Select abilities to customize how your warrior plays. ), imageAlign: 'right', image: getImgUrl('code-preview.png'), imageAlt: 'Code Preview', title: Code, }, ]} layout="twoColumn" /> ); const Play = () => ( Run your code and check how your warrior does. ), imageAlign: 'left', image: getImgUrl('play-preview.png'), imageAlt: 'Play Preview', title: Play, }, ]} layout="twoColumn" /> ); const Make = () => ( Make your own towers and share them with the world. Add new abilities, effects, and units to the game. ), imageAlign: 'right', image: getImgUrl('make-preview.png'), imageAlt: 'Make Preview', title: Make, }, ]} layout="twoColumn" /> ); const sh = (...args) => `~~~sh\n${String.raw(...args)}\n~~~`; const QuickStart = () => (

Quick Start

  1. Install WarriorJS: {sh`npm install --global @warriorjs/cli`}
  2. Launch the game: {sh`warriorjs`}
  3. Create your warrior and begin your journey!
); const Sponsors = () => { if (!siteConfig.sponsors.length) { return null; } return (

Sponsors

{siteConfig.sponsors.map((sponsor, index) => ( {`Sponsored ))}
); }; const Index = ({ language }) => (
); Index.propTypes = { language: PropTypes.string.isRequired, }; module.exports = Index; ================================================ FILE: apps/website/sidebars.json ================================================ { "play": { "Game": [ "player/overview", "player/object", "player/gameplay", "player/perspective", "player/scoring", "player/epic-mode", "player/towers" ], "Concepts": [ "player/units", "player/warrior", "player/abilities", "player/spaces" ], "Player API": ["player/space-api", "player/unit-api", "player/turn-api"], "Tips & Tricks": [ "player/general-tips", "player/js-tips", "player/ai-tips", "player/cli-tips" ], "CLI": ["player/install", "player/options"] }, "make": { "Guide": [ "maker/introduction", "maker/creating-tower", "maker/adding-levels", "maker/defining-abilities", "maker/defining-units", "maker/refactoring", "maker/testing", "maker/publishing" ], "Maker API": ["maker/space-api", "maker/unit-api"] }, "community": { "Community": [ "community/socialize", "community/ecosystem", "community/roadmap" ] } } ================================================ FILE: apps/website/siteConfig.js ================================================ const pkg = require('../package'); const sponsors = require('./data/sponsors'); const gitHubUrl = pkg.repository; const twitterUsername = 'warrior_js'; const siteConfig = { gitHubUrl, twitterUsername, sponsors, title: 'WarriorJS Docs', tagline: 'An exciting game of programming and Artificial Intelligence', url: pkg.homepage, baseUrl: '/', projectName: 'warriorjs', organizationName: 'olistic', cname: 'warrior.js.org', noIndex: false, cleanUrl: true, editUrl: `${gitHubUrl}/edit/master/docs/`, translationRecruitingLink: 'https://crowdin.com/project/warriorjs', headerLinks: [ { doc: 'player/overview', label: 'Player' }, { doc: 'maker/introduction', label: 'Maker' }, { doc: 'community/socialize', label: 'Community' }, { languages: true }, { search: true }, { href: gitHubUrl, label: 'GitHub' }, ], headerIcon: 'img/warriorjs-text.svg', footerIcon: 'img/warriorjs-sword.svg', favicon: 'img/favicon.png', twitterImage: 'img/warriorjs.png', ogImage: 'img/warriorjs.png', colors: { primaryColor: '#2e3440', secondaryColor: '#3b4252', accentColor: '#8fbcbb', }, scripts: ['https://buttons.github.io/buttons.js'], disableHeaderTitle: true, disableTitleTagline: true, onPageNav: 'separate', gaTrackingId: 'UA-118632697-1', algolia: { apiKey: 'af0d3f56837aacc96ccd573d9208966c', indexName: 'warriorjs', }, }; module.exports = siteConfig; ================================================ FILE: apps/website/static/.circleci/config.yml ================================================ # This config file will prevent tests from being run on the gh-pages branch. version: 2 jobs: build: machine: true branches: ignore: gh-pages steps: - run: echo "Skipping tests on gh-pages branch" ================================================ FILE: apps/website/static/css/custom.css ================================================ /* * Global */ blockquote { background: #eceff4; border-left-color: #e5e9f0; } .container .wrapper h3 { margin: 12px 0 0 0; padding: 6px 0; } /* * Links */ .mainContainer a { color: $accentColor; } .mainContainer .button { background: transparent; border: 1px solid $primaryColor; color: $primaryColor; } .mainContainer .button:hover { background: $primaryColor; color: #eceff4; } /* * Header */ .fixedHeaderContainer .headerWrapper .logo { height: 50%; } @media only screen and (max-width: 375px) { .fixedHeaderContainer .headerWrapper .logo { display: block; } } /* Languages */ #languages-menu { font-size: 0px; padding: 0; } #languages-menu .languages-icon { margin: 0; padding: 6px 10px; } #languages-dropdown { width: auto; right: 0; } @media only screen and (min-width: 1024px) { .nav-site > span > li { position: relative; } } /* Search */ .reactNavSearchWrapper input#search_input_react { background-color: $secondaryColor; } @media only screen and (max-width: 735px) { .reactNavSearchWrapper input#search_input_react { background-color: $secondaryColor; } } /* * Docs Sidebar */ .docsNavContainer nav.toc .toggleNav .navItem:hover { color: $accentColor; } .docsNavContainer nav.toc .toggleNav .navItem.navItemActive { color: $accentColor; } /* * Footer */ .footerSection { background: $primaryColor !important; } .footerSection .sitemap .nav-home { height: 80px; margin: 0px 20px 0 0; padding: 7px; width: 80px; } .footerSection .sitemap .nav-home img { transform-origin: left; transform: translate(50%, -50%) rotate(90deg); } @media only screen and (max-width: 735px) { .footerSection .sitemap div { margin: 0 0 20px 0; } .footerSection .sitemap .nav-home img { transform: translate(0, 50%); } } .footerSection .sitemap iframe { margin: 2px -10px; padding: 0px 10px; } /* * Home */ .homeContainer { background: $primaryColor; } .homeContainer .homeWrapper .projectTitle img { height: 100%; margin-bottom: 0.5em; max-height: 200px; } .homeContainer .homeWrapper .projectTitle small { color: $accentColor; } .homeContainer .homeWrapper .button { border-color: $accentColor; color: $accentColor; } .homeContainer .homeWrapper .button:hover { background: $accentColor; color: $primaryColor; } .homeContainer .homeWrapper .button.primary { background: $accentColor; color: $primaryColor; } .mainContainer .productShowcaseSection, .mainContainer .container { background: #e5e9f0; } .mainContainer .lightBackground { background: #eceff4; } /* Quick Start */ .quickStart ol { list-style: none; margin: 0 auto; max-width: 800px; padding: 1em 0; text-align: left; } .quickStart ol li { margin-bottom: 42px; } .quickStart ol li::before { color: $accentColor; font-size: 42px; margin-right: 10px; } .quickStart ol li:nth-of-type(1)::before { content: '1.'; } .quickStart ol li:nth-of-type(2)::before { content: '2.'; } .quickStart ol li:nth-of-type(3)::before { content: '3.'; } ================================================ FILE: apps/website/static/css/nord.css ================================================ /* ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ title Nord highlight.js + project nord-highlightjs + version 0.1.0 + repository https://github.com/arcticicestudio/nord-highlightjs + author Arctic Ice Studio + email development@arcticicestudio.com + copyright Copyright (C) 2017 + ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ [References] Nord https://github.com/arcticicestudio/nord highlight.js http://highlightjs.readthedocs.io/en/latest/style-guide.html http://highlightjs.readthedocs.io/en/latest/css-classes-reference.html */ .hljs { display: block; overflow-x: auto; padding: 0.5em; background: #2e3440; } .hljs, .hljs-subst { color: #d8dee9; } .hljs-selector-tag { color: #81a1c1; } .hljs-selector-id { color: #8fbcbb; font-weight: bold; } .hljs-selector-class { color: #8fbcbb; } .hljs-selector-attr { color: #8fbcbb; } .hljs-selector-pseudo { color: #88c0d0; } .hljs-addition { background-color: rgba(163, 190, 140, 0.5); } .hljs-deletion { background-color: rgba(191, 97, 106, 0.5); } .hljs-built_in, .hljs-type { color: #8fbcbb; } .hljs-class { color: #8fbcbb; } .hljs-function { color: #88c0d0; } .hljs-function > .hljs-title { color: #88c0d0; } .hljs-keyword, .hljs-literal, .hljs-symbol { color: #81a1c1; } .hljs-number { color: #b48ead; } .hljs-regexp { color: #ebcb8b; } .hljs-string { color: #a3be8c; } .hljs-title { color: #8fbcbb; } .hljs-params { color: #d8dee9; } .hljs-bullet { color: #81a1c1; } .hljs-code { color: #8fbcbb; } .hljs-emphasis { font-style: italic; } .hljs-formula { color: #8fbcbb; } .hljs-strong { font-weight: bold; } .hljs-link:hover { text-decoration: underline; } .hljs-quote { color: #4c566a; } .hljs-comment { color: #4c566a; } .hljs-doctag { color: #8fbcbb; } .hljs-meta, .hljs-meta-keyword { color: #5e81ac; } .hljs-meta-string { color: #a3be8c; } .hljs-attr { color: #8fbcbb; } .hljs-attribute { color: #d8dee9; } .hljs-builtin-name { color: #81a1c1; } .hljs-name { color: #81a1c1; } .hljs-section { color: #88c0d0; } .hljs-tag { color: #81a1c1; } .hljs-variable { color: #d8dee9; } .hljs-template-variable { color: #d8dee9; } .hljs-template-tag { color: #5e81ac; } .abnf .hljs-attribute { color: #88c0d0; } .abnf .hljs-symbol { color: #ebcb8b; } .apache .hljs-attribute { color: #88c0d0; } .apache .hljs-section { color: #81a1c1; } .arduino .hljs-built_in { color: #88c0d0; } .aspectj .hljs-meta { color: #d08770; } .aspectj > .hljs-title { color: #88c0d0; } .bnf .hljs-attribute { color: #8fbcbb; } .clojure .hljs-name { color: #88c0d0; } .clojure .hljs-symbol { color: #ebcb8b; } .coq .hljs-built_in { color: #88c0d0; } .cpp .hljs-meta-string { color: #8fbcbb; } .css .hljs-built_in { color: #88c0d0; } .css .hljs-keyword { color: #d08770; } .diff .hljs-meta { color: #8fbcbb; } .ebnf .hljs-attribute { color: #8fbcbb; } .glsl .hljs-built_in { color: #88c0d0; } .groovy .hljs-meta:not(:first-child) { color: #d08770; } .haxe .hljs-meta { color: #d08770; } .java .hljs-meta { color: #d08770; } .ldif .hljs-attribute { color: #8fbcbb; } .lisp .hljs-name { color: #88c0d0; } .lua .hljs-built_in { color: #88c0d0; } .moonscript .hljs-built_in { color: #88c0d0; } .nginx .hljs-attribute { color: #88c0d0; } .nginx .hljs-section { color: #5e81ac; } .pf .hljs-built_in { color: #88c0d0; } .processing .hljs-built_in { color: #88c0d0; } .scss .hljs-keyword { color: #81a1c1; } .stylus .hljs-keyword { color: #81a1c1; } .swift .hljs-meta { color: #d08770; } .vim .hljs-built_in { color: #88c0d0; font-style: italic; } .yaml .hljs-meta { color: #d08770; } ================================================ FILE: apps/website/static/googlee0ff7b5bc8d30f78.html ================================================ google-site-verification: googlee0ff7b5bc8d30f78.html ================================================ FILE: apps/website/utils/getDocUrl.js ================================================ const siteConfig = require(`${process.cwd()}/siteConfig.js`); const docsUrl = `${siteConfig.baseUrl}docs`; function getDocUrl(doc, language) { if (language) { return `${docsUrl}/${language}/${doc}`; } return `${docsUrl}/${doc}`; } module.exports = getDocUrl; ================================================ FILE: apps/website/utils/getImgUrl.js ================================================ const siteConfig = require(`${process.cwd()}/siteConfig.js`); const imgUrl = `${siteConfig.baseUrl}img`; function getImgUrl(img) { return `${imgUrl}/${img}`; } module.exports = getImgUrl; ================================================ FILE: biome.json ================================================ { "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", "assist": { "actions": { "source": { "organizeImports": { "level": "on", "options": { "groups": [":NODE:", ":PACKAGE:", ":BLANK_LINE:", ":PATH:"] } } } } }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2, "lineWidth": 100 }, "linter": { "enabled": true, "rules": { "recommended": true, "correctness": { "noUnusedImports": "error", "noUnusedVariables": "error" }, "style": { "noNonNullAssertion": "off", "useConst": "error", "useNodejsImportProtocol": "error", "useTemplate": "error", "useImportType": { "level": "error", "options": { "style": "inlineType" } }, "useExportType": "error", "useForOf": "error", "useThrowOnlyError": "error", "useDefaultParameterLast": "error", "useCollapsedElseIf": "error" }, "suspicious": { "noExplicitAny": "off", "useIterableCallbackReturn": "off" } } }, "javascript": { "formatter": { "quoteStyle": "single", "trailingCommas": "all" } }, "files": { "includes": ["apps/**", "libs/**", "towers/**", "!apps/website", "!**/dist"] } } ================================================ FILE: docs/community/ecosystem.md ================================================ --- id: ecosystem title: Ecosystem --- WarriorJS uses NPM/Yarn, so any WarriorJS related project can live on [npm](https://npmjs.com). Official WarriorJS packages live under the [@warriorjs scope](https://npmjs.com/org/warriorjs), whereas community packages should be prefixed with `warriorjs-`. Please also put the keywords "warriorjs" and "warriorjs-tower" (if you're publishing a tower) in package.json's keywords field. ================================================ FILE: docs/community/roadmap.md ================================================ --- id: roadmap title: Roadmap & Contribution --- ## WarriorJS TODOs Some future plans are briefly discussed in the repo's [issues page](https://github.com/olistic/warriorjs/issues). ## Contribution Opportunities These are the many ways you can help: - Submit patches and features - Make [towers](player/towers.md) (new levels for the game) - Improve this documentation and website - Report bugs - Follow us on [Twitter](https://twitter.com/warrior_js) - Participate in the [Spectrum community](https://spectrum.chat/warriorjs) - And [donate financially](https://opencollective.com/warriorjs)! Please read our [contribution guide](https://github.com/olistic/warriorjs/blob/master/CONTRIBUTING.md) to get started. ================================================ FILE: docs/community/socialize.md ================================================ --- id: socialize title: Socialize --- Do you have any questions, ideas, or suggestions? - Come say hi in [Spectrum](https://spectrum.chat/warriorjs) - Tweet us [@warrior_js](https://twitter.com/warrior_js) ================================================ FILE: docs/maker/adding-levels.md ================================================ --- id: adding-levels title: Adding Levels --- A level is another JavaScript object: ```js const Level1 = { // Level definition. }; ``` Let's start off by writing a description and a tip for our level: ```js const Level1 = { description: "You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.", tip: "Call `warrior.walk()` to walk forward in the Player's `playTurn` method.", }; ``` We also need to define two numbers: the time bonus and the ace score. The time bonus is earned by the player depending on how fast they complete the level (it is decremented turn by turn until it reaches zero). The ace score, on the other hand, is used to calculate the level grade (in epic mode only). Any score greater or equal to the ace score will get an S. Let's add those numbers: ```js const Level1 = { description: "You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.", tip: "Call `warrior.walk()` to walk forward in the Player's `playTurn` method.", timeBonus: 15, aceScore: 10, }; ``` > These two numbers will need to be fine-tuned when play testing the tower. For > this guide, we've already done that. The next thing to do is to define the floor of the level, starting by its size: ```js const Level1 = { description: "You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.", tip: "Call `warrior.walk()` to walk forward in the Player's `playTurn` method.", timeBonus: 15, aceScore: 10, floor: { size: { width: 8, height: 1, }, }, }; ``` Then, we need to position the stairs so that the Warrior can move to the next level: ```js const Level1 = { description: "You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.", tip: "Call `warrior.walk()` to walk forward in the Player's `playTurn` method.", timeBonus: 15, aceScore: 10, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, }, }; ``` Speaking of warrior, let's define the Warrior for this level: ```js const Level1 = { description: "You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.", tip: "Call `warrior.walk()` to walk forward in the Player's `playTurn` method.", timeBonus: 15, aceScore: 10, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { character: '@', maxHealth: 20, position: { x: 0, y: 0, facing: 'east', }, }, }, }; ``` With that, the level is complete. But before continuing, let's define another level: ```js const Level2 = { description: 'The cold became more intense. In the distance, you see a pair of deep and blue eyes, a blue that burns like ice.', 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.", clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.', timeBonus: 20, aceScore: 26, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { character: '@', maxHealth: 20, position: { x: 0, y: 0, facing: 'east', }, }, }, }; ``` > As things started to get more challenging for the player, this time we added a > clue. Clues are optional and will only be shown upon demand. Now we need to add these two levels to the tower. Levels are added to the `levels` array of the tower: ```js module.exports = { name: 'Game of Thrones', description: 'There is only one war that matters: the Great War. And it is here.', levels: [Level1, Level2], }; ``` Superb! But as you could have noticed, we instruct the player to call `warrior.attack()`, `warrior.feel()`, and `warrior.walk()` but we haven't taught the Warrior how to do any of that. Let's do that next! ================================================ FILE: docs/maker/creating-tower.md ================================================ --- id: creating-tower title: Creating Your Tower --- A WarriorJS tower is a regular JavaScript module with a single export: ```js module.exports = { // Tower definition. }; ``` Let's define the name of our tower and write a brief description: ```js module.exports = { name: 'Game of Thrones', description: 'There is only one war that matters: the Great War. And it is here.', }; ``` Cool! But there's nothing to climb yet. Let's add some levels to this tower! ================================================ FILE: docs/maker/defining-abilities.md ================================================ --- id: defining-abilities title: Defining Abilities --- An ability is a JavaScript function that receives the unit that possesses the ability as the only parameter and returns a JavaScript object: ```js function walk(unit) { return { // Ability definition. }; } ``` The walk ability is an action, so first of all let's indicate that: ```js function walk(unit) { return { action: true, }; } ``` Then, we need to write a description for the ability so that the player knows what it does: ```js function walk(unit) { return { action: true, description: 'Move one space in the given direction (forward by default).', }; } ``` And last but not least, we need to write the ability's logic in the `perform` function. Here, we can use any of the methods in the [Unit Maker API](maker/unit-api.md). Let's do that: ```js function walk(unit) { return { action: true, description: 'Move one space in the given direction (forward by default).', perform(direction = 'forward') { const space = unit.getSpaceAt(direction); if (space.isEmpty()) { unit.move(direction); unit.log(`walks ${direction}`); } else { unit.log(`walks ${direction} and bumps into ${space}`); } }, }; } ``` Abilities are are added to the units under a key in the `abilities` object. Let's add the walk ability to the Warrior under the `walk` key (because we want the player to invoke it by calling `warrior.walk()`): ```js const Level1 = { description: "You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.", tip: "Call `warrior.walk()` to walk forward in the Player's `playTurn` method.", timeBonus: 15, aceScore: 10, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { character: '@', maxHealth: 20, position: { x: 0, y: 0, facing: 'east', }, abilities: { walk: walk, }, }, }, }; ``` For the second level, we need to add two more abilities: attack and feel. First, let's define the attack ability: ```js function valyrianSteelSwordAttack(unit) { return { action: true, description: 'Attack a unit in the given direction (forward by default) with your Valyrian steel sword, dealing 5 HP of damage.', perform(direction = 'forward') { const receiver = unit.getSpaceAt(direction).getUnit(); if (receiver) { unit.log(`attacks ${direction} and hits ${receiver}`); unit.damage(receiver, 5); } else { unit.log(`attacks ${direction} and hits nothing`); } }, }; } ``` Secondly, let's define the feel ability. Contrary to attack, feel is a sense, so we can omit the `action` key: ```js function feel(unit) { return { description: 'Return the adjacent space in the given direction (forward by default).', perform(direction = 'forward') { return unit.getSensedSpaceAt(direction); }, }; } ``` > **IMPORTANT:** When returning one or multiple spaces from senses, use > `unit.getSensedSpaceAt()` instead of `unit.getSpaceAt()`. The former returns a > space that exposes only the [Space Player API](player/space-api.md), whereas > the latter exposes the [Space Maker API](maker/space-api.md) and is meant to > be used internally, like in the attack ability before. Finally, let's add them to the Warrior of the second level: ```js const Level2 = { description: 'The cold became more intense. In the distance, you see a pair of deep and blue eyes, a blue that burns like ice.', 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.", clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.', timeBonus: 20, aceScore: 26, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { character: '@', maxHealth: 20, position: { x: 0, y: 0, facing: 'east', }, abilities: { attack: valyrianSteelSwordAttack, feel: feel, }, }, }, }; ``` > We don't add the walk ability again in the second level because the Warrior > already learned it in the first level. This is very nice, but the Warrior is not wielding that Valyrian steel sword for nothing. Let's add an enemy he can fight! ================================================ FILE: docs/maker/defining-units.md ================================================ --- id: defining-units title: Defining Units --- A unit is a JavaScript object: ```js const WhiteWalker = { // Unit definition. }; ``` Let's start off by adding the name of the unit: ```js const WhiteWalker = { name: 'White Walker', }; ``` > We didn't add a name for the Warrior because it's supplied by the player > during the game. Just like the Warrior, other units also need a character and a max health value: ```js const WhiteWalker = { name: 'White Walker', character: 'w', maxHealth: 12, }; ``` Let's define a new attack ability: ```js function iceCrystalSwordAttack(unit) { return { action: true, description: 'Attack a unit in the given direction (forward by default) with your ice blade, dealing 3 HP of damage.', perform(direction = 'forward') { const receiver = unit.getSpaceAt(direction).getUnit(); if (receiver) { unit.log(`attacks ${direction} and hits ${receiver}`); unit.damage(receiver, 3); } else { unit.log(`attacks ${direction} and hits nothing`); } }, }; } ``` And add it to the White Walker. Let's also add the same feel ability we'd already defined: ```js const WhiteWalker = { name: 'White Walker', character: 'w', maxHealth: 12, abilities: { attack: iceCrystalSwordAttack, feel: feel, }, }; ``` Finally, we need to define the AI of our White Walker. It'll be a very rudimentary AI: the White Walker will start his turn by feeling in every direction looking for an enemy (the Warrior). If he finds it in any direction, he'll attack in that direction. Let's write that logic in the `playTurn` function: ```js const WhiteWalker = { name: 'White Walker', character: 'w', maxHealth: 12, abilities: { attack: iceCrystalSwordAttack, feel: feel, }, playTurn(whiteWalker) { const enemyDirection = ['forward', 'right', 'backward', 'left'].find( direction => { const unit = whiteWalker.feel(direction).getUnit(); return unit && unit.isEnemy(); }, ); if (enemyDirection) { whiteWalker.attack(enemyDirection); } }, }; ``` > We didn't write the AI for the Warrior either because it's also supplied by > the player during the game. Now we need to add the White Walker to the second level. Units other than the Warrior are added to the `units` array of the floor: ```js const Level2 = { description: 'The cold became more intense. In the distance, you see a pair of deep and blue eyes, a blue that burns like ice.', 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.", clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.', timeBonus: 20, aceScore: 26, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { character: '@', maxHealth: 20, position: { x: 0, y: 0, facing: 'east', }, abilities: { attack: valyrianSteelSwordAttack, feel: feel, }, }, units: [ { ...WhiteWalker, position: { x: 4, y: 0, facing: 'west', }, }, ], }, }; ``` > Here, we used spread properties to merge the unit definition with its position > in the floor. Congratulations! You've created your first tower. At this point, this tower is fully playable, but it can use some refactoring. ================================================ FILE: docs/maker/introduction.md ================================================ --- id: introduction title: Introduction --- In this guide, you'll learn how to make your own tower by following a quick example. This guide assumes that you're familiar with the basic concepts of WarriorJS. It's also important that you have played the game, ideally having completed at least the "The Narrow Path" tower. Let's get started! ================================================ FILE: docs/maker/publishing.md ================================================ --- id: publishing title: Publishing --- This is the minimal structure of a tower package: ```sh warriorjs-tower-got ├── index.js └── package.json ``` Where `index.js` would contain the code we've been writing through this guide, and `package.json` the npm package info: ```json { "name": "warriorjs-tower-got", "version": "0.1.0", "description": "There is only one war that matters: the Great War. And it is here.", "main": "index.js", "keywords": ["warriorjs-tower"], "dependencies": { "@warriorjs/geography": "^0.4.0" } } ``` Some special considerations: - The package name must start with `warriorjs-tower-` for the tower to be automatically loaded by WarriorJS. - `warriorjs-tower` should be in the "keywords" field for better discoverability of your tower. When working on a tower, you can use [`npm pack`](https://docs.npmjs.com/cli/pack) to create a tarball for it, and then install it where you installed `@warriorjs/cli` by doing: ```sh npm install ``` After doing that, running `warriorjs` should load your tower automatically. Once you've tested and adjusted your tower, you're ready to publish it to [npm](https://npmjs.com) for others to play it. Follow this [guide](https://docs.npmjs.com/getting-started/publishing-npm-packages) to learn how to publish a package to npm. ================================================ FILE: docs/maker/refactoring.md ================================================ --- id: refactoring title: Refactoring --- At this point, you should have the following code: ```js function valyrianSteelSwordAttack(unit) { return { action: true, description: 'Attack a unit in the given direction (forward by default) with your Valyrian steel sword, dealing 5 HP of damage.', perform(direction = 'forward') { const receiver = unit.getSpaceAt(direction).getUnit(); if (receiver) { unit.log(`attacks ${direction} and hits ${receiver}`); unit.damage(receiver, 5); } else { unit.log(`attacks ${direction} and hits nothing`); } }, }; } function iceCrystalSwordAttack(unit) { return { action: true, description: 'Attack a unit in the given direction (forward by default) with your ice blade, dealing 3 HP of damage.', perform(direction = 'forward') { const receiver = unit.getSpaceAt(direction).getUnit(); if (receiver) { unit.log(`attacks ${direction} and hits ${receiver}`); unit.damage(receiver, 3); } else { unit.log(`attacks ${direction} and hits nothing`); } }, }; } function feel(unit) { return { description: 'Return the adjacent space in the given direction (forward by default).', perform(direction = 'forward') { return unit.getSensedSpaceAt(direction); }, }; } function walk(unit) { return { action: true, description: 'Move one space in the given direction (forward by default).', perform(direction = 'forward') { const space = unit.getSpaceAt(direction); if (space.isEmpty()) { unit.move(direction); unit.log(`walks ${direction}`); } else { unit.log(`walks ${direction} and bumps into ${space}`); } }, }; } const WhiteWalker = { name: 'White Walker', character: 'w', maxHealth: 12, abilities: { attack: iceCrystalSwordAttack, feel: feel, }, playTurn(whiteWalker) { const enemyDirection = ['forward', 'right', 'backward', 'left'].find( direction => { const unit = whiteWalker.feel(direction).getUnit(); return unit && unit.isEnemy(); }, ); if (enemyDirection) { whiteWalker.attack(enemyDirection); } }, }; const Level1 = { description: "You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.", tip: "Call `warrior.walk()` to walk forward in the Player's `playTurn` method.", timeBonus: 15, aceScore: 10, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { character: '@', maxHealth: 20, position: { x: 0, y: 0, facing: 'east', }, abilities: { walk: walk, }, }, }, }; const Level2 = { description: 'The cold became more intense. In the distance, you see a pair of deep and blue eyes, a blue that burns like ice.', 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.", clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.', timeBonus: 20, aceScore: 26, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { character: '@', maxHealth: 20, position: { x: 0, y: 0, facing: 'east', }, abilities: { attack: valyrianSteelSwordAttack, feel: feel, }, }, units: [ { ...WhiteWalker, position: { x: 4, y: 0, facing: 'west', }, }, ], }, }; module.exports = { name: 'Game of Thrones', description: 'There is only one war that matters: the Great War. And it is here.', levels: [Level1, Level2], }; ``` Just like what we did with the White Walker definition, we can extract the common fields of the Warrior to an object and then use spread properties to add it to every level: ```js const Warrior = { character: '@', maxHealth: 20, }; const Level1 = { description: "You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.", tip: "Call `warrior.walk()` to walk forward in the Player's `playTurn` method.", timeBonus: 15, aceScore: 10, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { ...Warrior, abilities: { walk: walk, }, position: { x: 0, y: 0, facing: 'east', }, }, }, }; const Level2 = { description: 'The cold became more intense. In the distance, you see a pair of deep and blue eyes, a blue that burns like ice.', 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.", clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.', timeBonus: 20, aceScore: 26, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { ...Warrior, abilities: { attack: valyrianSteelSwordAttack, feel: feel, }, position: { x: 0, y: 0, facing: 'east', }, }, units: [ { ...WhiteWalker, position: { x: 4, y: 0, facing: 'west', }, }, ], }, }; ``` With regard to the abilities, we can see that both attack abilities are very similar. Let's do something about that: ```js function attackCreator({ power, weapon }) { return unit => ({ action: true, description: `Attack a unit in the given direction (forward by default) with your ${weapon}, dealing ${power} HP of damage.`, perform(direction = 'forward') { const receiver = unit.getSpaceAt(direction).getUnit(); if (receiver) { unit.log(`attacks ${direction} and hits ${receiver}`); unit.damage(receiver, power); } else { unit.log(`attacks ${direction} and hits nothing`); } }, }); } const valyrianSteelSwordAttack = attackCreator({ power: 5, weapon: 'Valyrian steel sword', }); const iceCrystalSwordAttack = attackCreator({ power: 3, weapon: 'ice blade', }); ``` > We can call this the "ability creator" pattern, where we define a function > (the ability creator) which returns another function (the ability) customized > with the parameters we passed to the creator. To end with the refactor, let's get rid of all those magic strings representing directions. There's an official package called [`@warriorjs/geography`](https://github.com/olistic/warriorjs/tree/master/libs/warriorjs-geography) that exposes a bunch of constants and methods related to directioning. Let's use it: ```js const { EAST, FORWARD, RELATIVE_DIRECTIONS, WEST, } = require('@warriorjs/geography'); function attackCreator({ power, weapon }) { return unit => ({ action: true, description: `Attack a unit in the given direction (forward by default) with your ${weapon}, dealing ${power} HP of damage.`, perform(direction = FORWARD) { const receiver = unit.getSpaceAt(direction).getUnit(); if (receiver) { unit.log(`attacks ${direction} and hits ${receiver}`); unit.damage(receiver, power); } else { unit.log(`attacks ${direction} and hits nothing`); } }, }); } const valyrianSteelSwordAttack = attackCreator({ power: 5, weapon: 'Valyrian steel sword', }); const iceCrystalSwordAttack = attackCreator({ power: 3, weapon: 'ice blade', }); function feel(unit) { return { description: 'Return the adjacent space in the given direction (forward by default).', perform(direction = FORWARD) { return unit.getSensedSpaceAt(direction); }, }; } function walk(unit) { return { action: true, description: 'Move one space in the given direction (forward by default).', perform(direction = FORWARD) { const space = unit.getSpaceAt(direction); if (space.isEmpty()) { unit.move(direction); unit.log(`walks ${direction}`); } else { unit.log(`walks ${direction} and bumps into ${space}`); } }, }; } const Warrior = { character: '@', maxHealth: 20, }; const WhiteWalker = { name: 'White Walker', character: 'w', maxHealth: 12, abilities: { attack: iceCrystalSwordAttack, feel: feel, }, playTurn(whiteWalker) { const enemyDirection = RELATIVE_DIRECTIONS.find(direction => { const unit = whiteWalker.feel(direction).getUnit(); return unit && unit.isEnemy(); }); if (enemyDirection) { whiteWalker.attack(enemyDirection); } }, }; const Level1 = { description: "You've entered the ancient castle of Eastwatch to escape from a blizzard. But it's deadly cold inside too.", tip: "Call `warrior.walk()` to walk forward in the Player's `playTurn` method.", timeBonus: 15, aceScore: 10, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { ...Warrior, abilities: { walk: walk, }, position: { x: 0, y: 0, facing: EAST, }, }, }, }; const Level2 = { description: 'The cold became more intense. In the distance, you see a pair of deep and blue eyes, a blue that burns like ice.', 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.", clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.', timeBonus: 20, aceScore: 26, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { ...Warrior, abilities: { attack: valyrianSteelSwordAttack, feel: feel, }, position: { x: 0, y: 0, facing: EAST, }, }, units: [ { ...WhiteWalker, position: { x: 4, y: 0, facing: WEST, }, }, ], }, }; module.exports = { name: 'Game of Thrones', description: 'There is only one war that matters: the Great War. And it is here.', levels: [Level1, Level2], }; ``` Much better! Keep reading to learn how to test and publish your tower so that other players can play! ================================================ FILE: docs/maker/space-api.md ================================================ --- id: space-api title: Space API --- As a maker, you call the same methods the player call on a sensed space, but on a regular space. ## Class Methods Here are the various methods that are available to you: ### `space.isEmpty()`: Determines if nothing (except maybe stairs) is at this space. **Returns** _(boolean)_: Whether this space is empty or not. ### `space.isStairs()` Determines if the stairs are at this space. **Returns** _(boolean)_: Whether the stairs are at this space or not. ### `space.isWall()` Determines if this is the edge of the level. **Returns** _(boolean)_: Whether this space is a wall or not. ### `space.isUnit()` Determines if there's a unit at this space. **Returns** _(boolean)_: Whether a unit is at this space or not. ### `space.getUnit()` Returns the unit located at this space (if any). **This unit will be a regular unit, not a sensed unit.** **Returns** _(Unit)_: The unit at this location or `undefined` if there's none. ## Instance Properties ### `location` _(number[])_ The absolute location of this space as the pair of coordinates `[x, y]`. ================================================ FILE: docs/maker/testing.md ================================================ --- id: testing title: Testing --- First, you want to make the top of your tower can be reached. And then, you'll want to fine-tune the time bonus and ace score values for each level. ================================================ FILE: docs/maker/unit-api.md ================================================ --- id: unit-api title: Unit API --- As a maker, you call methods on units when writing the logic for the abilities you create. ## Class Methods Here are the various methods that are available to you: ### `unit.heal(amount)` Adds the given amount of health in HP. Health can't go over max health. **Arguments** `amount` _(number)_: The amount of HP to add. ### `unit.takeDamage(amount)` Subtracts the given amount of health in HP. If the unit is bound, it will unbound when taking damage. Health can't go under zero. If it reaches zero, the unit will die and vanish from the floor. **Arguments** `amount` _(number)_: The amount of HP to subtract. ### `unit.damage(receiver, amount)` Damages another unit. If the other unit dies, the damager will earn or lose points equal to the dead unit's reward depending on whether that unit was an enemy or a friend, respectively. **Arguments** `receiver` _(Unit)_: The unit to damage. `amount` _(number)_: The amount of HP to inflict. ### `unit.isAlive()` Determines if the unit is alive. A unit is alive if it has a position. **Returns** _(boolean)_: Whether the unit is alive or not. ### `unit.release(unit)` Releases (unbinds) another unit. If the other unit was a friend, the releaser will earn points equal to the released unit's reward. **Arguments** `receiver` _(Unit)_: The unit to release. ### `unit.unbind()` Unbinds the unit. ### `unit.bind()` Binds the unit. ### `unit.isBound()` Determines if the unit is bound. **Returns** _(boolean)_: Whether this unit is bound or not. ### `unit.earnPoints(points)` Adds the given points to the score. **Arguments** `points` _(number)_: The points to earn. ### `unit.losePoints(points)` Subtracts the given points from the score. **Arguments** `points` _(number)_: The points to lose. ### `unit.triggerEffect(effect)` Triggers the given effect. **Arguments** `effect` _(string)_: The name of the effect. ### `unit.isUnderEffect(effect)` Determines if the unit is under the given effect. **Arguments** `effect` _(string)_: The name of the effect. **Returns** _(boolean)_: Whether this unit is under the given effect or not. ### `unit.getOtherUnits()` Returns the units in the floor minus this unit. **Returns** _(Unit[])_: The other units in the floor. ### `unit.getSpace()` Returns the space where this unit is located. **Returns** _(Space)_: The space this unit is located at. ### `unit.getSensedSpaceAt(direction, forward = 1, right = 0)` Returns the sensed space located at the direction and number of spaces. Use this method when returning spaces from senses. **Always return sensed spaces to the player.** **Arguments** `direction` _(string)_: The direction. `forward` _(number)_: The number of spaces forward. `right` _(number)_: The number of spaces to the right. **Returns** _(SensedSpace)_: The sensed space. ### `unit.getSpaceAt(direction, forward = 1, right = 0)` Returns the space located at the direction and number of spaces. Use this method internally. **Never return a regular space to the player.** **Arguments** `direction` _(string)_: The direction. `forward` _(number)_: The number of spaces forward. `right` _(number)_: The number of spaces to the right. **Returns** _(Space)_: The space. ### `unit.getDirectionOfStairs()` Returns the direction of the stairs with reference to this unit. **Returns** _(string)_: The relative direction of the stairs. ### `unit.getDirectionOf(space)` Returns the direction of the given space with reference to this unit. **Arguments** `space` _(SensedSpace)_: The space to get the direction of. **Returns** _(string)_: The relative direction of the space. ### `unit.getDistanceOf(space)` Returns the distance between the given space and this unit. **Arguments** `space` _(SensedSpace)_: The space to calculate the distance of. **Returns** _(number)_: The distance of the space. ### `unit.move(direction, forward = 1, right = 0)` Moves the unit in the given direction and number of spaces. **Arguments** `direction` _(string)_: The direction. `forward` _(number)_: The number of spaces forward. `right` _(number)_: The number of spaces to the right. ### `unit.rotate(direction)` Rotates the unit in a given direction. **Arguments** `direction` _(string)_: The direction in which to rotate. ### `unit.vanish()` Vanishes the unit from the floor. ### `unit.log(message)` Logs a message to the play log. **Arguments** `message` _(string)_: The message to log. ## Instance Properties ### `name` _(string)_ The name of the unit. ### `character` _(string)_ The character that represents the unit in the floor map. ### `health` _(number)_ The total damage the unit may take before dying, in HP. ### `maxHealth` _(number)_ The maximum `health` value. ### `reward` _(number)_ The number of points to reward when interacting. ### `enemy` _(boolean)_ Whether the unit belongs to the enemy side or not. ### `bound` _(boolean)_ Whether the unit is bound or not. ================================================ FILE: docs/player/abilities.md ================================================ --- id: abilities title: Abilities --- An **ability** is a skill possessed by a unit. As the player, you activate abilities during your warrior's turn. > Ability selection is the way you can customize how your warrior plays. ## Learning new abilities When you first start, your warrior will only have a few abilities. Additional abilities are acquired progressing through the tower. With each level, you'll learn new things or find artifacts that will expand your capabilities. ## Ability types There are two types of abilities: actions and senses. ### Actions An **action** is an ability that affects the game in some way. Is through actions that you're able to inflict damage, protect yourself or other units, and interact with your environment. > Only one action can be performed per turn, so choose wisely. ### Senses A **sense**, on the contrary, doesn't affect the game but gathers information about the floor. You can perform senses as often as you want per turn to collect information about your surroundings and to aid you in choosing the best action according to the circumstances. > Since what you sense will change each turn, you may want to record the > information you gather for use on the next turn. For example, you can > determine if you are being attacked if your health has gone down since the > last turn. ================================================ FILE: docs/player/ai-tips.md ================================================ --- id: ai-tips sidebar_label: Artificial Intelligence title: AI Tips --- - Once you've made some progress in the tower, your code may have turned into a bunch of nested if/else statements. If that's the case, you may want to apply some AI concepts like [FSMs and the State pattern](http://gameprogrammingpatterns.com/state.html), or more trendy things like [Behavior Trees](https://www.gamasutra.com/blogs/ChrisSimpson/20140717/221339/Behavior_trees_for_AI_How_they_work.php). ================================================ FILE: docs/player/cli-tips.md ================================================ --- id: cli-tips sidebar_label: CLI title: CLI Tips --- - Running `warriorjs` while you are in your profile's directory will auto-select that profile so you don't have to each time. - Make sure to try the different options you can pass to the `warriorjs` command. Run `warriorjs --help` to see them all. * If you're on Windows, consider using [cmder](http://cmder.net) instead of `cmd.exe`. ================================================ FILE: docs/player/effects.md ================================================ --- id: effects title: Effects --- An **effect** is any kind of status that affects a unit. They're generally applied by actions and can have a positive or negative impact. Most effects are temporary. ================================================ FILE: docs/player/epic-mode.md ================================================ --- id: epic-mode title: Epic Mode --- Once you reach the top of the tower, you'll have the option to enter **epic mode**. If you choose so, running `warriorjs` again will run your current `Player.js` through all levels in the tower without stopping. You'll most likely not succeed the first time around in epic mode. If that's the case, you'll need to make adjustments to your warrior by editing `Player.js`. Once your warrior reaches the top again, you will receive a grade for each level, along with an average grade for the tower. The grades, from best to worst, are: S, A, B, C, D, and F. Try to get an S on each level for the ultimate score! ================================================ FILE: docs/player/gameplay.md ================================================ --- id: gameplay title: Gameplay --- The play happens through a series of turns. On each one and starting with your warrior, the units in the floor will have the chance to use their abilities. ## Code Open the `Player.js` file in your profile's directory. You should see some starting code: ```js class Player { playTurn(warrior) { // Cool code goes here. } } ``` You need to fill the `playTurn` method with logic to teach the warrior what to do depending on the situation. See the README in your profile's directory for details on what's on the current level and what abilities your warrior has available to deal with it. Here is an example from the "The Narrow Path" tower which will instruct the warrior to walk if there's nothing ahead, otherwise attack: ```js class Player { playTurn(warrior) { if (warrior.feel().isEmpty()) { warrior.walk(); } else { warrior.attack(); } } } ``` > This is assuming your warrior has "attack", "feel", and "walk" abilities > available. ## Play Once you're done editing `Player.js`, save the file and run the `warriorjs` command again to start playing the level. You cannot change your code in the middle of a level, so you must take into account everything that may happen on that level and give your warrior the proper instructions from the start. ## Outcome Losing all of your health will cause you to fail the level. You're not punished by this; just go back to the `Player.js` file, improve your code, and try again. Once you pass a level (by reaching the stairs), the README will be updated for the next level. Alter the `Player.js` file and run `warriorjs` again to play the next level. ================================================ FILE: docs/player/general-tips.md ================================================ --- id: general-tips sidebar_label: General title: General Tips --- - **If you ever get stuck on a level, review the README** and be sure you're trying each ability out. - **If you can't keep your health up, be sure to rest when no enemy is around** (while keeping an eye on your health). Also, try to use far-ranged weapons whenever possible (such as the bow in the "The Narrow Path" tower). - **Senses are cheap, so use them liberally.** Store the sensed information to help you better determine what actions to take in the future. - **If you're aiming for points, remember to sweep the area.** Even if you're close to the stairs, don't go in until you've gotten everything (if you have the health). Use far-ranged senses (such as "look" and "listen" in the "The Narrow Path" tower) to determine if there are any enemies left. ================================================ FILE: docs/player/install.md ================================================ --- id: install title: Install --- Let's start by installing WarriorJS globally with [npm](https://npmjs.com). Open the terminal and run: ```sh npm install --global @warriorjs/cli ``` > **IMPORTANT:** [Node.js](https://nodejs.org) >=8 needs to be installed in your > computer before running the `npm` command. The recommended installation method > is through the [official installer](https://nodejs.org/en/download). After installing the game, you can execute it by running the `warriorjs` command in the terminal: ```sh warriorjs ``` That's it! This will guide you through the creation of your warrior. Give your warrior a proper name and choose the "The Narrow Path" tower. After you've done that, you should have the following file structure: ```sh warriorjs └── aldric-the-narrow-path ├── Player.js └── README.md ``` - `aldric-the-narrow-path` is your profile's directory. - `Player.js` is your warrior's brain, you'll be editing this file often. - `README.md` contains the instructions for the current level. Go ahead and open `README.md` to find the instructions for the first level. ================================================ FILE: docs/player/js-tips.md ================================================ --- id: js-tips sidebar_label: JavaScript title: JavaScript Tips --- - Don't simply fill up the `playTurn` method with a lot of code, **organize your code with methods and classes**. For example: ```js class Player { playTurn(warrior) { if (this.isInjured(warrior)) { warrior.rest(); } } isInjured(warrior) { return warrior.health() < 20; } } ``` - If you want some code to be executed at the beginning of each level, **define a [constructor][] in the `Player` class**, like this: ```js class Player { constructor() { // This code will be executed only once, at the beginning of the level. this.health = 20; } // ... } ``` - You can call methods of the Space API directly after a sense. For example, the "feel" sense in the "The Narrow Path" tower returns one space. You can call `isEmpty()` on this to determine if the space is clear before walking there: ```js class Player { playTurn(warrior) { if (warrior.feel().isEmpty()) { warrior.walk(); } } } ``` - Some senses (like "look" and "listen" in the "The Narrow Path" tower) return an array of spaces instead, so **you might find many of the [Array prototype methods][] really useful**. Here is an example of the [Array.prototype.find][] method: ```js class Player { // ... isEnemyInSight(warrior) { const spaceWithUnit = warrior.look().find(space => space.isUnit()); return spaceWithUnit && spaceWithUnit.getUnit().isEnemy(); } } ``` [constructor]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Classes/constructor [array prototype methods]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/prototype#Methods [array.prototype.find]: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/find ================================================ FILE: docs/player/object.md ================================================ --- id: object title: Object --- The goal of the game is to climb to the top of a tower. You progress through the tower by reaching the stairs on each level, but the higher you are, the more difficult it gets. With each level, your abilities will grow along with the difficulty. But it's up to you to put those abilities to good use and make your warrior smart enough to face the dangers that stand between him or her and the stairs. ================================================ FILE: docs/player/options.md ================================================ --- id: options title: Options --- There are various options you can pass to the `warriorjs` command to customize the game. You can run `warriorjs --help` to see all the available options. Here is a detailed list: ## `--directory ` Path to a directory under which to run the game. By default, the current working directory is used. ## `--level ` (epic mode only) Practice a level. Use this option on levels you are having difficulty or want to fine-tune the scoring. ## `--silent` Suppress play log. Use this option if you just care about the outcome of playing a level, and not each step of the play. ## `--time ` Delay each turn by seconds. By default, each step of each turn is delayed by 0.6 seconds. ## `--yes` Assume yes in non-destructive confirmation dialogs. ================================================ FILE: docs/player/overview.md ================================================ --- id: overview title: Overview --- In WarriorJS, you are a warrior climbing a tall tower to reach _The JavaScript Sword_ at the top level. Legend has it that the sword bearer becomes enlightened in the JavaScript language, but be warned: the journey will not be easy. On each floor, you need to write JavaScript to instruct the warrior to battle enemies, rescue captives, and reach the stairs alive... **No matter if you are new to programming or a JavaScript guru, WarriorJS will put your skills to the test. Will you dare?** ================================================ FILE: docs/player/perspective.md ================================================ --- id: perspective title: Perspective --- Even though this is a text-based game, think of it as two-dimensional where you are viewing the level's floor from overhead. Each floor is always rectangular in shape and is made up of a number of squares. At its edge there are walls; you can't move there. You can move to any other square, including the stairs, that isn't already occupied by another unit (only one unit can be on a given square at a time). Here is an example of a floor map and key: ``` ╔════╗ ║C s>║ ║ S s║ ║C @ ║ ╚════╝ > = stairs @ = Aldric (20 HP) s = Sludge (12 HP) S = Thick Sludge (24 HP) C = Captive (1 HP) ``` ================================================ FILE: docs/player/scoring.md ================================================ --- id: scoring title: Scoring --- Your objective is to not only reach the stairs, but to get the highest score you can. There are many ways you can earn points on a level: - **Defeat an enemy** to add his max health to your score. - **Rescue a captive** to earn a reward. - **Pass the level within the bonus time** to earn the amount of bonus time remaining (each level has a bonus time that decreases turn by turn). - **Defeat all enemies and rescue all captives** to receive a 20% overall bonus. But you must be careful, because you can also lose points: - **Kill a captive** and you'll receive a penalty. A total score is kept as you progress through the levels. When you pass a level, that score is added to your total. ================================================ FILE: docs/player/space-api.md ================================================ --- id: space-api title: Space API --- Whenever you sense an area, often one or multiple spaces (in an array) will be returned. For example, the "feel" sense in the "The Narrow Path" tower returns one space: ```js const space = warrior.feel(); ``` You can call methods on a space to gather information about what's there. ## Class Methods Here are the various methods that are available to you: ### `space.getLocation()`: Returns the relative location of this space as the number of spaces forward and to the right of your position. **Returns** _(number[])_: The relative location of this space as the offset `[forward, right]`. ### `space.isEmpty()`: Determines if nothing (except maybe stairs) is at this space. **Returns** _(boolean)_: Whether this space is empty or not. ### `space.isStairs()` Determines if the stairs are at this space. **Returns** _(boolean)_: Whether the stairs are at this space or not. ### `space.isWall()` Determines if this is the edge of the level. **Returns** _(boolean)_: Whether this space is a wall or not. ### `space.isUnit()` Determines if there's a unit at this space. **Returns** _(boolean)_: Whether a unit is at this space or not. ### `space.getUnit()` Returns the unit located at this space (if any). **Returns** _(Unit)_: The unit at this location or `undefined` if there's none. ================================================ FILE: docs/player/spaces.md ================================================ --- id: spaces title: Spaces --- A **space** is an object representing a square in the floor. A space can be empty, or it can have a wall or a unit located at it. One of the spaces in the floor has the stairs, which you need to climb to move on to the next level. ================================================ FILE: docs/player/towers.md ================================================ --- id: towers title: Towers --- A **tower** is a WarriorJS world. In addition to defining levels, towers can also add new abilities, effects, and units to the game. WarriorJS CLI ships with an entry-level tower built-in. You'll need to install any additional tower you want to play. ## Installing Towers Towers are automatically loaded if you have them installed in the same `node_modules` directory where `@warriorjs/cli` is located. This means that if you have installed the game globally, you'll need to install additional towers globally. If, on the other hand, you're running the game from a local installation, you'll need to install additional towers locally. Tower package names start with `@warriorjs/tower-` for official towers, or `warriorjs-tower-` for community towers. ### Official Towers - [`@warriorjs/tower-the-narrow-path`][warriorjs-tower-the-narrow-path] - [`@warriorjs/tower-the-powder-keep`][warriorjs-tower-the-powder-keep] (beta) ### Community Towers Have you made a tower? [Add it][add-community-tower] to the list! ## Making Towers Follow this [guide](maker/introduction.md). [warriorjs-tower-the-narrow-path]: https://github.com/olistic/warriorjs/tree/master/towers/the-narrow-path [warriorjs-tower-the-powder-keep]: https://github.com/olistic/warriorjs/tree/master/towers/the-powder-keep [add-community-tower]: https://github.com/olistic/warriorjs/edit/master/docs/player/towers.md ================================================ FILE: docs/player/turn-api.md ================================================ --- id: turn-api title: Turn API --- The `playTurn` method in `Player.js` gets passed an instance of your warrior's turn. The methods you can call on that turn are determined by the abilities your warrior has available in the current level. See the README in your profile's directory to find that out. Here is an example extracted from the README of the second level in the "Baby Steps" tower: ```markdown ### Abilities #### Actions - `warrior.attack()` - `warrior.walk()` #### Senses - `warrior.feel()` ``` In this level, your warrior has the abilities "attack", "feel", and "walk", which means you can call these three methods on your turn: `warrior.attack()`, `warrior.feel()`, and `warrior.walk()`. > Many abilities can be performed in the following directions: "forward", > "backward", "left", and "right". You have to pass a string with the direction > as the first argument, e.g. `warrior.walk('backward')`. ================================================ FILE: docs/player/unit-api.md ================================================ --- id: unit-api title: Unit API --- You can call `getUnit()` on a space to retrieve the unit located there (but keep in mind that not all spaces have units on them): ```js const unit = space.getUnit(); ``` You can call methods on a unit to know more about it. ## Class Methods Here are the various methods that are available to you: ### `unit.isBound()` Determines if the unit is bound. **Returns** _(boolean)_: Whether this unit is bound or not. ### `unit.isEnemy()`: Determines if the unit is an enemy. **Returns** _(boolean)_: Whether this is an enemy unit or not. ================================================ FILE: docs/player/units.md ================================================ --- id: units title: Units --- A **unit** is any character that populates the floors of the tower, including your warrior. ## Attributes A unit has the following attributes: - **Health**: the total damage the unit may take before dying, measured in Health Points (HP). - **Max Health**: the starting Health value. ## Abilities & Effects A unit can also have abilities and be under effects. ================================================ FILE: docs/player/warrior.md ================================================ --- id: warrior title: Warrior --- The **warrior** is the player-character in WarriorJS, which means it's controlled by you. To create a warrior, you'll be guided through a two-step process where you'll determine your warrior's name and the tower you want to climb. Once created, you'll use the warrior to progress through that tower. > The warrior and tower combination is usually referred to as a **profile**. The > number of profiles you can have is unlimited, but you can't use the same > warrior/tower combination twice. A warrior has the same attributes that any other unit. It also has abilities and can be under effects. ================================================ FILE: lefthook.yml ================================================ pre-commit: commands: lint: glob: "*.{js,ts,json,css,md,yaml}" run: pnpm biome check --write --no-errors-on-unmatched {staged_files} stage_fixed: true ================================================ FILE: libs/README.md ================================================ # Packages Shared libraries that power the WarriorJS game engine. | Package | Version | | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------- | | [`@warriorjs/core`][warriorjs-core] | [![npm][warriorjs-core-badge]][warriorjs-core-npm] | | [`@warriorjs/scoring`][warriorjs-scoring] | [![npm][warriorjs-scoring-badge]][warriorjs-scoring-npm] | | [`@warriorjs/spatial`][warriorjs-spatial] | [![npm][warriorjs-spatial-badge]][warriorjs-spatial-npm] | | [`@warriorjs/abilities`][warriorjs-abilities] | [![npm][warriorjs-abilities-badge]][warriorjs-abilities-npm] | | [`@warriorjs/effects`][warriorjs-effects] | [![npm][warriorjs-effects-badge]][warriorjs-effects-npm] | | [`@warriorjs/units`][warriorjs-units] | [![npm][warriorjs-units-badge]][warriorjs-units-npm] | - [`@warriorjs/core`][warriorjs-core] is where the game mechanics are implemented; it exposes the `warriorjs.runLevel` function to run the given level with the given code and return the result of that run. - [`@warriorjs/scoring`][warriorjs-scoring] exposes functions for computing level scores and converting numeric grades to letter grades. - [`@warriorjs/spatial`][warriorjs-spatial] exposes some constants and functions related to directioning (absolute and relative) of units and abilities in the game. - [`@warriorjs/abilities`][warriorjs-abilities] defines the abilities that are used in the official towers. - [`@warriorjs/effects`][warriorjs-effects] defines the effects that are used in the official towers. - [`@warriorjs/units`][warriorjs-units] defines the units that are used in the official towers. [warriorjs-core]: /libs/core [warriorjs-core-badge]: https://img.shields.io/npm/v/@warriorjs/core.svg?style=flat-square [warriorjs-core-npm]: https://www.npmjs.com/package/@warriorjs/core [warriorjs-scoring]: /libs/scoring [warriorjs-scoring-badge]: https://img.shields.io/npm/v/@warriorjs/scoring.svg?style=flat-square [warriorjs-scoring-npm]: https://www.npmjs.com/package/@warriorjs/scoring [warriorjs-spatial]: /libs/spatial [warriorjs-spatial-badge]: https://img.shields.io/npm/v/@warriorjs/spatial.svg?style=flat-square [warriorjs-spatial-npm]: https://www.npmjs.com/package/@warriorjs/spatial [warriorjs-abilities]: /libs/abilities [warriorjs-abilities-badge]: https://img.shields.io/npm/v/@warriorjs/abilities.svg?style=flat-square [warriorjs-abilities-npm]: https://www.npmjs.com/package/@warriorjs/abilities [warriorjs-effects]: /libs/effects [warriorjs-effects-badge]: https://img.shields.io/npm/v/@warriorjs/effects.svg?style=flat-square [warriorjs-effects-npm]: https://www.npmjs.com/package/@warriorjs/effects [warriorjs-units]: /libs/units [warriorjs-units-badge]: https://img.shields.io/npm/v/@warriorjs/units.svg?style=flat-square [warriorjs-units-npm]: https://www.npmjs.com/package/@warriorjs/units ================================================ FILE: libs/abilities/README.md ================================================ # @warriorjs/abilities > WarriorJS official abilities. ## [Actions][actions] ### `unit.attack([direction])`: Attack a unit in the given direction (forward by default) dealing `[power]` HP of damage. ### `unit.bind([direction])`: Bind a unit in the given direction (forward by default) to keep him from moving. ### `unit.detonate([direction])`: Detonate a bomb in a given direction (forward by default) dealing `[targetPower]` HP of damage to that space and `[surroundingPower]` HP of damage to surrounding 4 spaces (including yourself). ### `unit.pivot([direction])`: Rotate in the given direction (backward by default). ### `unit.rescue([direction])`: Release a unit from his chains in the given direction (forward by default). ### `unit.rest()`: Gain `[healthGainPercentage]` of max health back, but do nothing more. ### `unit.shoot([direction])`: Shoot your bow & arrow in the given direction (forward by default) dealing `[power]` HP of damage to the first unit in a range of `[range]` spaces. ### `unit.walk([direction])`: Move one space in the given direction (forward by default). ## [Senses][senses] ### `unit.directionOf(space)`: Return the direction (forward, right, backward or left) to the given [space][spaces]. ### `unit.directionOfStairs()`: Return the direction (forward, right, backward or left) the stairs are from your location. ### `unit.distanceOf(space)`: Return an integer representing the distance to the given [space][spaces]. ### `unit.feel([direction])`: Return the adjacent [space][spaces] in the given direction (forward by default). ### `unit.health()`: Return an integer representing your health. ### `unit.listen()`: Return an array of all [spaces][spaces] which have units in them (excluding yourself). ### `unit.look([direction])`: Returns an array of up to `[range]` [spaces][spaces] in the given direction (forward by default). ### `unit.think(thought)`: Think out loud (`console.log` replacement). [actions]: https://warrior.js.org/docs/player/abilities#actions [senses]: https://warrior.js.org/docs/player/abilities#senses [spaces]: https://warrior.js.org/docs/player/spaces ================================================ FILE: libs/abilities/package.json ================================================ { "name": "@warriorjs/abilities", "version": "0.13.0", "description": "WarriorJS base abilities", "author": "Matias Olivera ", "license": "MIT", "homepage": "https://warrior.js.org", "repository": "https://github.com/olistic/warriorjs/tree/master/libs/abilities", "type": "module", "main": "dist/index.js", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, "files": [ "dist" ], "publishConfig": { "access": "public" }, "engines": { "node": ">=24" }, "scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit" }, "dependencies": { "@warriorjs/core": "workspace:^", "@warriorjs/spatial": "workspace:^" } } ================================================ FILE: libs/abilities/src/Attack.test.ts ================================================ import { Action } from '@warriorjs/core'; import { BACKWARD, FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Attack from './Attack.js'; describe('Attack', () => { let attack: Attack; let unit: any; beforeEach(() => { unit = { damage: vi.fn(), log: vi.fn(), }; attack = new Attack(unit, { power: 3 }); }); test('is an action', () => { expect(attack).toBeInstanceOf(Action); }); test('has a description', () => { expect(attack.description).toBe( `Attacks a unit in the given direction (\`'${FORWARD}'\` by default), dealing 3 HP of damage.`, ); }); test('has meta for type generation', () => { expect(attack.meta).toEqual({ params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }); }); test('.with() returns an AbilityBinding', () => { const binding = Attack.with({ power: 5 }); expect(binding).toEqual([Attack, { power: 5 }]); }); describe('performing', () => { test('attacks forward by default', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); attack.perform(); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD); }); test('allows to specify direction', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); attack.perform(LEFT); expect(unit.getSpaceAt).toHaveBeenCalledWith(LEFT); }); test('misses if no receiver', () => { unit.getSpaceAt = () => ({ getUnit: () => null }); attack.perform(); expect(unit.log).toHaveBeenCalledWith(`attacks ${FORWARD} and hits nothing`); expect(unit.damage).not.toHaveBeenCalled(); }); describe('with receiver', () => { beforeEach(() => { unit.getSpaceAt = () => ({ getUnit: () => 'receiver' }); }); test('damages receiver', () => { attack.perform(); expect(unit.log).toHaveBeenCalledWith(`attacks ${FORWARD} and hits receiver`); expect(unit.damage).toHaveBeenCalledWith('receiver', 3); }); test('reduces power when attacking backward', () => { attack.perform(BACKWARD); expect(unit.log).toHaveBeenCalledWith(`attacks ${BACKWARD} and hits receiver`); expect(unit.damage).toHaveBeenCalledWith('receiver', 2); }); }); }); }); ================================================ FILE: libs/abilities/src/Attack.ts ================================================ import { type AbilityBinding, Action } from '@warriorjs/core'; import { BACKWARD, FORWARD, type RelativeDirection } from '@warriorjs/spatial'; import { type AbilityMeta, type Unit } from './types.js'; const defaultDirection = FORWARD; interface AttackConfig { power: number; } class Attack extends Action { readonly description: string; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; private power: number; constructor(unit: Unit, { power }: AttackConfig) { super(unit); this.description = `Attacks a unit in the given direction (\`'${defaultDirection}'\` by default), dealing ${power} HP of damage.`; this.power = power; } perform(direction: RelativeDirection = defaultDirection): void { const receiver = this.unit.getSpaceAt(direction).getUnit(); if (receiver) { this.unit.log(`attacks ${direction} and hits ${receiver}`); const attackingBackward = direction === BACKWARD; const amount = attackingBackward ? Math.ceil(this.power / 2.0) : this.power; this.unit.damage(receiver, amount); } else { this.unit.log(`attacks ${direction} and hits nothing`); } } static with(config: AttackConfig): AbilityBinding { return [Attack, config]; } } export default Attack; ================================================ FILE: libs/abilities/src/Bind.test.ts ================================================ import { Action } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Bind from './Bind.js'; describe('Bind', () => { let bind: Bind; let unit: any; beforeEach(() => { unit = { log: vi.fn() }; bind = new Bind(unit); }); test('is an action', () => { expect(bind).toBeInstanceOf(Action); }); test('has a description', () => { expect(bind.description).toBe( `Binds a unit in the given direction (\`'${FORWARD}'\` by default) to keep them from moving.`, ); }); test('has meta for type generation', () => { expect(bind.meta).toEqual({ params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }); }); describe('performing', () => { test('binds forward by default', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); bind.perform(); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD); }); test('allows to specify direction', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); bind.perform(LEFT); expect(unit.getSpaceAt).toHaveBeenCalledWith(LEFT); }); test('misses if no receiver', () => { unit.getSpaceAt = () => ({ getUnit: () => null }); bind.perform(); expect(unit.log).toHaveBeenCalledWith(`binds ${FORWARD} and restricts nothing`); }); describe('with receiver', () => { let receiver: any; beforeEach(() => { receiver = { bind: vi.fn(), toString: () => 'receiver', }; unit.getSpaceAt = () => ({ getUnit: () => receiver }); }); test('binds receiver', () => { bind.perform(); expect(unit.log).toHaveBeenCalledWith(`binds ${FORWARD} and restricts receiver`); expect(receiver.bind).toHaveBeenCalled(); }); }); }); }); ================================================ FILE: libs/abilities/src/Bind.ts ================================================ import { Action } from '@warriorjs/core'; import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; import { type AbilityMeta } from './types.js'; const defaultDirection = FORWARD; class Bind extends Action { readonly description = `Binds a unit in the given direction (\`'${defaultDirection}'\` by default) to keep them from moving.`; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; perform(direction: RelativeDirection = defaultDirection): void { const receiver = this.unit.getSpaceAt(direction).getUnit(); if (receiver) { this.unit.log(`binds ${direction} and restricts ${receiver}`); receiver.bind(); } else { this.unit.log(`binds ${direction} and restricts nothing`); } } } export default Bind; ================================================ FILE: libs/abilities/src/Detonate.test.ts ================================================ import { Action } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Detonate from './Detonate.js'; describe('Detonate', () => { let detonate: Detonate; let unit: any; beforeEach(() => { unit = { damage: vi.fn(), isUnderEffect: () => false, log: vi.fn(), }; detonate = new Detonate(unit, { targetPower: 4, surroundingPower: 2 }); }); test('is an action', () => { expect(detonate).toBeInstanceOf(Action); }); test('has a description', () => { expect(detonate.description).toBe( `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).`, ); }); test('has meta for type generation', () => { expect(detonate.meta).toEqual({ params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }); }); test('.with() returns an AbilityBinding', () => { const binding = Detonate.with({ targetPower: 4, surroundingPower: 2 }); expect(binding).toEqual([Detonate, { targetPower: 4, surroundingPower: 2 }]); }); describe('performing', () => { test('detonates forward by default', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); detonate.perform(); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD); }); test('allows to specify direction', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); detonate.perform(LEFT); expect(unit.getSpaceAt).toHaveBeenCalledWith(LEFT); }); test('damages receivers depending on their position', () => { const targetReceiver = { isUnderEffect: () => false }; const surroundingReceiver = { isUnderEffect: () => false }; unit.getSpaceAt = vi .fn() .mockReturnValueOnce({ getUnit: () => targetReceiver }) .mockReturnValueOnce({ getUnit: () => null }) .mockReturnValueOnce({ getUnit: () => null }) .mockReturnValueOnce({ getUnit: () => surroundingReceiver }) .mockReturnValueOnce({ getUnit: () => unit }); detonate.perform(); expect(unit.log).toHaveBeenCalledWith( `detonates a bomb ${FORWARD} launching a deadly explosion`, ); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 1, 1); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 1, -1); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 2, 0); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 0, 0); expect(unit.damage).toHaveBeenCalledWith(targetReceiver, 4); expect(unit.damage).toHaveBeenCalledWith(surroundingReceiver, 2); expect(unit.damage).toHaveBeenCalledWith(unit, 2); }); test('triggers ticking effect on receivers under effect', () => { const receiver = { isUnderEffect: () => true, triggerEffect: vi.fn(), log: vi.fn(), }; unit.getSpaceAt = vi .fn() .mockReturnValueOnce({ getUnit: () => receiver }) .mockReturnValue({ getUnit: () => null }); detonate.perform(); expect(receiver.log).toHaveBeenCalledWith( 'caught in the blast, detonating the ticking explosive', ); expect(receiver.triggerEffect).toHaveBeenCalledWith('ticking'); }); }); }); ================================================ FILE: libs/abilities/src/Detonate.ts ================================================ import { type AbilityBinding, Action } from '@warriorjs/core'; import { FORWARD, type RelativeDirection, type RelativeOffset } from '@warriorjs/spatial'; import { type AbilityMeta, type Space, type Unit } from './types.js'; const defaultDirection = FORWARD; const surroundingOffsets: RelativeOffset[] = [ [1, 1], [1, -1], [2, 0], [0, 0], ]; interface DetonateConfig { targetPower: number; surroundingPower: number; } class Detonate extends Action { readonly description: string; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; private targetPower: number; private surroundingPower: number; constructor(unit: Unit, { targetPower, surroundingPower }: DetonateConfig) { super(unit); 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).`; this.targetPower = targetPower; this.surroundingPower = surroundingPower; } perform(direction: RelativeDirection = defaultDirection): void { this.unit.log(`detonates a bomb ${direction} launching a deadly explosion`); const targetSpace = this.unit.getSpaceAt(direction); this.bomb(targetSpace, this.targetPower); surroundingOffsets .map(([forward, right]) => this.unit.getSpaceAt(direction, forward, right)) .forEach((surroundingSpace) => { this.bomb(surroundingSpace, this.surroundingPower); }); } private bomb(space: Space, power: number): void { const receiver = space.getUnit(); if (receiver) { this.unit.damage(receiver, power); if (receiver.isUnderEffect('ticking')) { receiver.log('caught in the blast, detonating the ticking explosive'); receiver.triggerEffect('ticking'); } } } static with(config: DetonateConfig): AbilityBinding { return [Detonate, config]; } } export default Detonate; ================================================ FILE: libs/abilities/src/DirectionOf.test.ts ================================================ import { Sense } from '@warriorjs/core'; import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import DirectionOf from './DirectionOf.js'; describe('DirectionOf', () => { let directionOf: DirectionOf; let unit: any; beforeEach(() => { unit = { getDirectionOf: vi.fn() }; directionOf = new DirectionOf(unit); }); test('is a sense', () => { expect(directionOf).toBeInstanceOf(Sense); }); test('has a description', () => { expect(directionOf.description).toBe( `Returns the direction (${FORWARD}, ${RIGHT}, ${BACKWARD} or ${LEFT}) to the given space.`, ); }); test('has meta for type generation', () => { expect(directionOf.meta).toEqual({ params: [{ name: 'space', type: 'Space' }], returns: 'Direction', }); }); describe('performing', () => { test('returns direction of specified space', () => { unit.getDirectionOf.mockReturnValue(RIGHT); expect(directionOf.perform({} as any)).toBe(RIGHT); }); }); }); ================================================ FILE: libs/abilities/src/DirectionOf.ts ================================================ import { Sense, type SensedSpace } from '@warriorjs/core'; import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { type AbilityMeta } from './types.js'; class DirectionOf extends Sense { readonly description = `Returns the direction (${FORWARD}, ${RIGHT}, ${BACKWARD} or ${LEFT}) to the given space.`; readonly meta: AbilityMeta = { params: [{ name: 'space', type: 'Space' }], returns: 'Direction', }; perform(space: SensedSpace) { return this.unit.getDirectionOf(space); } } export default DirectionOf; ================================================ FILE: libs/abilities/src/DirectionOfStairs.test.ts ================================================ import { Sense } from '@warriorjs/core'; import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import DirectionOfStairs from './DirectionOfStairs.js'; describe('DirectionOfStairs', () => { let directionOfStairs: DirectionOfStairs; let unit: any; beforeEach(() => { unit = { getDirectionOfStairs: vi.fn() }; directionOfStairs = new DirectionOfStairs(unit); }); test('is a sense', () => { expect(directionOfStairs).toBeInstanceOf(Sense); }); test('has a description', () => { expect(directionOfStairs.description).toBe( `Returns the direction (${FORWARD}, ${RIGHT}, ${BACKWARD} or ${LEFT}) the stairs are from your location.`, ); }); test('has meta for type generation', () => { expect(directionOfStairs.meta).toEqual({ params: [], returns: 'Direction', }); }); describe('performing', () => { test('returns direction of stairs', () => { unit.getDirectionOfStairs.mockReturnValue(RIGHT); expect(directionOfStairs.perform()).toBe(RIGHT); }); }); }); ================================================ FILE: libs/abilities/src/DirectionOfStairs.ts ================================================ import { Sense } from '@warriorjs/core'; import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { type AbilityMeta } from './types.js'; class DirectionOfStairs extends Sense { readonly description = `Returns the direction (${FORWARD}, ${RIGHT}, ${BACKWARD} or ${LEFT}) the stairs are from your location.`; readonly meta: AbilityMeta = { params: [], returns: 'Direction', }; perform() { return this.unit.getDirectionOfStairs(); } } export default DirectionOfStairs; ================================================ FILE: libs/abilities/src/DistanceOf.test.ts ================================================ import { Sense } from '@warriorjs/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import DistanceOf from './DistanceOf.js'; describe('DistanceOf', () => { let distanceOf: DistanceOf; let unit: any; beforeEach(() => { unit = { getDistanceOf: vi.fn() }; distanceOf = new DistanceOf(unit); }); test('is a sense', () => { expect(distanceOf).toBeInstanceOf(Sense); }); test('has a description', () => { expect(distanceOf.description).toBe( 'Returns an integer representing the distance to the given space.', ); }); test('has meta for type generation', () => { expect(distanceOf.meta).toEqual({ params: [{ name: 'space', type: 'Space' }], returns: 'number', }); }); describe('performing', () => { test('returns distance of specified space', () => { unit.getDistanceOf.mockReturnValue(3); expect(distanceOf.perform({} as any)).toBe(3); }); }); }); ================================================ FILE: libs/abilities/src/DistanceOf.ts ================================================ import { Sense, type SensedSpace } from '@warriorjs/core'; import { type AbilityMeta } from './types.js'; class DistanceOf extends Sense { readonly description = 'Returns an integer representing the distance to the given space.'; readonly meta: AbilityMeta = { params: [{ name: 'space', type: 'Space' }], returns: 'number', }; perform(space: SensedSpace) { return this.unit.getDistanceOf(space); } } export default DistanceOf; ================================================ FILE: libs/abilities/src/Feel.test.ts ================================================ import { Sense } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Feel from './Feel.js'; describe('Feel', () => { let feel: Feel; let unit: any; beforeEach(() => { unit = { getSensedSpaceAt: vi.fn() }; feel = new Feel(unit); }); test('is a sense', () => { expect(feel).toBeInstanceOf(Sense); }); test('has a description', () => { expect(feel.description).toBe( `Returns the adjacent space in the given direction (\`'${FORWARD}'\` by default).`, ); }); test('has meta for type generation', () => { expect(feel.meta).toEqual({ params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'Space', }); }); describe('performing', () => { test('feels forward by default', () => { feel.perform(); expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(FORWARD); }); test('allows to specify direction', () => { feel.perform(LEFT); expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(LEFT); }); test('returns adjacent space in specified direction', () => { unit.getSensedSpaceAt.mockReturnValue('space'); expect(feel.perform()).toBe('space'); }); }); }); ================================================ FILE: libs/abilities/src/Feel.ts ================================================ import { Sense } from '@warriorjs/core'; import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; import { type AbilityMeta } from './types.js'; const defaultDirection = FORWARD; class Feel extends Sense { readonly description = `Returns the adjacent space in the given direction (\`'${defaultDirection}'\` by default).`; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'Space', }; perform(direction: RelativeDirection = defaultDirection) { return this.unit.getSensedSpaceAt(direction); } } export default Feel; ================================================ FILE: libs/abilities/src/Health.test.ts ================================================ import { Sense } from '@warriorjs/core'; import { beforeEach, describe, expect, test } from 'vitest'; import Health from './Health.js'; describe('Health', () => { let health: Health; let unit: any; beforeEach(() => { unit = { health: 10 }; health = new Health(unit); }); test('is a sense', () => { expect(health).toBeInstanceOf(Sense); }); test('has a description', () => { expect(health.description).toBe('Returns an integer representing your health.'); }); test('has meta for type generation', () => { expect(health.meta).toEqual({ params: [], returns: 'number', }); }); describe('performing', () => { test('returns the amount of health', () => { expect(health.perform()).toBe(10); }); }); }); ================================================ FILE: libs/abilities/src/Health.ts ================================================ import { Sense } from '@warriorjs/core'; import { type AbilityMeta } from './types.js'; class Health extends Sense { readonly description = 'Returns an integer representing your health.'; readonly meta: AbilityMeta = { params: [], returns: 'number', }; perform() { return this.unit.health; } } export default Health; ================================================ FILE: libs/abilities/src/Listen.test.ts ================================================ import { Sense } from '@warriorjs/core'; import { FORWARD, NORTH } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Listen from './Listen.js'; describe('Listen', () => { let listen: Listen; let unit: any; beforeEach(() => { unit = { position: { location: [1, 1], orientation: NORTH, }, getOtherUnits: () => [ { getSpace: () => ({ location: [0, 0] }) }, { getSpace: () => ({ location: [2, 3] }) }, ], getSensedSpaceAt: vi.fn(), }; listen = new Listen(unit); }); test('is a sense', () => { expect(listen).toBeInstanceOf(Sense); }); test('has a description', () => { expect(listen.description).toBe( 'Returns an array of all spaces which have units in them (excluding yourself).', ); }); test('has meta for type generation', () => { expect(listen.meta).toEqual({ params: [], returns: 'Space[]', }); }); describe('performing', () => { test('returns all spaces which have units in them', () => { unit.getSensedSpaceAt.mockReturnValueOnce('space1').mockReturnValueOnce('space2'); expect(listen.perform()).toEqual(['space1', 'space2']); expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(FORWARD, 1, -1); expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(FORWARD, -2, 1); }); }); }); ================================================ FILE: libs/abilities/src/Listen.ts ================================================ import { Sense } from '@warriorjs/core'; import { FORWARD, getRelativeOffset } from '@warriorjs/spatial'; import { type AbilityMeta } from './types.js'; class Listen extends Sense { readonly description = 'Returns an array of all spaces which have units in them (excluding yourself).'; readonly meta: AbilityMeta = { params: [], returns: 'Space[]', }; perform() { return this.unit .getOtherUnits() .map((anotherUnit: any) => getRelativeOffset( anotherUnit.getSpace().location, this.unit.position.location, this.unit.position.orientation, ), ) .map(([forward, right]: [number, number]) => this.unit.getSensedSpaceAt(FORWARD, forward, right), ); } } export default Listen; ================================================ FILE: libs/abilities/src/Look.test.ts ================================================ import { Sense } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Look from './Look.js'; describe('Look', () => { let look: Look; let unit: any; beforeEach(() => { unit = { getSensedSpaceAt: vi.fn() }; look = new Look(unit, { range: 3 }); }); test('is a sense', () => { expect(look).toBeInstanceOf(Sense); }); test('has a description', () => { expect(look.description).toBe( `Returns an array of up to 3 spaces in the given direction (\`'${FORWARD}'\` by default).`, ); }); test('has meta for type generation', () => { expect(look.meta).toEqual({ params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'Space[]', }); }); test('.with() returns an AbilityBinding', () => { const binding = Look.with({ range: 3 }); expect(binding).toEqual([Look, { range: 3 }]); }); describe('performing', () => { test('looks forward by default', () => { look.perform(); expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(FORWARD, 1); expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(FORWARD, 2); expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(FORWARD, 3); }); test('allows to specify direction', () => { look.perform(LEFT); expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(LEFT, 1); expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(LEFT, 2); expect(unit.getSensedSpaceAt).toHaveBeenCalledWith(LEFT, 3); }); test('returns spaces in range in specified direction', () => { const space1 = { isWall: () => false }; const space2 = { isWall: () => false }; const space3 = { isWall: () => false }; const space4 = { isWall: () => false }; unit.getSensedSpaceAt .mockReturnValueOnce(space1) .mockReturnValueOnce(space2) .mockReturnValueOnce(space3) .mockReturnValueOnce(space4); expect(look.perform()).toEqual([space1, space2, space3]); }); test("can't see through walls", () => { const space1 = { isWall: () => false }; const space2 = { isWall: () => true }; const space3 = { isWall: () => false }; unit.getSensedSpaceAt .mockReturnValueOnce(space1) .mockReturnValueOnce(space2) .mockReturnValueOnce(space3); expect(look.perform()).toEqual([space1, space2]); }); }); }); ================================================ FILE: libs/abilities/src/Look.ts ================================================ import { type AbilityBinding, Sense } from '@warriorjs/core'; import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; import { type AbilityMeta, type Unit } from './types.js'; const defaultDirection = FORWARD; interface LookConfig { range: number; } class Look extends Sense { readonly description: string; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'Space[]', }; private range: number; constructor(unit: Unit, { range }: LookConfig) { super(unit); this.description = `Returns an array of up to ${range} spaces in the given direction (\`'${defaultDirection}'\` by default).`; this.range = range; } perform(direction: RelativeDirection = defaultDirection) { const offsets = Array.from(new Array(this.range), (_, index) => index + 1); const spaces = offsets.map((offset) => this.unit.getSensedSpaceAt(direction, offset)); const firstWallIndex = spaces.findIndex((space) => space?.isWall()); return firstWallIndex === -1 ? spaces : spaces.slice(0, firstWallIndex + 1); } static with(config: LookConfig): AbilityBinding { return [Look, config]; } } export default Look; ================================================ FILE: libs/abilities/src/MaxHealth.test.ts ================================================ import { Sense } from '@warriorjs/core'; import { beforeEach, describe, expect, test } from 'vitest'; import MaxHealth from './MaxHealth.js'; describe('MaxHealth', () => { let maxHealth: MaxHealth; let unit: any; beforeEach(() => { unit = { maxHealth: 10 }; maxHealth = new MaxHealth(unit); }); test('is a sense', () => { expect(maxHealth).toBeInstanceOf(Sense); }); test('has a description', () => { expect(maxHealth.description).toBe('Returns an integer representing your maximum health.'); }); test('has meta for type generation', () => { expect(maxHealth.meta).toEqual({ params: [], returns: 'number', }); }); describe('performing', () => { test('returns the maximum health', () => { expect(maxHealth.perform()).toBe(10); }); }); }); ================================================ FILE: libs/abilities/src/MaxHealth.ts ================================================ import { Sense } from '@warriorjs/core'; import { type AbilityMeta } from './types.js'; class MaxHealth extends Sense { readonly description = 'Returns an integer representing your maximum health.'; readonly meta: AbilityMeta = { params: [], returns: 'number', }; perform() { return this.unit.maxHealth; } } export default MaxHealth; ================================================ FILE: libs/abilities/src/Pivot.test.ts ================================================ import { Action } from '@warriorjs/core'; import { BACKWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Pivot from './Pivot.js'; describe('Pivot', () => { let pivot: Pivot; let unit: any; beforeEach(() => { unit = { rotate: vi.fn(), log: vi.fn(), }; pivot = new Pivot(unit); }); test('is an action', () => { expect(pivot).toBeInstanceOf(Action); }); test('has a description', () => { expect(pivot.description).toBe( `Rotates in the given direction (\`'${BACKWARD}'\` by default).`, ); }); test('has meta for type generation', () => { expect(pivot.meta).toEqual({ params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }); }); describe('performing', () => { test('flips around when not passing direction', () => { pivot.perform(); expect(unit.log).toHaveBeenCalledWith(`pivots ${BACKWARD}`); expect(unit.rotate).toHaveBeenCalledWith(BACKWARD); }); test('rotates in specified direction', () => { pivot.perform(RIGHT); expect(unit.log).toHaveBeenCalledWith(`pivots ${RIGHT}`); expect(unit.rotate).toHaveBeenCalledWith(RIGHT); }); }); }); ================================================ FILE: libs/abilities/src/Pivot.ts ================================================ import { Action } from '@warriorjs/core'; import { BACKWARD, type RelativeDirection } from '@warriorjs/spatial'; import { type AbilityMeta } from './types.js'; const defaultDirection = BACKWARD; class Pivot extends Action { readonly description = `Rotates in the given direction (\`'${defaultDirection}'\` by default).`; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; perform(direction: RelativeDirection = defaultDirection): void { this.unit.rotate(direction); this.unit.log(`pivots ${direction}`); } } export default Pivot; ================================================ FILE: libs/abilities/src/Rescue.test.ts ================================================ import { Action } from '@warriorjs/core'; import { FORWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Rescue from './Rescue.js'; describe('Rescue', () => { let rescue: Rescue; let unit: any; beforeEach(() => { unit = { release: vi.fn(), log: vi.fn(), }; rescue = new Rescue(unit); }); test('is an action', () => { expect(rescue).toBeInstanceOf(Action); }); test('has a description', () => { expect(rescue.description).toBe( `Releases a unit from their chains in the given direction (\`'${FORWARD}'\` by default).`, ); }); test('has meta for type generation', () => { expect(rescue.meta).toEqual({ params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }); }); describe('performing', () => { test('rescues forward by default', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); rescue.perform(); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD); }); test('allows to specify direction', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); rescue.perform(RIGHT); expect(unit.getSpaceAt).toHaveBeenCalledWith(RIGHT); }); test('misses if no receiver', () => { unit.getSpaceAt = () => ({ getUnit: () => null }); rescue.perform(); expect(unit.log).toHaveBeenCalledWith(`unbinds ${FORWARD} and rescues nothing`); }); describe('with receiver', () => { let receiver: any; beforeEach(() => { receiver = { isBound: () => true, toString: () => 'receiver', }; unit.getSpaceAt = () => ({ getUnit: () => receiver }); }); test("does nothing to receiver if it's not bound", () => { receiver.isBound = () => false; rescue.perform(); expect(unit.log).toHaveBeenCalledWith(`unbinds ${FORWARD} and rescues nothing`); expect(unit.release).not.toHaveBeenCalled(); }); test('releases receiver', () => { rescue.perform(); expect(unit.log).toHaveBeenCalledWith(`unbinds ${FORWARD} and rescues receiver`); expect(unit.release).toHaveBeenCalledWith(receiver); }); }); }); }); ================================================ FILE: libs/abilities/src/Rescue.ts ================================================ import { Action } from '@warriorjs/core'; import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; import { type AbilityMeta } from './types.js'; const defaultDirection = FORWARD; class Rescue extends Action { readonly description = `Releases a unit from their chains in the given direction (\`'${defaultDirection}'\` by default).`; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; perform(direction: RelativeDirection = defaultDirection): void { const receiver = this.unit.getSpaceAt(direction).getUnit(); if (receiver?.isBound()) { this.unit.log(`unbinds ${direction} and rescues ${receiver}`); this.unit.release(receiver); } else { this.unit.log(`unbinds ${direction} and rescues nothing`); } } } export default Rescue; ================================================ FILE: libs/abilities/src/Rest.test.ts ================================================ import { Action } from '@warriorjs/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Rest from './Rest.js'; describe('Rest', () => { let rest: Rest; let unit: any; beforeEach(() => { unit = { maxHealth: 20, health: 10, heal: vi.fn(), log: vi.fn(), }; rest = new Rest(unit, { healthGain: 0.1 }); }); test('is an action', () => { expect(rest).toBeInstanceOf(Action); }); test('has a description', () => { expect(rest.description).toBe('Gains 10% of max health back, but does nothing more.'); }); test('has meta for type generation', () => { expect(rest.meta).toEqual({ params: [], returns: 'void', }); }); test('.with() returns an AbilityBinding', () => { const binding = Rest.with({ healthGain: 0.1 }); expect(binding).toEqual([Rest, { healthGain: 0.1 }]); }); describe('performing', () => { test('gives health back', () => { rest.perform(); expect(unit.log).toHaveBeenCalledWith('rests'); expect(unit.heal).toHaveBeenCalledWith(2); }); test("doesn't add health when at max", () => { unit.health = 20; rest.perform(); expect(unit.log).toHaveBeenCalledWith('has nothing to heal'); expect(unit.heal).not.toHaveBeenCalled(); }); }); }); ================================================ FILE: libs/abilities/src/Rest.ts ================================================ import { type AbilityBinding, Action } from '@warriorjs/core'; import { type AbilityMeta, type Unit } from './types.js'; interface RestConfig { healthGain: number; } class Rest extends Action { readonly description: string; readonly meta: AbilityMeta = { params: [], returns: 'void', }; private healthGain: number; constructor(unit: Unit, { healthGain }: RestConfig) { super(unit); this.description = `Gains ${healthGain * 100}% of max health back, but does nothing more.`; this.healthGain = healthGain; } perform(): void { if (this.unit.health < this.unit.maxHealth) { this.unit.log('rests'); const amount = Math.round(this.unit.maxHealth * this.healthGain); this.unit.heal(amount); } else { this.unit.log('has nothing to heal'); } } static with(config: RestConfig): AbilityBinding { return [Rest, config]; } } export default Rest; ================================================ FILE: libs/abilities/src/Shoot.test.ts ================================================ import { Action } from '@warriorjs/core'; import { FORWARD, LEFT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Shoot from './Shoot.js'; describe('Shoot', () => { let shoot: Shoot; let unit: any; beforeEach(() => { unit = { damage: vi.fn(), log: vi.fn(), }; shoot = new Shoot(unit, { power: 3, range: 3 }); }); test('is an action', () => { expect(shoot).toBeInstanceOf(Action); }); test('has a description', () => { expect(shoot.description).toBe( `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.`, ); }); test('has meta for type generation', () => { expect(shoot.meta).toEqual({ params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }); }); test('.with() returns an AbilityBinding', () => { const binding = Shoot.with({ power: 3, range: 3 }); expect(binding).toEqual([Shoot, { power: 3, range: 3 }]); }); describe('performing', () => { test('shoots forward by default', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); shoot.perform(); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 1); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 2); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD, 3); }); test('allows to specify direction', () => { unit.getSpaceAt = vi.fn(() => ({ getUnit: () => null })); shoot.perform(LEFT); expect(unit.getSpaceAt).toHaveBeenCalledWith(LEFT, 1); expect(unit.getSpaceAt).toHaveBeenCalledWith(LEFT, 2); expect(unit.getSpaceAt).toHaveBeenCalledWith(LEFT, 3); }); test('misses if no receiver', () => { unit.getSpaceAt = vi .fn() .mockReturnValueOnce({ getUnit: () => null }) .mockReturnValueOnce({ getUnit: () => null }) .mockReturnValueOnce({ getUnit: () => null }) .mockReturnValueOnce({ getUnit: () => 'anotherUnit' }); shoot.perform(); expect(unit.log).toHaveBeenCalledWith(`shoots ${FORWARD} and hits nothing`); expect(unit.damage).not.toHaveBeenCalled(); }); describe('with receiver', () => { beforeEach(() => { unit.getSpaceAt = vi .fn() .mockReturnValueOnce({ getUnit: () => null }) .mockReturnValueOnce({ getUnit: () => 'receiver' }) .mockReturnValueOnce({ getUnit: () => 'anotherUnit' }); }); test('damages receiver', () => { shoot.perform(); expect(unit.log).toHaveBeenCalledWith(`shoots ${FORWARD} and hits receiver`); expect(unit.damage).toHaveBeenCalledWith('receiver', 3); }); test('shoots only first unit', () => { shoot.perform(); expect(unit.damage).not.toHaveBeenCalledWith('anotherUnit', 3); }); }); }); }); ================================================ FILE: libs/abilities/src/Shoot.ts ================================================ import { type AbilityBinding, Action } from '@warriorjs/core'; import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; import { type AbilityMeta, type Unit } from './types.js'; const defaultDirection = FORWARD; interface ShootConfig { power: number; range: number; } class Shoot extends Action { readonly description: string; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; private power: number; private range: number; constructor(unit: Unit, { power, range }: ShootConfig) { super(unit); 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.`; this.power = power; this.range = range; } perform(direction: RelativeDirection = defaultDirection): void { const offsets = Array.from(new Array(this.range), (_, index) => index + 1); const receiver = offsets .map((offset) => this.unit.getSpaceAt(direction, offset).getUnit()) .find((unitInRange) => unitInRange); if (receiver) { this.unit.log(`shoots ${direction} and hits ${receiver}`); this.unit.damage(receiver, this.power); } else { this.unit.log(`shoots ${direction} and hits nothing`); } } static with(config: ShootConfig): AbilityBinding { return [Shoot, config]; } } export default Shoot; ================================================ FILE: libs/abilities/src/Think.test.ts ================================================ import { Sense } from '@warriorjs/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Think from './Think.js'; describe('Think', () => { let think: Think; let unit: any; beforeEach(() => { unit = { log: vi.fn() }; think = new Think(unit); }); test('is a sense', () => { expect(think).toBeInstanceOf(Sense); }); test('has a description', () => { expect(think.description).toBe('Thinks out loud (`console.log` replacement).'); }); test('has meta for type generation', () => { expect(think.meta).toEqual({ params: [{ name: 'args', type: 'any', rest: true }], returns: 'void', }); }); describe('performing', () => { test('thinks nothing by default', () => { think.perform(); expect(unit.log).toHaveBeenCalledWith('thinks nothing'); }); test('allows to specify thought', () => { think.perform('he should be brave'); expect(unit.log).toHaveBeenCalledWith('thinks he should be brave'); }); test('allows complex thoughts', () => { think.perform('that %o', { brave: true }); expect(unit.log).toHaveBeenCalledWith('thinks that { brave: true }'); }); }); }); ================================================ FILE: libs/abilities/src/Think.ts ================================================ import util from 'node:util'; import { Sense } from '@warriorjs/core'; import { type AbilityMeta } from './types.js'; class Think extends Sense { readonly description = 'Thinks out loud (`console.log` replacement).'; readonly meta: AbilityMeta = { params: [{ name: 'args', type: 'any', rest: true }], returns: 'void', }; perform(...args: unknown[]) { const thought = args.length > 0 ? util.format(...args) : 'nothing'; this.unit.log(`thinks ${thought}`); } } export default Think; ================================================ FILE: libs/abilities/src/Walk.test.ts ================================================ import { Action } from '@warriorjs/core'; import { FORWARD, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Walk from './Walk.js'; describe('Walk', () => { let walk: Walk; let unit: any; beforeEach(() => { unit = { move: vi.fn(), log: vi.fn(), }; walk = new Walk(unit); }); test('is an action', () => { expect(walk).toBeInstanceOf(Action); }); test('has a description', () => { expect(walk.description).toBe( `Moves one space in the given direction (\`'${FORWARD}'\` by default).`, ); }); test('has meta for type generation', () => { expect(walk.meta).toEqual({ params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }); }); describe('performing', () => { test('walks forward by default', () => { unit.getSpaceAt = vi.fn(() => ({ isEmpty: () => true })); walk.perform(); expect(unit.getSpaceAt).toHaveBeenCalledWith(FORWARD); }); test('allows to specify direction', () => { unit.getSpaceAt = vi.fn(() => ({ isEmpty: () => true })); walk.perform(RIGHT); expect(unit.getSpaceAt).toHaveBeenCalledWith(RIGHT); }); test('keeps position if something is in the way', () => { unit.getSpaceAt = () => ({ isEmpty: () => false, toString: () => 'space', }); walk.perform(); expect(unit.log).toHaveBeenCalledWith(`walks ${FORWARD} and bumps into space`); expect(unit.move).not.toHaveBeenCalled(); }); test('moves in specified direction if space is empty', () => { unit.getSpaceAt = () => ({ isEmpty: () => true }); walk.perform(RIGHT); expect(unit.log).toHaveBeenCalledWith(`walks ${RIGHT}`); expect(unit.move).toHaveBeenCalledWith(RIGHT); }); }); }); ================================================ FILE: libs/abilities/src/Walk.ts ================================================ import { Action } from '@warriorjs/core'; import { FORWARD, type RelativeDirection } from '@warriorjs/spatial'; import { type AbilityMeta } from './types.js'; const defaultDirection = FORWARD; class Walk extends Action { readonly description = `Moves one space in the given direction (\`'${defaultDirection}'\` by default).`; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; perform(direction: RelativeDirection = defaultDirection): void { const space = this.unit.getSpaceAt(direction); if (space.isEmpty()) { this.unit.move(direction); this.unit.log(`walks ${direction}`); } else { this.unit.log(`walks ${direction} and bumps into ${space}`); } } } export default Walk; ================================================ FILE: libs/abilities/src/index.ts ================================================ export { default as Attack } from './Attack.js'; export { default as Bind } from './Bind.js'; export { default as Detonate } from './Detonate.js'; export { default as DirectionOf } from './DirectionOf.js'; export { default as DirectionOfStairs } from './DirectionOfStairs.js'; export { default as DistanceOf } from './DistanceOf.js'; export { default as Feel } from './Feel.js'; export { default as Health } from './Health.js'; export { default as Listen } from './Listen.js'; export { default as Look } from './Look.js'; export { default as MaxHealth } from './MaxHealth.js'; export { default as Pivot } from './Pivot.js'; export { default as Rescue } from './Rescue.js'; export { default as Rest } from './Rest.js'; export { default as Shoot } from './Shoot.js'; export { default as Think } from './Think.js'; export { default as Walk } from './Walk.js'; ================================================ FILE: libs/abilities/src/types.ts ================================================ import { type SensedSpace } from '@warriorjs/core'; import { type AbsoluteDirection, type Location, type RelativeDirection } from '@warriorjs/spatial'; export interface Space { location: Location; getUnit(): Unit | null; isEmpty(): boolean; isStairs(): boolean; isUnit(): boolean; isWall(): boolean; } export interface Unit { health: number; maxHealth: number; position: { location: Location; orientation: AbsoluteDirection; }; getSpaceAt(direction: RelativeDirection, forward?: number, right?: number): Space; getSensedSpaceAt(direction: RelativeDirection, forward?: number, right?: number): SensedSpace; getDirectionOf(space: SensedSpace): RelativeDirection; getDirectionOfStairs(): RelativeDirection; getDistanceOf(space: SensedSpace): number; getOtherUnits(): Array<{ getSpace(): { location: Location } }>; getSpace(): { location: Location }; move(direction: RelativeDirection): void; rotate(direction: RelativeDirection): void; damage(receiver: Unit, amount: number): void; heal(amount: number): void; release(receiver: Unit): void; bind(): void; isBound(): boolean; isUnderEffect(effect: string): boolean; triggerEffect(effect: string): void; log(message: string): void; } export interface AbilityParam { name: string; type: 'Direction' | 'Space' | 'number' | 'any'; optional?: boolean; rest?: boolean; } export interface AbilityMeta { params: AbilityParam[]; returns: 'void' | 'number' | 'string' | 'Direction' | 'Space' | 'Space[]'; } ================================================ FILE: libs/abilities/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist" }, "exclude": ["**/*.test.ts"] } ================================================ FILE: libs/abilities/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src" }, "include": ["src"] } ================================================ FILE: libs/core/README.md ================================================ # @warriorjs/core > WarriorJS core. ## Install ```sh npm install @warriorjs/core ``` ## Usage ```js const warriorjs = require('@warriorjs/core'); import { runLevel } from '@warriorjs/core'); import * as warriorjs from '@warriorjs/core'; ``` ## API Reference ### warriorjs.runLevel(levelConfig: Object, playerCode: string) Runs the given level config with the given player code. ### warriorjs.getLevel(levelConfig: Object) Returns the level for the given level config. ================================================ FILE: libs/core/package.json ================================================ { "name": "@warriorjs/core", "version": "0.14.0", "description": "WarriorJS core", "author": "Matias Olivera ", "license": "MIT", "homepage": "https://warrior.js.org", "repository": "https://github.com/olistic/warriorjs/tree/master/libs/core", "type": "module", "main": "dist/index.js", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, "keywords": [ "warriorjs", "warriorjs-core", "warrior", "epic", "battle", "game", "learn", "polish", "refine", "test", "js", "javascript", "nodejs", "ai", "artificial-intelligence", "skills" ], "files": [ "dist" ], "publishConfig": { "access": "public" }, "engines": { "node": ">=24" }, "scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit" }, "dependencies": { "@warriorjs/spatial": "workspace:^", "esbuild": "^0.27.3" } } ================================================ FILE: libs/core/src/Ability.test.ts ================================================ import { describe, expect, test, vi } from 'vitest'; import Ability, { type AbilityMeta } from './Ability.js'; import Action from './Action.js'; import Sense from './Sense.js'; class ConcreteAction extends Action { readonly description = 'test action'; readonly meta: AbilityMeta = { params: [], returns: 'void' }; perform = vi.fn(); } class ConcreteSense extends Sense { readonly description = 'test sense'; readonly meta: AbilityMeta = { params: [], returns: 'number' }; perform = vi.fn(() => 42); } describe('Ability', () => { test('stores unit reference', () => { const unit = {} as any; const action = new ConcreteAction(unit); expect(action).toBeInstanceOf(Ability); }); test('Action and Sense both extend Ability', () => { expect(new ConcreteAction({} as any)).toBeInstanceOf(Ability); expect(new ConcreteSense({} as any)).toBeInstanceOf(Ability); }); }); ================================================ FILE: libs/core/src/Ability.ts ================================================ export interface AbilityParam { name: string; type: 'Direction' | 'Space' | 'number' | 'any'; optional?: boolean; rest?: boolean; } export interface AbilityMeta { params: AbilityParam[]; returns: 'void' | 'number' | 'string' | 'Direction' | 'Space' | 'Space[]'; } export interface AbilityClass { new (unit: any, config?: any): Ability; } export type AbilityBinding = [AbilityClass, object]; export type AbilityEntry = AbilityBinding | AbilityClass; abstract class Ability { protected unit: any; abstract readonly description: string; abstract readonly meta: AbilityMeta; constructor(unit: any, _config?: Record) { this.unit = unit; } abstract perform(...args: unknown[]): unknown; } export default Ability; ================================================ FILE: libs/core/src/Action.test.ts ================================================ import { describe, expect, test, vi } from 'vitest'; import Ability, { type AbilityMeta } from './Ability.js'; import Action from './Action.js'; import Sense from './Sense.js'; class TestAction extends Action { readonly description = 'test action'; readonly meta: AbilityMeta = { params: [], returns: 'void' }; perform = vi.fn(); static with(config: { power: number }) { return [TestAction, config] as const; } } describe('Action', () => { test('extends Ability', () => { const action = new TestAction({} as any); expect(action).toBeInstanceOf(Ability); expect(action).toBeInstanceOf(Action); }); test('is not an instance of Sense', () => { const action = new TestAction({} as any); expect(action).not.toBeInstanceOf(Sense); }); test('has description and meta', () => { const action = new TestAction({} as any); expect(action.description).toBe('test action'); expect(action.meta).toEqual({ params: [], returns: 'void' }); }); test('perform can be called', () => { const action = new TestAction({} as any); action.perform(); expect(action.perform).toHaveBeenCalled(); }); test('.with() returns an AbilityBinding', () => { const binding = TestAction.with({ power: 5 }); expect(binding[0]).toBe(TestAction); expect(binding[1]).toEqual({ power: 5 }); }); }); ================================================ FILE: libs/core/src/Action.ts ================================================ import Ability from './Ability.js'; abstract class Action extends Ability { abstract perform(...args: unknown[]): void; } export default Action; ================================================ FILE: libs/core/src/Effect.test.ts ================================================ import { describe, expect, test, vi } from 'vitest'; import Effect from './Effect.js'; class TestEffect extends Effect { readonly description = 'test effect'; passTurn = vi.fn(); trigger = vi.fn(); static with(config: { time: number }) { return [TestEffect, config] as const; } } describe('Effect', () => { test('stores unit reference', () => { const unit = { log: vi.fn() }; const effect = new TestEffect(unit); expect(effect).toBeInstanceOf(Effect); }); test('has description', () => { const effect = new TestEffect({}); expect(effect.description).toBe('test effect'); }); test('passTurn can be called', () => { const effect = new TestEffect({}); effect.passTurn(); expect(effect.passTurn).toHaveBeenCalled(); }); test('trigger can be called', () => { const effect = new TestEffect({}); effect.trigger(); expect(effect.trigger).toHaveBeenCalled(); }); test('.with() returns an EffectBinding', () => { const binding = TestEffect.with({ time: 5 }); expect(binding[0]).toBe(TestEffect); expect(binding[1]).toEqual({ time: 5 }); }); }); ================================================ FILE: libs/core/src/Effect.ts ================================================ export interface EffectClass { new (unit: any, config?: any): Effect; } export type EffectBinding = [EffectClass, object]; export type EffectEntry = EffectBinding | EffectClass; abstract class Effect { protected unit: any; abstract readonly description: string; constructor(unit: any, _config?: Record) { this.unit = unit; } abstract passTurn(): void; abstract trigger(): void; } export default Effect; ================================================ FILE: libs/core/src/Floor.test.ts ================================================ import { NORTH } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test } from 'vitest'; import Floor from './Floor.js'; import Space from './Space.js'; import Unit from './Unit.js'; import Warrior from './Warrior.js'; describe('Floor', () => { let floor: Floor; beforeEach(() => { floor = new Floor(2, 3, [1, 2]); }); test('returns its map', () => { const unit = new Unit(); floor.addUnit(unit, { x: 0, y: 1, facing: NORTH }); const map = floor.getMap(); expect(map[1][1].isEmpty()).toBe(true); expect(map[3][2].isStairs()).toBe(true); expect(map[0][0].isWall()).toBe(true); expect(map[2][1].isUnit()).toBe(true); }); test("doesn't consider corners out of bounds", () => { expect(floor.isOutOfBounds([0, 0])).toBe(false); expect(floor.isOutOfBounds([1, 0])).toBe(false); expect(floor.isOutOfBounds([1, 2])).toBe(false); expect(floor.isOutOfBounds([0, 2])).toBe(false); }); test('considers out of bounds when going beyond sides', () => { expect(floor.isOutOfBounds([-1, 0])).toBe(true); expect(floor.isOutOfBounds([0, -1])).toBe(true); expect(floor.isOutOfBounds([0, 3])).toBe(true); expect(floor.isOutOfBounds([2, 0])).toBe(true); }); test('knows where the stairs are located', () => { expect(floor.isStairs([0, 0])).toBe(false); expect(floor.isStairs([1, 2])).toBe(true); }); test('returns the space at the stairs location', () => { const stairsSpace = floor.getStairsSpace(); expect(stairsSpace.location).toEqual(floor.stairsLocation); }); test('returns the space at the specified location', () => { const space = floor.getSpaceAt([0, 0]); expect(space).toBeInstanceOf(Space); expect(space.location).toEqual([0, 0]); }); test('adds a unit and fetches it at that position', () => { const unit = new Unit(); floor.addUnit(unit, { x: 0, y: 1, facing: NORTH }); expect(floor.getUnitAt([0, 1])).toBe(unit); }); test('adds the warrior and fetches it at that position', () => { const warrior = new Warrior(); floor.addWarrior(warrior, { x: 0, y: 1, facing: NORTH }); expect(floor.getUnitAt([0, 1])).toBe(warrior); }); test('knows which unit is the warrior after adding it', () => { expect(floor.warrior).toBeNull(); const warrior = new Warrior(); floor.addWarrior(warrior, { x: 0, y: 1, facing: NORTH }); expect(floor.warrior).toBe(warrior); }); test("doesn't consider a unit to be on the floor if it's not alive", () => { const unit = new Unit(); floor.addUnit(unit, { x: 0, y: 1, facing: NORTH }); unit.isAlive = () => false; expect(floor.getUnits()).not.toContain(unit); }); }); ================================================ FILE: libs/core/src/Floor.ts ================================================ import { type Location } from '@warriorjs/spatial'; import Position from './Position.js'; import Space from './Space.js'; import { type PositionConfig } from './types.js'; import type Unit from './Unit.js'; import type Warrior from './Warrior.js'; class Floor { width: number; height: number; stairsLocation: Location; units: Unit[]; warrior: Warrior | null; constructor(width: number, height: number, stairsLocation: Location) { this.width = width; this.height = height; this.stairsLocation = stairsLocation; this.units = []; this.warrior = null; } getMap(): Space[][] { const map: Space[][] = []; for (let y = -1; y < this.height + 1; y += 1) { const row: Space[] = []; for (let x = -1; x < this.width + 1; x += 1) { row.push(this.getSpaceAt([x, y])); } map.push(row); } return map; } isOutOfBounds([x, y]: Location): boolean { return x < 0 || y < 0 || x > this.width - 1 || y > this.height - 1; } isStairs([x, y]: Location): boolean { const [stairsX, stairsY] = this.stairsLocation; return x === stairsX && y === stairsY; } getStairsSpace(): Space { return this.getSpaceAt(this.stairsLocation); } getSpaceAt(location: Location): Space { return new Space(this, location); } addWarrior(warrior: Warrior, position: PositionConfig): void { this.addUnit(warrior, position); this.warrior = warrior; } addUnit(unit: Unit, { x, y, facing }: PositionConfig): void { const unitWithPosition = unit; const location: Location = [x, y]; unitWithPosition.position = new Position(this, location, facing); this.units.push(unitWithPosition); } getUnitAt(location: Location): Unit | undefined { return this.getUnits().find((unit) => unit.position?.isAt(location)); } getUnits(): Unit[] { return this.units.filter((unit) => unit.isAlive()); } } export default Floor; ================================================ FILE: libs/core/src/Level.test.ts ================================================ import { EAST, FORWARD } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Floor from './Floor.js'; import Level from './Level.js'; import Warrior from './Warrior.js'; describe('Level', () => { let floor: Floor; let level: Level; let warrior: Warrior; beforeEach(() => { warrior = new Warrior('Joe', '@', '#8fbcbb', 20); warrior.log = vi.fn(); floor = new Floor(2, 1, [1, 0]); floor.addUnit(warrior, { x: 0, y: 0, facing: EAST }); floor.warrior = warrior; level = new Level(1, 'a description', 'a tip', 'a clue', floor); }); describe('playing', () => { beforeEach(() => { warrior.prepareTurn = vi.fn(); warrior.performTurn = vi.fn(); }); test('calls prepareTurn and playTurn on each unit once per turn', () => { level.play(2); expect(warrior.prepareTurn).toHaveBeenCalledTimes(2); expect(warrior.performTurn).toHaveBeenCalledTimes(2); }); test('plays for a max number of turns which defaults to 200', () => { level.play(); expect(warrior.prepareTurn).toHaveBeenCalledTimes(200); expect(warrior.performTurn).toHaveBeenCalledTimes(200); }); test('returns immediately when passed', () => { level.wasPassed = () => true; level.play(2); expect(warrior.performTurn).not.toHaveBeenCalled(); }); test('returns immediately when failed', () => { level.wasFailed = () => true; level.play(2); expect(warrior.performTurn).not.toHaveBeenCalled(); }); }); test('considers passed when warrior is on stairs', () => { warrior.move(FORWARD); expect(level.wasPassed()).toBe(true); }); test('considers failed when warrior is dead', () => { warrior.isAlive = () => false; expect(level.wasFailed()).toBe(true); }); test('has a minimal JSON representation', () => { expect(level.toJSON()).toEqual({ number: 1, description: 'a description', tip: 'a tip', clue: 'a clue', floorMap: level.floor.getMap(), warriorStatus: level.floor.warrior?.getStatus(), warriorAbilities: level.floor.warrior?.getAbilities(), }); }); }); ================================================ FILE: libs/core/src/Level.ts ================================================ import type Floor from './Floor.js'; import Logger, { type TurnEvent } from './Logger.js'; const maxTurns = 200; class Level { number: number; description: string; tip: string; clue: string; floor: Floor; constructor(number: number, description: string, tip: string, clue: string, floor: Floor) { this.number = number; this.description = description; this.tip = tip; this.clue = clue; this.floor = floor; } play(turns: number = maxTurns): { passed: boolean; turns: TurnEvent[][]; initialState: TurnEvent | null; } { Logger.play(this.floor); for (let n = 0; n < turns; n += 1) { if (this.wasPassed() || this.wasFailed()) { break; } Logger.turn(); this.floor.getUnits().forEach((unit) => unit.prepareTurn()); this.floor.getUnits().forEach((unit) => unit.performTurn()); } const passed = this.wasPassed(); return { passed, turns: Logger.turns, initialState: Logger.initialState, }; } wasPassed(): boolean { const stairsSpace = this.floor.getStairsSpace(); return stairsSpace.getUnit() === this.floor.warrior; } wasFailed(): boolean { return !this.floor.warrior?.isAlive(); } toJSON(): any { return { number: this.number, description: this.description, tip: this.tip, clue: this.clue, floorMap: this.floor.getMap(), warriorStatus: this.floor.warrior?.getStatus(), warriorAbilities: this.floor.warrior?.getAbilities(), }; } } export default Level; ================================================ FILE: libs/core/src/Logger.ts ================================================ import type Floor from './Floor.js'; import type Unit from './Unit.js'; export interface TurnEvent { message: string; unit: { name: string; color: string } | null; floorMap: { character: string; unit?: { color: string } }[][]; warriorStatus: { health: number; score: number } | undefined; } const Logger: { floor: Floor | null; turns: TurnEvent[][]; lastTurn: TurnEvent[] | null; initialState: TurnEvent | null; play(floor: Floor): void; turn(): void; unit(unit: Unit, message: string): void; } = { floor: null, turns: [], lastTurn: null, initialState: null, play(floor: Floor) { Logger.floor = floor; Logger.turns = []; Logger.lastTurn = null; Logger.initialState = { message: '', unit: null, floorMap: JSON.parse(JSON.stringify(floor.getMap())), warriorStatus: floor.warrior?.getStatus(), }; }, turn() { Logger.lastTurn = []; Logger.turns.push(Logger.lastTurn); }, unit(unit: Unit, message: string) { Logger.lastTurn?.push({ message, unit: JSON.parse(JSON.stringify(unit)), floorMap: JSON.parse(JSON.stringify(Logger.floor?.getMap())), warriorStatus: Logger.floor?.warrior?.getStatus(), }); }, }; export default Logger; ================================================ FILE: libs/core/src/Position.test.ts ================================================ import { BACKWARD, EAST, FORWARD, LEFT, NORTH, RIGHT, SOUTH, WEST } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test } from 'vitest'; import Floor from './Floor.js'; import Unit from './Unit.js'; describe('Position', () => { let floor: Floor; let unit: Unit; let position: any; beforeEach(() => { floor = new Floor(5, 6, [0, 0]); unit = new Unit(); floor.addUnit(unit, { x: 1, y: 2, facing: NORTH }); position = unit.position; }); test("can determine if it's at a given location", () => { expect(position.isAt([1, 1])).toBe(false); expect(position.isAt([1, 2])).toBe(true); }); test('returns the space at its location', () => { expect(position.getSpace().location).toEqual(position.location); }); test('gets relative space in front', () => { floor.addUnit(new Unit(), { x: 1, y: 1, facing: NORTH }); expect(position.getRelativeSpace(FORWARD, [1, 0]).isEmpty()).toBe(false); }); test('gets relative space in front two spaces yonder', () => { floor.addUnit(new Unit(), { x: 1, y: 0, facing: NORTH }); expect(position.getRelativeSpace(FORWARD, [2, 0]).isEmpty()).toBe(false); }); test('gets relative space in front when rotated', () => { floor.addUnit(new Unit(), { x: 2, y: 2, facing: NORTH }); position.rotate(RIGHT); expect(position.getRelativeSpace(FORWARD, [1, 0]).isEmpty()).toBe(false); }); test('gets relative space diagonally', () => { floor.addUnit(new Unit(), { x: 2, y: 1, facing: NORTH }); expect(position.getRelativeSpace(FORWARD, [1, 1]).isEmpty()).toBe(false); }); test('gets relative space diagonally when rotated', () => { floor.addUnit(new Unit(), { x: 2, y: 1, facing: NORTH }); position.rotate(BACKWARD); expect(position.getRelativeSpace(FORWARD, [-1, -1]).isEmpty()).toBe(false); }); test('returns distance of given space', () => { expect(position.getDistanceOf(floor.getSpaceAt([5, 3]))).toBe(5); expect(position.getDistanceOf(floor.getSpaceAt([4, 2]))).toBe(3); }); test('returns relative direction of given space', () => { expect(position.getRelativeDirectionOf(floor.getSpaceAt([5, 3]))).toEqual(RIGHT); position.rotate(RIGHT); expect(position.getRelativeDirectionOf(floor.getSpaceAt([1, 4]))).toEqual(RIGHT); }); test('rotates position on floor relatively', () => { expect(position.orientation).toEqual(NORTH); [EAST, SOUTH, WEST, NORTH, EAST].forEach((direction: string) => { position.rotate(RIGHT); expect(position.orientation).toEqual(direction); }); }); test('moves position on floor relatively', () => { expect(floor.getUnitAt([1, 2])).toBe(unit); position.move(BACKWARD, [1, 1]); expect(floor.getUnitAt([1, 2])).toBeUndefined(); expect(floor.getUnitAt([0, 3])).toBe(unit); position.rotate(LEFT); position.move(RIGHT, [1, 0]); expect(floor.getUnitAt([0, 3])).toBeUndefined(); expect(floor.getUnitAt([0, 2])).toBe(unit); }); }); ================================================ FILE: libs/core/src/Position.ts ================================================ import { type AbsoluteDirection, getAbsoluteDirection, getAbsoluteOffset, getDirectionOfLocation, getDistanceOfLocation, getRelativeDirection, type Location, type RelativeDirection, type RelativeOffset, rotateRelativeOffset, translateLocation, verifyAbsoluteDirection, } from '@warriorjs/spatial'; import type Floor from './Floor.js'; import type Space from './Space.js'; class Position { floor: Floor; location: Location; orientation: AbsoluteDirection; constructor(floor: Floor, location: Location, orientation: string) { verifyAbsoluteDirection(orientation); this.floor = floor; this.location = location; this.orientation = orientation; } isAt([x, y]: Location): boolean { const [locationX, locationY] = this.location; return locationX === x && locationY === y; } getSpace(): Space { return this.floor.getSpaceAt(this.location); } getRelativeSpace(direction: RelativeDirection, relativeOffset: RelativeOffset): Space { const offset = getAbsoluteOffset( rotateRelativeOffset(relativeOffset, direction), this.orientation, ); const spaceLocation = translateLocation(this.location, offset); return this.floor.getSpaceAt(spaceLocation); } getDistanceOf(space: Space): number { return getDistanceOfLocation(space.location, this.location); } getRelativeDirectionOf(space: Space): RelativeDirection { return getRelativeDirection( getDirectionOfLocation(space.location, this.location), this.orientation, ); } move(direction: RelativeDirection, relativeOffset: RelativeOffset): void { const offset = getAbsoluteOffset( rotateRelativeOffset(relativeOffset, direction), this.orientation, ); this.location = translateLocation(this.location, offset); } rotate(direction: RelativeDirection): void { this.orientation = getAbsoluteDirection(direction, this.orientation); } } export default Position; ================================================ FILE: libs/core/src/Sense.test.ts ================================================ import { describe, expect, test, vi } from 'vitest'; import Ability, { type AbilityMeta } from './Ability.js'; import Action from './Action.js'; import Sense from './Sense.js'; class TestSense extends Sense { readonly description = 'test sense'; readonly meta: AbilityMeta = { params: [], returns: 'number' }; perform = vi.fn(() => 42); } describe('Sense', () => { test('extends Ability', () => { const sense = new TestSense({} as any); expect(sense).toBeInstanceOf(Ability); expect(sense).toBeInstanceOf(Sense); }); test('is not an instance of Action', () => { const sense = new TestSense({} as any); expect(sense).not.toBeInstanceOf(Action); }); test('has description and meta', () => { const sense = new TestSense({} as any); expect(sense.description).toBe('test sense'); expect(sense.meta).toEqual({ params: [], returns: 'number' }); }); test('perform returns a value', () => { const sense = new TestSense({} as any); expect(sense.perform()).toBe(42); }); }); ================================================ FILE: libs/core/src/Sense.ts ================================================ import Ability from './Ability.js'; abstract class Sense extends Ability { abstract perform(...args: unknown[]): unknown; } export default Sense; ================================================ FILE: libs/core/src/Space.test.ts ================================================ import { NORTH } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test } from 'vitest'; import Floor from './Floor.js'; import Space from './Space.js'; import Unit from './Unit.js'; describe('Space', () => { let floor: Floor; let space: Space; beforeEach(() => { floor = new Floor(2, 3, [0, 2]); space = floor.getSpaceAt([0, 0]); }); describe('out of bounds', () => { beforeEach(() => { space = floor.getSpaceAt([-1, 1]); }); test('is not empty', () => { expect(space.isEmpty()).toBe(false); }); test('is not stairs', () => { expect(space.isStairs()).toBe(false); }); test('is wall', () => { expect(space.isWall()).toBe(true); }); test('has name "wall"', () => { expect(space.toString()).toEqual('wall'); }); describe('upper left corner', () => { beforeEach(() => { space = floor.getSpaceAt([-1, -1]); }); test("appears as '\u2554' on map", () => { expect(space.getCharacter()).toBe('\u2554'); }); }); describe('upper right corner', () => { beforeEach(() => { space = floor.getSpaceAt([2, -1]); }); test("appears as '\u2557' on map", () => { expect(space.getCharacter()).toBe('\u2557'); }); }); describe('lower left corner', () => { beforeEach(() => { space = floor.getSpaceAt([-1, 3]); }); test("appears as '\u255a' on map", () => { expect(space.getCharacter()).toBe('\u255a'); }); }); describe('lower right corner', () => { beforeEach(() => { space = floor.getSpaceAt([2, 3]); }); test("appears as '\u255d' on map", () => { expect(space.getCharacter()).toBe('\u255d'); }); }); describe('upper side', () => { beforeEach(() => { space = floor.getSpaceAt([1, -1]); }); test("appears as '\u2550' on map", () => { expect(space.getCharacter()).toBe('\u2550'); }); }); describe('lower side', () => { beforeEach(() => { space = floor.getSpaceAt([1, 3]); }); test("appears as '\u2550' on map", () => { expect(space.getCharacter()).toBe('\u2550'); }); }); describe('left side', () => { beforeEach(() => { space = floor.getSpaceAt([-1, 1]); }); test("appears as '\u2551' on map", () => { expect(space.getCharacter()).toBe('\u2551'); }); }); describe('right side', () => { beforeEach(() => { space = floor.getSpaceAt([2, 1]); }); test("appears as '\u2551' on map", () => { expect(space.getCharacter()).toBe('\u2551'); }); }); }); describe('with nothing on it', () => { beforeEach(() => { space = floor.getSpaceAt([0, 0]); }); test('is empty', () => { expect(space.isEmpty()).toBe(true); }); test('is not stairs', () => { expect(space.isStairs()).toBe(false); }); test('is not wall', () => { expect(space.isWall()).toBe(false); }); test('is not unit', () => { expect(space.isUnit()).toBe(false); }); test("doesn't fetch a unit", () => { expect(space.getUnit()).toBeUndefined(); }); test('has name "nothing"', () => { expect(space.toString()).toEqual('nothing'); }); test("appears as ' ' on map", () => { expect(space.getCharacter()).toBe(' '); }); }); describe('with stairs', () => { beforeEach(() => { space = floor.getSpaceAt([0, 2]); }); test('is empty', () => { expect(space.isEmpty()).toBe(true); }); test('is stairs', () => { expect(space.isStairs()).toBe(true); }); test('is not wall', () => { expect(space.isWall()).toBe(false); }); test('is not unit', () => { expect(space.isUnit()).toBe(false); }); test("doesn't fetch a unit", () => { expect(space.getUnit()).toBeUndefined(); }); test('has name "nothing"', () => { expect(space.toString()).toEqual('nothing'); }); test("appears as '>' on map", () => { expect(space.getCharacter()).toBe('>'); }); describe('with unit', () => { let unit: Unit; beforeEach(() => { unit = new Unit('Foo', 'f'); floor.addUnit(unit, { x: 0, y: 2, facing: NORTH }); }); test('is still stairs', () => { expect(space.isStairs()).toBe(true); }); test('is also unit', () => { expect(space.isUnit()).toBe(true); }); test('has name of unit', () => { expect(space.toString()).toEqual('Foo'); }); test('appears as unit character on map', () => { expect(space.getCharacter()).toBe('f'); }); }); }); describe('with unit', () => { let unit: Unit; beforeEach(() => { unit = new Unit('Foo', 'f'); floor.addUnit(unit, { x: 0, y: 0, facing: NORTH }); }); test('is not empty', () => { expect(space.isEmpty()).toBe(false); }); test('is not stairs', () => { expect(space.isStairs()).toBe(false); }); test('is not wall', () => { expect(space.isWall()).toBe(false); }); test('is unit', () => { expect(space.isUnit()).toBe(true); }); test('fetches the unit', () => { expect(space.getUnit()).toBe(unit); }); test('has name of unit', () => { expect(space.toString()).toEqual('Foo'); }); test('appears as its character on map', () => { expect(space.getCharacter()).toBe('f'); }); }); describe('sensed space', () => { let sensingUnit: Unit; let sensedSpace: any; beforeEach(() => { sensingUnit = new Unit(); floor.addUnit(sensingUnit, { x: 1, y: 1, facing: NORTH }); sensedSpace = space.as(sensingUnit); }); test('allows calling sensed space methods', () => { const allowedApi = ['getLocation', 'getUnit', 'isEmpty', 'isStairs', 'isUnit', 'isWall']; allowedApi.forEach((propertyName) => { sensedSpace[propertyName](); }); }); test("doesn't allow calling other space methods", () => { const forbiddenApi = ['as', 'getCharacter']; forbiddenApi.forEach((propertyName: string) => { expect(sensedSpace).not.toHaveProperty(propertyName); }); }); test('has a location relative to the sensing unit', () => { expect(sensedSpace.getLocation()).toEqual([1, -1]); }); test('can get full space back', () => { const fullSpace = Space.from(sensedSpace, sensingUnit); expect(fullSpace).toBeInstanceOf(Space); expect(fullSpace.floor).toBe(space.floor); expect(fullSpace.location).toEqual(space.location); }); }); test('has a minimal JSON representation', () => { expect(space.toJSON()).toEqual({ character: space.getCharacter(), unit: space.getUnit(), }); }); }); ================================================ FILE: libs/core/src/Space.ts ================================================ import { getAbsoluteOffset, getRelativeOffset, type Location, type RelativeOffset, translateLocation, } from '@warriorjs/spatial'; import type Floor from './Floor.js'; import type Unit from './Unit.js'; const upperLeftWallCharacter = '\u2554'; const upperRightWallCharacter = '\u2557'; const lowerLeftWallCharacter = '\u255a'; const lowerRightWallCharacter = '\u255d'; const verticalWallCharacter = '\u2551'; const horizontalWallCharacter = '\u2550'; const emptyCharacter = ' '; const stairsCharacter = '>'; export interface SensedSpace { getLocation(): RelativeOffset; getUnit(): SensedUnit | null; isEmpty(): boolean; isStairs(): boolean; isUnit(): boolean; isWall(): boolean; } export interface SensedUnit { isBound(): boolean; isEnemy(): boolean; isUnderEffect(name: string): boolean; } class Space { floor: Floor; location: Location; static from(sensedSpace: SensedSpace, unit: Unit): Space { const { floor, location, orientation } = unit.position!; const offset = getAbsoluteOffset(sensedSpace.getLocation(), orientation); const spaceLocation = translateLocation(location, offset); return new Space(floor, spaceLocation); } constructor(floor: Floor, location: Location) { this.floor = floor; this.location = location; } getCharacter(): string { if (this.isUnit()) { return this.getUnit()!.character; } if (this.isWall()) { const [locationX, locationY] = this.location; if (locationX < 0) { if (locationY < 0) { return upperLeftWallCharacter; } if (locationY > this.floor.height - 1) { return lowerLeftWallCharacter; } return verticalWallCharacter; } if (locationX > this.floor.width - 1) { if (locationY < 0) { return upperRightWallCharacter; } if (locationY > this.floor.height - 1) { return lowerRightWallCharacter; } return verticalWallCharacter; } return horizontalWallCharacter; } if (this.isStairs()) { return stairsCharacter; } return emptyCharacter; } isEmpty(): boolean { return !this.isUnit() && !this.isWall(); } isStairs(): boolean { return this.floor.isStairs(this.location); } isWall(): boolean { return this.floor.isOutOfBounds(this.location); } isUnit(): boolean { return !!this.getUnit(); } getUnit(): Unit | undefined { return this.floor.getUnitAt(this.location); } as(unit: Unit): SensedSpace { return { getLocation: () => getRelativeOffset(this.location, unit.position!.location, unit.position!.orientation), getUnit: () => { const spaceUnit = this.getUnit.call(this); return spaceUnit ? spaceUnit.as(unit) : null; }, isEmpty: this.isEmpty.bind(this), isStairs: this.isStairs.bind(this), isUnit: this.isUnit.bind(this), isWall: this.isWall.bind(this), }; } toString(): string { if (this.isUnit()) { return this.getUnit()!.toString(); } if (this.isWall()) { return 'wall'; } return 'nothing'; } toJSON(): { character: string; unit: Unit | undefined } { return { character: this.getCharacter(), unit: this.getUnit(), }; } } export default Space; ================================================ FILE: libs/core/src/Unit.test.ts ================================================ import { BACKWARD, FORWARD, LEFT, NORTH, RIGHT, SOUTH } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Action from './Action.js'; import Floor from './Floor.js'; import Sense from './Sense.js'; import Unit from './Unit.js'; class MockAction extends Action { readonly description = 'mock action'; readonly meta = { params: [], returns: 'void' as const }; perform = vi.fn(); } class MockSense extends Sense { readonly description = 'mock sense'; readonly meta = { params: [], returns: 'void' as const }; perform = vi.fn(); } describe('Unit', () => { let unit: Unit; let floor: Floor; beforeEach(() => { unit = new Unit('Joe', '@', '#8fbcbb', 20); unit.log = vi.fn(); floor = new Floor(5, 6, [0, 0]); floor.addUnit(unit, { x: 1, y: 2, facing: NORTH }); }); test('has a name', () => { expect(unit.name).toBe('Joe'); }); test('has a character that represents it', () => { expect(unit.character).toBe('@'); }); test('has a color', () => { expect(unit.color).toBe('#8fbcbb'); }); test('has a max health', () => { expect(unit.maxHealth).toBe(20); }); test('has a reward which defaults to max health', () => { expect(unit.reward).toBe(20); }); test('allows to specify reward', () => { expect(new Unit('Foo', 'f', '#fff', 20, 30).reward).toBe(30); }); test('has an enemy status which defaults to true', () => { expect(unit.enemy).toBe(true); }); test('allows to specify enemy status', () => { expect(new Unit('Foo', 'f', '#fff', 20, 30, false).enemy).toBe(false); }); test('has a bound status which defaults to false', () => { expect(unit.bound).toBe(false); }); test('has a position which is null before adding the unit to the floor', () => { expect(new Unit('Foo', 'f', '#fff', 20).position).toBeNull(); }); test('allows to specify bound status', () => { expect(new Unit('Foo', 'f', '#fff', 20, 30, false, true).bound).toBe(true); }); test('has a health which defaults to max health', () => { expect(unit.health).toBe(20); }); test('starts with a score of zero', () => { expect(unit.score).toBe(0); }); test('has a collection of abilities which starts empty', () => { expect(unit.abilities).toBeInstanceOf(Map); expect(unit.abilities.size).toBe(0); }); test('has a collection of effects which starts empty', () => { expect(unit.effects).toBeInstanceOf(Map); expect(unit.effects.size).toBe(0); }); test('has a turn which starts as null', () => { expect(unit.turn).toBeNull(); }); describe('next turn', () => { let turn: any; let feel: MockSense; let walk: MockAction; beforeEach(() => { feel = new MockSense(unit); walk = new MockAction(unit); unit.addAbility('feel', feel); unit.addAbility('walk', walk); turn = unit.getNextTurn(); }); test('defines a function for each ability of the unit', () => { expect(turn.feel).toBeInstanceOf(Function); expect(turn.walk).toBeInstanceOf(Function); }); describe('with actions', () => { test('has no action performed at first', () => { expect(turn.action).toBeNull(); }); test('can call action and recall it', () => { turn.walk(); expect(turn.action).toEqual(['walk', []]); }); test('includes arguments passed to action', () => { turn.walk('forward'); expect(turn.action).toEqual(['walk', ['forward']]); }); test("can't call multiple actions per turn", () => { turn.walk(); expect(() => { turn.walk(); }).toThrow('Only one action can be performed per turn.'); }); test('defers execution when calling action', () => { turn.walk(); expect(walk.perform).not.toHaveBeenCalled(); }); }); describe('with senses', () => { test('can call multiple senses per turn', () => { turn.feel(); turn.feel(); }); test('executes immediately when calling sense', () => { turn.feel(); expect(feel.perform).toHaveBeenCalled(); }); }); }); test('prepares turn by calling playTurn with next turn object', () => { unit.getNextTurn = () => 'nextTurn' as any; unit.playTurn = vi.fn(); unit.prepareTurn(); expect(unit.playTurn).toHaveBeenCalledWith('nextTurn'); }); test('calls passTurn once on effects when calling perform on turn', () => { const ticking = { passTurn: vi.fn(), trigger: vi.fn() }; unit.addEffect('ticking', ticking as any); unit.turn = { action: null }; unit.performTurn(); expect(ticking.passTurn).toHaveBeenCalledTimes(1); }); test('performs action when calling perform on turn', () => { const walk = { perform: vi.fn() }; unit.addAbility('walk', walk as any); unit.turn = { action: ['walk', ['backward']] }; unit.performTurn(); expect(walk.perform).toHaveBeenCalledWith('backward'); }); test("doesn't throw when calling performTurn when there is no action", () => { unit.turn = { action: null }; unit.performTurn(); }); describe('when healing', () => { test('adds health', () => { unit.health = 5; unit.heal(3); expect(unit.health).toBe(8); expect(unit.log).toHaveBeenCalledWith('recovers 3 HP, up to 8 HP'); }); test("doesn't go over max health", () => { unit.health = 19; unit.heal(2); expect(unit.health).toBe(20); expect(unit.log).toHaveBeenCalledWith('recovers 2 HP, up to 20 HP'); }); test("doesn't add health when at max", () => { unit.heal(1); expect(unit.health).toBe(20); expect(unit.log).toHaveBeenCalledWith('recovers 1 HP, up to 20 HP'); }); }); describe('when taking damage', () => { test('subtracts health', () => { unit.takeDamage(3); expect(unit.health).toBe(17); expect(unit.log).toHaveBeenCalledWith('takes 3 damage, 17 HP left'); }); test("doesn't go under zero health", () => { unit.takeDamage(21); expect(unit.health).toBe(0); expect(unit.log).toHaveBeenCalledWith('takes 21 damage, 0 HP left'); }); test('dies when running out of health', () => { unit.takeDamage(20); expect(unit.isAlive()).toBe(false); expect(unit.log).toHaveBeenCalledWith('takes 20 damage, 0 HP left'); expect(unit.log).toHaveBeenCalledWith('dies'); }); }); describe('when dead', () => { beforeEach(() => { unit.position = null; }); test("doesn't perform any action", () => { const walk = { perform: vi.fn() }; unit.addAbility('walk', walk as any); unit.turn = { action: ['walk', []] }; unit.performTurn(); expect(walk.perform).not.toHaveBeenCalled(); }); }); test('can damage another unit', () => { const receiver = new Unit(); receiver.health = 10; receiver.position = {} as any; receiver.log = vi.fn(); unit.damage(receiver, 3); expect(receiver.health).toBe(7); }); describe('when dealing damage', () => { let receiver: Unit; beforeEach(() => { receiver = new Unit(); receiver.maxHealth = 5; receiver.reward = 10; receiver.health = 5; receiver.position = {} as any; receiver.as = () => ({ isEnemy: () => true, isBound: () => false, isUnderEffect: () => false, }); receiver.log = vi.fn(); }); test('earns points equal to reward when killing unit', () => { unit.earnPoints = vi.fn(); unit.damage(receiver, 5); expect(unit.earnPoints).toHaveBeenCalledWith(10); }); test("doesn't earn points when not killing unit", () => { unit.earnPoints = vi.fn(); unit.damage(receiver, 3); expect(unit.earnPoints).not.toHaveBeenCalled(); }); test('lose points equal to reward when killing a friend', () => { receiver.as = () => ({ isEnemy: () => false, isBound: () => false, isUnderEffect: () => false, }); unit.losePoints = vi.fn(); unit.damage(receiver, 5); expect(unit.losePoints).toHaveBeenCalledWith(10); }); }); test('considers itself alive with position', () => { expect(unit.isAlive()).toBe(true); }); test('considers itself dead when no position', () => { unit.position = null; expect(unit.isAlive()).toBe(false); }); describe('when releasing', () => { let receiver: Unit; beforeEach(() => { receiver = new Unit(); receiver.reward = 10; receiver.bound = true; receiver.position = {} as any; receiver.as = () => ({ isEnemy: () => true, isBound: () => false, isUnderEffect: () => false, }); receiver.log = vi.fn(); }); test('unbinds the unit', () => { receiver.unbind = vi.fn(); unit.release(receiver); expect(receiver.unbind).toHaveBeenCalled(); }); test("doesn't earn points", () => { unit.earnPoints = vi.fn(); unit.release(receiver); expect(unit.earnPoints).not.toHaveBeenCalled(); }); describe('friendly unit', () => { beforeEach(() => { receiver.as = () => ({ isEnemy: () => false, isBound: () => false, isUnderEffect: () => false, }); }); test('vanishes the unit', () => { receiver.vanish = vi.fn(); unit.release(receiver); expect(receiver.vanish).toHaveBeenCalled(); }); test('earns points equal to reward', () => { unit.earnPoints = vi.fn(); unit.release(receiver); expect(unit.earnPoints).toHaveBeenCalledWith(10); }); }); }); test('is bound after calling bind', () => { unit.bind(); expect(unit.isBound()).toBe(true); }); describe('when bound', () => { beforeEach(() => { unit.bind(); }); test("doesn't perform any action", () => { const walk = { perform: vi.fn() }; unit.addAbility('walk', walk as any); unit.turn = { action: ['walk', []] }; unit.performTurn(); expect(walk.perform).not.toHaveBeenCalled(); }); test('is released from bonds when calling unbind', () => { unit.unbind(); expect(unit.isBound()).toBe(false); }); test('is released from bonds when taking damage', () => { unit.takeDamage(2); expect(unit.isBound()).toBe(false); }); }); test('can earn points', () => { unit.earnPoints(5); expect(unit.score).toBe(5); }); test('can lose points', () => { unit.score = 10; unit.losePoints(5); expect(unit.score).toBe(5); }); test('can lose points under zero', () => { unit.score = 3; unit.losePoints(5); expect(unit.score).toBe(-2); }); test('allows to add abilities', () => { expect(unit.abilities.has('walk')).toBe(false); unit.addAbility('walk', {} as any); expect(unit.abilities.has('walk')).toBe(true); }); test('allows to add effects', () => { expect(unit.effects.has('ticking')).toBe(false); unit.addEffect('ticking', {} as any); expect(unit.effects.has('ticking')).toBe(true); }); test('can trigger a given effect when it has such effect', () => { const ticking = { trigger: vi.fn(), passTurn: vi.fn() }; unit.addEffect('ticking', ticking as any); const itching = { trigger: vi.fn(), passTurn: vi.fn() }; unit.triggerEffect('ticking'); unit.triggerEffect('itching'); expect(ticking.trigger).toHaveBeenCalled(); expect(itching.trigger).not.toHaveBeenCalled(); }); test('considers itself under an effect when it has such effect', () => { unit.addEffect('ticking', {} as any); expect(unit.isUnderEffect('ticking')).toBe(true); }); test("doesn't fetch itself when fetching other units", () => { const anotherUnit = new Unit(); floor.addUnit(anotherUnit, { x: 3, y: 4, facing: NORTH }); expect(unit.getOtherUnits()).not.toContain(unit); expect(unit.getOtherUnits()).toContain(anotherUnit); }); test("returns the space where it's located", () => { const space = unit.getSpace(); expect(space.location).toEqual(unit.position?.location); }); test('returns sensed space at a given direction and number of spaces', () => { const space = { as: vi.fn() } as any; unit.getSpaceAt = vi.fn(() => space); unit.getSensedSpaceAt(RIGHT, 2, 1); expect(unit.getSpaceAt).toHaveBeenCalledWith(RIGHT, 2, 1); expect(space.as).toHaveBeenCalledWith(unit); }); test('returns space at a given direction and number of spaces', () => { unit.position = { getRelativeSpace: vi.fn() } as any; unit.getSpaceAt(RIGHT, 2, 1); expect(unit.position?.getRelativeSpace).toHaveBeenCalledWith(RIGHT, [2, 1]); }); test('returns immediate space at a given direction if number of spaces is omitted', () => { unit.position = { getRelativeSpace: vi.fn() } as any; unit.getSpaceAt(LEFT); expect(unit.position?.getRelativeSpace).toHaveBeenCalledWith(LEFT, [1, 0]); }); test('returns the direction of the stairs', () => { expect(unit.getDirectionOfStairs()).toEqual(FORWARD); }); test('returns the direction of a given space', () => { expect(unit.getDirectionOf(unit.getSensedSpaceAt(FORWARD, 1))).toEqual(FORWARD); expect(unit.getDirectionOf(unit.getSensedSpaceAt(RIGHT, 1))).toEqual(RIGHT); expect(unit.getDirectionOf(unit.getSensedSpaceAt(BACKWARD, 1))).toEqual(BACKWARD); expect(unit.getDirectionOf(unit.getSensedSpaceAt(LEFT, 1))).toEqual(LEFT); }); test('returns the distance of a given space', () => { expect(unit.getDistanceOf(unit.getSensedSpaceAt(FORWARD, 2, -1))).toBe(3); }); describe('when moving', () => { beforeEach(() => { unit.position = { move: vi.fn() } as any; }); test('moves in the given direction by a given number of spaces', () => { unit.move(RIGHT, 2, 1); expect(unit.position?.move).toHaveBeenCalledWith(RIGHT, [2, 1]); }); test('moves one space in the given direction if number of spaces is omitted', () => { unit.move(LEFT); expect(unit.position?.move).toHaveBeenCalledWith(LEFT, [1, 0]); }); }); describe('when rotating', () => { beforeEach(() => { unit.position = { rotate: vi.fn() } as any; }); test('rotates in the given direction', () => { unit.rotate(RIGHT); expect(unit.position?.rotate).toHaveBeenCalledWith(RIGHT); }); }); describe('when vanishing', () => { test('disappears from the floor', () => { expect(unit.position).not.toBeNull(); unit.vanish(); expect(unit.position).toBeNull(); }); }); describe('sensed unit', () => { let sensingUnit: Unit; let sensedUnit: any; beforeEach(() => { sensingUnit = new Unit(); sensingUnit.enemy = false; floor.addUnit(sensingUnit, { x: 0, y: 1, facing: SOUTH }); sensedUnit = unit.as(sensingUnit); }); test('allows calling sensed unit methods', () => { const allowedApi = ['isBound', 'isEnemy', 'isUnderEffect']; allowedApi.forEach((propertyName) => { sensedUnit[propertyName](); }); }); test("is considered enemy if it doesn't fight for the same side", () => { expect(sensedUnit.isEnemy()).toBe(true); }); test("doesn't allow calling other unit methods", () => { const forbiddenApi = [ 'addAbility', 'addEffect', 'as', 'bind', 'damage', 'earnPoints', 'getDirectionOf', 'getDirectionOfStairs', 'getDistanceOf', 'getNextTurn', 'getOtherUnits', 'getSpace', 'getSpaceAt', 'heal', 'isAlive', 'log', 'losePoints', 'move', 'performTurn', 'prepareTurn', 'rotate', 'takeDamage', 'triggerEffect', 'unbind', 'vanish', ]; forbiddenApi.forEach((propertyName) => { expect(sensedUnit).not.toHaveProperty(propertyName); }); }); }); test('has a nice string representation', () => { expect(unit.toString()).toBe(unit.name); }); test('has a minimal JSON representation', () => { expect(unit.toJSON()).toEqual({ name: 'Joe', color: '#8fbcbb', maxHealth: 20, }); }); }); ================================================ FILE: libs/core/src/Unit.ts ================================================ import { type RelativeDirection } from '@warriorjs/spatial'; import type Ability from './Ability.js'; import { type AbilityEntry } from './Ability.js'; import Action from './Action.js'; import type Effect from './Effect.js'; import Logger from './Logger.js'; import type Position from './Position.js'; import Space, { type SensedSpace, type SensedUnit } from './Space.js'; export type Turn = Record any>; interface TurnState { action: [string, any[]] | null; [key: string]: any; } export interface UnitClass { new (): Unit; declaredAbilities?: Record; } class Unit { static declaredAbilities?: Record; name: string; character: string; color: string; maxHealth: number; reward: number; enemy: boolean; bound: boolean; position: Position | null; health: number; score: number; abilities: Map; effects: Map; turn: TurnState | null; constructor( name?: string, character?: string, color?: string, maxHealth?: number, reward: number | null = null, enemy: boolean = true, bound: boolean = false, ) { this.name = name!; this.character = character!; this.color = color!; this.maxHealth = maxHealth!; this.reward = reward === null ? maxHealth! : reward; this.enemy = enemy; this.bound = bound; this.position = null; this.health = maxHealth!; this.score = 0; this.abilities = new Map(); this.effects = new Map(); this.turn = null; } getNextTurn(): TurnState { const turn: TurnState = { action: null }; this.abilities.forEach((ability, name) => { if (ability instanceof Action) { Object.defineProperty(turn, name, { value: (...args: any[]) => { if (turn.action) { throw new Error('Only one action can be performed per turn.'); } turn.action = [name, args]; }, }); } else { Object.defineProperty(turn, name, { value: (...args: any[]) => ability.perform(...args), }); } }); return turn; } playTurn(_turn: Turn): void {} prepareTurn(): void { this.turn = this.getNextTurn(); this.playTurn(this.turn); } performTurn(): void { if (this.isAlive()) { this.effects.forEach((effect) => effect.passTurn()); if (this.turn?.action && !this.isBound()) { const [name, args] = this.turn.action; this.abilities.get(name)?.perform(...args); } } } heal(amount: number): void { const revisedAmount = this.health + amount > this.maxHealth ? this.maxHealth - this.health : amount; this.health += revisedAmount; this.log(`recovers ${amount} HP, up to ${this.health} HP`); } takeDamage(amount: number): void { if (this.isBound()) { this.unbind(); } const revisedAmount = this.health - amount < 0 ? this.health : amount; this.health -= revisedAmount; this.log(`takes ${amount} damage, ${this.health} HP left`); if (this.health === 0) { this.vanish(); this.log('dies'); } } damage(receiver: Unit, amount: number): void { receiver.takeDamage(amount); if (!receiver.isAlive()) { if (receiver.as(this).isEnemy()) { this.earnPoints(receiver.reward); } else { this.losePoints(receiver.reward); } } } isAlive(): boolean { return this.position !== null; } release(receiver: Unit): void { if (!receiver.as(this).isEnemy()) { receiver.vanish(); } receiver.unbind(); if (!receiver.isAlive()) { this.earnPoints(receiver.reward); } } unbind(): void { this.bound = false; this.log('released from bonds'); } bind(): void { this.bound = true; } isBound(): boolean { return this.bound; } earnPoints(points: number): void { this.score += points; } losePoints(points: number): void { this.score -= points; } addAbility(name: string, ability: Ability): void { this.abilities.set(name, ability); } addEffect(name: string, effect: Effect): void { this.effects.set(name, effect); } triggerEffect(name: string): void { const effect = this.effects.get(name); if (effect) { effect.trigger(); } } isUnderEffect(name: string): boolean { return this.effects.has(name); } getOtherUnits(): Unit[] { return this.position!.floor.getUnits().filter((unit) => unit !== this); } getSpace(): Space { return this.position!.getSpace(); } getSensedSpaceAt( direction: RelativeDirection, forward: number = 1, right: number = 0, ): SensedSpace { return this.getSpaceAt(direction, forward, right).as(this); } getSpaceAt(direction: RelativeDirection, forward: number = 1, right: number = 0): Space { return this.position!.getRelativeSpace(direction, [forward, right]); } getDirectionOfStairs(): RelativeDirection { return this.position!.getRelativeDirectionOf(this.position!.floor.getStairsSpace()); } getDirectionOf(sensedSpace: SensedSpace): RelativeDirection { const space = Space.from(sensedSpace, this); return this.position!.getRelativeDirectionOf(space); } getDistanceOf(sensedSpace: SensedSpace): number { const space = Space.from(sensedSpace, this); return this.position!.getDistanceOf(space); } move(direction: RelativeDirection, forward: number = 1, right: number = 0): void { this.position!.move(direction, [forward, right]); } rotate(direction: RelativeDirection): void { this.position?.rotate(direction); } vanish(): void { this.position = null; } log(message: string): void { Logger.unit(this, message); } as(unit: Unit): SensedUnit { return { isBound: this.isBound.bind(this), isEnemy: () => this.enemy !== unit.enemy, isUnderEffect: this.isUnderEffect.bind(this), }; } toString(): string { return this.name; } toJSON(): { name: string; color: string; maxHealth: number } { return { name: this.name, color: this.color, maxHealth: this.maxHealth, }; } } export default Unit; ================================================ FILE: libs/core/src/Warrior.test.ts ================================================ import { beforeEach, describe, expect, test, vi } from 'vitest'; import Action from './Action.js'; import Sense from './Sense.js'; import Warrior from './Warrior.js'; class MockAction extends Action { readonly description: string; readonly meta = { params: [], returns: 'void' as const }; constructor(unit: any, description: string) { super(unit); this.description = description; } perform = vi.fn(); } class MockSense extends Sense { readonly description: string; readonly meta = { params: [], returns: 'void' as const }; constructor(unit: any, description: string) { super(unit); this.description = description; } perform = vi.fn(); } describe('Warrior', () => { let warrior: Warrior; beforeEach(() => { warrior = new Warrior('Joe', '@', '#8fbcbb', 20); warrior.addAbility('feel', new MockSense(warrior, 'a description')); warrior.addAbility('walk', new MockAction(warrior, 'a description')); warrior.log = vi.fn(); }); test('is upset for not doing anything when no action', () => { warrior.turn = { action: null }; warrior.performTurn(); expect(warrior.log).toHaveBeenCalledWith('does nothing'); }); test('is upset for not doing anything when bound', () => { warrior.bind(); warrior.turn = { action: ['walk', []] }; warrior.performTurn(); expect(warrior.log).toHaveBeenCalledWith('does nothing'); }); test('is proud of earning points', () => { warrior.earnPoints(5); expect(warrior.log).toHaveBeenCalledWith('earns 5 points'); }); test('is upset for losing points', () => { warrior.losePoints(5); expect(warrior.log).toHaveBeenCalledWith('loses 5 points'); }); test('has a grouped collection of abilities', () => { expect(warrior.getAbilities()).toEqual({ actions: [{ name: 'walk', description: 'a description' }], senses: [{ name: 'feel', description: 'a description' }], }); }); test('has a status', () => { expect(warrior.getStatus()).toEqual({ health: 20, score: 0, }); }); }); ================================================ FILE: libs/core/src/Warrior.ts ================================================ import Action from './Action.js'; import Unit from './Unit.js'; interface AbilityInfo { name: string; isAction: boolean; description?: string; } class Warrior extends Unit { constructor(name?: string, character?: string, color?: string, maxHealth?: number) { super(name, character, color, maxHealth, null, false); } performTurn(): void { super.performTurn(); const turn = this.turn as { action: [string, any[]] | null }; if (!turn.action || this.isBound()) { this.log('does nothing'); } } earnPoints(points: number): void { super.earnPoints(points); this.log(`earns ${points} points`); } losePoints(points: number): void { super.losePoints(points); this.log(`loses ${points} points`); } getAbilities(): { actions: Omit[]; senses: Omit[]; } { const abilities: AbilityInfo[] = [...this.abilities].map(([name, ability]) => ({ name, isAction: ability instanceof Action, description: ability.description, })); const sortedAbilities = abilities.sort((a, b) => (a.name > b.name ? 1 : -1)); const actions = sortedAbilities .filter((ability) => ability.isAction) .map(({ isAction, ...rest }) => rest); const senses = sortedAbilities .filter((ability) => !ability.isAction) .map(({ isAction, ...rest }) => rest); return { actions, senses, }; } getStatus(): { health: number; score: number } { return { health: this.health, score: this.score, }; } } export default Warrior; ================================================ FILE: libs/core/src/getLevel.test.ts ================================================ import { EAST, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial'; import { expect, test } from 'vitest'; import { type AbilityMeta } from './Ability.js'; import Action from './Action.js'; import getLevel from './getLevel.js'; import Sense from './Sense.js'; import { type LevelConfig } from './types.js'; import Unit from './Unit.js'; class TestWalk extends Action { readonly description = "Moves one space in the given direction (`'forward'` by default)."; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; perform() {} } class TestAttack extends Action { readonly description: string; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; constructor(unit: any, { power }: { power: number }) { super(unit); this.description = `Attacks a unit in the given direction (\`'forward'\` by default), dealing ${power} HP of damage.`; } perform() {} static with(config: { power: number }) { return [TestAttack, config] as [new (unit: any, config: any) => TestAttack, object]; } } class TestFeel extends Sense { readonly description = "Returns the adjacent space in the given direction (`'forward'` by default)."; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'Space', }; perform() {} } class TestSludge extends Unit { static declaredAbilities = { attack: TestAttack.with({ power: 3 }), feel: TestFeel, }; constructor() { super('Sludge', 's', '#d08770', 12); this.playTurn = (turn: any) => { const playerDirection = RELATIVE_DIRECTIONS.find((direction) => { const space = turn.feel(direction); return space.isUnit() && space.getUnit().isPlayer(); }); if (playerDirection) { turn.attack(playerDirection); } }; } } const levelConfig = { number: 2, description: "It's too dark to see anything, but you smell sludge nearby.", 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.", clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.', floor: { size: { width: 8, height: 1 }, stairs: { x: 7, y: 0 }, warrior: { name: 'Joe', character: '@', color: '#8fbcbb', maxHealth: 20, abilities: { walk: TestWalk, attack: TestAttack.with({ power: 5 }), feel: TestFeel, }, position: { x: 0, y: 0, facing: EAST }, }, units: [ { unit: TestSludge, position: { x: 4, y: 0, facing: WEST }, }, ], }, } satisfies LevelConfig; test('returns level', () => { expect(getLevel(levelConfig)).toEqual({ number: 2, description: "It's too dark to see anything, but you smell sludge nearby.", 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.", clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.', floorMap: [ [ { character: '\u2554' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2557' }, ], [ { character: '\u2551' }, { character: '@', unit: { name: 'Joe', color: '#8fbcbb', maxHealth: 20 }, }, { character: ' ' }, { character: ' ' }, { character: ' ' }, { character: 's', unit: { name: 'Sludge', color: '#d08770', maxHealth: 12 }, }, { character: ' ' }, { character: ' ' }, { character: '>' }, { character: '\u2551' }, ], [ { character: '\u255a' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u2550' }, { character: '\u255d' }, ], ], warriorStatus: { health: 20, score: 0 }, warriorAbilities: { actions: [ { name: 'attack', description: "Attacks a unit in the given direction (`'forward'` by default), dealing 5 HP of damage.", }, { name: 'walk', description: "Moves one space in the given direction (`'forward'` by default).", }, ], senses: [ { name: 'feel', description: "Returns the adjacent space in the given direction (`'forward'` by default).", }, ], }, }); }); ================================================ FILE: libs/core/src/getLevel.ts ================================================ import loadLevel from './loadLevel.js'; import { type LevelConfig } from './types.js'; function getLevel(levelConfig: LevelConfig): any { const level = loadLevel(levelConfig); return JSON.parse(JSON.stringify(level)); } export default getLevel; ================================================ FILE: libs/core/src/getLevelConfig.test.ts ================================================ import { expect, test } from 'vitest'; import getLevelConfig from './getLevelConfig.js'; const tower = { name: 'Foo', description: 'A test tower', warrior: { character: '@', color: '#fff', maxHealth: 20, }, levels: [ { floor: { warrior: { abilities: { a: 1 }, position: { x: 0, y: 0, facing: 'east' } }, size: { width: 1, height: 1 }, stairs: { x: 0, y: 0 }, units: [], }, }, { floor: { warrior: { abilities: { b: 2, c: 3 }, position: { x: 0, y: 0, facing: 'east' } }, size: { width: 1, height: 1 }, stairs: { x: 0, y: 0 }, units: [], }, }, { floor: { warrior: { position: { x: 0, y: 0, facing: 'east' } }, size: { width: 1, height: 1 }, stairs: { x: 0, y: 0 }, units: [], }, }, { floor: { warrior: { abilities: { a: 4 }, position: { x: 0, y: 0, facing: 'east' } }, size: { width: 1, height: 1 }, stairs: { x: 0, y: 0 }, units: [], }, }, ], } as any; test('merges tower warrior with level warrior', () => { const config = getLevelConfig(tower, 1, 'Joe', false); expect(config).not.toBeNull(); expect(config!.floor.warrior).toEqual({ character: '@', color: '#fff', maxHealth: 20, name: 'Joe', abilities: { a: 1 }, position: { x: 0, y: 0, facing: 'east' }, }); }); test('accumulates abilities from all levels if epic', () => { const config = getLevelConfig(tower, 1, 'Joe', true); expect(config!.floor.warrior.abilities).toEqual({ a: 4, b: 2, c: 3 }); }); test('accumulates abilities up to current level', () => { const config = getLevelConfig(tower, 2, 'Joe', false); expect(config!.floor.warrior.abilities).toEqual({ a: 1, b: 2, c: 3 }); }); test('returns null for non-existent level', () => { expect(getLevelConfig(tower, 5, 'Joe', false)).toBeNull(); }); test('does not mutate original tower config', () => { const config = getLevelConfig(tower, 1, 'Joe', false); config!.floor.warrior.name = 'Modified'; expect(tower.warrior).not.toHaveProperty('name'); expect(tower.levels[0].floor.warrior).not.toHaveProperty('name'); }); ================================================ FILE: libs/core/src/getLevelConfig.ts ================================================ import { type LevelConfig, type TowerDefinition } from './types.js'; function deepClone(obj: T): T { if (obj === null || typeof obj !== 'object' || obj.constructor !== Object) { return obj; } if (Array.isArray(obj)) { return obj.map((item) => deepClone(item)) as T; } const clone = {} as Record; for (const key of Object.keys(obj)) { clone[key] = deepClone((obj as Record)[key]); } return clone as T; } /** * Returns the config for the level with the given number. * * @param tower The tower. * @param levelNumber The number of the level. * @param warriorName The name of the warrior. * @param epic Whether the level is to be used in epic mode or not. * * @returns The level config. */ function getLevelConfig( tower: TowerDefinition, levelNumber: number, warriorName: string, epic: boolean, ): LevelConfig | null { const level = tower.levels[levelNumber - 1]; if (!level) { return null; } const levelConfig = deepClone(level) as unknown as LevelConfig; const levels = epic ? tower.levels : tower.levels.slice(0, levelNumber); const warriorAbilities = Object.assign( {}, ...levels.map( ({ floor: { warrior: { abilities }, }, }) => abilities || {}, ), ); levelConfig.number = levelNumber; levelConfig.floor.warrior = { ...tower.warrior, ...levelConfig.floor.warrior, name: warriorName, abilities: warriorAbilities, }; return levelConfig; } export default getLevelConfig; ================================================ FILE: libs/core/src/index.ts ================================================ export type { AbilityBinding, AbilityEntry, AbilityMeta, AbilityParam } from './Ability.js'; export { default as Ability } from './Ability.js'; export { default as Action } from './Action.js'; export type { EffectBinding, EffectEntry } from './Effect.js'; export { default as Effect } from './Effect.js'; export { default as getLevel } from './getLevel.js'; export { default as getLevelConfig } from './getLevelConfig.js'; export type { TurnEvent } from './Logger.js'; export { default as runLevel } from './runLevel.js'; export { default as Sense } from './Sense.js'; export type { SensedSpace, SensedUnit } from './Space.js'; export type { LevelConfig, LevelDefinition, TowerDefinition, UnitConfig, WarriorConfig, WarriorDefinition, WarriorOverrides, } from './types.js'; export type { Turn, UnitClass } from './Unit.js'; export { default as Unit } from './Unit.js'; ================================================ FILE: libs/core/src/loadLevel.ts ================================================ import { type AbilityEntry } from './Ability.js'; import { type EffectEntry } from './Effect.js'; import Floor from './Floor.js'; import Level from './Level.js'; import loadPlayer from './loadPlayer.js'; import { type LevelConfig, type UnitConfig } from './types.js'; import type Unit from './Unit.js'; import Warrior from './Warrior.js'; function loadAbilities(unit: Unit, abilities: Record = {}): void { for (const [name, entry] of Object.entries(abilities)) { if (Array.isArray(entry)) { const [AbilityClass, config] = entry; unit.addAbility(name, new AbilityClass(unit, config)); } else { const AbilityClass = entry; unit.addAbility(name, new AbilityClass(unit)); } } } function loadEffects(unit: Unit, effects: Record = {}): void { for (const [name, entry] of Object.entries(effects)) { if (Array.isArray(entry)) { const [EffectClass, config] = entry; unit.addEffect(name, new EffectClass(unit, config)); } else { const EffectClass = entry; unit.addEffect(name, new EffectClass(unit)); } } } function loadWarrior( warrior: LevelConfig['floor']['warrior'], floor: Floor, playerCode?: string, language: 'javascript' | 'typescript' = 'javascript', ): void { const { name, character, color, maxHealth, abilities, position } = warrior; const unit = new Warrior(name, character, color, maxHealth); loadAbilities(unit, abilities); unit.playTurn = playerCode ? loadPlayer(playerCode, language) : () => {}; floor.addWarrior(unit, position); } function loadUnit({ unit: UnitClass, effects, position }: UnitConfig, floor: Floor): void { const unit = new UnitClass(); if (UnitClass.declaredAbilities) { loadAbilities(unit, UnitClass.declaredAbilities); } if (effects) { loadEffects(unit, effects); } floor.addUnit(unit, position); } function loadLevel( { number, description, tip, clue, floor: { size, stairs, warrior, units = [] } }: LevelConfig, playerCode?: string, language: 'javascript' | 'typescript' = 'javascript', ): Level { const { width, height } = size; const floor = new Floor(width, height, [stairs.x, stairs.y]); loadWarrior(warrior, floor, playerCode, language); for (const entry of units) { loadUnit(entry as UnitConfig, floor); } return new Level(number!, description!, tip!, clue!, floor); } export default loadLevel; ================================================ FILE: libs/core/src/loadPlayer.test.ts ================================================ import { describe, expect, test, vi } from 'vitest'; import loadPlayer from './loadPlayer.js'; test('runs player code and returns playTurn function', () => { const playerCode = ` class Player { playTurn(warrior) { warrior.walk(); } } `; const warrior = { walk: vi.fn() }; const playTurn = loadPlayer(playerCode); playTurn(warrior); expect(warrior.walk).toHaveBeenCalled(); }); test('throws if invalid syntax', () => { const playerCode = ` class Player { playTurn() {} `; expect(() => { loadPlayer(playerCode); }).toThrow('Check your syntax and try again!'); }); test('throws if Player class is not defined', () => { const playerCode = 'function playTurn() {}'; expect(() => { loadPlayer(playerCode); }).toThrow('You must define a Player class!'); }); test('throws if playTurn method is not defined', () => { const playerCode = 'class Player {}'; expect(() => { loadPlayer(playerCode); }).toThrow('Your Player class must define a playTurn method!'); }); test("throws when playing turn if there's something wrong", () => { const playerCode = ` class Player { playTurn(warrior) { warrior.walk(); } } `; const playTurn = loadPlayer(playerCode); const warrior = {} as any; expect(() => { playTurn(warrior); }).toThrow('warrior.walk is not a function'); }); test('strips export default Player in JavaScript code', () => { const playerCode = ` class Player { playTurn(warrior) { warrior.walk(); } } export default Player; `; const warrior = { walk: vi.fn() }; const playTurn = loadPlayer(playerCode); playTurn(warrior); expect(warrior.walk).toHaveBeenCalled(); }); test('strips export default Player in TypeScript code', () => { const tsCode = ` import type { Warrior } from './types.js'; class Player { playTurn(warrior: Warrior): void { warrior.walk(); } } export default Player; `; const warrior = { walk: vi.fn() }; const playTurn = loadPlayer(tsCode, 'typescript'); playTurn(warrior); expect(warrior.walk).toHaveBeenCalled(); }); describe('TypeScript support', () => { test('loads TypeScript player code', () => { const tsCode = ` class Player { playTurn(warrior: any): void {} } `; const playTurn = loadPlayer(tsCode, 'typescript'); expect(typeof playTurn).toBe('function'); }); test('executes TypeScript player code correctly', () => { const tsCode = ` class Player { playTurn(warrior: any): void { warrior.walk(); } } `; const warrior = { walk: vi.fn() }; const playTurn = loadPlayer(tsCode, 'typescript'); playTurn(warrior); expect(warrior.walk).toHaveBeenCalled(); }); test('strips type-only imports from TypeScript code', () => { const tsCode = ` import type { Warrior } from './types.js'; class Player { playTurn(warrior: Warrior): void {} } `; const playTurn = loadPlayer(tsCode, 'typescript'); expect(typeof playTurn).toBe('function'); }); test('handles TypeScript interfaces and type annotations', () => { const tsCode = ` interface Turn { walk(): void; } class Player { private count: number = 0; playTurn(warrior: Turn): void { this.count++; } } `; const playTurn = loadPlayer(tsCode, 'typescript'); expect(typeof playTurn).toBe('function'); }); test('throws on invalid TypeScript syntax', () => { const badCode = ` class Player { playTurn(warrior: ): void {} } `; expect(() => loadPlayer(badCode, 'typescript')).toThrow(); }); test('throws when Player class is not defined in TypeScript', () => { const code = `const x: number = 1;`; expect(() => loadPlayer(code, 'typescript')).toThrow('You must define a Player class!'); }); test('throws when playTurn method is missing in TypeScript', () => { const code = `class Player { name: string = 'test'; }`; expect(() => loadPlayer(code, 'typescript')).toThrow( 'Your Player class must define a playTurn method!', ); }); }); ================================================ FILE: libs/core/src/loadPlayer.ts ================================================ import assert from 'node:assert'; import vm from 'node:vm'; import { transformSync } from 'esbuild'; import { type Turn } from './Unit.js'; const playerCodeTimeout = 3000; function loadPlayer( playerCode: string, language: 'javascript' | 'typescript' = 'javascript', ): (turn: Turn) => void { const playerCodeFilename = language === 'typescript' ? 'Player.ts' : 'Player.js'; const loader = language === 'typescript' ? 'ts' : 'js'; let code: string; try { ({ code } = transformSync(playerCode, { loader, format: 'cjs' })); } catch (err: any) { const error: any = new Error(`Check your syntax and try again!\n\n${err.message}`); error.code = 'InvalidPlayerCode'; throw error; } const sandbox = vm.createContext({ module: { exports: {} }, exports: {}, }); // Do not collect stack frames for errors in the player code. vm.runInContext('Error.stackTraceLimit = 0;', sandbox); try { vm.runInContext(code, sandbox, { filename: playerCodeFilename, timeout: playerCodeTimeout, }); } catch (err: any) { const error: any = new Error(`Check your syntax and try again!\n\n${err.stack}`); error.code = 'InvalidPlayerCode'; throw error; } try { const player: any = vm.runInContext('new Player();', sandbox, { timeout: playerCodeTimeout, }); assert(typeof player.playTurn === 'function', 'playTurn is not defined'); const playTurn = (turn: Turn): void => { try { player.playTurn(turn); } catch (err: any) { const error: any = new Error(err.message); error.code = 'InvalidPlayerCode'; throw error; } }; return playTurn; } catch (err: any) { if (err.message === 'Player is not defined') { const error: any = new Error('You must define a Player class!'); error.code = 'InvalidPlayerCode'; throw error; } else if (err.message === 'playTurn is not defined') { const error: any = new Error('Your Player class must define a playTurn method!'); error.code = 'InvalidPlayerCode'; throw error; } throw err; } } export default loadPlayer; ================================================ FILE: libs/core/src/runLevel.test.ts ================================================ import { BACKWARD, EAST, FORWARD, RELATIVE_DIRECTIONS, WEST } from '@warriorjs/spatial'; import { expect, test } from 'vitest'; import { type AbilityMeta } from './Ability.js'; import Action from './Action.js'; import runLevel from './runLevel.js'; import Sense from './Sense.js'; import { type LevelConfig } from './types.js'; import Unit from './Unit.js'; class TestWalk extends Action { readonly description = 'Walks forward'; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; perform(direction = FORWARD) { const space = this.unit.getSpaceAt(direction); if (space.isEmpty()) { this.unit.move(direction); this.unit.log(`walks ${direction}`); } else { this.unit.log(`walks ${direction} and bumps into ${space}`); } } } class TestAttack extends Action { readonly description: string; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'void', }; private power: number; constructor(unit: any, { power }: { power: number }) { super(unit); this.description = `Attacks dealing ${power} HP`; this.power = power; } perform(direction = FORWARD) { const receiver = this.unit.getSpaceAt(direction).getUnit(); if (receiver) { this.unit.log(`attacks ${direction} and hits ${receiver}`); const amount = direction === BACKWARD ? Math.ceil(this.power / 2.0) : this.power; this.unit.damage(receiver, amount); } else { this.unit.log(`attacks ${direction} and hits nothing`); } } static with(config: { power: number }) { return [TestAttack, config] as [new (unit: any, config: any) => TestAttack, object]; } } class TestFeel extends Sense { readonly description = 'Feels ahead'; readonly meta: AbilityMeta = { params: [{ name: 'direction', type: 'Direction', optional: true }], returns: 'Space', }; perform(direction = FORWARD) { return this.unit.getSensedSpaceAt(direction); } } class TestSludge extends Unit { static declaredAbilities = { attack: TestAttack.with({ power: 3 }), feel: TestFeel, }; constructor() { super('Sludge', 's', '#d08770', 12); this.playTurn = (turn: any) => { const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { const unit = turn.feel(direction).getUnit(); return unit?.isEnemy() && !unit.isBound(); }); if (threatDirection) { turn.attack(threatDirection); } }; } } const levelConfig = { floor: { size: { width: 8, height: 1 }, stairs: { x: 7, y: 0 }, warrior: { name: 'Joe', character: '@', color: '#8fbcbb', maxHealth: 20, abilities: { walk: TestWalk, attack: TestAttack.with({ power: 5 }), feel: TestFeel, }, position: { x: 0, y: 0, facing: EAST }, }, units: [ { unit: TestSludge, position: { x: 4, y: 0, facing: WEST }, }, ], }, } satisfies LevelConfig; test('passes level with a winner player code', () => { const playerCode = ` class Player { playTurn(warrior) { const spaceAhead = warrior.feel(); if (spaceAhead.isUnit() && spaceAhead.getUnit().isEnemy()) { warrior.attack(); } else { warrior.walk(); } } } `; const { passed } = runLevel(levelConfig, playerCode); expect(passed).toBe(true); }); test('fails level with a loser player code', () => { const playerCode = ` class Player { playTurn(warrior) {} } `; const { passed } = runLevel(levelConfig, playerCode); expect(passed).toBe(false); }); ================================================ FILE: libs/core/src/runLevel.ts ================================================ import { type TurnEvent } from './Logger.js'; import loadLevel from './loadLevel.js'; import { type LevelConfig } from './types.js'; function runLevel( levelConfig: LevelConfig, playerCode: string, language: 'javascript' | 'typescript' = 'javascript', ): { passed: boolean; turns: TurnEvent[][]; initialState: TurnEvent | null } { return loadLevel(levelConfig, playerCode, language).play(); } export default runLevel; ================================================ FILE: libs/core/src/types.ts ================================================ import { type AbsoluteDirection } from '@warriorjs/spatial'; import { type AbilityEntry } from './Ability.js'; import { type EffectEntry } from './Effect.js'; import { type UnitClass } from './Unit.js'; /** Dimensions. */ export type Size = { width: number; height: number }; /** A location as an object with named coordinates. */ export type LocationConfig = { x: number; y: number }; /** A position (location + facing direction). */ export type PositionConfig = LocationConfig & { facing: AbsoluteDirection }; export interface UnitConfig { unit: UnitClass; position: PositionConfig; effects?: Record; } export interface WarriorConfig { name?: string; character: string; color: string; maxHealth: number; position: PositionConfig; abilities?: Record; } export interface LevelConfig { number?: number; description?: string; tip?: string; clue?: string; timeBonus?: number; aceScore?: number; floor: { size: Size; stairs: LocationConfig; warrior: WarriorConfig; units?: UnitConfig[]; }; } export interface WarriorDefinition { character: string; color: string; maxHealth: number; } export interface WarriorOverrides { position: PositionConfig; abilities?: Record; maxHealth?: number; } export interface LevelDefinition { description: string; tip: string; clue?: string; timeBonus: number; aceScore: number; floor: { size: Size; stairs: LocationConfig; warrior: WarriorOverrides; units: UnitConfig[]; }; } export interface TowerDefinition { name: string; description: string; warrior: WarriorDefinition; levels: LevelDefinition[]; } ================================================ FILE: libs/core/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist" }, "exclude": ["**/*.test.ts"] } ================================================ FILE: libs/core/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src" }, "include": ["src"] } ================================================ FILE: libs/effects/README.md ================================================ # @warriorjs/effects > WarriorJS official effects. ## [Effects](https://warrior.js.org/docs/player/effects) ### ticking Kills you and all surrounding units when time reaches zero. ================================================ FILE: libs/effects/package.json ================================================ { "name": "@warriorjs/effects", "version": "0.12.2", "description": "WarriorJS base effects", "author": "Matias Olivera ", "license": "MIT", "homepage": "https://warrior.js.org", "repository": "https://github.com/olistic/warriorjs/tree/master/libs/effects", "type": "module", "main": "dist/index.js", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, "files": [ "dist" ], "publishConfig": { "access": "public" }, "engines": { "node": ">=24" }, "scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit" }, "dependencies": { "@warriorjs/core": "workspace:^" } } ================================================ FILE: libs/effects/src/Ticking.test.ts ================================================ import { Effect } from '@warriorjs/core'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import Ticking from './Ticking.js'; describe('Ticking', () => { let ticking: Ticking; let unit: { health: number; takeDamage: ReturnType; log: ReturnType; getOtherUnits?: () => { health: number; takeDamage: ReturnType }[]; }; beforeEach(() => { unit = { health: 20, takeDamage: vi.fn(), log: vi.fn(), }; ticking = new Ticking(unit, { time: 3 }); }); test('extends Effect', () => { expect(ticking).toBeInstanceOf(Effect); }); test('has a description', () => { expect(ticking.description).toBe('Kills you and all surrounding units when time reaches zero.'); }); test('.with() returns a binding', () => { const binding = Ticking.with({ time: 5 }); expect(binding[0]).toBe(Ticking); expect(binding[1]).toEqual({ time: 5 }); }); describe('passing turn', () => { test('counts down bomb timer once', () => { ticking.passTurn(); expect(ticking.time).toBe(2); expect(unit.log).toHaveBeenCalledWith('is ticking'); }); test("doesn't count down bomb timer below zero", () => { ticking.trigger = () => {}; ticking.time = 0; ticking.passTurn(); expect(ticking.time).toBe(0); }); test('triggers when bomb time reaches zero', () => { ticking.trigger = vi.fn(); ticking.time = 2; ticking.passTurn(); expect(ticking.trigger).not.toHaveBeenCalled(); ticking.passTurn(); expect(ticking.trigger).toHaveBeenCalled(); }); }); describe('triggering', () => { test('kills each unit on the floor', () => { const anotherUnit = { health: 10, takeDamage: vi.fn(), }; unit.getOtherUnits = () => [anotherUnit as never]; ticking.trigger(); expect(unit.log).toHaveBeenCalledWith( 'explodes, collapsing the ceiling and killing every unit', ); expect(anotherUnit.takeDamage).toHaveBeenCalledWith(10); expect(unit.takeDamage).toHaveBeenCalledWith(20); }); }); }); ================================================ FILE: libs/effects/src/Ticking.ts ================================================ import { Effect, type EffectBinding } from '@warriorjs/core'; interface TickingConfig { time: number; } class Ticking extends Effect { readonly description = 'Kills you and all surrounding units when time reaches zero.'; time: number; constructor(unit: any, { time }: TickingConfig) { super(unit); this.time = time; } passTurn(): void { if (this.time) { this.time -= 1; } this.unit.log('is ticking'); if (!this.time) { this.trigger(); } } trigger(): void { this.unit.log('explodes, collapsing the ceiling and killing every unit'); [...this.unit.getOtherUnits(), this.unit].forEach((anotherUnit: any) => anotherUnit.takeDamage(anotherUnit.health), ); } static with(config: TickingConfig): EffectBinding { return [Ticking, config]; } } export default Ticking; ================================================ FILE: libs/effects/src/index.ts ================================================ export { default as Ticking } from './Ticking.js'; ================================================ FILE: libs/effects/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist" }, "exclude": ["**/*.test.ts"] } ================================================ FILE: libs/effects/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src" }, "include": ["src"] } ================================================ FILE: libs/scoring/package.json ================================================ { "name": "@warriorjs/scoring", "version": "0.14.0", "description": "WarriorJS scoring utilities", "author": "Matias Olivera ", "license": "MIT", "homepage": "https://warrior.js.org", "repository": "https://github.com/olistic/warriorjs/tree/master/libs/scoring", "type": "module", "main": "dist/index.js", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, "files": [ "dist" ], "publishConfig": { "access": "public" }, "engines": { "node": ">=24" }, "scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit" } } ================================================ FILE: libs/scoring/src/getClearBonus.test.ts ================================================ import { expect, test, vi } from 'vitest'; import getClearBonus from './getClearBonus.js'; import getLastEvent from './getLastEvent.js'; import isFloorClear from './isFloorClear.js'; vi.mock('./getLastEvent.js'); vi.mock('./isFloorClear.js'); test('returns the 20% of the sum of the warrior score and the time bonus with clear level', () => { vi.mocked(getLastEvent).mockReturnValue({ floorMap: 'map' }); vi.mocked(isFloorClear).mockReturnValue(true); expect(getClearBonus([['turn-events']] as unknown[][], 3, 2)).toBe(1); expect(getLastEvent).toHaveBeenCalledWith([['turn-events']]); expect(isFloorClear).toHaveBeenCalledWith('map'); }); test('returns zero if the level is not clear', () => { vi.mocked(getLastEvent).mockReturnValue({ floorMap: 'map' }); vi.mocked(isFloorClear).mockReturnValue(false); expect(getClearBonus([['turn-events']] as unknown[][], 3, 2)).toBe(0); expect(getLastEvent).toHaveBeenCalledWith([['turn-events']]); expect(isFloorClear).toHaveBeenCalledWith('map'); }); ================================================ FILE: libs/scoring/src/getClearBonus.ts ================================================ import getLastEvent from './getLastEvent.js'; import isFloorClear from './isFloorClear.js'; /** * Returns the bonus for clearing the level. * * @param turns The turns that happened during the play. * @param warriorScore The score of the warrior. * @param timeBonus The time bonus. * @returns The clear bonus. */ function getClearBonus(turns: unknown[][], warriorScore: number, timeBonus: number): number { const lastEvent = getLastEvent(turns); if (!isFloorClear(lastEvent.floorMap as { unit?: unknown }[][])) { return 0; } return Math.round((warriorScore + timeBonus) * 0.2); } export default getClearBonus; ================================================ FILE: libs/scoring/src/getGradeLetter.test.ts ================================================ import { expect, test } from 'vitest'; import getGradeLetter from './getGradeLetter.js'; test('returns letter based on grade', () => { expect(getGradeLetter(1.0)).toBe('S'); expect(getGradeLetter(0.99)).toBe('A'); expect(getGradeLetter(0.9)).toBe('A'); expect(getGradeLetter(0.89)).toBe('B'); expect(getGradeLetter(0.8)).toBe('B'); expect(getGradeLetter(0.79)).toBe('C'); expect(getGradeLetter(0.7)).toBe('C'); expect(getGradeLetter(0.69)).toBe('D'); expect(getGradeLetter(0.6)).toBe('D'); expect(getGradeLetter(0.59)).toBe('F'); expect(getGradeLetter(0)).toBe('F'); }); ================================================ FILE: libs/scoring/src/getGradeLetter.ts ================================================ /** * Returns the letter for the given grade. * * @param grade The grade. * @returns The grade letter. */ function getGradeLetter(grade: number): string { if (grade >= 1.0) { return 'S'; } if (grade >= 0.9) { return 'A'; } if (grade >= 0.8) { return 'B'; } if (grade >= 0.7) { return 'C'; } if (grade >= 0.6) { return 'D'; } return 'F'; } export default getGradeLetter; ================================================ FILE: libs/scoring/src/getLastEvent.test.ts ================================================ import { expect, test } from 'vitest'; import getLastEvent from './getLastEvent.js'; test('returns the last event of the play', () => { const turns = [['turn1'], ['turn2'], ['event1', 'event2', 'event3']]; expect(getLastEvent(turns)).toBe('event3'); }); ================================================ FILE: libs/scoring/src/getLastEvent.ts ================================================ /** * Returns the last event of the play. * * @param turns The turns that happened during the play. * @returns The last event. */ function getLastEvent(turns: unknown[][]): Record { const lastTurnEvents = turns.at(-1)!; return lastTurnEvents.at(-1) as Record; } export default getLastEvent; ================================================ FILE: libs/scoring/src/getLevelScore.test.ts ================================================ import { beforeEach, describe, expect, test, vi } from 'vitest'; import getClearBonus from './getClearBonus.js'; import getLevelScore from './getLevelScore.js'; import getRemainingTimeBonus from './getRemainingTimeBonus.js'; import getWarriorScore from './getWarriorScore.js'; vi.mock('./getClearBonus.js'); vi.mock('./getRemainingTimeBonus.js'); vi.mock('./getWarriorScore.js'); const levelConfig = { timeBonus: 16 }; test('returns null when level failed', () => { expect(getLevelScore({ passed: false, turns: [] }, levelConfig)).toBeNull(); }); describe('level passed', () => { let levelResult: { passed: boolean; turns: unknown[][] }; beforeEach(() => { levelResult = { passed: true, turns: [['turn-events']] as unknown[][], }; }); test('has warrior score part', () => { vi.mocked(getWarriorScore).mockReturnValue(8); expect(getLevelScore(levelResult, levelConfig)?.warrior).toBe(8); }); test('has time bonus part', () => { vi.mocked(getRemainingTimeBonus).mockReturnValue(10); expect(getLevelScore(levelResult, levelConfig)?.timeBonus).toBe(10); expect(getRemainingTimeBonus).toHaveBeenCalledWith([['turn-events']], 16); }); test('has clear bonus part', () => { vi.mocked(getWarriorScore).mockReturnValue(8); vi.mocked(getRemainingTimeBonus).mockReturnValue(12); vi.mocked(getClearBonus).mockReturnValue(4); expect(getLevelScore(levelResult, levelConfig)?.clearBonus).toBe(4); expect(getClearBonus).toHaveBeenCalledWith([['turn-events']], 8, 12); }); }); ================================================ FILE: libs/scoring/src/getLevelScore.ts ================================================ import getClearBonus from './getClearBonus.js'; import getRemainingTimeBonus from './getRemainingTimeBonus.js'; import getWarriorScore from './getWarriorScore.js'; interface LevelResult { passed: boolean; turns: unknown[][]; } interface LevelConfig { timeBonus: number; } interface LevelScore { clearBonus: number; timeBonus: number; warrior: number; } /** * Returns the score for the given level. * * @param result The level result. * @param levelConfig The level config. * @returns The score of the level, broken down into its components. */ function getLevelScore( { passed, turns }: LevelResult, { timeBonus }: LevelConfig, ): LevelScore | null { if (!passed) { return null; } const warriorScore = getWarriorScore(turns); const remainingTimeBonus = getRemainingTimeBonus(turns, timeBonus); const clearBonus = getClearBonus(turns, warriorScore, remainingTimeBonus); return { clearBonus, timeBonus: remainingTimeBonus, warrior: warriorScore, }; } export default getLevelScore; ================================================ FILE: libs/scoring/src/getRemainingTimeBonus.test.ts ================================================ import { expect, test, vi } from 'vitest'; import getRemainingTimeBonus from './getRemainingTimeBonus.js'; import getTurnCount from './getTurnCount.js'; vi.mock('./getTurnCount.js'); test('subtracts the number of turns played from the initial time bonus', () => { vi.mocked(getTurnCount).mockReturnValue(3); expect(getRemainingTimeBonus([['turn-events']] as unknown[][], 10)).toBe(7); expect(getTurnCount).toHaveBeenCalledWith([['turn-events']]); }); test("doesn't go below zero", () => { vi.mocked(getTurnCount).mockReturnValue(11); expect(getRemainingTimeBonus([['turn-events']] as unknown[][], 10)).toBe(0); expect(getTurnCount).toHaveBeenCalledWith([['turn-events']]); }); ================================================ FILE: libs/scoring/src/getRemainingTimeBonus.ts ================================================ import getTurnCount from './getTurnCount.js'; /** * Returns the remaining time bonus. * * @param turns The turns that happened during the play. * @param timeBonus The initial time bonus. * @returns The time bonus. */ function getRemainingTimeBonus(turns: unknown[][], timeBonus: number): number { const turnCount = getTurnCount(turns); const remainingTimeBonus = timeBonus - turnCount; return Math.max(remainingTimeBonus, 0); } export default getRemainingTimeBonus; ================================================ FILE: libs/scoring/src/getTurnCount.test.ts ================================================ import { expect, test } from 'vitest'; import getTurnCount from './getTurnCount.js'; test('returns the number of turns played', () => { const turns = [['turn1'], ['turn2'], ['turn3']]; expect(getTurnCount(turns)).toBe(3); }); ================================================ FILE: libs/scoring/src/getTurnCount.ts ================================================ /** * Returns the number of turns played. * * @param turns The turns that happened during the play. * @returns The turn count. */ function getTurnCount(turns: unknown[][]): number { return turns.length; } export default getTurnCount; ================================================ FILE: libs/scoring/src/getWarriorScore.test.ts ================================================ import { expect, test, vi } from 'vitest'; import getLastEvent from './getLastEvent.js'; import getWarriorScore from './getWarriorScore.js'; vi.mock('./getLastEvent.js'); test('returns the score of the warrior at the end of the play', () => { vi.mocked(getLastEvent).mockReturnValue({ warriorStatus: { score: 42 } }); expect(getWarriorScore([['turn-events']] as unknown[][])).toBe(42); expect(getLastEvent).toHaveBeenCalledWith([['turn-events']]); }); ================================================ FILE: libs/scoring/src/getWarriorScore.ts ================================================ import getLastEvent from './getLastEvent.js'; /** * Returns the score of the warrior. * * @param turns The turns that happened during the play. * @returns The score of the warrior. */ function getWarriorScore(turns: unknown[][]): number { const lastEvent = getLastEvent(turns); return (lastEvent as { warriorStatus: { score: number } }).warriorStatus.score; } export default getWarriorScore; ================================================ FILE: libs/scoring/src/index.ts ================================================ export { default as getGradeLetter } from './getGradeLetter.js'; export { default as getLevelScore } from './getLevelScore.js'; ================================================ FILE: libs/scoring/src/isFloorClear.test.ts ================================================ import { expect, test } from 'vitest'; import isFloorClear from './isFloorClear.js'; test('considers clear when there are no units other than the warrior', () => { const floorMap = [ [{}, {}], [{ unit: 'warrior' }, {}], ]; expect(isFloorClear(floorMap)).toBe(true); }); test("doesn't consider clear when there are other units apart from the warrior", () => { const floorMap = [ [{}, {}], [{ unit: 'warrior' }, { unit: 'foo' }], ]; expect(isFloorClear(floorMap)).toBe(false); }); ================================================ FILE: libs/scoring/src/isFloorClear.ts ================================================ interface Space { unit?: unknown; } /** * Checks if the floor is clear. * * The floor is clear when there are no units other than the warrior. * * @param floorMap The floor map. * @returns Whether the floor is clear or not. */ function isFloorClear(floorMap: Space[][]): boolean { const spaces = floorMap.reduce((acc, val) => acc.concat(val), []); const unitCount = spaces.filter((space: Space) => !!space.unit).length; return unitCount <= 1; } export default isFloorClear; ================================================ FILE: libs/scoring/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist" }, "exclude": ["**/*.test.ts"] } ================================================ FILE: libs/scoring/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src" }, "include": ["src"] } ================================================ FILE: libs/spatial/README.md ================================================ # @warriorjs/spatial > WarriorJS directioning. ## Install ```sh npm install @warriorjs/spatial ``` ## Usage ```js import { FORWARD, getAbsoluteDirection } from '@warriorjs/spatial'); ``` ## Top Level Exports All methods in the API Reference below, plus the following constants: ### `NORTH` _(string)_ A constant representing absolute direction north. ### `EAST` _(string)_ A constant representing absolute direction east. ### `SOUTH` _(string)_ A constant representing absolute direction south. ### `WEST` _(string)_ A constant representing absolute direction west. ### `ABSOLUTE_DIRECTIONS` _(string[])_ The absolute directions in clockwise order. ### `FORWARD` _(string)_ A constant representing relative direction forward. ### `RIGHT` _(string)_ A constant representing relative direction right. ### `BACKWARD` _(string)_ A constant representing relative direction backward. ### `LEFT` _(string)_ A constant representing relative direction left. ### `RELATIVE_DIRECTIONS` _(string[])_ The relative directions in clockwise order. ## API Reference ### `getAbsoluteDirection(direction: string, referenceDirection: string)` Returns the absolute direction for a given direction, with reference to another direction (reference direction). ### `getAbsoluteOffset(relativeOffset: number[], referenceDirection: string)` Returns the absolute offset for a given relative offset with reference to a given direction (reference direction). ### `getDirectionOfLocation(location: number[], referenceLocation: number[])` Returns the direction of a location from another location (reference location). ### `getDistanceOfLocation(location: number[], referenceLocation: number[])` Returns the Manhattan distance of a location from another location (reference location). ### `getRelativeDirection(direction: string, referenceDirection: string)` Returns the relative direction for a given direction, with reference to a another direction (reference direction). ### `getRelativeOffset(location: number[], referenceLocation: number[], referenceDirection: string)` Returns the relative offset for a given location, with reference to another location (reference location) and direction (reference direction). ### `rotateRelativeOffset(offset: number[], direction: string)` Rotates the given relative offset in the given direction. ### `translateLocation(location: number[], offset: number[])` Translates the given location by a given offset. ### `verifyAbsoluteDirection(direction: string)` Checks if the given direction is a valid absolute direction. ### `verifyRelativeDirection(direction: string)` Checks if the given direction is a valid relative direction. ================================================ FILE: libs/spatial/package.json ================================================ { "name": "@warriorjs/spatial", "version": "0.7.0", "description": "WarriorJS directioning", "author": "Matias Olivera ", "license": "MIT", "homepage": "https://warrior.js.org", "repository": "https://github.com/olistic/warriorjs/tree/master/libs/spatial", "type": "module", "main": "dist/index.js", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, "files": [ "dist" ], "publishConfig": { "access": "public" }, "engines": { "node": ">=24" }, "scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit" } } ================================================ FILE: libs/spatial/src/absoluteDirections.test.ts ================================================ import { beforeEach, describe, expect, test } from 'vitest'; import { ABSOLUTE_DIRECTIONS, EAST, getAbsoluteDirection, getAbsoluteOffset, NORTH, SOUTH, verifyAbsoluteDirection, WEST, } from './absoluteDirections.js'; import { BACKWARD, FORWARD, LEFT, type RelativeDirection, RIGHT } from './relativeDirections.js'; test("exports a NORTH constant whose value is 'north'", () => { expect(NORTH).toBe('north'); }); test("exports a EAST constant whose value is 'east'", () => { expect(EAST).toBe('east'); }); test("exports a SOUTH constant whose value is 'south'", () => { expect(SOUTH).toBe('south'); }); test("exports a WEST constant whose value is 'west'", () => { expect(WEST).toBe('west'); }); test('exports an array with the absolute directions in clockwise order', () => { expect(ABSOLUTE_DIRECTIONS).toEqual([NORTH, EAST, SOUTH, WEST]); }); describe('verifyAbsoluteDirection', () => { test("doesn't throw if direction is valid", () => { const validDirections = ABSOLUTE_DIRECTIONS; validDirections.forEach((validDirection) => verifyAbsoluteDirection(validDirection)); }); test('throws an error if direction is not valid', () => { const invalidDirections = ['', 'foo', 'north\n', 'North', 'southern']; invalidDirections.forEach((invalidDirection) => { expect(() => { verifyAbsoluteDirection(invalidDirection); }).toThrow( `Unknown direction: '${invalidDirection}'. Should be one of: '${NORTH}', '${EAST}', '${SOUTH}' or '${WEST}'.`, ); }); }); }); describe('getAbsoluteDirection', () => { let direction: RelativeDirection; describe('forward', () => { beforeEach(() => { direction = FORWARD; }); test('is to the north when facing north', () => { expect(getAbsoluteDirection(direction, NORTH)).toBe(NORTH); }); test('is to the east when facing east', () => { expect(getAbsoluteDirection(direction, EAST)).toBe(EAST); }); test('is to the south when facing south', () => { expect(getAbsoluteDirection(direction, SOUTH)).toBe(SOUTH); }); test('is to the west when facing west', () => { expect(getAbsoluteDirection(direction, WEST)).toBe(WEST); }); }); describe('right', () => { beforeEach(() => { direction = RIGHT; }); test('is to the east when facing north', () => { expect(getAbsoluteDirection(direction, NORTH)).toBe(EAST); }); test('is to the south when facing east', () => { expect(getAbsoluteDirection(direction, EAST)).toBe(SOUTH); }); test('is to the west when facing south', () => { expect(getAbsoluteDirection(direction, SOUTH)).toBe(WEST); }); test('is to the north when facing west', () => { expect(getAbsoluteDirection(direction, WEST)).toBe(NORTH); }); }); describe('backward', () => { beforeEach(() => { direction = BACKWARD; }); test('is to the south when facing north', () => { expect(getAbsoluteDirection(direction, NORTH)).toBe(SOUTH); }); test('is to the west when facing east', () => { expect(getAbsoluteDirection(direction, EAST)).toBe(WEST); }); test('is to the north when facing south', () => { expect(getAbsoluteDirection(direction, SOUTH)).toBe(NORTH); }); test('is to the east when facing west', () => { expect(getAbsoluteDirection(direction, WEST)).toBe(EAST); }); }); describe('left', () => { beforeEach(() => { direction = LEFT; }); test('is to the west when facing north', () => { expect(getAbsoluteDirection(direction, NORTH)).toBe(WEST); }); test('is to the north when facing east', () => { expect(getAbsoluteDirection(direction, EAST)).toBe(NORTH); }); test('is to the east when facing south', () => { expect(getAbsoluteDirection(direction, SOUTH)).toBe(EAST); }); test('is to the south when facing west', () => { expect(getAbsoluteDirection(direction, WEST)).toBe(SOUTH); }); }); }); describe('getAbsoluteOffset', () => { test('returns the absolute offset based on direction', () => { expect(getAbsoluteOffset([1, 2], NORTH)).toEqual([2, -1]); expect(getAbsoluteOffset([1, 2], EAST)).toEqual([1, 2]); expect(getAbsoluteOffset([1, 2], SOUTH)).toEqual([-2, 1]); expect(getAbsoluteOffset([1, 2], WEST)).toEqual([-1, -2]); }); }); ================================================ FILE: libs/spatial/src/absoluteDirections.ts ================================================ import { type AbsoluteOffset, type RelativeOffset } from './location.js'; import { RELATIVE_DIRECTIONS, type RelativeDirection } from './relativeDirections.js'; export const NORTH = 'north'; export const EAST = 'east'; export const SOUTH = 'south'; export const WEST = 'west'; /** * The absolute directions in clockwise order. */ export const ABSOLUTE_DIRECTIONS = [NORTH, EAST, SOUTH, WEST] as const; /** An absolute direction. */ export type AbsoluteDirection = (typeof ABSOLUTE_DIRECTIONS)[number]; /** * Checks if the given direction is a valid absolute direction. * * @param direction The direction. * * @throws Will throw if the direction is not valid. */ export function verifyAbsoluteDirection(direction: string): asserts direction is AbsoluteDirection { if (!(ABSOLUTE_DIRECTIONS as readonly string[]).includes(direction)) { throw new Error( `Unknown direction: '${direction}'. Should be one of: '${NORTH}', '${EAST}', '${SOUTH}' or '${WEST}'.`, ); } } /** * Returns the absolute direction for a given direction, with reference to * another direction (reference direction). * * @param direction The direction (relative). * @param referenceDirection The reference direction (absolute). * * @returns The absolute direction. */ export function getAbsoluteDirection( direction: RelativeDirection, referenceDirection: AbsoluteDirection, ): AbsoluteDirection { const index = (ABSOLUTE_DIRECTIONS.indexOf(referenceDirection) + RELATIVE_DIRECTIONS.indexOf(direction)) % 4; return ABSOLUTE_DIRECTIONS[index]; } /** * Returns the absolute offset for a given relative offset with reference * to a given direction (reference direction). * * @param offset The relative offset as [forward, right]. * @param referenceDirection The reference direction (absolute). * * @returns The absolute offset as [deltaX, deltaY]. */ export function getAbsoluteOffset( [forward, right]: RelativeOffset, referenceDirection: AbsoluteDirection, ): AbsoluteOffset { if (referenceDirection === NORTH) { return [right, -forward]; } if (referenceDirection === EAST) { return [forward, right]; } if (referenceDirection === SOUTH) { return [-right, forward]; } return [-forward, -right]; } ================================================ FILE: libs/spatial/src/index.ts ================================================ export type { AbsoluteDirection } from './absoluteDirections.js'; export { ABSOLUTE_DIRECTIONS, EAST, getAbsoluteDirection, getAbsoluteOffset, NORTH, SOUTH, verifyAbsoluteDirection, WEST, } from './absoluteDirections.js'; export type { AbsoluteOffset, Location, RelativeOffset } from './location.js'; export { getDirectionOfLocation, getDistanceOfLocation, translateLocation, } from './location.js'; export type { RelativeDirection } from './relativeDirections.js'; export { BACKWARD, FORWARD, getRelativeDirection, getRelativeOffset, LEFT, RELATIVE_DIRECTIONS, RIGHT, rotateRelativeOffset, verifyRelativeDirection, } from './relativeDirections.js'; ================================================ FILE: libs/spatial/src/location.test.ts ================================================ import { describe, expect, test } from 'vitest'; import { EAST, NORTH, SOUTH, WEST } from './absoluteDirections.js'; import { getDirectionOfLocation, getDistanceOfLocation, translateLocation } from './location.js'; describe('translateLocation', () => { test('translates the given location by the given offset', () => { expect(translateLocation([1, 2], [2, -1])).toEqual([3, 1]); }); }); describe('getDirectionOfLocation', () => { test('returns the direction from a given location to another given location', () => { expect(getDirectionOfLocation([1, 1], [1, 2])).toEqual(NORTH); expect(getDirectionOfLocation([2, 2], [1, 2])).toEqual(EAST); expect(getDirectionOfLocation([1, 3], [1, 2])).toEqual(SOUTH); expect(getDirectionOfLocation([0, 2], [1, 2])).toEqual(WEST); }); }); describe('getDistanceOfLocation', () => { test('returns the distance between the two given locations', () => { expect(getDistanceOfLocation([5, 3], [1, 2])).toBe(5); expect(getDistanceOfLocation([4, 2], [1, 2])).toBe(3); }); }); ================================================ FILE: libs/spatial/src/location.ts ================================================ import { type AbsoluteDirection, EAST, NORTH, SOUTH, WEST } from './absoluteDirections.js'; /** A location as [x, y]. */ export type Location = [number, number]; /** An absolute offset as [deltaX, deltaY]. */ export type AbsoluteOffset = [number, number]; /** A relative offset as [forward, right]. */ export type RelativeOffset = [number, number]; /** * Translates the given location by a given offset. * * @param location The location as [x, y]. * @param offset The offset as [deltaX, deltaY]. * * @returns The translated location. */ export function translateLocation([x, y]: Location, [deltaX, deltaY]: AbsoluteOffset): Location { return [x + deltaX, y + deltaY]; } /** * Returns the direction of a location from another location (reference * location). * * @param location The location as [x, y]. * @param referenceLocation The reference location as [x, y]. * * @returns The direction. */ export function getDirectionOfLocation([x1, y1]: Location, [x2, y2]: Location): AbsoluteDirection { if (Math.abs(x2 - x1) > Math.abs(y2 - y1)) { if (x1 > x2) { return EAST; } return WEST; } if (y1 > y2) { return SOUTH; } return NORTH; } /** * Returns the Manhattan distance of a location from another location (reference * location). * * @param location The location as [x, y]. * @param referenceLocation The reference location as [x, y]. * * @returns The distance between the locations. */ export function getDistanceOfLocation([x1, y1]: Location, [x2, y2]: Location): number { return Math.abs(x2 - x1) + Math.abs(y2 - y1); } ================================================ FILE: libs/spatial/src/relativeDirections.test.ts ================================================ import { beforeEach, describe, expect, test } from 'vitest'; import { type AbsoluteDirection, EAST, NORTH, SOUTH, WEST } from './absoluteDirections.js'; import { BACKWARD, FORWARD, getRelativeDirection, getRelativeOffset, LEFT, RELATIVE_DIRECTIONS, RIGHT, rotateRelativeOffset, verifyRelativeDirection, } from './relativeDirections.js'; test("exports a FORWARD constant whose value is 'forward'", () => { expect(FORWARD).toBe('forward'); }); test("exports a RIGHT constant whose value is 'right'", () => { expect(RIGHT).toBe('right'); }); test("exports a BACKWARD constant whose value is 'backward'", () => { expect(BACKWARD).toBe('backward'); }); test("exports a LEFT constant whose value is 'left'", () => { expect(LEFT).toBe('left'); }); test('exports an array with the relative directions in clockwise order', () => { expect(RELATIVE_DIRECTIONS).toEqual([FORWARD, RIGHT, BACKWARD, LEFT]); }); describe('verifyRelativeDirection', () => { test("doesn't throw if direction is valid", () => { const validDirections = RELATIVE_DIRECTIONS; validDirections.forEach((validDirection) => verifyRelativeDirection(validDirection)); }); test('throws an error if direction is not valid', () => { const invalidDirections = ['', 'foo', 'forward\n', 'Forward', 'backwards']; invalidDirections.forEach((invalidDirection) => { expect(() => { verifyRelativeDirection(invalidDirection); }).toThrow( `Unknown direction: '${invalidDirection}'. Should be one of: '${FORWARD}', '${RIGHT}', '${BACKWARD}' or '${LEFT}'.`, ); }); }); }); describe('getRelativeDirection', () => { let direction: AbsoluteDirection; describe('north', () => { beforeEach(() => { direction = NORTH; }); test('is forward when facing north', () => { expect(getRelativeDirection(direction, NORTH)).toBe(FORWARD); }); test('is to the left when facing east', () => { expect(getRelativeDirection(direction, EAST)).toBe(LEFT); }); test('is backward when facing south', () => { expect(getRelativeDirection(direction, SOUTH)).toBe(BACKWARD); }); test('is to the right when facing west', () => { expect(getRelativeDirection(direction, WEST)).toBe(RIGHT); }); }); describe('east', () => { beforeEach(() => { direction = EAST; }); test('is to the right when facing north', () => { expect(getRelativeDirection(direction, NORTH)).toBe(RIGHT); }); test('is forward when facing east', () => { expect(getRelativeDirection(direction, EAST)).toBe(FORWARD); }); test('is to the left when facing south', () => { expect(getRelativeDirection(direction, SOUTH)).toBe(LEFT); }); test('is backward when facing west', () => { expect(getRelativeDirection(direction, WEST)).toBe(BACKWARD); }); }); describe('south', () => { beforeEach(() => { direction = SOUTH; }); test('is backward when facing north', () => { expect(getRelativeDirection(direction, NORTH)).toBe(BACKWARD); }); test('is to the right when facing east', () => { expect(getRelativeDirection(direction, EAST)).toBe(RIGHT); }); test('is forward when facing south', () => { expect(getRelativeDirection(direction, SOUTH)).toBe(FORWARD); }); test('is to the left when facing west', () => { expect(getRelativeDirection(direction, WEST)).toBe(LEFT); }); }); describe('west', () => { beforeEach(() => { direction = WEST; }); test('is to the left when facing north', () => { expect(getRelativeDirection(direction, NORTH)).toBe(LEFT); }); test('is backward when facing east', () => { expect(getRelativeDirection(direction, EAST)).toBe(BACKWARD); }); test('is to the right when facing south', () => { expect(getRelativeDirection(direction, SOUTH)).toBe(RIGHT); }); test('is forward when facing west', () => { expect(getRelativeDirection(direction, WEST)).toBe(FORWARD); }); }); }); describe('getRelativeOffset', () => { test('returns the relative offset based on location and direction', () => { expect(getRelativeOffset([3, 3], [1, 2], NORTH)).toEqual([-1, 2]); expect(getRelativeOffset([3, 3], [1, 2], EAST)).toEqual([2, 1]); expect(getRelativeOffset([3, 3], [1, 2], SOUTH)).toEqual([1, -2]); expect(getRelativeOffset([3, 3], [1, 2], WEST)).toEqual([-2, -1]); expect(getRelativeOffset([0, 0], [1, 2], NORTH)).toEqual([2, -1]); expect(getRelativeOffset([0, 0], [1, 2], EAST)).toEqual([-1, -2]); expect(getRelativeOffset([0, 0], [1, 2], SOUTH)).toEqual([-2, 1]); expect(getRelativeOffset([0, 0], [1, 2], WEST)).toEqual([1, 2]); }); }); describe('rotateRelativeOffset', () => { test('rotates the relative offset in the given direction', () => { expect(rotateRelativeOffset([1, 2], FORWARD)).toEqual([1, 2]); expect(rotateRelativeOffset([1, 2], RIGHT)).toEqual([-2, 1]); expect(rotateRelativeOffset([1, 2], BACKWARD)).toEqual([-1, -2]); expect(rotateRelativeOffset([1, 2], LEFT)).toEqual([2, -1]); }); }); ================================================ FILE: libs/spatial/src/relativeDirections.ts ================================================ import { ABSOLUTE_DIRECTIONS, type AbsoluteDirection } from './absoluteDirections.js'; import { type Location, type RelativeOffset } from './location.js'; export const FORWARD = 'forward'; export const RIGHT = 'right'; export const BACKWARD = 'backward'; export const LEFT = 'left'; /** * The relative directions in clockwise order. */ export const RELATIVE_DIRECTIONS = [FORWARD, RIGHT, BACKWARD, LEFT] as const; /** A relative direction. */ export type RelativeDirection = (typeof RELATIVE_DIRECTIONS)[number]; /** * Checks if the given direction is a valid relative direction. * * @param direction The direction. * * @throws Will throw if the direction is not valid. */ export function verifyRelativeDirection(direction: string): asserts direction is RelativeDirection { if (!(RELATIVE_DIRECTIONS as readonly string[]).includes(direction)) { throw new Error( `Unknown direction: '${direction}'. Should be one of: '${FORWARD}', '${RIGHT}', '${BACKWARD}' or '${LEFT}'.`, ); } } /** * Returns the relative direction for a given direction, with reference to a * another direction (reference direction). * * @param direction The direction (absolute). * @param referenceDirection The reference direction (absolute). * * @returns The relative direction. */ export function getRelativeDirection( direction: AbsoluteDirection, referenceDirection: AbsoluteDirection, ): RelativeDirection { const index = (ABSOLUTE_DIRECTIONS.indexOf(direction) - ABSOLUTE_DIRECTIONS.indexOf(referenceDirection) + RELATIVE_DIRECTIONS.length) % RELATIVE_DIRECTIONS.length; return RELATIVE_DIRECTIONS[index]; } /** * Returns the relative offset for a given location, with reference to another * location (reference location) and direction (reference direction). * * @param location The location. * @param referenceLocation The reference location. * @param referenceDirection The reference direction (absolute). * * @returns The relative offset as [forward, right]. */ export function getRelativeOffset( [x1, y1]: Location, [x2, y2]: Location, referenceDirection: AbsoluteDirection, ): RelativeOffset { const [deltaX, deltaY] = [x1 - x2, y1 - y2]; if (referenceDirection === 'north') { return [-deltaY, deltaX]; } if (referenceDirection === 'east') { return [deltaX, deltaY]; } if (referenceDirection === 'south') { return [deltaY, -deltaX]; } return [-deltaX, -deltaY]; } /** * Rotates the given relative offset in the given direction. * * @param offset The relative offset as [forward, right]. * @param direction The direction (relative direction). * * @returns The rotated offset as [forward, right]. */ export function rotateRelativeOffset( [forward, right]: RelativeOffset, direction: RelativeDirection, ): RelativeOffset { if (direction === FORWARD) { return [forward, right]; } if (direction === RIGHT) { return [-right, forward]; } if (direction === BACKWARD) { return [-forward, -right]; } return [right, -forward]; } ================================================ FILE: libs/spatial/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist" }, "exclude": ["**/*.test.ts"] } ================================================ FILE: libs/spatial/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src" }, "include": ["src"] } ================================================ FILE: libs/units/README.md ================================================ # @warriorjs/units > WarriorJS official units. ### Archer - **Character:** a - **Max Health:** 7 HP - **Abilities:** - look (3 range) - shoot (3 range, 3 power) - **AI:** Attack first enemy in line of sight in any direction. ### Captive - **Character:** C - **Max Health:** 1 HP - **Abilities:** None. - **AI:** Wait to be rescued. ### Sludge - **Character:** s - **Max Health:** 12 HP - **Abilities:** - feel - attack (3 power) - **AI:** Attack first adjacent enemy in any direction. ### Thick Sludge - **Character:** S - **Max Health:** 24 HP - **Abilities:** - feel - attack (3 power) - **AI:** Attack first adjacent enemy in any direction. ### Warrior - **Character:** @ - **Max Health:** 20 HP - **Abilities:** Determined by the level. - **AI:** Provided by the player. ### Wizard - **Character:** w - **Max Health:** 3 HP - **Abilities:** - look (3 range) - shoot (3 range, 11 power) - **AI:** Attack first enemy in line of sight in any direction. ================================================ FILE: libs/units/package.json ================================================ { "name": "@warriorjs/units", "version": "0.13.0", "description": "WarriorJS base units", "author": "Matias Olivera ", "license": "MIT", "homepage": "https://warrior.js.org", "repository": "https://github.com/olistic/warriorjs/tree/master/libs/units", "type": "module", "main": "dist/index.js", "exports": { ".": { "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, "files": [ "dist" ], "publishConfig": { "access": "public" }, "engines": { "node": ">=24" }, "scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit" }, "dependencies": { "@warriorjs/abilities": "workspace:^", "@warriorjs/core": "workspace:^", "@warriorjs/spatial": "workspace:^" } } ================================================ FILE: libs/units/src/Archer.test.ts ================================================ import { beforeEach, describe, expect, test } from 'vitest'; import Archer from './Archer.js'; import RangedUnit from './RangedUnit.js'; describe('Archer', () => { let archer: Archer; beforeEach(() => { archer = new Archer(); }); test('extends RangedUnit', () => { expect(archer).toBeInstanceOf(RangedUnit); }); test("appears as 'a' on map", () => { expect(archer.character).toBe('a'); }); test('has #ebcb8b color', () => { expect(archer.color).toBe('#ebcb8b'); }); test('has 7 max health', () => { expect(archer.maxHealth).toBe(7); }); test('has shoot ability', () => { expect(Archer.declaredAbilities).toHaveProperty('shoot'); }); test('has look ability', () => { expect(Archer.declaredAbilities).toHaveProperty('look'); }); }); ================================================ FILE: libs/units/src/Archer.ts ================================================ import { Look, Shoot } from '@warriorjs/abilities'; import RangedUnit from './RangedUnit.js'; class Archer extends RangedUnit { static declaredAbilities = { look: Look.with({ range: 3 }), shoot: Shoot.with({ range: 3, power: 3 }), }; constructor() { super('Archer', 'a', '#ebcb8b', 7); } } export default Archer; ================================================ FILE: libs/units/src/Captive.test.ts ================================================ import { Unit } from '@warriorjs/core'; import { beforeEach, describe, expect, test } from 'vitest'; import Captive from './Captive.js'; describe('Captive', () => { let captive: Captive; beforeEach(() => { captive = new Captive(); }); test('extends Unit', () => { expect(captive).toBeInstanceOf(Unit); }); test("appears as 'C' on map", () => { expect(captive.character).toBe('C'); }); test('has #81a1c1 color', () => { expect(captive.color).toBe('#81a1c1'); }); test('has 1 max health', () => { expect(captive.maxHealth).toBe(1); }); test('has a reward of 20 points', () => { expect(captive.reward).toBe(20); }); test('is not an enemy', () => { expect(captive.enemy).toBe(false); }); test('is bound', () => { expect(captive.bound).toBe(true); }); }); ================================================ FILE: libs/units/src/Captive.ts ================================================ import { Unit } from '@warriorjs/core'; class Captive extends Unit { constructor() { super('Captive', 'C', '#81a1c1', 1, 20, false, true); } } export default Captive; ================================================ FILE: libs/units/src/MeleeUnit.test.ts ================================================ import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import MeleeUnit from './MeleeUnit.js'; class TestMeleeUnit extends MeleeUnit { constructor() { super('Melee', 'm', '#aaa', 10); } } describe('MeleeUnit', () => { let unit: TestMeleeUnit; let turn: any; let space: any; beforeEach(() => { unit = new TestMeleeUnit(); space = { getUnit: () => undefined }; turn = { attack: vi.fn(), feel: vi.fn(() => space), }; }); test('feels in all directions looking for threats', () => { unit.playTurn(turn); expect(turn.feel).toHaveBeenCalledWith(FORWARD); expect(turn.feel).toHaveBeenCalledWith(RIGHT); expect(turn.feel).toHaveBeenCalledWith(BACKWARD); expect(turn.feel).toHaveBeenCalledWith(LEFT); }); test('attacks the first enemy it finds', () => { turn.feel.mockReturnValueOnce({ getUnit: () => ({ isEnemy: () => true, isBound: () => false }), }); unit.playTurn(turn); expect(turn.attack).toHaveBeenCalledWith(FORWARD); }); test('does not attack if no enemies found', () => { unit.playTurn(turn); expect(turn.attack).not.toHaveBeenCalled(); }); test('does not attack bound enemies', () => { turn.feel.mockReturnValue({ getUnit: () => ({ isEnemy: () => true, isBound: () => true }), }); unit.playTurn(turn); expect(turn.attack).not.toHaveBeenCalled(); }); test('does not attack non-enemies', () => { turn.feel.mockReturnValue({ getUnit: () => ({ isEnemy: () => false, isBound: () => false }), }); unit.playTurn(turn); expect(turn.attack).not.toHaveBeenCalled(); }); test('stops looking once it finds a threat', () => { turn.feel.mockReturnValueOnce({ getUnit: () => undefined }).mockReturnValueOnce({ getUnit: () => ({ isEnemy: () => true, isBound: () => false }), }); unit.playTurn(turn); expect(turn.feel).toHaveBeenCalledTimes(2); expect(turn.attack).toHaveBeenCalledWith(RIGHT); }); }); ================================================ FILE: libs/units/src/MeleeUnit.ts ================================================ import { type Turn, Unit } from '@warriorjs/core'; import { RELATIVE_DIRECTIONS } from '@warriorjs/spatial'; abstract class MeleeUnit extends Unit { playTurn(turn: Turn) { const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { const unit = turn.feel(direction).getUnit(); return unit?.isEnemy() && !unit.isBound(); }); if (threatDirection) { turn.attack(threatDirection); } } } export default MeleeUnit; ================================================ FILE: libs/units/src/RangedUnit.test.ts ================================================ import { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial'; import { beforeEach, describe, expect, test, vi } from 'vitest'; import RangedUnit from './RangedUnit.js'; class TestRangedUnit extends RangedUnit { constructor() { super('Ranged', 'r', '#bbb', 8); } } describe('RangedUnit', () => { let unit: TestRangedUnit; let turn: any; let emptySpaces: any[]; beforeEach(() => { unit = new TestRangedUnit(); emptySpaces = [{ isUnit: () => false }, { isUnit: () => false }]; turn = { shoot: vi.fn(), look: vi.fn(() => emptySpaces), }; }); test('looks in all directions for threats', () => { unit.playTurn(turn); expect(turn.look).toHaveBeenCalledWith(FORWARD); expect(turn.look).toHaveBeenCalledWith(RIGHT); expect(turn.look).toHaveBeenCalledWith(BACKWARD); expect(turn.look).toHaveBeenCalledWith(LEFT); }); test('shoots the first direction with an enemy', () => { turn.look.mockReturnValueOnce([ { isUnit: () => false }, { isUnit: () => true, getUnit: () => ({ isEnemy: () => true, isBound: () => false }) }, ]); unit.playTurn(turn); expect(turn.shoot).toHaveBeenCalledWith(FORWARD); }); test('does not shoot if no enemies found', () => { unit.playTurn(turn); expect(turn.shoot).not.toHaveBeenCalled(); }); test('does not shoot bound enemies', () => { turn.look.mockReturnValue([ { isUnit: () => true, getUnit: () => ({ isEnemy: () => true, isBound: () => true }) }, ]); unit.playTurn(turn); expect(turn.shoot).not.toHaveBeenCalled(); }); test('does not shoot non-enemies', () => { turn.look.mockReturnValue([ { isUnit: () => true, getUnit: () => ({ isEnemy: () => false, isBound: () => false }) }, ]); unit.playTurn(turn); expect(turn.shoot).not.toHaveBeenCalled(); }); test('stops looking once it finds a threat', () => { turn.look .mockReturnValueOnce([{ isUnit: () => false }]) .mockReturnValueOnce([ { isUnit: () => true, getUnit: () => ({ isEnemy: () => true, isBound: () => false }) }, ]); unit.playTurn(turn); expect(turn.look).toHaveBeenCalledTimes(2); expect(turn.shoot).toHaveBeenCalledWith(RIGHT); }); }); ================================================ FILE: libs/units/src/RangedUnit.ts ================================================ import { type Turn, Unit } from '@warriorjs/core'; import { RELATIVE_DIRECTIONS } from '@warriorjs/spatial'; abstract class RangedUnit extends Unit { playTurn(turn: Turn) { const threatDirection = RELATIVE_DIRECTIONS.find((direction) => { const spaceWithUnit = turn.look(direction).find((space: any) => space.isUnit()); return spaceWithUnit?.getUnit().isEnemy() && !spaceWithUnit.getUnit().isBound(); }); if (threatDirection) { turn.shoot(threatDirection); } } } export default RangedUnit; ================================================ FILE: libs/units/src/Sludge.test.ts ================================================ import { beforeEach, describe, expect, test } from 'vitest'; import MeleeUnit from './MeleeUnit.js'; import Sludge from './Sludge.js'; describe('Sludge', () => { let sludge: Sludge; beforeEach(() => { sludge = new Sludge(); }); test('extends MeleeUnit', () => { expect(sludge).toBeInstanceOf(MeleeUnit); }); test("appears as 's' on map", () => { expect(sludge.character).toBe('s'); }); test('has #d08770 color', () => { expect(sludge.color).toBe('#d08770'); }); test('has 12 max health', () => { expect(sludge.maxHealth).toBe(12); }); test('has attack ability', () => { expect(Sludge.declaredAbilities).toHaveProperty('attack'); }); test('has feel ability', () => { expect(Sludge.declaredAbilities).toHaveProperty('feel'); }); }); ================================================ FILE: libs/units/src/Sludge.ts ================================================ import { Attack, Feel } from '@warriorjs/abilities'; import MeleeUnit from './MeleeUnit.js'; class Sludge extends MeleeUnit { static declaredAbilities = { attack: Attack.with({ power: 3 }), feel: Feel, }; constructor() { super('Sludge', 's', '#d08770', 12); } } export default Sludge; ================================================ FILE: libs/units/src/ThickSludge.test.ts ================================================ import { beforeEach, describe, expect, test } from 'vitest'; import MeleeUnit from './MeleeUnit.js'; import ThickSludge from './ThickSludge.js'; describe('ThickSludge', () => { let thickSludge: ThickSludge; beforeEach(() => { thickSludge = new ThickSludge(); }); test('extends MeleeUnit', () => { expect(thickSludge).toBeInstanceOf(MeleeUnit); }); test("appears as 'S' on map", () => { expect(thickSludge.character).toBe('S'); }); test('has #bf616a color', () => { expect(thickSludge.color).toBe('#bf616a'); }); test('has 24 max health', () => { expect(thickSludge.maxHealth).toBe(24); }); test('has attack ability', () => { expect(ThickSludge.declaredAbilities).toHaveProperty('attack'); }); test('has feel ability', () => { expect(ThickSludge.declaredAbilities).toHaveProperty('feel'); }); }); ================================================ FILE: libs/units/src/ThickSludge.ts ================================================ import { Attack, Feel } from '@warriorjs/abilities'; import MeleeUnit from './MeleeUnit.js'; class ThickSludge extends MeleeUnit { static declaredAbilities = { attack: Attack.with({ power: 3 }), feel: Feel, }; constructor() { super('Thick Sludge', 'S', '#bf616a', 24); } } export default ThickSludge; ================================================ FILE: libs/units/src/Wizard.test.ts ================================================ import { beforeEach, describe, expect, test } from 'vitest'; import RangedUnit from './RangedUnit.js'; import Wizard from './Wizard.js'; describe('Wizard', () => { let wizard: Wizard; beforeEach(() => { wizard = new Wizard(); }); test('extends RangedUnit', () => { expect(wizard).toBeInstanceOf(RangedUnit); }); test("appears as 'w' on map", () => { expect(wizard.character).toBe('w'); }); test('has #b48ead color', () => { expect(wizard.color).toBe('#b48ead'); }); test('has 3 max health', () => { expect(wizard.maxHealth).toBe(3); }); test('has shoot ability', () => { expect(Wizard.declaredAbilities).toHaveProperty('shoot'); }); test('has look ability', () => { expect(Wizard.declaredAbilities).toHaveProperty('look'); }); }); ================================================ FILE: libs/units/src/Wizard.ts ================================================ import { Look, Shoot } from '@warriorjs/abilities'; import RangedUnit from './RangedUnit.js'; class Wizard extends RangedUnit { static declaredAbilities = { look: Look.with({ range: 3 }), shoot: Shoot.with({ range: 3, power: 11 }), }; constructor() { super('Wizard', 'w', '#b48ead', 3); } } export default Wizard; ================================================ FILE: libs/units/src/index.ts ================================================ export { default as Archer } from './Archer.js'; export { default as Captive } from './Captive.js'; export { default as Sludge } from './Sludge.js'; export { default as ThickSludge } from './ThickSludge.js'; export { default as Wizard } from './Wizard.js'; ================================================ FILE: libs/units/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist" }, "exclude": ["**/*.test.ts"] } ================================================ FILE: libs/units/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src" }, "include": ["src"] } ================================================ FILE: logo/LICENSE ================================================ # Attribution 4.0 International Creative 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. ### Using Creative Commons Public Licenses Creative 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. * **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). * **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). ## Creative Commons Attribution 4.0 International Public License By 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. ### Section 1 – Definitions. a. **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. b. **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. c. **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. d. **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. e. **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. f. **Licensed Material** means the artistic or literary work, database, or other material to which the Licensor applied this Public License. g. **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. h. **Licensor** means the individual(s) or entity(ies) granting rights under this Public License. i. **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. j. **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. k. **You** means the individual or entity exercising the Licensed Rights under this Public License. Your has a corresponding meaning. ### Section 2 – Scope. a. **_License grant._** 1. 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: A. reproduce and Share the Licensed Material, in whole or in part; and B. produce, reproduce, and Share Adapted Material. 2. **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. 3. **Term.** The term of this Public License is specified in Section 6(a). 4. **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. 5. **Downstream recipients.** 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. 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. 6. **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). b. **_Other rights._** 1. 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. 2. Patent and trademark rights are not licensed under this Public License. 3. 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. ### Section 3 – License Conditions. Your exercise of the Licensed Rights is expressly made subject to the following conditions. a. **_Attribution._** 1. If You Share the Licensed Material (including in modified form), You must: A. retain the following if it is supplied by the Licensor with the Licensed Material: 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); ii. a copyright notice; iii. a notice that refers to this Public License; iv. a notice that refers to the disclaimer of warranties; v. a URI or hyperlink to the Licensed Material to the extent reasonably practicable; B. indicate if You modified the Licensed Material and retain an indication of any previous modifications; and 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. 2. 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. 3. If requested by the Licensor, You must remove any of the information required by Section 3(a)(1)(A) to the extent reasonably practicable. 4. 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. ### Section 4 – Sui Generis Database Rights. Where the Licensed Rights include Sui Generis Database Rights that apply to Your use of the Licensed Material: a. 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; b. 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 c. You must comply with the conditions in Section 3(a) if You Share all or a substantial portion of the contents of the database. For 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. ### Section 5 – Disclaimer of Warranties and Limitation of Liability. a. **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.** b. **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.** c. 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. ### Section 6 – Term and Termination. a. 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. b. Where Your right to use the Licensed Material has terminated under Section 6(a), it reinstates: 1. automatically as of the date the violation is cured, provided it is cured within 30 days of Your discovery of the violation; or 2. upon express reinstatement by the Licensor. For 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. c. 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. d. Sections 1, 5, 6, 7, and 8 survive termination of this Public License. ### Section 7 – Other Terms and Conditions. a. The Licensor shall not be bound by any additional or different terms or conditions communicated by You unless expressly agreed. b. 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. ### Section 8 – Interpretation. a. 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. b. 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. c. 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. d. 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. > 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. > > Creative Commons may be contacted at [creativecommons.org](https://creativecommons.org). ================================================ FILE: logo/README.md ================================================

The WarriorJS logo concept, in dark.

The WarriorJS logo concept, in light.

================================================ FILE: package.json ================================================ { "private": true, "type": "module", "packageManager": "pnpm@10.11.0", "engines": { "node": ">=24" }, "homepage": "https://warrior.js.org", "repository": "https://github.com/olistic/warriorjs", "scripts": { "build": "turbo run build --filter='!warriorjs-website'", "clean": "rm -rf {apps,libs,towers}/*/dist", "lint": "biome check", "lint:fix": "biome check --write", "typecheck": "turbo run typecheck --filter='!warriorjs-website'", "test": "vitest run", "test:coverage": "vitest run --coverage", "test:watch": "vitest", "prepare": "lefthook install" }, "devDependencies": { "@biomejs/biome": "^2.4.6", "@types/node": "^25.4.0", "@vitest/coverage-v8": "^4.0.18", "lefthook": "^2.1.4", "turbo": "^2.4.4", "typescript": "^5.9.3", "vitest": "^4.0.18" }, "pnpm": { "onlyBuiltDependencies": ["lefthook"] } } ================================================ FILE: pnpm-workspace.yaml ================================================ onlyBuiltDependencies: - lefthook packages: - 'apps/*' - 'libs/*' - 'towers/*' settings: strictPeerDependencies: false ================================================ FILE: towers/README.md ================================================ # [Towers](https://warrior.js.org/docs/player/towers) The towers available in WarriorJS are independent packages that add new universes (levels, abilities and units) to the game. | Package | Version | | ------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------- | | [`@warriorjs/tower-the-narrow-path`][warriorjs-tower-the-narrow-path] | [![npm][warriorjs-tower-the-narrow-path-badge]][warriorjs-tower-the-narrow-path-npm] | | [`@warriorjs/tower-the-powder-keep`][warriorjs-tower-the-powder-keep] | [![npm][warriorjs-tower-the-powder-keep-badge]][warriorjs-tower-the-powder-keep-npm] | - [`@warriorjs/tower-the-narrow-path`][warriorjs-tower-the-narrow-path] is "The Narrow Path", the entry-level tower. You should play this first. - [`@warriorjs/tower-the-powder-keep`][warriorjs-tower-the-powder-keep] is "The Powder Keep", a more challenging tower. > You can find community maintained towers in [npm][community-towers-npm]. [warriorjs-tower-the-narrow-path]: /towers/the-narrow-path [warriorjs-tower-the-narrow-path-badge]: https://img.shields.io/npm/v/@warriorjs/tower-the-narrow-path.svg?style=flat-square [warriorjs-tower-the-narrow-path-npm]: https://www.npmjs.com/package/@warriorjs/tower-the-narrow-path [warriorjs-tower-the-powder-keep]: /towers/the-powder-keep [warriorjs-tower-the-powder-keep-badge]: https://img.shields.io/npm/v/@warriorjs/tower-the-powder-keep.svg?style=flat-square [warriorjs-tower-the-powder-keep-npm]: https://www.npmjs.com/package/@warriorjs/tower-the-powder-keep [community-towers-npm]: https://www.npmjs.com/search?q=warriorjs-tower ================================================ FILE: towers/the-narrow-path/README.md ================================================ # @warriorjs/tower-the-narrow-path > A corridor of stone where the only way out is forward. The walls press close. Torchlight flickers against wet stone, casting long shadows down a passage that stretches beyond sight. Whatever waits ahead, there is no turning back — only forward, one step at a time. **9 levels.** Walk, fight, rest, rescue, pivot, shoot — each floor teaches one new ability and asks you to combine it with everything you've learned so far. Start here. ## Install `@warriorjs/cli` ships with this tower built-in. Just run: ```sh warriorjs ``` To install it separately: ```sh npm install @warriorjs/tower-the-narrow-path ``` See the [Towers](https://warrior.js.org/docs/player/towers) docs for more. ================================================ FILE: towers/the-narrow-path/package.json ================================================ { "name": "@warriorjs/tower-the-narrow-path", "version": "0.13.0", "description": "The Narrow Path", "author": "Matias Olivera ", "license": "MIT", "homepage": "https://warrior.js.org", "repository": "https://github.com/olistic/warriorjs/tree/master/towers/the-narrow-path", "keywords": [ "warriorjs-tower" ], "type": "module", "main": "dist/index.js", "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.js", "types": "./dist/index.d.ts" } }, "files": [ "dist" ], "publishConfig": { "access": "public" }, "engines": { "node": ">=24" }, "scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit" }, "dependencies": { "@warriorjs/abilities": "workspace:^", "@warriorjs/core": "workspace:^", "@warriorjs/spatial": "workspace:^", "@warriorjs/units": "workspace:^" } } ================================================ FILE: towers/the-narrow-path/src/index.ts ================================================ import { Attack, Feel, Health, Look, MaxHealth, Pivot, Rescue, Rest, Shoot, Think, Walk, } from '@warriorjs/abilities'; import { type TowerDefinition } from '@warriorjs/core'; import { EAST, WEST } from '@warriorjs/spatial'; import { Archer, Captive, Sludge, ThickSludge, Wizard } from '@warriorjs/units'; const tower: TowerDefinition = { name: 'The Narrow Path', description: 'A corridor of stone where the only way out is forward', warrior: { character: '@', color: '#8fbcbb', maxHealth: 20, }, levels: [ { description: 'A long hallway stretches before you, torchlight glinting off stairs at the far end. The air is still. Nothing stirs.', tip: "The path is clear. Call `warrior.walk()` to walk forward in the Player's `playTurn` method.", timeBonus: 15, aceScore: 10, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { abilities: { think: Think, walk: Walk, }, position: { x: 0, y: 0, facing: EAST, }, }, units: [], }, }, { description: 'The torches have gone out. Darkness swallows the corridor, but the stench of sludge hangs thick in the air.', 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.", clue: 'Add an if/else condition using `warrior.feel().isEmpty()` to decide whether to attack or walk.', timeBonus: 20, aceScore: 26, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { abilities: { attack: Attack.with({ power: 5 }), feel: Feel, }, position: { x: 0, y: 0, facing: EAST, }, }, units: [ { unit: Sludge, position: { x: 4, y: 0, facing: WEST, }, }, ], }, }, { description: 'The air is heavy and wet, almost hard to breathe. The stench is overwhelming — there must be a horde of them.', 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.', clue: "When there's no enemy ahead of you, call `warrior.rest()` until your health is full before walking forward.", timeBonus: 35, aceScore: 71, floor: { size: { width: 9, height: 1, }, stairs: { x: 8, y: 0, }, warrior: { abilities: { health: Health, maxHealth: MaxHealth, rest: Rest.with({ healthGain: 0.1 }), }, position: { x: 0, y: 0, facing: EAST, }, }, units: [ { unit: Sludge, position: { x: 2, y: 0, facing: WEST, }, }, { unit: Sludge, position: { x: 4, y: 0, facing: WEST, }, }, { unit: Sludge, position: { x: 5, y: 0, facing: WEST, }, }, { unit: Sludge, position: { x: 7, y: 0, facing: WEST, }, }, ], }, }, { description: 'A faint creak echoes off the walls. Somewhere ahead, bow strings are being drawn.', 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.", 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.", timeBonus: 45, aceScore: 90, floor: { size: { width: 7, height: 1, }, stairs: { x: 6, y: 0, }, warrior: { position: { x: 0, y: 0, facing: EAST, }, }, units: [ { unit: ThickSludge, position: { x: 2, y: 0, facing: WEST, }, }, { unit: Archer, position: { x: 3, y: 0, facing: WEST, }, }, { unit: ThickSludge, position: { x: 5, y: 0, facing: WEST, }, }, ], }, }, { description: 'Muffled cries echo through the stone. Someone is alive down here — and bound.', 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.', clue: "Don't forget to constantly check if you are being attacked. Rest until your health is full if you're not taking damage.", timeBonus: 45, aceScore: 123, floor: { size: { width: 7, height: 1, }, stairs: { x: 6, y: 0, }, warrior: { abilities: { rescue: Rescue, }, position: { x: 0, y: 0, facing: EAST, }, }, units: [ { unit: Captive, position: { x: 2, y: 0, facing: WEST, }, }, { unit: Archer, position: { x: 3, y: 0, facing: WEST, }, }, { unit: Archer, position: { x: 4, y: 0, facing: WEST, }, }, { unit: ThickSludge, position: { x: 5, y: 0, facing: WEST, }, }, { unit: Captive, position: { x: 6, y: 0, facing: WEST, }, }, ], }, }, { description: 'The corridor opens wider than before. Cries reach you from both ends — ahead and behind.', tip: "Danger on two fronts. Pass `'backward'` to `walk()`, `feel()`, `rescue()`, and `attack()` to act behind you. Archers have a limited attack distance.", 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.", timeBonus: 55, aceScore: 105, floor: { size: { width: 8, height: 1, }, stairs: { x: 7, y: 0, }, warrior: { position: { x: 2, y: 0, facing: EAST, }, }, units: [ { unit: Captive, position: { x: 0, y: 0, facing: EAST, }, }, { unit: ThickSludge, position: { x: 4, y: 0, facing: WEST, }, }, { unit: Archer, position: { x: 6, y: 0, facing: WEST, }, }, { unit: Archer, position: { x: 7, y: 0, facing: WEST, }, }, ], }, }, { description: 'Cold stone meets your outstretched hand. A dead end — but a draft at your back tells you the way lies behind.', tip: "Fighting backward dulls your blade. Use `warrior.feel().isWall()` to detect the wall, and `warrior.pivot()` to turn and face what's coming.", timeBonus: 30, aceScore: 50, floor: { size: { width: 6, height: 1, }, stairs: { x: 0, y: 0, }, warrior: { abilities: { pivot: Pivot, }, position: { x: 5, y: 0, facing: EAST, }, }, units: [ { unit: Archer, position: { x: 1, y: 0, facing: EAST, }, }, { unit: ThickSludge, position: { x: 3, y: 0, facing: EAST, }, }, ], }, }, { description: 'Low chanting reverberates through the passage. Wizards. Your hand finds a bow left behind by some less fortunate soul.', 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.', clue: "Wizards are deadly but low in health. Kill them before they've time to attack.", timeBonus: 20, aceScore: 46, floor: { size: { width: 6, height: 1, }, stairs: { x: 5, y: 0, }, warrior: { abilities: { look: Look.with({ range: 3 }), shoot: Shoot.with({ power: 3, range: 3 }), }, position: { x: 0, y: 0, facing: EAST, }, }, units: [ { unit: Captive, position: { x: 2, y: 0, facing: WEST, }, }, { unit: Wizard, position: { x: 3, y: 0, facing: WEST, }, }, { unit: Wizard, position: { x: 4, y: 0, facing: WEST, }, }, ], }, }, { description: "The passage splits open into a long chamber. Enemies ahead, enemies behind. Everything you've survived has led to this.", tip: 'Trust your instincts. Watch your back.', clue: "Don't just keep shooting the bow while you're being attacked from behind.", timeBonus: 40, aceScore: 100, floor: { size: { width: 11, height: 1, }, stairs: { x: 0, y: 0, }, warrior: { position: { x: 5, y: 0, facing: EAST, }, }, units: [ { unit: Captive, position: { x: 1, y: 0, facing: EAST, }, }, { unit: Archer, position: { x: 2, y: 0, facing: EAST, }, }, { unit: ThickSludge, position: { x: 7, y: 0, facing: WEST, }, }, { unit: Wizard, position: { x: 9, y: 0, facing: WEST, }, }, { unit: Captive, position: { x: 10, y: 0, facing: WEST, }, }, ], }, }, ], }; export default tower; ================================================ FILE: towers/the-narrow-path/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist" }, "exclude": ["**/*.test.ts"] } ================================================ FILE: towers/the-narrow-path/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src" }, "include": ["src"] } ================================================ FILE: towers/the-powder-keep/README.md ================================================ # @warriorjs/tower-the-powder-keep > An old fortress where something ticks beneath the floor. The keep was abandoned long ago, but not emptied. Its rooms are vast and dark, and somewhere deep within, a faint ticking pulses through the walls like a second heartbeat. Move carefully. Not everything here can be fought — some things can only be outrun. **9 levels.** Navigate open rooms, listen for sounds, bind enemies, defuse ticking bombs. Floors are two-dimensional — threats come from every direction. ## Install Install alongside `@warriorjs/cli`: ```sh npm install --global @warriorjs/tower-the-powder-keep ``` Or locally: ```sh npm install @warriorjs/tower-the-powder-keep ``` Then run `warriorjs` and select The Powder Keep. See the [Towers](https://warrior.js.org/docs/player/towers) docs for more. ================================================ FILE: towers/the-powder-keep/package.json ================================================ { "name": "@warriorjs/tower-the-powder-keep", "version": "0.13.0", "description": "The Powder Keep", "author": "Matias Olivera ", "license": "MIT", "homepage": "https://warrior.js.org", "repository": "https://github.com/olistic/warriorjs/tree/master/towers/the-powder-keep", "keywords": [ "warriorjs-tower" ], "type": "module", "main": "dist/index.js", "exports": { ".": { "import": "./dist/index.js", "require": "./dist/index.js", "types": "./dist/index.d.ts" } }, "files": [ "dist" ], "publishConfig": { "access": "public" }, "engines": { "node": ">=24" }, "scripts": { "build": "tsc -p tsconfig.build.json", "typecheck": "tsc --noEmit" }, "dependencies": { "@warriorjs/abilities": "workspace:^", "@warriorjs/core": "workspace:^", "@warriorjs/effects": "workspace:^", "@warriorjs/spatial": "workspace:^", "@warriorjs/units": "workspace:^" } } ================================================ FILE: towers/the-powder-keep/src/index.ts ================================================ import { Attack, Bind, Detonate, DirectionOf, DirectionOfStairs, DistanceOf, Feel, Health, Listen, Look, MaxHealth, Rescue, Rest, Think, Walk, } from '@warriorjs/abilities'; import { type TowerDefinition } from '@warriorjs/core'; import { Ticking } from '@warriorjs/effects'; import { EAST, NORTH, SOUTH, WEST } from '@warriorjs/spatial'; import { Captive, Sludge, ThickSludge } from '@warriorjs/units'; const tower: TowerDefinition = { name: 'The Powder Keep', description: 'An old fortress where something ticks beneath the floor', warrior: { character: '@', color: '#8fbcbb', maxHealth: 20, }, levels: [ { description: '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.', 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.", timeBonus: 20, aceScore: 19, floor: { size: { width: 6, height: 4, }, stairs: { x: 2, y: 3, }, warrior: { abilities: { directionOfStairs: DirectionOfStairs, think: Think, walk: Walk, }, position: { x: 0, y: 1, facing: EAST, }, }, units: [], }, }, { description: 'The next chamber is not empty. Shapes shift in the darkness on all sides, between you and the stairs.', tip: 'Threats can come from any direction now. You can attack and feel forward, left, right, and backward.', 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.", timeBonus: 40, aceScore: 84, floor: { size: { width: 4, height: 2, }, stairs: { x: 3, y: 1, }, warrior: { abilities: { attack: Attack.with({ power: 5 }), feel: Feel, health: Health, maxHealth: MaxHealth, rest: Rest.with({ healthGain: 0.1 }), }, position: { x: 0, y: 0, facing: EAST, }, }, units: [ { unit: Sludge, position: { x: 1, y: 0, facing: WEST, }, }, { unit: ThickSludge, position: { x: 2, y: 1, facing: WEST, }, }, { unit: Sludge, position: { x: 1, y: 1, facing: NORTH, }, }, ], }, }, { description: 'Slime presses against you from every direction. You are surrounded.', tip: 'Too many to fight at once. Call `warrior.bind()` to hold an enemy in place while you deal with the others.', clue: 'Count the number of unbound enemies around you. Bind an enemy if there are two or more.', timeBonus: 50, aceScore: 101, floor: { size: { width: 3, height: 3, }, stairs: { x: 0, y: 0, }, warrior: { position: { x: 1, y: 1, facing: EAST, }, abilities: { bind: Bind, rescue: Rescue, }, }, units: [ { unit: Sludge, position: { x: 1, y: 0, facing: WEST, }, }, { unit: Captive, position: { x: 1, y: 2, facing: WEST, }, }, { unit: Sludge, position: { x: 0, y: 1, facing: WEST, }, }, { unit: Sludge, position: { x: 2, y: 1, facing: WEST, }, }, ], }, }, { description: 'Your eyes are useless here, but your ears sharpen. Breathing. Struggling. Faint sounds scattered across the room.', 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.', clue: 'Walk towards a unit with `warrior.walk(warrior.directionOf(warrior.listen()[0]))`. Once `warrior.listen().length === 0`, head for the stairs.', timeBonus: 55, aceScore: 144, floor: { size: { width: 4, height: 3, }, stairs: { x: 3, y: 2, }, warrior: { position: { x: 1, y: 1, facing: EAST, }, abilities: { directionOf: DirectionOf, listen: Listen, }, }, units: [ { unit: Captive, position: { x: 0, y: 0, facing: EAST, }, }, { unit: Captive, position: { x: 0, y: 2, facing: EAST, }, }, { unit: Sludge, position: { x: 2, y: 0, facing: SOUTH, }, }, { unit: ThickSludge, position: { x: 3, y: 1, facing: WEST, }, }, { unit: Sludge, position: { x: 2, y: 2, facing: NORTH, }, }, ], }, }, { description: 'The stairs are right beside you — you could leave now. But the room beyond is not empty, and neither is your conscience.', tip: 'Leaving is easy. Clearing the floor is worth more. Use `warrior.feel().isStairs()` and `warrior.feel().isEmpty()` to choose your path.', 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.', timeBonus: 45, aceScore: 107, floor: { size: { width: 5, height: 2, }, stairs: { x: 1, y: 1, }, warrior: { position: { x: 0, y: 1, facing: EAST, }, }, units: [ { unit: ThickSludge, position: { x: 4, y: 0, facing: WEST, }, }, { unit: ThickSludge, position: { x: 3, y: 1, facing: NORTH, }, }, { unit: Captive, position: { x: 4, y: 1, facing: WEST, }, }, ], }, }, { description: 'A rhythmic ticking cuts through the silence. Somewhere in the dark, a captive kneels over a bomb that will not wait.', tip: "Time is short. Rescue captives with `space.getUnit().isUnderEffect('ticking')` first — they won't last long.", clue: "Avoid fighting enemies at first. Use `warrior.listen()` and `space.getUnit().isUnderEffect('ticking')` and quickly rescue those captives.", timeBonus: 50, aceScore: 108, floor: { size: { width: 6, height: 2, }, stairs: { x: 5, y: 0, }, warrior: { position: { x: 0, y: 1, facing: EAST, }, }, units: [ { unit: Sludge, position: { x: 1, y: 0, facing: WEST, }, }, { unit: Sludge, position: { x: 3, y: 1, facing: WEST, }, }, { unit: Captive, position: { x: 0, y: 0, facing: WEST, }, }, { unit: Captive, effects: { ticking: Ticking.with({ time: 7 }), }, position: { x: 4, y: 1, facing: WEST, }, }, ], }, }, { description: 'The ticking again. Faster now. But the sludge between you and the captive does not intend to move.', tip: 'No way around — only through. Kill the sludge and reach the captive before the bomb goes off.', clue: 'Determine the direction of the ticking captive and kill any enemies blocking that path. You may need to bind surrounding enemies first.', timeBonus: 70, aceScore: 134, floor: { size: { width: 5, height: 3, }, stairs: { x: 4, y: 0, }, warrior: { position: { x: 0, y: 1, facing: EAST, }, }, units: [ { unit: Sludge, position: { x: 1, y: 0, facing: SOUTH, }, }, { unit: Sludge, position: { x: 1, y: 2, facing: NORTH, }, }, { unit: Captive, position: { x: 2, y: 1, facing: WEST, }, }, { unit: Captive, effects: { ticking: Ticking.with({ time: 10 }), }, position: { x: 4, y: 1, facing: WEST, }, }, { unit: Captive, position: { x: 2, y: 0, facing: WEST, }, }, ], }, }, { description: "Your boot catches a leather satchel half-buried in dust. Bombs. The keep's former garrison left something useful behind.", tip: 'Fire answers numbers. Use `warrior.look()` to spot clustered enemies, and `warrior.detonate()` to thin the herd. Mind your health.', clue: 'Calling `warrior.look()` will return an array of spaces. If the first two contain enemies, detonate a bomb with `warrior.detonate()`.', timeBonus: 30, aceScore: 91, floor: { size: { width: 7, height: 1, }, stairs: { x: 6, y: 0, }, warrior: { position: { x: 0, y: 0, facing: EAST, }, abilities: { detonate: Detonate.with({ targetPower: 8, surroundingPower: 4 }), look: Look.with({ range: 3 }), }, }, units: [ { unit: Captive, effects: { ticking: Ticking.with({ time: 9 }), }, position: { x: 5, y: 0, facing: WEST, }, }, { unit: ThickSludge, position: { x: 2, y: 0, facing: WEST, }, }, { unit: Sludge, position: { x: 3, y: 0, facing: WEST, }, }, ], }, }, { description: 'The final chamber writhes with sludge — more than you have ever seen. The ticking beneath the floor has not stopped.', 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.', clue: 'Be sure to bind the surrounding enemies before fighting. Check your health before detonating explosives.', timeBonus: 70, aceScore: 176, floor: { size: { width: 4, height: 3, }, stairs: { x: 3, y: 0, }, warrior: { position: { x: 0, y: 1, facing: EAST, }, abilities: { distanceOf: DistanceOf, }, }, units: [ { unit: Captive, effects: { ticking: Ticking.with({ time: 20 }), }, position: { x: 2, y: 0, facing: SOUTH, }, }, { unit: Captive, position: { x: 2, y: 2, facing: NORTH, }, }, { unit: Sludge, position: { x: 0, y: 0, facing: SOUTH, }, }, { unit: Sludge, position: { x: 1, y: 0, facing: SOUTH, }, }, { unit: Sludge, position: { x: 1, y: 1, facing: EAST, }, }, { unit: Sludge, position: { x: 2, y: 1, facing: EAST, }, }, { unit: Sludge, position: { x: 3, y: 1, facing: EAST, }, }, { unit: Sludge, position: { x: 0, y: 2, facing: NORTH, }, }, { unit: Sludge, position: { x: 1, y: 2, facing: NORTH, }, }, ], }, }, ], }; export default tower; ================================================ FILE: towers/the-powder-keep/tsconfig.build.json ================================================ { "extends": "./tsconfig.json", "compilerOptions": { "outDir": "dist" }, "exclude": ["**/*.test.ts"] } ================================================ FILE: towers/the-powder-keep/tsconfig.json ================================================ { "extends": "../../tsconfig.json", "compilerOptions": { "rootDir": "src" }, "include": ["src"] } ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", "declaration": true, "declarationMap": true, "sourceMap": true, "strict": true, "esModuleInterop": true, "skipLibCheck": true, "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true } } ================================================ FILE: turbo.json ================================================ { "$schema": "https://turbo.build/schema.json", "tasks": { "build": { "dependsOn": ["^build"], "outputs": ["dist/**"] }, "test": { "dependsOn": ["build"] }, "lint": {}, "typecheck": { "dependsOn": ["^build"] } } } ================================================ FILE: vitest.config.ts ================================================ import { defineConfig } from 'vitest/config'; export default defineConfig({ test: { globals: true, environment: 'node', clearMocks: true, include: ['libs/**/src/**/*.test.ts', 'apps/**/src/**/*.test.{ts,tsx}'], exclude: ['**/node_modules/**', '**/dist/**'], coverage: { include: ['libs/**/src/**/*.ts', 'apps/**/src/**/*.{ts,tsx}'], exclude: ['libs/warriorjs-tower-**/src/**', '**/*.test.{ts,tsx}'], thresholds: { lines: 80, functions: 80, branches: 80, statements: 80, }, }, }, });