Showing preview only (616K chars total). Download the full file or copy to clipboard to get everything.
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
================================================
<!--
⚠️ PLEASE READ BEFORE DELETING THIS TEMPLATE! ⚠️
Thanks for your contribution. Please follow this guide before submitting an
issue:
1. Do you have a setup/usage question?
================================
- Look for prior or closed issues (but please avoid replying to them if they're
too old).
- Check the docs: https://warrior.js.org/docs
- Start a thread on our Spectrum Help channel:
https://spectrum.chat/warriorjs/help
2. Do you think you found a bug?
================================
- Make sure you're on the latest version of WarriorJS (`npm i -g @warriorjs/cli`)
- Consider submitting a PR with a failing test instead.
- Use the "BUG TEMPLATE" below to report a bug.
- Don't forget to provide reproduction steps.
- If you can't provide a reproduction, snippets of code can help, but are
incomplete reports.
3. Do you have a feature request?
================================
- Look for old & closed issues (replying might be ok if they're not too old or
have no conclusion).
- Otherwise: Remove this entire template and provide thoughtful commentary *and
code samples* on what this feature means for you. Example:
- What will it allow you to do that you can't do today?
- How will it make current work-arounds straightforward?
- What potential bugs and edge cases does it help to avoid?
- Please keep it product-centric.
-->
<!-- BUG TEMPLATE -->
# Environment
<!--
Please run this command from the directory under which you're running warriorjs
and paste its contents here:
npx envinfo --system --binaries --npmPackages @warriorjs/* --npmGlobalPackages @warriorjs/* --markdown
-->
# 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
================================================
<div align="center">
<a href="https://warrior.js.org">
<img alt="WarriorJS Banner" title="WarriorJS" src="logo/warriorjs-banner-dark.png?raw=true">
</a>
</div>
<br />
<div align="center">
<strong>Learn JavaScript and TypeScript by writing code that fights</strong>
</div>
<br />
<div align="center">
<a href="https://github.com/olistic/warriorjs/actions/workflows/ci.yml">
<img alt="CI" src="https://img.shields.io/github/actions/workflow/status/olistic/warriorjs/ci.yml?branch=master&style=flat-square">
</a>
<a href="https://codecov.io/gh/olistic/warriorjs">
<img alt="Codecov" src="https://img.shields.io/codecov/c/github/olistic/warriorjs.svg?style=flat-square">
</a>
</div>
<br />
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<string, unknown>): void;
namespace mock {
function restore(): void;
}
export default mock;
}
declare module 'array-shuffle' {
function arrayShuffle<T>(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 <hi@matiasolivera.com>",
"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<number, number>;
[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<string, unknown> {
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<string, unknown> {
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<void> {
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<string, TowerInfo>();
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 (
<PlaySession
context={context}
profile={session.profile}
initialLevel={session.initialLevel}
/>
);
}
return (
<GameMenu
context={context}
onStart={(profile, level) => 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(<ConfirmPrompt message="Continue?" onConfirm={vi.fn()} />);
const output = lastFrame()!;
expect(output).toContain('Continue?');
expect(output).toContain('(y/N)');
});
test('shows Y/n hint when defaultValue is true', () => {
const { lastFrame } = render(
<ConfirmPrompt message="Continue?" defaultValue={true} onConfirm={vi.fn()} />,
);
expect(lastFrame()!).toContain('(Y/n)');
});
test('confirms with true on y', () => {
const onConfirm = vi.fn();
const { stdin } = render(<ConfirmPrompt message="Continue?" onConfirm={onConfirm} />);
stdin.write('y');
expect(onConfirm).toHaveBeenCalledWith(true);
});
test('confirms with false on n', () => {
const onConfirm = vi.fn();
const { stdin } = render(<ConfirmPrompt message="Continue?" onConfirm={onConfirm} />);
stdin.write('n');
expect(onConfirm).toHaveBeenCalledWith(false);
});
test('uses default value on enter', () => {
const onConfirm = vi.fn();
const { stdin } = render(
<ConfirmPrompt message="Continue?" defaultValue={true} onConfirm={onConfirm} />,
);
stdin.write('\r');
expect(onConfirm).toHaveBeenCalledWith(true);
});
test('shows Yes in submitted state', async () => {
const { stdin, lastFrame } = render(<ConfirmPrompt message="Continue?" onConfirm={vi.fn()} />);
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(<ConfirmPrompt message="Continue?" onConfirm={vi.fn()} />);
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(<ConfirmPrompt message="Continue?" onConfirm={onConfirm} />);
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 (
<Text>
<Text bold>{message}</Text> {result ? 'Yes' : 'No'}
</Text>
);
}
const hint = defaultValue === true ? 'Y/n' : 'y/N';
return (
<Box gap={1}>
<Text bold>{message}</Text>
<Text dimColor>{`(${hint})`}</Text>
</Box>
);
}
================================================
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(<Divider />);
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 <Text dimColor>{'─'.repeat(stdout.columns || 80)}</Text>;
}
================================================
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(
<ErrorMessage message="Something went wrong" onDismiss={vi.fn()} />,
);
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(<ErrorMessage message="Error" onDismiss={onDismiss} />);
stdin.write('x');
expect(onDismiss).toHaveBeenCalled();
});
test('renders null after dismissal', async () => {
const { stdin, lastFrame } = render(<ErrorMessage message="Error" onDismiss={vi.fn()} />);
stdin.write('x');
await waitForRender();
expect(lastFrame()!).toBe('');
});
test('ignores subsequent key presses', async () => {
const onDismiss = vi.fn();
const { stdin } = render(<ErrorMessage message="Error" onDismiss={onDismiss} />);
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(<ErrorMessage message="Fatal error" />);
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(<ErrorMessage message="Fatal error" />);
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 (
<Box flexDirection="column">
<Text color="red">{message}</Text>
{dismissable && <Text dimColor>Press any key to continue...</Text>}
</Box>
);
}
================================================
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(<FloorMap floorMap={floorMap} />);
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(<FloorMap floorMap={floorMap} />);
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 (
<Box flexDirection="column" marginX={1} marginY={1}>
{floorMap.map((row, rowIndex) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static grid
<Box key={rowIndex}>
{row.map((space, colIndex) => (
// biome-ignore lint/suspicious/noArrayIndexKey: static grid
<Text key={colIndex} color={getSpaceColor(space)}>
{space.character}
</Text>
))}
</Box>
))}
</Box>
);
}
================================================
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> = {}): 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> = {}): 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> = {}): 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<typeof vi.fn<(profile: Profile, levelNumber: number) => 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(<GameMenu context={context} onStart={onStart} />);
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(<GameMenu context={context} onStart={onStart} />);
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(<GameMenu context={context} onStart={onStart} />);
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(<GameMenu context={context} onStart={onStart} />);
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(<GameMenu context={context} onStart={onStart} />);
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(<GameMenu context={context} onStart={onStart} />);
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(<GameMenu context={context} onStart={onStart} />);
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(<GameMenu context={context} onStart={onStart} />);
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(<GameMenu context={context} onStart={onStart} />);
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(<GameMenu context={context} onStart={onStart} />);
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(<GameMenu context={context} onStart={onStart} />);
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<GameMenuStep | null>(() => {
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<React.ReactElement[]>([]);
const [finalMessage, setFinalMessage] = useState<React.ReactElement | null>(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(
<ErrorMessage
message={`Level ${context.practiceLevel} doesn't exist. This tower has ${selectedProfile.tower.levels.length} levels.`}
/>,
);
return;
}
onStart(selectedProfile, context.practiceLevel);
} else {
onStart(selectedProfile, 1);
}
} else {
if (context.practiceLevel) {
showFinalMessage(
<ErrorMessage message="The -l option is only available in epic mode. Remove it to play normally." />,
);
return;
}
if (selectedProfile.levelNumber === 0) {
context.onPrepareNextLevel();
const readmePath = selectedProfile.getReadmeFilePath();
showFinalMessage(
<Box flexDirection="column">
<Divider />
<Text bold>
{'Level 1 is ready. See '}
<Link url={`file://${path.resolve(readmePath)}`} fallback={false}>
{readmePath}
</Link>
{' for instructions.'}
</Text>
</Box>,
);
return;
}
onStart(selectedProfile, selectedProfile.levelNumber);
}
} catch (err: unknown) {
showFinalMessage(<ErrorMessage message={err instanceof Error ? err.message : String(err)} />);
}
}
useEffect(() => {
if (initialized.current) return;
initialized.current = true;
if (context.error) {
showFinalMessage(<ErrorMessage message={context.error} />);
} 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 (
<SelectPrompt
message="A tower of enemies awaits. Do you dare enter?"
items={items}
onSelect={(value) => {
if (value === 'start') {
pushHistory(
<Text key={history.length}>
<Text bold>A tower of enemies awaits. Do you dare enter?</Text> Venture forth
</Text>,
);
setStep({ type: 'wizard', initialStep: 'new' });
} else {
showFinalMessage(<Text>Even the bravest need a moment to prepare.</Text>);
}
}}
/>
);
}
case 'wizard':
return (
<ProfileWizard
context={context}
suggestedName={suggestedName!}
initialStep={step.initialStep}
onComplete={(selectedProfile) => handleProfileReady(selectedProfile)}
onCancel={() => {
popHistory();
setStep({ type: 'start' });
}}
onError={(message) => showFinalMessage(<ErrorMessage message={message} />)}
/>
);
}
};
return (
<Box flexDirection="column">
<WelcomeScreen version={context.version} directory={context.runDirectoryPath} />
<Divider />
{history}
{renderStep()}
{finalMessage}
</Box>
);
}
================================================
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(
<Header warriorName="Olric" towerName="The Narrow Path" levelNumber={3} score={125} />,
);
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(<Header warriorName="Olric" towerName="The Narrow Path" />);
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 (
<Box flexDirection="row" justifyContent="space-between" width="100%">
<Text bold>{'0={==> WarriorJS'}</Text>
<Box gap={1}>
<Text bold>{warriorName}</Text>
<Text dimColor>{'·'}</Text>
<Text>{towerName}</Text>
{levelNumber !== undefined && (
<>
<Text dimColor>{'·'}</Text>
<Text dimColor>{`Level ${levelNumber}`}</Text>
</>
)}
{score !== undefined && (
<>
<Text dimColor>{'·'}</Text>
<Text dimColor>{`Score ${score}`}</Text>
</>
)}
</Box>
</Box>
);
}
================================================
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(
<LevelCompleteScreen
levelReport={makeLevelReport({ passed: true, hasNextLevel: true })}
levelRun={makeLevelRun()}
action={{ type: 'prompt' }}
onSelect={vi.fn()}
/>,
);
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(
<LevelCompleteScreen
levelReport={makeLevelReport({ passed: true, hasNextLevel: false })}
levelRun={makeLevelRun()}
action={{ type: 'prompt' }}
onSelect={vi.fn()}
/>,
);
expect(lastFrame()!).toContain('Enter epic mode');
});
test('shows failed menu with Try again', () => {
const { lastFrame } = render(
<LevelCompleteScreen
levelReport={makeLevelReport({ passed: false })}
levelRun={makeLevelRun()}
action={{ type: 'prompt' }}
onSelect={vi.fn()}
/>,
);
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(
<LevelCompleteScreen
levelReport={makeLevelReport({ passed: false, hasClue: true, isShowingClue: false })}
levelRun={makeLevelRun()}
action={{ type: 'prompt' }}
onSelect={vi.fn()}
/>,
);
expect(lastFrame()!).toContain('Reveal clues');
});
test('hides Reveal clues when already showing', () => {
const { lastFrame } = render(
<LevelCompleteScreen
levelReport={makeLevelReport({ passed: false, hasClue: true, isShowingClue: true })}
levelRun={makeLevelRun()}
action={{ type: 'prompt' }}
onSelect={vi.fn()}
/>,
);
expect(lastFrame()!).not.toContain('Reveal clues');
});
test('renders next-level action message', () => {
const { lastFrame } = render(
<LevelCompleteScreen
levelReport={makeLevelReport()}
levelRun={makeLevelRun()}
action={{ type: 'next-level', readmePath: 'path/to/README' }}
onSelect={vi.fn()}
/>,
);
const output = lastFrame()!;
expect(output).toContain('path/to/README');
expect(output).toContain('instructions');
});
test('renders clue action message', () => {
const { lastFrame } = render(
<LevelCompleteScreen
levelReport={makeLevelReport()}
levelRun={makeLevelRun()}
action={{ type: 'clue', readmePath: 'path/to/README' }}
onSelect={vi.fn()}
/>,
);
const output = lastFrame()!;
expect(output).toContain('path/to/README');
expect(output).toContain('clues');
});
test('renders stay action message', () => {
const { lastFrame } = render(
<LevelCompleteScreen
levelReport={makeLevelReport()}
levelRun={makeLevelRun()}
action={{ type: 'stay' }}
onSelect={vi.fn()}
/>,
);
expect(lastFrame()!).toContain('stayed on the current level');
});
test('renders epic-mode action message', () => {
const { lastFrame } = render(
<LevelCompleteScreen
levelReport={makeLevelReport()}
levelRun={makeLevelRun()}
action={{ type: 'epic-mode' }}
onSelect={vi.fn()}
/>,
);
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(
<LevelCompleteScreen
levelReport={makeLevelReport({ passed: true, hasNextLevel: true })}
levelRun={makeLevelRun()}
action={{ type: 'prompt' }}
onSelect={onSelect}
/>,
);
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 (
<PlayLayout
turns={levelRun.turns}
warriorName={levelRun.warriorName}
towerName={levelRun.towerName}
levelNumber={levelRun.levelNumber}
totalScore={levelRun.totalScore}
>
<ResultScreen {...levelReport} />
<Divider />
{action.type === 'prompt' && (
<SelectPrompt message="" items={menuItems} onSelect={onSelect} />
)}
{action.type === 'next-level' && (
<Text bold>
{'See '}
<Link url={`file://${path.resolve(action.readmePath)}`} fallback={false}>
{action.readmePath}
</Link>
{' for instructions.'}
</Text>
)}
{action.type === 'clue' && (
<Text bold>
{'See '}
<Link url={`file://${path.resolve(action.readmePath)}`} fallback={false}>
{action.readmePath}
</Link>
{' for the clues.'}
</Text>
)}
{action.type === 'stay' && (
<Text bold>You stayed on the current level. Aim for more points next time.</Text>
)}
{action.type === 'epic-mode' && <Text bold>Run warriorjs again to play epic mode.</Text>}
</PlayLayout>
);
}
================================================
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(<LogArea turns={turns} currentTurn={2} />);
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(<LogArea turns={turns} currentTurn={2} maxLines={3} />);
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(<LogArea turns={turns} currentTurn={0} />);
const output = lastFrame()!;
expect(output).not.toContain('Turn');
});
test('shows only turns up to currentTurn', () => {
const { lastFrame } = render(<LogArea turns={turns} currentTurn={1} />);
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<string, string> {
const map = new Map<string, string>();
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<string, string>): 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<string, string>,
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
<Text key={i} color={unitColor}>
{part}
</Text>
);
}
if (STAT_RE.test(part)) {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: split parts are positional
<Text key={i} bold>
{part}
</Text>
);
}
// biome-ignore lint/suspicious/noArrayIndexKey: split parts are positional
return <Text key={i}>{part}</Text>;
});
}
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 (
<Box flexDirection="column">
{visibleLines.map((line, index) => {
if (line.type === 'turn') {
return (
<Text key={`t${line.turnNumber}`} dimColor>
{`Turn ${line.turnNumber}`}
</Text>
);
}
const event = line.event!;
const text = event.unit ? `${event.unit.name} ${event.message}` : event.message;
return (
// biome-ignore lint/suspicious/noArrayIndexKey: unique with turnNumber
<Box key={`e${line.turnNumber}-${index}`} gap={1}>
<Text dimColor>{'>'}</Text>
<Text>{colorizeMessage(text, unitColors, colorizeRegex)}</Text>
</Box>
);
})}
</Box>
);
}
================================================
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> = {}): 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(
<PlayLayout
turns={turns}
warriorName="Olric"
towerName="The Narrow Path"
levelNumber={3}
totalScore={42}
>
<Text>child content</Text>
</PlayLayout>,
);
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(
<PlayLayout
turns={[]}
warriorName="Olric"
towerName="The Narrow Path"
levelNumber={1}
totalScore={0}
>
<Text>content</Text>
</PlayLayout>,
);
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 (
<Box flexDirection="column" width="100%">
<Header
warriorName={warriorName}
towerName={towerName}
levelNumber={levelNumber}
score={totalScore}
/>
<Box flexDirection="column">{lastEvent && <FloorMap floorMap={lastEvent.floorMap} />}</Box>
<Divider />
{children}
</Box>
);
}
================================================
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(
<PlayScreen
turns={turns}
initialState={initialState}
warriorName="Olric"
towerName="The Narrow Path"
levelNumber={1}
totalScore={0}
maxHealth={20}
onPlaybackComplete={vi.fn()}
/>,
);
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 (
<Box flexDirection="column" width="100%">
<Header
warriorName={warriorName}
towerName={towerName}
levelNumber={levelNumber}
score={totalScore}
/>
<Box flexDirection="column">
{lastEvent && (
<>
<FloorMap floorMap={lastEvent.floorMap} />
<WarriorStatus
health={lastEvent.warriorStatus.health}
maxHealth={maxHealth}
score={lastEvent.warriorStatus.score}
/>
</>
)}
</Box>
<Divider />
<Box flexDirection="column" height={10}>
<LogArea turns={turnsWithInitial} currentTurn={state.currentTurn} />
</Box>
<Divider />
<Scrubber
currentTurn={state.currentTurn}
totalTurns={turnsWithInitial.length}
speed={state.speed}
mode={state.mode}
isPlaying={state.isPlaying}
/>
</Box>
);
}
================================================
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<string, number>,
} 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(
<PlaySession context={mockContext} profile={mockProfile} initialLevel={1} />,
);
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(
<PlaySession context={mockContext} profile={mockProfile} initialLevel={1} />,
);
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(
<PlaySession context={mockContext} profile={mockProfile} initialLevel={1} />,
);
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(
<PlaySession context={mockContext} profile={mockProfile} initialLevel={1} />,
);
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(
<PlaySession context={mockContext} profile={mockProfile} initialLevel={1} />,
);
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(
<PlaySession context={mockContext} profile={mockProfile} initialLevel={1} />,
);
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 (
<PlayScreen
{...state.levelRun}
reviewMode={state.reviewMode}
onPlaybackComplete={handlePlayComplete}
/>
);
case 'levelComplete':
return (
<LevelCompleteScreen
levelReport={state.levelReport}
levelRun={state.levelRun}
action={state.action}
onSelect={handleLevelCompleteChoice}
/>
);
case 'towerComplete':
return (
<TowerCompleteScreen
averageGrade={profile.calculateAverageGrade() ?? 0}
levelGrades={profile.currentEpicGrades as Record<string, number>}
/>
);
case 'error':
return <ErrorMessage message={state.message} />;
}
}
================================================
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> = {}): 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<typeof vi.fn<(profile: Profile) => void>>;
let onCancel: ReturnType<typeof vi.fn<() => void>>;
let onError: ReturnType<typeof vi.fn<(message: string) => 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="choose-profile"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="choose-profile"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="choose-profile"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
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(
<ProfileWizard
context={context}
suggestedName=""
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
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(
<ProfileWizard
context={context}
suggestedName=""
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="choose-profile"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName=""
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName=""
initialStep="new"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
// 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(
<ProfileWizard
context={context}
suggestedName="Hero"
initialStep="choose-profile"
onComplete={onComplete}
onCancel={onCancel}
onError={onError}
/>,
);
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<WizardStep>(
initialStep === 'choose-profile'
? { type: 'choose-profile' }
: { type: 'create-name', from: 'new' },
);
const [history, setHistory] = useState<React.ReactElement[]>([]);
const pushHistory = (question: string, answer: string) => {
setHistory((prev) => [
...prev,
<Text key={prev.length}>
<Text bold>{question}</Text> {answer}
</Text>,
]);
};
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 (
<SelectPrompt
key="choose-profile"
message="Your sword awaits. Which warrior answers the call?"
items={items}
onSelect={(value) => {
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 (
<TextPrompt
key="create-name"
message="Every legend needs a name. Enter one for your warrior:"
defaultValue={suggestedName}
initialValue={step.initialValue}
onSubmit={(name) => {
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 (
<ErrorMessage
key="create-name-error"
message="A warrior without a name is just a shadow. Try again."
onDismiss={() => setStep({ type: 'create-name' })}
/>
);
case 'create-language': {
const items: { label: string; value: 'typescript' | 'javascript' }[] = [
{ label: 'TypeScript (recommended)', value: 'typescript' },
{ label: 'JavaScript', value: 'javascript' },
];
return (
<SelectPrompt
key="create-language"
message="Choose your language:"
items={items}
initialIndex={step.initialIndex}
onSelect={(value) => {
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 (
<SelectPrompt
key="create-tower"
message="Choose a tower:"
items={items}
initialIndex={step.initialIndex}
onSelect={(tower) => {
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 (
<Box flexDirection="column">
<Text>{`A warrior named ${step.warriorName} is already climbing ${step.tower}.`}</Text>
<ConfirmPrompt
message="Do you want to replace your existing profile for this tower?"
onConfirm={(yes) => {
if (!yes) {
onError('Unable to continue without a profile.');
return;
}
const profile = context.onCreateProfile(
step.warriorName,
step.language,
step.tower,
);
onComplete(profile);
}}
/>
</Box>
);
}
};
return (
<Box flexDirection="column">
{history}
{renderStep()}
</Box>
);
}
================================================
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(
<ResultScreen
passed={true}
levelNumber={3}
hasNextLevel={true}
scoreParts={{ warrior: 10, timeBonus: 5, clearBonus: 3 }}
totalScore={18}
grade={0.9}
isEpic={false}
previousScore={100}
/>,
);
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(
<ResultScreen
passed={false}
levelNumber={3}
hasNextLevel={true}
scoreParts={{ warrior: 0, timeBonus: 0, clearBonus: 0 }}
totalScore={0}
grade={0}
isEpic={false}
previousScore={100}
/>,
);
const output = lastFrame()!;
expect(output).toContain('failed');
});
test('renders congratulations when tower complete', () => {
const { lastFrame } = render(
<ResultScreen
passed={true}
levelNumber={5}
hasNextLevel={false}
scoreParts={{ warrior: 20, timeBonus: 10, clearBonus: 5 }}
totalScore={35}
grade={1.0}
isEpic={false}
previousScore={200}
/>,
);
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 (
<Text
color={'red'}
>{`You failed level ${levelNumber}. Update your code and try again.`}</Text>
);
}
const successMessage = hasNextLevel
? 'Success! You have found the stairs.'
: 'CONGRATULATIONS! You have climbed to the top of the tower.';
return (
<Box flexDirection="column">
<Text color={'green'}>{successMessage}</Text>
<Box flexDirection="column" marginTop={1}>
<Text>
{'Warrior Score: '}
<Text color={'yellow'}>{scoreParts.warrior}</Text>
</Text>
<Text>
{'Time Bonus: '}
<Text color={'yellow'}>{scoreParts.timeBonus}</Text>
</Text>
<Text>
{'Clear Bonus: '}
<Text color={'yellow'}>{scoreParts.clearBonus}</Text>
</Text>
{isEpic && (
<Text>
{'Level Grade: '}
<Text color={'yellow'}>{getGradeLetter(grade)}</Text>
</Text>
)}
<Text>
{'Total Score: '}
<Text
color={'yellow'}
>{`${previousScore} + ${totalScore} = ${previousScore + totalScore}`}</Text>
</Text>
</Box>
</Box>
);
}
================================================
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(
<Scrubber currentTurn={4} totalTurns={15} speed={1} mode="playback" isPlaying={true} />,
);
const output = lastFrame()!;
expect(output).toContain('Turn 4/14');
expect(output).toContain('[space] pause');
});
test('shows play hint when paused', () => {
const { lastFrame } = render(
<Scrubber currentTurn={4} totalTurns={15} speed={2} mode="playback" isPlaying={false} />,
);
const output = lastFrame()!;
expect(output).toContain('[space] play');
});
test('highlights active speed', () => {
const { lastFrame } = render(
<Scrubber currentTurn={0} totalTurns={10} speed={2} mode="playback" isPlaying={true} />,
);
const output = lastFrame()!;
expect(output).toContain('2x');
});
test('shows review mode controls', () => {
const { lastFrame } = render(
<Scrubber currentTurn={14} totalTurns={15} speed={1} mode="review" isPlaying={false} />,
);
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 (
<Box gap={2}>
<Text dimColor>{turnDisplay}</Text>
<Box gap={1}>
<Text dimColor>{'[tab]'}</Text>
{[1, 2, 4].map((s) => (
<Text key={s} dimColor={s !== speed} bold={s === speed}>
{`${s}x`}
</Text>
))}
</Box>
<Text dimColor>{isPlaying ? '[space] pause' : '[space] play'}</Text>
<Text dimColor>{'[s] skip'}</Text>
</Box>
);
}
return (
<Box gap={2}>
<Text dimColor>{turnDisplay}</Text>
<Text dimColor>{'[←/→] step'}</Text>
<Text dimColor>{'[esc] go back'}</Text>
</Box>
);
}
================================================
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(
<SelectPrompt message="Pick one:" items={items} onSelect={vi.fn()} />,
);
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(<SelectPrompt message="Pick:" items={items} onSelect={vi.fn()} />);
const output = lastFrame()!;
expect(output).toContain('❯');
});
test('calls onSelect with value on enter', () => {
const onSelect = vi.fn();
const { stdin } = render(<SelectPrompt message="Pick:" items={items} onSelect={onSelect} />);
stdin.write('\r');
expect(onSelect).toHaveBeenCalledWith('a');
});
test('navigates down and selects second item', async () => {
const onSelect = vi.fn();
const { stdin } = render(<SelectPrompt message="Pick:" items={items} onSelect={onSelect} />);
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(<SelectPrompt message="Pick:" items={items} onSelect={onSelect} />);
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(
<SelectPrompt message="Pick:" items={items} onSelect={vi.fn()} />,
);
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(
<SelectPrompt message="" items={items} onSelect={vi.fn()} />,
);
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(
<SelectPrompt message="Pick:" items={items} onSelect={vi.fn()} onCancel={onCancel} />,
);
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(
<SelectPrompt message="Pick:" items={itemsWithSep} onSelect={vi.fn()} />,
);
const output = lastFrame()!;
expect(output).toContain('───');
});
test('respects initialIndex', () => {
const onSelect = vi.fn();
const { stdin } = render(
<SelectPrompt message="Pick:" items={items} initialIndex={2} onSelect={onSelect} />,
);
stdin.write('\r');
expect(onSelect).toHaveBeenCalledWith('c');
});
test('ignores input after submission', async () => {
const onSelect = vi.fn();
const { stdin } = render(<SelectPrompt message="Pick:" items={items} onSelect={onSelect} />);
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<T> {
label: string;
value: T;
separator?: false;
}
interface SelectSeparator {
separator: true;
}
type SelectItem<T> = SelectChoice<T> | SelectSeparator;
interface SelectPromptProps<T> {
message: string;
items: SelectItem<T>[];
initialIndex?: number;
onSelect: (value: T) => void;
onCancel?: () => void;
}
export default function SelectPrompt<T>({
message,
items,
initialIndex = 0,
onSelect,
onCancel,
}: SelectPromptProps<T>): React.ReactElement {
const choices = items.filter((item): item is SelectChoice<T> => !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 ? (
<Text>
<Text bold>{message}</Text> {answer}
</Text>
) : (
<Text>{answer}</Text>
);
}
let choiceIndex = 0;
return (
<Box flexDirection="column">
{message ? <Text bold>{message}</Text> : null}
{items.map((item, index) => {
if (item.separator) {
return (
// biome-ignore lint/suspicious/noArrayIndexKey: static item list
<Text key={`sep-${index}`} dimColor>
{'───'}
</Text>
);
}
const currentChoiceIndex = choiceIndex++;
const isSelected = currentChoiceIndex === selectedIndex;
return (
// biome-ignore lint/suspicious/noArrayIndexKey: static item list
<Box key={`choice-${index}`} gap={1}>
<Text color={isSelected ? 'yellow' : undefined}>{isSelected ? '❯' : ' '}</Text>
<Text dimColor={!isSelected}>{item.label}</Text>
</Box>
);
})}
</Box>
);
}
================================================
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(<TextPrompt message="Enter name:" onSubmit={vi.fn()} />);
expect(lastFrame()!).toContain('Enter name:');
});
test('shows default value as hint', () => {
const { lastFrame } = render(
<TextPrompt message="Name:" defaultValue="Olric" onSubmit={vi.fn()} />,
);
expect(lastFrame()!).toContain('(Olric)');
});
test('submits typed value on enter', async () => {
const onSubmit = vi.fn();
const { stdin } = render(<TextPrompt message="Name:" onSubmit={onSubmit} />);
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(
<TextPrompt message="Name:" defaultValue="Olric" onSubmit={onSubmit} />,
);
stdin.write('\r');
expect(onSubmit).toHaveBeenCalledWith('Olric');
});
test('backspace removes last character', async () => {
const onSubmit = vi.fn();
const { stdin } = render(<TextPrompt message="Name:" onSubmit={onSubmit} />);
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(<TextPrompt message="Name:" onSubmit={vi.fn()} />);
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(<TextPrompt message="Name:" onSubmit={vi.fn()} onCancel={onCancel} />);
stdin.write('\x1B');
await waitForRender();
expect(onCancel).toHaveBeenCalled();
});
test('ignores input after submission', async () => {
const onSubmit = vi.fn();
const { stdin } = render(<TextPrompt message="Name:" onSubmit={onSubmit} />);
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(<TextPrompt message="Name:" initialValue="pre" onSubmit={onSubmit} />);
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 (
<Text>
<Text bold>{message}</Text> {display}
</Text>
);
}
return (
<Box gap={1}>
<Text bold>{message}</Text>
{value ? (
<Text>{value}</Text>
) : defaultValue ? (
<Text dimColor>{`(${defaultValue})`}</Text>
) : null}
</Box>
);
}
================================================
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(
<TowerCompleteScreen averageGrade={0.95} levelGrades={{ '1': 1.0, '2': 0.9 }} />,
);
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<string, number>;
}
export default function TowerCompleteScreen({
averageGrade,
levelGrades,
}: TowerCompleteScreenProps): React.ReactElement {
return (
<Box flexDirection="column">
<Text>
{'Your average grade for this tower is: '}
<Text color={'yellow'}>{getGradeLetter(averageGrade)}</Text>
</Text>
<Box flexDirection="column" marginTop={1}>
{Object.keys(levelGrades)
.sort()
.map((levelNumber) => (
<Text key={levelNumber}>
{' Level '}
{levelNumber}
{': '}
<Text color={'yellow'}>{getGradeLetter(levelGrades[levelNumber]!)}</Text>
</Text>
))}
</Box>
<Text dimColor>{'\nTo practice a level, use the -l option.'}</Text>
</Box>
);
}
================================================
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(<WarriorArt />);
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 <Text color={brandColor}>{art}</Text>;
}
================================================
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(<WarriorStatus health={12} maxHealth={20} score={25} />);
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 (
<Box gap={2}>
<Text color="red">{`❤ ${health}/${maxHealth}`}</Text>
<Text color="yellow">{`◆ ${score}`}</Text>
</Box>
);
}
================================================
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(
<WelcomeScreen version={versionString} directory="~/Projects/warriorjs" />,
);
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 (
<Box gap={3}>
<WarriorArt />
<Box flexDirection="column" marginTop={1}>
<Box gap={1}>
<Text bold>WarriorJS</Text>
<Text dimColor>{version}</Text>
</Box>
<Text dimColor>Write code. Fight enemies. Climb the tower.</Text>
<Text dimColor>{formatDirectory(directory)}</Text>
</Box>
</Box>
);
}
================================================
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<Record<string, unknown>> = {}): 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> = {}): 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<HookRef>();
function TestComponent() {
const hook = usePlaySession(params);
(ref as React.MutableRefObject<HookRef>).current = hook;
return null;
}
const instance = render(React.createElement(TestComponent));
return { ref: ref as React.RefObject<HookRef>, ...instance };
}
// --- Tests ---
describe('usePlaySession', () => {
let exit: ReturnType<typeof vi.fn<() => void>>;
let context: GameContext;
let mockProfile: Profile;
beforeEach(() => {
vi.clearAllMocks();
exit = vi.fn();
context = createMockContext();
mockProfile = createMockProfile();
mockedGetLevelConfig.mockReturnValue(defaultLevelConfig as ReturnType<typeof getLevelConfig>);
mockedRunLevel.mockReturnValue(defaultLevelResult as ReturnType<typeof runLevel>);
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,
});
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
SYMBOL INDEX (452 symbols across 112 files)
FILE: apps/cli/src/Game.ts
type GameContext (line 18) | interface GameContext {
class Game (line 40) | class Game {
method constructor (line 48) | constructor(runDirectoryPath: string, practiceLevel: number | undefine...
method buildContext (line 55) | buildContext(): GameContext {
method createProfile (line 102) | createProfile(warriorName: string, language: 'javascript' | 'typescrip...
method isExistingProfile (line 110) | isExistingProfile(profile: Profile): boolean {
method getProfiles (line 117) | getProfiles(): Profile[] {
method getProfileDirectoriesPaths (line 124) | getProfileDirectoriesPaths(): string[] {
method ensureGameDirectory (line 130) | ensureGameDirectory(): void {
method prepareNextLevel (line 146) | prepareNextLevel(): void {
method generateProfileFiles (line 151) | generateProfileFiles(): void {
method prepareEpicMode (line 157) | prepareEpicMode(): void {
FILE: apps/cli/src/GameError.ts
class GameError (line 1) | class GameError extends Error {
method constructor (line 2) | constructor(message: string) {
FILE: apps/cli/src/Profile.ts
class Profile (line 13) | class Profile {
method load (line 28) | static load(profileDirectoryPath: string, towers: Tower[]): Profile | ...
method isProfileDirectory (line 60) | static isProfileDirectory(profileDirectoryPath: string): boolean {
method read (line 79) | static read(profileFilePath: string): string | null {
method decode (line 91) | static decode(encodedProfile: string): Record<string, unknown> {
method constructor (line 105) | constructor(
method makeProfileDirectory (line 125) | makeProfileDirectory(): void {
method readPlayerCode (line 129) | readPlayerCode(): string | null {
method getPlayerCodeFilePath (line 141) | getPlayerCodeFilePath(): string {
method getReadmeFilePath (line 146) | getReadmeFilePath(): string {
method goToNextLevel (line 150) | goToNextLevel(): void {
method requestClue (line 156) | requestClue(): void {
method isShowingClue (line 161) | isShowingClue(): boolean {
method enableEpicMode (line 165) | enableEpicMode(): void {
method isEpic (line 170) | isEpic(): boolean {
method tallyPoints (line 174) | tallyPoints(levelNumber: number, totalScore: number, grade?: number): ...
method getEpicScoreWithGrade (line 183) | getEpicScoreWithGrade(): string {
method updateEpicScore (line 191) | updateEpicScore(): void {
method calculateAverageGrade (line 200) | calculateAverageGrade(): number | null {
method save (line 209) | save(): void {
method getProfileFilePath (line 213) | getProfileFilePath(): string {
method encode (line 217) | encode(): string {
method toJSON (line 221) | toJSON(): Record<string, unknown> {
method toString (line 235) | toString(): string {
FILE: apps/cli/src/ProfileGenerator.ts
class ProfileGenerator (line 10) | class ProfileGenerator {
method constructor (line 14) | constructor(profile: Profile, levelConfig: LevelConfig) {
method generate (line 19) | generate(): void {
method generateReadmeFile (line 29) | generateReadmeFile(): void {
method generatePlayerCodeFile (line 34) | generatePlayerCodeFile(): void {
method generateTypesFile (line 39) | generateTypesFile(): void {
FILE: apps/cli/src/Tower.ts
class Tower (line 3) | class Tower {
method constructor (line 10) | constructor(
method hasLevel (line 24) | hasLevel(levelNumber: number): boolean {
method getLevel (line 28) | getLevel(levelNumber: number): LevelDefinition | undefined {
method toString (line 32) | toString(): string {
FILE: apps/cli/src/cli.ts
function run (line 13) | async function run(args: string[]): Promise<void> {
FILE: apps/cli/src/loadTowers.ts
type TowerInfo (line 16) | interface TowerInfo {
function getInternalTowersInfo (line 21) | function getInternalTowersInfo(): TowerInfo[] {
function getExternalTowersInfo (line 28) | function getExternalTowersInfo(): TowerInfo[] {
function loadTowers (line 58) | function loadTowers(): Tower[] {
FILE: apps/cli/src/parseArgs.ts
type ParsedArgs (line 3) | interface ParsedArgs {
function parseArgs (line 13) | function parseArgs(args: string[]): ParsedArgs {
FILE: apps/cli/src/ui/components/App.tsx
type AppProps (line 9) | interface AppProps {
function App (line 13) | function App({ context }: AppProps): React.ReactElement {
FILE: apps/cli/src/ui/components/ConfirmPrompt.tsx
type ConfirmPromptProps (line 5) | interface ConfirmPromptProps {
function ConfirmPrompt (line 11) | function ConfirmPrompt({
FILE: apps/cli/src/ui/components/Divider.tsx
function Divider (line 4) | function Divider(): React.ReactElement {
FILE: apps/cli/src/ui/components/ErrorMessage.tsx
type ErrorMessageProps (line 5) | interface ErrorMessageProps {
function ErrorMessage (line 10) | function ErrorMessage({
FILE: apps/cli/src/ui/components/FloorMap.tsx
type FloorSpace (line 4) | interface FloorSpace {
type FloorMapProps (line 9) | interface FloorMapProps {
constant STAIR_CHAR (line 13) | const STAIR_CHAR = '>';
function getSpaceColor (line 15) | function getSpaceColor(space: FloorSpace): string | undefined {
function FloorMap (line 22) | function FloorMap({ floorMap }: FloorMapProps): React.ReactElement {
FILE: apps/cli/src/ui/components/GameMenu.test.tsx
function createMockTower (line 14) | function createMockTower(name: string, overrides: Partial<Tower> = {}): ...
function createMockProfile (line 27) | function createMockProfile(name: string, overrides: Partial<Profile> = {...
function createMockContext (line 44) | function createMockContext(overrides: Partial<GameContext> = {}): GameCo...
FILE: apps/cli/src/ui/components/GameMenu.tsx
type GameMenuProps (line 17) | interface GameMenuProps {
function GameMenu (line 22) | function GameMenu({ context, onStart }: GameMenuProps): React.ReactEleme...
FILE: apps/cli/src/ui/components/Header.tsx
type HeaderProps (line 4) | interface HeaderProps {
function Header (line 11) | function Header({
FILE: apps/cli/src/ui/components/LevelCompleteScreen.tsx
function buildMenuItems (line 18) | function buildMenuItems(levelReport: LevelReport): { label: string; valu...
type LevelCompleteScreenProps (line 38) | interface LevelCompleteScreenProps {
function LevelCompleteScreen (line 45) | function LevelCompleteScreen({
FILE: apps/cli/src/ui/components/LogArea.tsx
type LogAreaProps (line 7) | interface LogAreaProps {
type LogLine (line 13) | interface LogLine {
function buildUnitColorMap (line 19) | function buildUnitColorMap(turns: TurnEvent[][]): Map<string, string> {
constant STAT_RE (line 31) | const STAT_RE = /\d+ damage|\d+ HP/;
function buildColorizeRegex (line 33) | function buildColorizeRegex(unitColors: Map<string, string>): RegExp {
function colorizeMessage (line 41) | function colorizeMessage(
function LogArea (line 71) | function LogArea({
FILE: apps/cli/src/ui/components/PlayLayout.test.tsx
function makeEvent (line 8) | function makeEvent(overrides: Partial<TurnEvent> = {}): TurnEvent {
FILE: apps/cli/src/ui/components/PlayLayout.tsx
type PlayLayoutProps (line 9) | interface PlayLayoutProps {
function PlayLayout (line 18) | function PlayLayout({
FILE: apps/cli/src/ui/components/PlayScreen.tsx
type PlayScreenProps (line 14) | interface PlayScreenProps {
function PlayScreen (line 26) | function PlayScreen({
FILE: apps/cli/src/ui/components/PlaySession.tsx
type PlaySessionProps (line 13) | interface PlaySessionProps {
function PlaySession (line 19) | function PlaySession({
FILE: apps/cli/src/ui/components/ProfileWizard.test.tsx
function createMockTower (line 10) | function createMockTower(name: string): Tower {
function createMockProfile (line 16) | function createMockProfile(name: string): Profile {
function createMockContext (line 23) | function createMockContext(overrides: Partial<GameContext> = {}): GameCo...
FILE: apps/cli/src/ui/components/ProfileWizard.tsx
type WizardStep (line 13) | type WizardStep =
type ProfileWizardProps (line 31) | interface ProfileWizardProps {
function ProfileWizard (line 40) | function ProfileWizard({
FILE: apps/cli/src/ui/components/ResultScreen.tsx
type ResultScreenProps (line 5) | interface ResultScreenProps {
function ResultScreen (line 16) | function ResultScreen({
FILE: apps/cli/src/ui/components/Scrubber.tsx
type ScrubberProps (line 4) | interface ScrubberProps {
function Scrubber (line 12) | function Scrubber({
FILE: apps/cli/src/ui/components/SelectPrompt.tsx
type SelectChoice (line 5) | interface SelectChoice<T> {
type SelectSeparator (line 11) | interface SelectSeparator {
type SelectItem (line 15) | type SelectItem<T> = SelectChoice<T> | SelectSeparator;
type SelectPromptProps (line 17) | interface SelectPromptProps<T> {
function SelectPrompt (line 25) | function SelectPrompt<T>({
FILE: apps/cli/src/ui/components/TextPrompt.tsx
type TextPromptProps (line 5) | interface TextPromptProps {
function TextPrompt (line 13) | function TextPrompt({
FILE: apps/cli/src/ui/components/TowerCompleteScreen.tsx
type TowerCompleteScreenProps (line 5) | interface TowerCompleteScreenProps {
function TowerCompleteScreen (line 10) | function TowerCompleteScreen({
FILE: apps/cli/src/ui/components/WarriorArt.tsx
function WarriorArt (line 14) | function WarriorArt(): React.ReactElement {
FILE: apps/cli/src/ui/components/WarriorStatus.tsx
type WarriorStatusProps (line 4) | interface WarriorStatusProps {
function WarriorStatus (line 10) | function WarriorStatus({
FILE: apps/cli/src/ui/components/WelcomeScreen.tsx
type WelcomeScreenProps (line 7) | interface WelcomeScreenProps {
function WelcomeScreen (line 12) | function WelcomeScreen({
FILE: apps/cli/src/ui/hooks/usePlaySession.test.ts
function createMockProfile (line 35) | function createMockProfile(overrides: Partial<Record<string, unknown>> =...
function createMockContext (line 62) | function createMockContext(overrides: Partial<GameContext> = {}): GameCo...
type HookRef (line 122) | interface HookRef {
function renderHook (line 128) | function renderHook(params: {
function setupWithResultScreen (line 692) | function setupWithResultScreen() {
FILE: apps/cli/src/ui/hooks/usePlaySession.ts
type UsePlaySessionParams (line 17) | interface UsePlaySessionParams {
type UsePlaySessionReturn (line 24) | interface UsePlaySessionReturn {
type LevelOutcome (line 30) | interface LevelOutcome {
function executeLevel (line 36) | function executeLevel(profile: Profile, levelNumber: number, context: Ga...
function evaluateLevel (line 87) | function evaluateLevel(
function usePlaySession (line 141) | function usePlaySession({
FILE: apps/cli/src/ui/hooks/usePlayback.ts
type PlaybackState (line 4) | interface PlaybackState {
type PlaybackAction (line 12) | type PlaybackAction =
constant SPEEDS (line 22) | const SPEEDS = [1, 2, 4];
function playbackReducer (line 24) | function playbackReducer(state: PlaybackState, action: PlaybackAction): ...
function usePlayback (line 80) | function usePlayback(
FILE: apps/cli/src/ui/testing.ts
function getLastContentFrame (line 6) | function getLastContentFrame(frames: string[]): string {
function makeLevelRun (line 13) | function makeLevelRun(overrides: Partial<LevelRun> = {}): LevelRun {
function makeLevelReport (line 40) | function makeLevelReport(overrides: Partial<LevelReport> = {}): LevelRep...
FILE: apps/cli/src/ui/types.ts
type LevelConfig (line 7) | interface LevelConfig {
type TurnEvent (line 14) | interface TurnEvent {
type LevelResult (line 21) | interface LevelResult {
type LevelRun (line 27) | interface LevelRun {
type LevelReport (line 37) | interface LevelReport {
type LevelEvaluation (line 50) | interface LevelEvaluation {
type LevelCompleteChoice (line 58) | type LevelCompleteChoice =
type LevelCompleteAction (line 66) | type LevelCompleteAction =
type PlaySessionState (line 73) | type PlaySessionState =
type GameMenuStep (line 84) | type GameMenuStep =
FILE: apps/cli/src/ui/utils/buildLevelReport.test.ts
function makeEvent (line 6) | function makeEvent(score: number, floorMap: { unit?: unknown }[][] = [[]...
FILE: apps/cli/src/ui/utils/buildLevelReport.ts
type BuildLevelReportParams (line 5) | interface BuildLevelReportParams {
function buildLevelReport (line 15) | function buildLevelReport({
FILE: apps/cli/src/utils/formatDirectory.ts
function formatDirectory (line 4) | function formatDirectory(directory: string): string {
FILE: apps/cli/src/utils/getFloorMap.ts
type FloorSpace (line 1) | interface FloorSpace {
function getFloorMap (line 6) | function getFloorMap(map: FloorSpace[][]): string {
FILE: apps/cli/src/utils/getFloorMapKey.ts
type FloorSpace (line 1) | interface FloorSpace {
function getFloorMapKey (line 9) | function getFloorMapKey(map: FloorSpace[][]): string {
FILE: apps/cli/src/utils/getTowerId.ts
function getTowerId (line 3) | function getTowerId(towerPackageName: string): string {
FILE: apps/cli/src/utils/getWarriorNameSuggestions.ts
function getWarriorNameSuggestions (line 56) | function getWarriorNameSuggestions(): string[] {
FILE: apps/cli/src/utils/renderPlayerCode.ts
function renderPlayerCode (line 5) | function renderPlayerCode(profile: Profile, _levelConfig: LevelConfig): ...
FILE: apps/cli/src/utils/renderReadme.ts
type Ability (line 7) | interface Ability {
function renderHeader (line 12) | function renderHeader(profile: Profile): string {
function renderTowerDescription (line 16) | function renderTowerDescription(description: string): string {
function renderLevelInfo (line 21) | function renderLevelInfo(level: any): string {
function renderClue (line 25) | function renderClue(profile: Profile, clue: string): string {
function renderFloorMap (line 30) | function renderFloorMap(level: any): string {
function renderAbilityList (line 34) | function renderAbilityList(abilities: Ability[]): string {
function renderActions (line 38) | function renderActions(actions: Ability[]): string {
function renderSenses (line 42) | function renderSenses(senses: Ability[]): string {
function renderAbilities (line 47) | function renderAbilities(level: any): string {
function renderNextSteps (line 56) | function renderNextSteps(profile: Profile): string {
function renderReadme (line 61) | function renderReadme(profile: Profile, levelConfig: LevelConfig): string {
FILE: apps/cli/src/utils/renderTypes.test.ts
class MockWalk (line 6) | class MockWalk extends Action {
method perform (line 12) | perform() {}
class MockFeel (line 15) | class MockFeel extends Sense {
method perform (line 21) | perform() {}
class MockHealth (line 24) | class MockHealth extends Sense {
method perform (line 30) | perform() {}
function makeLevelConfig (line 35) | function makeLevelConfig(abilities: Record<string, any>): any {
class RestAction (line 128) | class RestAction extends Action {
method perform (line 134) | perform() {}
FILE: apps/cli/src/utils/renderTypes.ts
type MethodEntry (line 5) | interface MethodEntry {
function renderHeader (line 12) | function renderHeader(): string {
function renderDirectionType (line 16) | function renderDirectionType(): string {
function renderSpaceInterfaces (line 20) | function renderSpaceInterfaces(): string {
function renderMethod (line 48) | function renderMethod(method: MethodEntry): string {
function renderWarriorInterface (line 52) | function renderWarriorInterface(methods: MethodEntry[]): string {
function instantiateAbility (line 60) | function instantiateAbility(entry: AbilityEntry): Ability {
function renderTypes (line 69) | function renderTypes(_profile: Profile, levelConfig: LevelConfig): string {
FILE: apps/website/utils/getDocUrl.js
function getDocUrl (line 5) | function getDocUrl(doc, language) {
FILE: apps/website/utils/getImgUrl.js
function getImgUrl (line 5) | function getImgUrl(img) {
FILE: libs/abilities/src/Attack.ts
type AttackConfig (line 8) | interface AttackConfig {
class Attack (line 12) | class Attack extends Action {
method constructor (line 21) | constructor(unit: Unit, { power }: AttackConfig) {
method perform (line 27) | perform(direction: RelativeDirection = defaultDirection): void {
method with (line 39) | static with(config: AttackConfig): AbilityBinding {
FILE: libs/abilities/src/Bind.ts
class Bind (line 8) | class Bind extends Action {
method perform (line 16) | perform(direction: RelativeDirection = defaultDirection): void {
FILE: libs/abilities/src/Detonate.ts
type DetonateConfig (line 14) | interface DetonateConfig {
class Detonate (line 19) | class Detonate extends Action {
method constructor (line 29) | constructor(unit: Unit, { targetPower, surroundingPower }: DetonateCon...
method perform (line 36) | perform(direction: RelativeDirection = defaultDirection): void {
method bomb (line 47) | private bomb(space: Space, power: number): void {
method with (line 58) | static with(config: DetonateConfig): AbilityBinding {
FILE: libs/abilities/src/DirectionOf.ts
class DirectionOf (line 6) | class DirectionOf extends Sense {
method perform (line 14) | perform(space: SensedSpace) {
FILE: libs/abilities/src/DirectionOfStairs.ts
class DirectionOfStairs (line 6) | class DirectionOfStairs extends Sense {
method perform (line 14) | perform() {
FILE: libs/abilities/src/DistanceOf.ts
class DistanceOf (line 5) | class DistanceOf extends Sense {
method perform (line 12) | perform(space: SensedSpace) {
FILE: libs/abilities/src/Feel.ts
class Feel (line 8) | class Feel extends Sense {
method perform (line 16) | perform(direction: RelativeDirection = defaultDirection) {
FILE: libs/abilities/src/Health.ts
class Health (line 5) | class Health extends Sense {
method perform (line 12) | perform() {
FILE: libs/abilities/src/Listen.ts
class Listen (line 6) | class Listen extends Sense {
method perform (line 14) | perform() {
FILE: libs/abilities/src/Look.ts
type LookConfig (line 8) | interface LookConfig {
class Look (line 12) | class Look extends Sense {
method constructor (line 21) | constructor(unit: Unit, { range }: LookConfig) {
method perform (line 27) | perform(direction: RelativeDirection = defaultDirection) {
method with (line 34) | static with(config: LookConfig): AbilityBinding {
FILE: libs/abilities/src/MaxHealth.ts
class MaxHealth (line 5) | class MaxHealth extends Sense {
method perform (line 12) | perform() {
FILE: libs/abilities/src/Pivot.ts
class Pivot (line 8) | class Pivot extends Action {
method perform (line 15) | perform(direction: RelativeDirection = defaultDirection): void {
FILE: libs/abilities/src/Rescue.ts
class Rescue (line 8) | class Rescue extends Action {
method perform (line 16) | perform(direction: RelativeDirection = defaultDirection): void {
FILE: libs/abilities/src/Rest.ts
type RestConfig (line 5) | interface RestConfig {
class Rest (line 9) | class Rest extends Action {
method constructor (line 18) | constructor(unit: Unit, { healthGain }: RestConfig) {
method perform (line 24) | perform(): void {
method with (line 34) | static with(config: RestConfig): AbilityBinding {
FILE: libs/abilities/src/Shoot.ts
type ShootConfig (line 8) | interface ShootConfig {
class Shoot (line 13) | class Shoot extends Action {
method constructor (line 23) | constructor(unit: Unit, { power, range }: ShootConfig) {
method perform (line 30) | perform(direction: RelativeDirection = defaultDirection): void {
method with (line 43) | static with(config: ShootConfig): AbilityBinding {
FILE: libs/abilities/src/Think.ts
class Think (line 6) | class Think extends Sense {
method perform (line 13) | perform(...args: unknown[]) {
FILE: libs/abilities/src/Walk.ts
class Walk (line 8) | class Walk extends Action {
method perform (line 16) | perform(direction: RelativeDirection = defaultDirection): void {
FILE: libs/abilities/src/types.ts
type Space (line 4) | interface Space {
type Unit (line 13) | interface Unit {
type AbilityParam (line 39) | interface AbilityParam {
type AbilityMeta (line 46) | interface AbilityMeta {
FILE: libs/core/src/Ability.test.ts
class ConcreteAction (line 7) | class ConcreteAction extends Action {
class ConcreteSense (line 13) | class ConcreteSense extends Sense {
FILE: libs/core/src/Ability.ts
type AbilityParam (line 1) | interface AbilityParam {
type AbilityMeta (line 8) | interface AbilityMeta {
type AbilityClass (line 13) | interface AbilityClass {
type AbilityBinding (line 17) | type AbilityBinding = [AbilityClass, object];
type AbilityEntry (line 19) | type AbilityEntry = AbilityBinding | AbilityClass;
method constructor (line 27) | constructor(unit: any, _config?: Record<string, unknown>) {
FILE: libs/core/src/Action.test.ts
class TestAction (line 7) | class TestAction extends Action {
method with (line 12) | static with(config: { power: number }) {
FILE: libs/core/src/Effect.test.ts
class TestEffect (line 5) | class TestEffect extends Effect {
method with (line 10) | static with(config: { time: number }) {
FILE: libs/core/src/Effect.ts
type EffectClass (line 1) | interface EffectClass {
type EffectBinding (line 5) | type EffectBinding = [EffectClass, object];
type EffectEntry (line 7) | type EffectEntry = EffectBinding | EffectClass;
method constructor (line 14) | constructor(unit: any, _config?: Record<string, unknown>) {
FILE: libs/core/src/Floor.ts
class Floor (line 9) | class Floor {
method constructor (line 16) | constructor(width: number, height: number, stairsLocation: Location) {
method getMap (line 24) | getMap(): Space[][] {
method isOutOfBounds (line 36) | isOutOfBounds([x, y]: Location): boolean {
method isStairs (line 40) | isStairs([x, y]: Location): boolean {
method getStairsSpace (line 45) | getStairsSpace(): Space {
method getSpaceAt (line 49) | getSpaceAt(location: Location): Space {
method addWarrior (line 53) | addWarrior(warrior: Warrior, position: PositionConfig): void {
method addUnit (line 58) | addUnit(unit: Unit, { x, y, facing }: PositionConfig): void {
method getUnitAt (line 65) | getUnitAt(location: Location): Unit | undefined {
method getUnits (line 69) | getUnits(): Unit[] {
FILE: libs/core/src/Level.ts
class Level (line 6) | class Level {
method constructor (line 13) | constructor(number: number, description: string, tip: string, clue: st...
method play (line 21) | play(turns: number = maxTurns): {
method wasPassed (line 48) | wasPassed(): boolean {
method wasFailed (line 53) | wasFailed(): boolean {
method toJSON (line 57) | toJSON(): any {
FILE: libs/core/src/Logger.ts
type TurnEvent (line 4) | interface TurnEvent {
method play (line 25) | play(floor: Floor) {
method turn (line 37) | turn() {
method unit (line 42) | unit(unit: Unit, message: string) {
FILE: libs/core/src/Position.ts
class Position (line 19) | class Position {
method constructor (line 24) | constructor(floor: Floor, location: Location, orientation: string) {
method isAt (line 31) | isAt([x, y]: Location): boolean {
method getSpace (line 36) | getSpace(): Space {
method getRelativeSpace (line 40) | getRelativeSpace(direction: RelativeDirection, relativeOffset: Relativ...
method getDistanceOf (line 49) | getDistanceOf(space: Space): number {
method getRelativeDirectionOf (line 53) | getRelativeDirectionOf(space: Space): RelativeDirection {
method move (line 60) | move(direction: RelativeDirection, relativeOffset: RelativeOffset): vo...
method rotate (line 68) | rotate(direction: RelativeDirection): void {
FILE: libs/core/src/Sense.test.ts
class TestSense (line 7) | class TestSense extends Sense {
FILE: libs/core/src/Space.ts
type SensedSpace (line 21) | interface SensedSpace {
type SensedUnit (line 30) | interface SensedUnit {
class Space (line 36) | class Space {
method from (line 40) | static from(sensedSpace: SensedSpace, unit: Unit): Space {
method constructor (line 47) | constructor(floor: Floor, location: Location) {
method getCharacter (line 52) | getCharacter(): string {
method isEmpty (line 93) | isEmpty(): boolean {
method isStairs (line 97) | isStairs(): boolean {
method isWall (line 101) | isWall(): boolean {
method isUnit (line 105) | isUnit(): boolean {
method getUnit (line 109) | getUnit(): Unit | undefined {
method as (line 113) | as(unit: Unit): SensedSpace {
method toString (line 128) | toString(): string {
method toJSON (line 140) | toJSON(): { character: string; unit: Unit | undefined } {
FILE: libs/core/src/Unit.test.ts
class MockAction (line 9) | class MockAction extends Action {
class MockSense (line 15) | class MockSense extends Sense {
FILE: libs/core/src/Unit.ts
type Turn (line 11) | type Turn = Record<string, (...args: any[]) => any>;
type TurnState (line 13) | interface TurnState {
type UnitClass (line 18) | interface UnitClass {
class Unit (line 23) | class Unit {
method constructor (line 39) | constructor(
method getNextTurn (line 63) | getNextTurn(): TurnState {
method playTurn (line 85) | playTurn(_turn: Turn): void {}
method prepareTurn (line 87) | prepareTurn(): void {
method performTurn (line 92) | performTurn(): void {
method heal (line 102) | heal(amount: number): void {
method takeDamage (line 109) | takeDamage(amount: number): void {
method damage (line 124) | damage(receiver: Unit, amount: number): void {
method isAlive (line 135) | isAlive(): boolean {
method release (line 139) | release(receiver: Unit): void {
method unbind (line 150) | unbind(): void {
method bind (line 155) | bind(): void {
method isBound (line 159) | isBound(): boolean {
method earnPoints (line 163) | earnPoints(points: number): void {
method losePoints (line 167) | losePoints(points: number): void {
method addAbility (line 171) | addAbility(name: string, ability: Ability): void {
method addEffect (line 175) | addEffect(name: string, effect: Effect): void {
method triggerEffect (line 179) | triggerEffect(name: string): void {
method isUnderEffect (line 186) | isUnderEffect(name: string): boolean {
method getOtherUnits (line 190) | getOtherUnits(): Unit[] {
method getSpace (line 194) | getSpace(): Space {
method getSensedSpaceAt (line 198) | getSensedSpaceAt(
method getSpaceAt (line 206) | getSpaceAt(direction: RelativeDirection, forward: number = 1, right: n...
method getDirectionOfStairs (line 210) | getDirectionOfStairs(): RelativeDirection {
method getDirectionOf (line 214) | getDirectionOf(sensedSpace: SensedSpace): RelativeDirection {
method getDistanceOf (line 219) | getDistanceOf(sensedSpace: SensedSpace): number {
method move (line 224) | move(direction: RelativeDirection, forward: number = 1, right: number ...
method rotate (line 228) | rotate(direction: RelativeDirection): void {
method vanish (line 232) | vanish(): void {
method log (line 236) | log(message: string): void {
method as (line 240) | as(unit: Unit): SensedUnit {
method toString (line 248) | toString(): string {
method toJSON (line 252) | toJSON(): { name: string; color: string; maxHealth: number } {
FILE: libs/core/src/Warrior.test.ts
class MockAction (line 7) | class MockAction extends Action {
method constructor (line 10) | constructor(unit: any, description: string) {
class MockSense (line 17) | class MockSense extends Sense {
method constructor (line 20) | constructor(unit: any, description: string) {
FILE: libs/core/src/Warrior.ts
type AbilityInfo (line 4) | interface AbilityInfo {
class Warrior (line 10) | class Warrior extends Unit {
method constructor (line 11) | constructor(name?: string, character?: string, color?: string, maxHeal...
method performTurn (line 15) | performTurn(): void {
method earnPoints (line 23) | earnPoints(points: number): void {
method losePoints (line 28) | losePoints(points: number): void {
method getAbilities (line 33) | getAbilities(): {
method getStatus (line 55) | getStatus(): { health: number; score: number } {
FILE: libs/core/src/getLevel.test.ts
class TestWalk (line 11) | class TestWalk extends Action {
method perform (line 17) | perform() {}
class TestAttack (line 20) | class TestAttack extends Action {
method constructor (line 26) | constructor(unit: any, { power }: { power: number }) {
method perform (line 30) | perform() {}
method with (line 31) | static with(config: { power: number }) {
class TestFeel (line 36) | class TestFeel extends Sense {
method perform (line 43) | perform() {}
class TestSludge (line 46) | class TestSludge extends Unit {
method constructor (line 52) | constructor() {
FILE: libs/core/src/getLevel.ts
function getLevel (line 4) | function getLevel(levelConfig: LevelConfig): any {
FILE: libs/core/src/getLevelConfig.ts
function deepClone (line 3) | function deepClone<T>(obj: T): T {
function getLevelConfig (line 29) | function getLevelConfig(
FILE: libs/core/src/loadLevel.ts
function loadAbilities (line 10) | function loadAbilities(unit: Unit, abilities: Record<string, AbilityEntr...
function loadEffects (line 22) | function loadEffects(unit: Unit, effects: Record<string, EffectEntry> = ...
function loadWarrior (line 34) | function loadWarrior(
function loadUnit (line 47) | function loadUnit({ unit: UnitClass, effects, position }: UnitConfig, fl...
function loadLevel (line 58) | function loadLevel(
FILE: libs/core/src/loadPlayer.ts
function loadPlayer (line 9) | function loadPlayer(
FILE: libs/core/src/runLevel.test.ts
class TestWalk (line 11) | class TestWalk extends Action {
method perform (line 17) | perform(direction = FORWARD) {
class TestAttack (line 28) | class TestAttack extends Action {
method constructor (line 35) | constructor(unit: any, { power }: { power: number }) {
method perform (line 40) | perform(direction = FORWARD) {
method with (line 50) | static with(config: { power: number }) {
class TestFeel (line 55) | class TestFeel extends Sense {
method perform (line 61) | perform(direction = FORWARD) {
class TestSludge (line 66) | class TestSludge extends Unit {
method constructor (line 72) | constructor() {
FILE: libs/core/src/runLevel.ts
function runLevel (line 5) | function runLevel(
FILE: libs/core/src/types.ts
type Size (line 8) | type Size = { width: number; height: number };
type LocationConfig (line 11) | type LocationConfig = { x: number; y: number };
type PositionConfig (line 14) | type PositionConfig = LocationConfig & { facing: AbsoluteDirection };
type UnitConfig (line 16) | interface UnitConfig {
type WarriorConfig (line 22) | interface WarriorConfig {
type LevelConfig (line 31) | interface LevelConfig {
type WarriorDefinition (line 46) | interface WarriorDefinition {
type WarriorOverrides (line 52) | interface WarriorOverrides {
type LevelDefinition (line 58) | interface LevelDefinition {
type TowerDefinition (line 72) | interface TowerDefinition {
FILE: libs/effects/src/Ticking.ts
type TickingConfig (line 3) | interface TickingConfig {
class Ticking (line 7) | class Ticking extends Effect {
method constructor (line 12) | constructor(unit: any, { time }: TickingConfig) {
method passTurn (line 17) | passTurn(): void {
method trigger (line 29) | trigger(): void {
method with (line 36) | static with(config: TickingConfig): EffectBinding {
FILE: libs/scoring/src/getClearBonus.ts
function getClearBonus (line 12) | function getClearBonus(turns: unknown[][], warriorScore: number, timeBon...
FILE: libs/scoring/src/getGradeLetter.ts
function getGradeLetter (line 7) | function getGradeLetter(grade: number): string {
FILE: libs/scoring/src/getLastEvent.ts
function getLastEvent (line 7) | function getLastEvent(turns: unknown[][]): Record<string, unknown> {
FILE: libs/scoring/src/getLevelScore.ts
type LevelResult (line 5) | interface LevelResult {
type LevelConfig (line 10) | interface LevelConfig {
type LevelScore (line 14) | interface LevelScore {
function getLevelScore (line 27) | function getLevelScore(
FILE: libs/scoring/src/getRemainingTimeBonus.ts
function getRemainingTimeBonus (line 10) | function getRemainingTimeBonus(turns: unknown[][], timeBonus: number): n...
FILE: libs/scoring/src/getTurnCount.ts
function getTurnCount (line 7) | function getTurnCount(turns: unknown[][]): number {
FILE: libs/scoring/src/getWarriorScore.ts
function getWarriorScore (line 9) | function getWarriorScore(turns: unknown[][]): number {
FILE: libs/scoring/src/isFloorClear.ts
type Space (line 1) | interface Space {
function isFloorClear (line 13) | function isFloorClear(floorMap: Space[][]): boolean {
FILE: libs/spatial/src/absoluteDirections.ts
constant NORTH (line 4) | const NORTH = 'north';
constant EAST (line 5) | const EAST = 'east';
constant SOUTH (line 6) | const SOUTH = 'south';
constant WEST (line 7) | const WEST = 'west';
constant ABSOLUTE_DIRECTIONS (line 12) | const ABSOLUTE_DIRECTIONS = [NORTH, EAST, SOUTH, WEST] as const;
type AbsoluteDirection (line 15) | type AbsoluteDirection = (typeof ABSOLUTE_DIRECTIONS)[number];
function verifyAbsoluteDirection (line 24) | function verifyAbsoluteDirection(direction: string): asserts direction i...
function getAbsoluteDirection (line 41) | function getAbsoluteDirection(
function getAbsoluteOffset (line 59) | function getAbsoluteOffset(
FILE: libs/spatial/src/location.ts
type Location (line 4) | type Location = [number, number];
type AbsoluteOffset (line 7) | type AbsoluteOffset = [number, number];
type RelativeOffset (line 10) | type RelativeOffset = [number, number];
function translateLocation (line 20) | function translateLocation([x, y]: Location, [deltaX, deltaY]: AbsoluteO...
function getDirectionOfLocation (line 33) | function getDirectionOfLocation([x1, y1]: Location, [x2, y2]: Location):...
function getDistanceOfLocation (line 58) | function getDistanceOfLocation([x1, y1]: Location, [x2, y2]: Location): ...
FILE: libs/spatial/src/relativeDirections.ts
constant FORWARD (line 4) | const FORWARD = 'forward';
constant RIGHT (line 5) | const RIGHT = 'right';
constant BACKWARD (line 6) | const BACKWARD = 'backward';
constant LEFT (line 7) | const LEFT = 'left';
constant RELATIVE_DIRECTIONS (line 12) | const RELATIVE_DIRECTIONS = [FORWARD, RIGHT, BACKWARD, LEFT] as const;
type RelativeDirection (line 15) | type RelativeDirection = (typeof RELATIVE_DIRECTIONS)[number];
function verifyRelativeDirection (line 24) | function verifyRelativeDirection(direction: string): asserts direction i...
function getRelativeDirection (line 41) | function getRelativeDirection(
function getRelativeOffset (line 63) | function getRelativeOffset(
function rotateRelativeOffset (line 93) | function rotateRelativeOffset(
FILE: libs/units/src/Archer.ts
class Archer (line 5) | class Archer extends RangedUnit {
method constructor (line 11) | constructor() {
FILE: libs/units/src/Captive.ts
class Captive (line 3) | class Captive extends Unit {
method constructor (line 4) | constructor() {
FILE: libs/units/src/MeleeUnit.test.ts
class TestMeleeUnit (line 6) | class TestMeleeUnit extends MeleeUnit {
method constructor (line 7) | constructor() {
FILE: libs/units/src/MeleeUnit.ts
method playTurn (line 5) | playTurn(turn: Turn) {
FILE: libs/units/src/RangedUnit.test.ts
class TestRangedUnit (line 6) | class TestRangedUnit extends RangedUnit {
method constructor (line 7) | constructor() {
FILE: libs/units/src/RangedUnit.ts
method playTurn (line 5) | playTurn(turn: Turn) {
FILE: libs/units/src/Sludge.ts
class Sludge (line 5) | class Sludge extends MeleeUnit {
method constructor (line 11) | constructor() {
FILE: libs/units/src/ThickSludge.ts
class ThickSludge (line 5) | class ThickSludge extends MeleeUnit {
method constructor (line 11) | constructor() {
FILE: libs/units/src/Wizard.ts
class Wizard (line 5) | class Wizard extends RangedUnit {
method constructor (line 11) | constructor() {
Condensed preview — 305 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (605K chars).
[
{
"path": ".editorconfig",
"chars": 203,
"preview": "root = true\n\n[*]\ncharset = utf-8\nend_of_line = lf\ninsert_final_newline = true\ntrim_trailing_whitespace = true\n\n[*.{js,js"
},
{
"path": ".gitattributes",
"chars": 12,
"preview": "* text=auto\n"
},
{
"path": ".github/ISSUE_TEMPLATE.md",
"chars": 1686,
"preview": "<!--\n⚠️ PLEASE READ BEFORE DELETING THIS TEMPLATE! ⚠️\n\nThanks for your contribution. Please follow this guide before sub"
},
{
"path": ".github/workflows/ci.yml",
"chars": 1117,
"preview": "name: CI\n\non:\n push:\n branches: [master]\n pull_request:\n branches: [master]\n\njobs:\n lint:\n name: Lint\n ru"
},
{
"path": ".gitignore",
"chars": 1095,
"preview": "# Logs\nlogs\n*.log\nturbo-debug.log*\nnpm-debug.log*\nyarn-debug.log*\nyarn-error.log*\n\n# Runtime data\npids\n*.pid\n*.seed\n*.pi"
},
{
"path": "CHANGELOG.md",
"chars": 10985,
"preview": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\nThe format is based on [Keep a Change"
},
{
"path": "CLAUDE.md",
"chars": 1867,
"preview": "# WarriorJS\n\nA game that teaches JavaScript and TypeScript through interactive coding challenges. Players write code to "
},
{
"path": "CODE_OF_CONDUCT.md",
"chars": 5485,
"preview": "# Contributor Covenant Code of Conduct\n\n## Our Pledge\n\nWe as members, contributors, and leaders pledge to make participa"
},
{
"path": "CONTRIBUTING.md",
"chars": 909,
"preview": "# Contributing\n\n## Code of Conduct\n\nThis project follows a [Code of Conduct](CODE_OF_CONDUCT.md). Please read it.\n\n## Ge"
},
{
"path": "LICENSE",
"chars": 1089,
"preview": "The MIT License (MIT)\n\nCopyright (c) 2015-present Matías Olivera\n\nPermission is hereby granted, free of charge, to any p"
},
{
"path": "README.md",
"chars": 2631,
"preview": "<div align=\"center\">\n <a href=\"https://warrior.js.org\">\n <img alt=\"WarriorJS Banner\" title=\"WarriorJS\" src=\"logo/war"
},
{
"path": "apps/README.md",
"chars": 717,
"preview": "# Apps\n\nEnd-user applications built on the WarriorJS packages.\n\n| Package | Ve"
},
{
"path": "apps/cli/README.md",
"chars": 216,
"preview": "# @warriorjs/cli\n\n> WarriorJS command line.\n\n## Install\n\n```sh\nnpm install --global @warriorjs/cli\n```\n\n## Usage\n\n```sh\n"
},
{
"path": "apps/cli/bin/warriorjs.js",
"chars": 135,
"preview": "#!/usr/bin/env node\n\nimport { hideBin } from 'yargs/helpers';\n\nimport('../dist/cli.js').then(({ run }) => run(hideBin(pr"
},
{
"path": "apps/cli/declarations.d.ts",
"chars": 474,
"preview": "declare module 'yargs' {\n function yargs(args?: string[]): any;\n export default yargs;\n}\n\ndeclare module 'yargs/helper"
},
{
"path": "apps/cli/package.json",
"chars": 1471,
"preview": "{\n \"name\": \"@warriorjs/cli\",\n \"version\": \"0.14.0\",\n \"description\": \"WarriorJS command line\",\n \"author\": \"Matias Oliv"
},
{
"path": "apps/cli/src/Game.test.ts",
"chars": 6944,
"preview": "import fs from 'node:fs';\nimport path from 'node:path';\nimport { getLevelConfig } from '@warriorjs/core';\nimport mock fr"
},
{
"path": "apps/cli/src/Game.ts",
"chars": 4996,
"preview": "import fs from 'node:fs';\nimport { createRequire } from 'node:module';\nimport path from 'node:path';\nimport { getLevelCo"
},
{
"path": "apps/cli/src/GameError.ts",
"chars": 164,
"preview": "class GameError extends Error {\n constructor(message: string) {\n super(message);\n Error.captureStackTrace(this, G"
},
{
"path": "apps/cli/src/Profile.test.ts",
"chars": 13002,
"preview": "import fs from 'node:fs';\nimport path from 'node:path';\nimport mock from 'mock-fs';\nimport { afterEach, beforeEach, desc"
},
{
"path": "apps/cli/src/Profile.ts",
"chars": 6385,
"preview": "import fs from 'node:fs';\nimport path from 'node:path';\nimport { getGradeLetter } from '@warriorjs/scoring';\n\nimport Gam"
},
{
"path": "apps/cli/src/ProfileGenerator.test.ts",
"chars": 3452,
"preview": "import fs from 'node:fs';\nimport mock from 'mock-fs';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\n"
},
{
"path": "apps/cli/src/ProfileGenerator.ts",
"chars": 1344,
"preview": "import fs from 'node:fs';\nimport path from 'node:path';\nimport { type LevelConfig } from '@warriorjs/core';\n\nimport type"
},
{
"path": "apps/cli/src/Tower.test.ts",
"chars": 1032,
"preview": "import { beforeEach, describe, expect, test } from 'vitest';\n\nimport Tower from './Tower.js';\n\ndescribe('Tower', () => {"
},
{
"path": "apps/cli/src/Tower.ts",
"chars": 758,
"preview": "import { type LevelDefinition, type WarriorDefinition } from '@warriorjs/core';\n\nclass Tower {\n id: string;\n name: str"
},
{
"path": "apps/cli/src/cli.test.ts",
"chars": 920,
"preview": "import { expect, test, vi } from 'vitest';\n\nimport { run } from './cli.js';\nimport Game from './Game.js';\n\nvi.mock('ink'"
},
{
"path": "apps/cli/src/cli.ts",
"chars": 563,
"preview": "import { render } from 'ink';\nimport React from 'react';\n\nimport Game from './Game.js';\nimport parseArgs from './parseAr"
},
{
"path": "apps/cli/src/loadTowers.test.ts",
"chars": 4262,
"preview": "import mock from 'mock-fs';\nimport { expect, test, vi } from 'vitest';\n\nimport loadTowers from './loadTowers.js';\nimport"
},
{
"path": "apps/cli/src/loadTowers.ts",
"chars": 2243,
"preview": "import { createRequire } from 'node:module';\nimport path from 'node:path';\nimport { findUpSync } from 'find-up';\nimport "
},
{
"path": "apps/cli/src/parseArgs.test.ts",
"chars": 2095,
"preview": "import { describe, expect, test, vi } from 'vitest';\n\nimport parseArgs from './parseArgs.js';\n\ntest(\"doesn't fail when n"
},
{
"path": "apps/cli/src/parseArgs.ts",
"chars": 1304,
"preview": "import yargs from 'yargs';\n\ninterface ParsedArgs {\n directory: string;\n level: number | undefined;\n silent: boolean;\n"
},
{
"path": "apps/cli/src/ui/components/App.tsx",
"chars": 795,
"preview": "import type React from 'react';\nimport { useState } from 'react';\n\nimport { type GameContext } from '../../Game.js';\nimp"
},
{
"path": "apps/cli/src/ui/components/ConfirmPrompt.test.tsx",
"chars": 2415,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { waitForRend"
},
{
"path": "apps/cli/src/ui/components/ConfirmPrompt.tsx",
"chars": 1198,
"preview": "import { Box, Text, useInput } from 'ink';\nimport type React from 'react';\nimport { useState } from 'react';\n\ninterface "
},
{
"path": "apps/cli/src/ui/components/Divider.test.tsx",
"chars": 411,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport Divider from './D"
},
{
"path": "apps/cli/src/ui/components/Divider.tsx",
"chars": 231,
"preview": "import { Text, useStdout } from 'ink';\nimport type React from 'react';\n\nexport default function Divider(): React.ReactEl"
},
{
"path": "apps/cli/src/ui/components/ErrorMessage.test.tsx",
"chars": 1906,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { waitForRend"
},
{
"path": "apps/cli/src/ui/components/ErrorMessage.tsx",
"chars": 743,
"preview": "import { Box, Text, useInput } from 'ink';\nimport type React from 'react';\nimport { useState } from 'react';\n\ninterface "
},
{
"path": "apps/cli/src/ui/components/FloorMap.test.tsx",
"chars": 957,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport FloorMap from './"
},
{
"path": "apps/cli/src/ui/components/FloorMap.tsx",
"chars": 1032,
"preview": "import { Box, Text } from 'ink';\nimport type React from 'react';\n\ninterface FloorSpace {\n character: string;\n unit?: {"
},
{
"path": "apps/cli/src/ui/components/GameMenu.test.tsx",
"chars": 7871,
"preview": "import { render } from 'ink-testing-library';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport {"
},
{
"path": "apps/cli/src/ui/components/GameMenu.tsx",
"chars": 5513,
"preview": "import path from 'node:path';\nimport { Box, Text, useApp } from 'ink';\nimport Link from 'ink-link';\nimport type React fr"
},
{
"path": "apps/cli/src/ui/components/Header.test.tsx",
"chars": 879,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport Header from './He"
},
{
"path": "apps/cli/src/ui/components/Header.tsx",
"chars": 934,
"preview": "import { Box, Text } from 'ink';\nimport type React from 'react';\n\ninterface HeaderProps {\n warriorName: string;\n tower"
},
{
"path": "apps/cli/src/ui/components/LevelCompleteScreen.test.tsx",
"chars": 4348,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { makeLevelRe"
},
{
"path": "apps/cli/src/ui/components/LevelCompleteScreen.tsx",
"chars": 2678,
"preview": "import path from 'node:path';\nimport { Text } from 'ink';\nimport Link from 'ink-link';\nimport type React from 'react';\ni"
},
{
"path": "apps/cli/src/ui/components/LogArea.test.tsx",
"chars": 2162,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport LogArea from './L"
},
{
"path": "apps/cli/src/ui/components/LogArea.tsx",
"chars": 3450,
"preview": "import { Box, Text } from 'ink';\nimport type React from 'react';\nimport { useMemo } from 'react';\n\nimport { type TurnEve"
},
{
"path": "apps/cli/src/ui/components/PlayLayout.test.tsx",
"chars": 1583,
"preview": "import { Text } from 'ink';\nimport { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest"
},
{
"path": "apps/cli/src/ui/components/PlayLayout.tsx",
"chars": 1017,
"preview": "import { Box } from 'ink';\nimport type React from 'react';\n\nimport { type TurnEvent } from '../types.js';\nimport Divider"
},
{
"path": "apps/cli/src/ui/components/PlayScreen.test.tsx",
"chars": 1563,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport PlayScreen fr"
},
{
"path": "apps/cli/src/ui/components/PlayScreen.tsx",
"chars": 2150,
"preview": "import { Box } from 'ink';\nimport type React from 'react';\nimport { useMemo } from 'react';\n\nimport { usePlayback } from"
},
{
"path": "apps/cli/src/ui/components/PlaySession.test.tsx",
"chars": 4125,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { type GameCo"
},
{
"path": "apps/cli/src/ui/components/PlaySession.tsx",
"chars": 1864,
"preview": "import { useApp } from 'ink';\nimport type React from 'react';\nimport { useEffect } from 'react';\n\nimport { type GameCont"
},
{
"path": "apps/cli/src/ui/components/ProfileWizard.test.tsx",
"chars": 21544,
"preview": "import { render } from 'ink-testing-library';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport {"
},
{
"path": "apps/cli/src/ui/components/ProfileWizard.tsx",
"chars": 7064,
"preview": "import { Box, Text } from 'ink';\nimport type React from 'react';\nimport { useState } from 'react';\n\nimport { type GameCo"
},
{
"path": "apps/cli/src/ui/components/ResultScreen.test.tsx",
"chars": 1666,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport ResultScreen from"
},
{
"path": "apps/cli/src/ui/components/ResultScreen.tsx",
"chars": 1770,
"preview": "import { getGradeLetter } from '@warriorjs/scoring';\nimport { Box, Text } from 'ink';\nimport type React from 'react';\n\ni"
},
{
"path": "apps/cli/src/ui/components/Scrubber.test.tsx",
"chars": 1325,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport Scrubber from './"
},
{
"path": "apps/cli/src/ui/components/Scrubber.tsx",
"chars": 1077,
"preview": "import { Box, Text } from 'ink';\nimport type React from 'react';\n\ninterface ScrubberProps {\n currentTurn: number;\n tot"
},
{
"path": "apps/cli/src/ui/components/SelectPrompt.test.tsx",
"chars": 3941,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { waitForRend"
},
{
"path": "apps/cli/src/ui/components/SelectPrompt.tsx",
"chars": 2367,
"preview": "import { Box, Text, useInput } from 'ink';\nimport type React from 'react';\nimport { useState } from 'react';\n\ninterface "
},
{
"path": "apps/cli/src/ui/components/TextPrompt.test.tsx",
"chars": 2868,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test, vi } from 'vitest';\n\nimport { waitForRend"
},
{
"path": "apps/cli/src/ui/components/TextPrompt.tsx",
"chars": 1526,
"preview": "import { Box, Text, useInput } from 'ink';\nimport type React from 'react';\nimport { useState } from 'react';\n\ninterface "
},
{
"path": "apps/cli/src/ui/components/TowerCompleteScreen.test.tsx",
"chars": 559,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport TowerCompleteScre"
},
{
"path": "apps/cli/src/ui/components/TowerCompleteScreen.tsx",
"chars": 1016,
"preview": "import { getGradeLetter } from '@warriorjs/scoring';\nimport { Box, Text } from 'ink';\nimport type React from 'react';\n\ni"
},
{
"path": "apps/cli/src/ui/components/WarriorArt.test.tsx",
"chars": 359,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport WarriorArt from '"
},
{
"path": "apps/cli/src/ui/components/WarriorArt.tsx",
"chars": 328,
"preview": "import { Text } from 'ink';\nimport type React from 'react';\n\nimport { brandColor } from '../theme.js';\n\nconst art = [\n "
},
{
"path": "apps/cli/src/ui/components/WarriorStatus.test.tsx",
"chars": 501,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\nimport WarriorStatus fro"
},
{
"path": "apps/cli/src/ui/components/WarriorStatus.tsx",
"chars": 429,
"preview": "import { Box, Text } from 'ink';\nimport type React from 'react';\n\ninterface WarriorStatusProps {\n health: number;\n max"
},
{
"path": "apps/cli/src/ui/components/WelcomeScreen.test.tsx",
"chars": 681,
"preview": "import { render } from 'ink-testing-library';\nimport { describe, expect, test } from 'vitest';\n\n// @ts-expect-error -- J"
},
{
"path": "apps/cli/src/ui/components/WelcomeScreen.tsx",
"chars": 734,
"preview": "import { Box, Text } from 'ink';\nimport type React from 'react';\n\nimport formatDirectory from '../../utils/formatDirecto"
},
{
"path": "apps/cli/src/ui/hooks/usePlaySession.test.ts",
"chars": 21504,
"preview": "import { render } from 'ink-testing-library';\nimport React, { act } from 'react';\nimport { beforeEach, describe, expect,"
},
{
"path": "apps/cli/src/ui/hooks/usePlaySession.ts",
"chars": 7640,
"preview": "import { getLevelConfig, runLevel } from '@warriorjs/core';\nimport { useCallback, useRef, useState } from 'react';\n\nimpo"
},
{
"path": "apps/cli/src/ui/hooks/usePlayback.test.ts",
"chars": 4640,
"preview": "import { describe, expect, test } from 'vitest';\n\nimport { type PlaybackState, playbackReducer } from './usePlayback.js'"
},
{
"path": "apps/cli/src/ui/hooks/usePlayback.ts",
"chars": 3727,
"preview": "import { useInput } from 'ink';\nimport { useEffect, useReducer, useRef } from 'react';\n\nexport interface PlaybackState {"
},
{
"path": "apps/cli/src/ui/testing.ts",
"chars": 1372,
"preview": "import { type LevelReport, type LevelRun } from './types.js';\n\nexport const waitForRender = () => new Promise((resolve) "
},
{
"path": "apps/cli/src/ui/theme.ts",
"chars": 37,
"preview": "export const brandColor = '#C5832B';\n"
},
{
"path": "apps/cli/src/ui/types.ts",
"chars": 2066,
"preview": "import type Profile from '../Profile.js';\n\n// UI-specific narrowings of @warriorjs/core types.\n// These pick only the fi"
},
{
"path": "apps/cli/src/ui/utils/buildLevelReport.test.ts",
"chars": 4268,
"preview": "import { describe, expect, test } from 'vitest';\n\nimport { type LevelConfig, type LevelResult, type TurnEvent } from '.."
},
{
"path": "apps/cli/src/ui/utils/buildLevelReport.ts",
"chars": 1314,
"preview": "import { getLevelScore } from '@warriorjs/scoring';\n\nimport { type LevelConfig, type LevelReport, type LevelResult } fro"
},
{
"path": "apps/cli/src/utils/formatDirectory.test.ts",
"chars": 886,
"preview": "import os from 'node:os';\nimport path from 'node:path';\nimport { describe, expect, test } from 'vitest';\n\nimport formatD"
},
{
"path": "apps/cli/src/utils/formatDirectory.ts",
"chars": 343,
"preview": "import os from 'node:os';\nimport path from 'node:path';\n\nexport default function formatDirectory(directory: string): str"
},
{
"path": "apps/cli/src/utils/getFloorMap.test.ts",
"chars": 283,
"preview": "import { expect, test } from 'vitest';\n\nimport getFloorMap from './getFloorMap.js';\n\ntest('returns the floor map', () =>"
},
{
"path": "apps/cli/src/utils/getFloorMap.ts",
"chars": 240,
"preview": "interface FloorSpace {\n character: string;\n [key: string]: unknown;\n}\n\nfunction getFloorMap(map: FloorSpace[][]): stri"
},
{
"path": "apps/cli/src/utils/getFloorMapKey.test.ts",
"chars": 342,
"preview": "import { expect, test } from 'vitest';\n\nimport getFloorMapKey from './getFloorMapKey.js';\n\ntest('returns the floor map k"
},
{
"path": "apps/cli/src/utils/getFloorMapKey.ts",
"chars": 631,
"preview": "interface FloorSpace {\n character: string;\n unit?: {\n name: string;\n maxHealth: number;\n };\n}\n\nfunction getFloo"
},
{
"path": "apps/cli/src/utils/getTowerId.test.ts",
"chars": 322,
"preview": "import { expect, test } from 'vitest';\n\nimport getTowerId from './getTowerId.js';\n\ntest('returns the tower id for offici"
},
{
"path": "apps/cli/src/utils/getTowerId.ts",
"chars": 207,
"preview": "const towerIdRegex = /(?:@warriorjs\\/|warriorjs-)tower-(.+)/;\n\nfunction getTowerId(towerPackageName: string): string {\n "
},
{
"path": "apps/cli/src/utils/getWarriorNameSuggestions.test.ts",
"chars": 377,
"preview": "import arrayShuffle from 'array-shuffle';\nimport { expect, test, vi } from 'vitest';\n\nimport getWarriorNameSuggestions f"
},
{
"path": "apps/cli/src/utils/getWarriorNameSuggestions.ts",
"chars": 757,
"preview": "import arrayShuffle from 'array-shuffle';\n\nconst warriorNames = [\n 'Aldric',\n 'Brenna',\n 'Cedric',\n 'Dahlia',\n 'Elr"
},
{
"path": "apps/cli/src/utils/renderPlayerCode.test.ts",
"chars": 862,
"preview": "import { describe, expect, test } from 'vitest';\n\nimport renderPlayerCode from './renderPlayerCode.js';\n\ndescribe('rende"
},
{
"path": "apps/cli/src/utils/renderPlayerCode.ts",
"chars": 562,
"preview": "import { type LevelConfig } from '@warriorjs/core';\n\nimport type Profile from '../Profile.js';\n\nfunction renderPlayerCod"
},
{
"path": "apps/cli/src/utils/renderReadme.test.ts",
"chars": 5030,
"preview": "import { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport renderReadme from './renderReadme.js';\n\nvi.mock"
},
{
"path": "apps/cli/src/utils/renderReadme.ts",
"chars": 2330,
"preview": "import { getLevel, type LevelConfig } from '@warriorjs/core';\n\nimport type Profile from '../Profile.js';\nimport getFloor"
},
{
"path": "apps/cli/src/utils/renderTypes.test.ts",
"chars": 5110,
"preview": "import { type AbilityMeta, Action, Sense } from '@warriorjs/core';\nimport { describe, expect, test } from 'vitest';\n\nimp"
},
{
"path": "apps/cli/src/utils/renderTypes.ts",
"chars": 3666,
"preview": "import { type Ability, type AbilityEntry, Action, type LevelConfig } from '@warriorjs/core';\n\nimport type Profile from '"
},
{
"path": "apps/cli/tsconfig.build.json",
"chars": 115,
"preview": "{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"outDir\": \"dist\"\n },\n \"exclude\": [\"**/*.test.ts\"]\n}\n"
},
{
"path": "apps/cli/tsconfig.json",
"chars": 155,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\",\n \"jsx\": \"react-jsx\"\n },\n \"includ"
},
{
"path": "apps/website/core/Footer.js",
"chars": 1901,
"preview": "const PropTypes = require('prop-types');\nconst React = require('react');\n\nconst GitHubButton = require('./GitHubButton')"
},
{
"path": "apps/website/core/GitHubButton.js",
"chars": 466,
"preview": "const PropTypes = require('prop-types');\nconst React = require('react');\n\nconst GitHubButton = ({ username, repo }) => ("
},
{
"path": "apps/website/core/TwitterButton.js",
"chars": 496,
"preview": "const PropTypes = require('prop-types');\nconst React = require('react');\n\nconst TwitterButton = ({ username }) => (\n <a"
},
{
"path": "apps/website/crowdin.yaml",
"chars": 1117,
"preview": "project_identifier_env: CROWDIN_WARRIORJS_PROJECT_ID\napi_key_env: CROWDIN_WARRIORJS_API_KEY\nbase_path: \"../\"\npreserve_hi"
},
{
"path": "apps/website/data/sponsors.json",
"chars": 3,
"preview": "[]\n"
},
{
"path": "apps/website/i18n/en.json",
"chars": 3949,
"preview": "{\n \"_comment\": \"This file is auto-generated by write-translations.js\",\n \"localized-strings\": {\n \"next\": \"Next\",\n "
},
{
"path": "apps/website/languages.js",
"chars": 2371,
"preview": "const languages = [\n {\n enabled: true,\n name: 'English',\n tag: 'en',\n },\n {\n enabled: false,\n name: '日"
},
{
"path": "apps/website/package.json",
"chars": 587,
"preview": "{\n \"name\": \"warriorjs-website\",\n \"version\": \"0.0.0\",\n \"private\": true,\n \"scripts\": {\n \"start\": \"docusaurus-start\""
},
{
"path": "apps/website/pages/en/index.js",
"chars": 5908,
"preview": "const PropTypes = require('prop-types');\nconst React = require('react');\n\nconst GitHubButton = require(`${process.cwd()}"
},
{
"path": "apps/website/sidebars.json",
"chars": 1041,
"preview": "{\n \"play\": {\n \"Game\": [\n \"player/overview\",\n \"player/object\",\n \"player/gameplay\",\n \"player/persp"
},
{
"path": "apps/website/siteConfig.js",
"chars": 1442,
"preview": "const pkg = require('../package');\nconst sponsors = require('./data/sponsors');\n\nconst gitHubUrl = pkg.repository;\nconst"
},
{
"path": "apps/website/static/.circleci/config.yml",
"chars": 225,
"preview": "# This config file will prevent tests from being run on the gh-pages branch.\nversion: 2\njobs:\n build:\n machine: true"
},
{
"path": "apps/website/static/css/custom.css",
"chars": 3076,
"preview": "/*\n * Global\n */\n\nblockquote {\n background: #eceff4;\n border-left-color: #e5e9f0;\n}\n\n.container .wrapper h3 {\n margin"
},
{
"path": "apps/website/static/css/nord.css",
"chars": 3992,
"preview": "/*\n++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++\ntitle Nord highlight.js "
},
{
"path": "apps/website/static/googlee0ff7b5bc8d30f78.html",
"chars": 53,
"preview": "google-site-verification: googlee0ff7b5bc8d30f78.html"
},
{
"path": "apps/website/utils/getDocUrl.js",
"chars": 273,
"preview": "const siteConfig = require(`${process.cwd()}/siteConfig.js`);\n\nconst docsUrl = `${siteConfig.baseUrl}docs`;\n\nfunction ge"
},
{
"path": "apps/website/utils/getImgUrl.js",
"chars": 193,
"preview": "const siteConfig = require(`${process.cwd()}/siteConfig.js`);\n\nconst imgUrl = `${siteConfig.baseUrl}img`;\n\nfunction getI"
},
{
"path": "biome.json",
"chars": 1409,
"preview": "{\n \"$schema\": \"https://biomejs.dev/schemas/2.4.6/schema.json\",\n \"assist\": {\n \"actions\": {\n \"source\": {\n "
},
{
"path": "docs/community/ecosystem.md",
"chars": 426,
"preview": "---\nid: ecosystem\ntitle: Ecosystem\n---\n\nWarriorJS uses NPM/Yarn, so any WarriorJS related project can live on\n[npm](http"
},
{
"path": "docs/community/roadmap.md",
"chars": 719,
"preview": "---\nid: roadmap\ntitle: Roadmap & Contribution\n---\n\n## WarriorJS TODOs\n\nSome future plans are briefly discussed in the re"
},
{
"path": "docs/community/socialize.md",
"chars": 209,
"preview": "---\nid: socialize\ntitle: Socialize\n---\n\nDo you have any questions, ideas, or suggestions?\n\n- Come say hi in [Spectrum](h"
},
{
"path": "docs/maker/adding-levels.md",
"chars": 4207,
"preview": "---\nid: adding-levels\ntitle: Adding Levels\n---\n\nA level is another JavaScript object:\n\n```js\nconst Level1 = {\n // Level"
},
{
"path": "docs/maker/creating-tower.md",
"chars": 476,
"preview": "---\nid: creating-tower\ntitle: Creating Your Tower\n---\n\nA WarriorJS tower is a regular JavaScript module with a single ex"
},
{
"path": "docs/maker/defining-abilities.md",
"chars": 4568,
"preview": "---\nid: defining-abilities\ntitle: Defining Abilities\n---\n\nAn ability is a JavaScript function that receives the unit tha"
},
{
"path": "docs/maker/defining-units.md",
"chars": 3569,
"preview": "---\nid: defining-units\ntitle: Defining Units\n---\n\nA unit is a JavaScript object:\n\n```js\nconst WhiteWalker = {\n // Unit "
},
{
"path": "docs/maker/introduction.md",
"chars": 344,
"preview": "---\nid: introduction\ntitle: Introduction\n---\n\nIn this guide, you'll learn how to make your own tower by following a quic"
},
{
"path": "docs/maker/publishing.md",
"chars": 1337,
"preview": "---\nid: publishing\ntitle: Publishing\n---\n\nThis is the minimal structure of a tower package:\n\n```sh\nwarriorjs-tower-got\n├"
},
{
"path": "docs/maker/refactoring.md",
"chars": 10775,
"preview": "---\nid: refactoring\ntitle: Refactoring\n---\n\nAt this point, you should have the following code:\n\n```js\nfunction valyrianS"
},
{
"path": "docs/maker/space-api.md",
"chars": 1109,
"preview": "---\nid: space-api\ntitle: Space API\n---\n\nAs a maker, you call the same methods the player call on a sensed space, but on\n"
},
{
"path": "docs/maker/testing.md",
"chars": 185,
"preview": "---\nid: testing\ntitle: Testing\n---\n\nFirst, you want to make the top of your tower can be reached. And then, you'll\nwant "
},
{
"path": "docs/maker/unit-api.md",
"chars": 5017,
"preview": "---\nid: unit-api\ntitle: Unit API\n---\n\nAs a maker, you call methods on units when writing the logic for the abilities\nyou"
},
{
"path": "docs/player/abilities.md",
"chars": 1339,
"preview": "---\nid: abilities\ntitle: Abilities\n---\n\nAn **ability** is a skill possessed by a unit. As the player, you activate\nabili"
},
{
"path": "docs/player/ai-tips.md",
"chars": 489,
"preview": "---\nid: ai-tips\nsidebar_label: Artificial Intelligence\ntitle: AI Tips\n---\n\n- Once you've made some progress in the tower"
},
{
"path": "docs/player/cli-tips.md",
"chars": 400,
"preview": "---\nid: cli-tips\nsidebar_label: CLI\ntitle: CLI Tips\n---\n\n- Running `warriorjs` while you are in your profile's directory"
},
{
"path": "docs/player/effects.md",
"chars": 202,
"preview": "---\nid: effects\ntitle: Effects\n---\n\nAn **effect** is any kind of status that affects a unit. They're generally\napplied b"
},
{
"path": "docs/player/epic-mode.md",
"chars": 651,
"preview": "---\nid: epic-mode\ntitle: Epic Mode\n---\n\nOnce you reach the top of the tower, you'll have the option to enter **epic\nmode"
},
{
"path": "docs/player/gameplay.md",
"chars": 1681,
"preview": "---\nid: gameplay\ntitle: Gameplay\n---\n\nThe play happens through a series of turns. On each one and starting with your\nwar"
},
{
"path": "docs/player/general-tips.md",
"chars": 847,
"preview": "---\nid: general-tips\nsidebar_label: General\ntitle: General Tips\n---\n\n- **If you ever get stuck on a level, review the RE"
},
{
"path": "docs/player/install.md",
"chars": 1109,
"preview": "---\nid: install\ntitle: Install\n---\n\nLet's start by installing WarriorJS globally with [npm](https://npmjs.com).\n\nOpen th"
},
{
"path": "docs/player/js-tips.md",
"chars": 1841,
"preview": "---\nid: js-tips\nsidebar_label: JavaScript\ntitle: JavaScript Tips\n---\n\n- Don't simply fill up the `playTurn` method with "
},
{
"path": "docs/player/object.md",
"chars": 435,
"preview": "---\nid: object\ntitle: Object\n---\n\nThe goal of the game is to climb to the top of a tower. You progress through the\ntower"
},
{
"path": "docs/player/options.md",
"chars": 803,
"preview": "---\nid: options\ntitle: Options\n---\n\nThere are various options you can pass to the `warriorjs` command to customize\nthe g"
},
{
"path": "docs/player/overview.md",
"chars": 531,
"preview": "---\nid: overview\ntitle: Overview\n---\n\nIn WarriorJS, you are a warrior climbing a tall tower to reach _The JavaScript\nSwo"
},
{
"path": "docs/player/perspective.md",
"chars": 636,
"preview": "---\nid: perspective\ntitle: Perspective\n---\n\nEven though this is a text-based game, think of it as two-dimensional where "
},
{
"path": "docs/player/scoring.md",
"chars": 736,
"preview": "---\nid: scoring\ntitle: Scoring\n---\n\nYour objective is to not only reach the stairs, but to get the highest score you\ncan"
},
{
"path": "docs/player/space-api.md",
"chars": 1350,
"preview": "---\nid: space-api\ntitle: Space API\n---\n\nWhenever you sense an area, often one or multiple spaces (in an array) will be\nr"
},
{
"path": "docs/player/spaces.md",
"chars": 266,
"preview": "---\nid: spaces\ntitle: Spaces\n---\n\nA **space** is an object representing a square in the floor.\n\nA space can be empty, or"
},
{
"path": "docs/player/towers.md",
"chars": 1429,
"preview": "---\nid: towers\ntitle: Towers\n---\n\nA **tower** is a WarriorJS world. In addition to defining levels, towers can\nalso add "
},
{
"path": "docs/player/turn-api.md",
"chars": 924,
"preview": "---\nid: turn-api\ntitle: Turn API\n---\n\nThe `playTurn` method in `Player.js` gets passed an instance of your warrior's\ntur"
},
{
"path": "docs/player/unit-api.md",
"chars": 582,
"preview": "---\nid: unit-api\ntitle: Unit API\n---\n\nYou can call `getUnit()` on a space to retrieve the unit located there (but keep\ni"
},
{
"path": "docs/player/units.md",
"chars": 398,
"preview": "---\nid: units\ntitle: Units\n---\n\nA **unit** is any character that populates the floors of the tower, including\nyour warri"
},
{
"path": "docs/player/warrior.md",
"chars": 632,
"preview": "---\nid: warrior\ntitle: Warrior\n---\n\nThe **warrior** is the player-character in WarriorJS, which means it's\ncontrolled by"
},
{
"path": "lefthook.yml",
"chars": 175,
"preview": "pre-commit:\n commands:\n lint:\n glob: \"*.{js,ts,json,css,md,yaml}\"\n run: pnpm biome check --write --no-erro"
},
{
"path": "libs/README.md",
"chars": 3484,
"preview": "# Packages\n\nShared libraries that power the WarriorJS game engine.\n\n| Package "
},
{
"path": "libs/abilities/README.md",
"chars": 2149,
"preview": "# @warriorjs/abilities\n\n> WarriorJS official abilities.\n\n## [Actions][actions]\n\n### `unit.attack([direction])`:\n\nAttack "
},
{
"path": "libs/abilities/package.json",
"chars": 770,
"preview": "{\n \"name\": \"@warriorjs/abilities\",\n \"version\": \"0.13.0\",\n \"description\": \"WarriorJS base abilities\",\n \"author\": \"Mat"
},
{
"path": "libs/abilities/src/Attack.test.ts",
"chars": 2383,
"preview": "import { Action } from '@warriorjs/core';\nimport { BACKWARD, FORWARD, LEFT } from '@warriorjs/spatial';\nimport { beforeE"
},
{
"path": "libs/abilities/src/Attack.ts",
"chars": 1334,
"preview": "import { type AbilityBinding, Action } from '@warriorjs/core';\nimport { BACKWARD, FORWARD, type RelativeDirection } from"
},
{
"path": "libs/abilities/src/Bind.test.ts",
"chars": 1935,
"preview": "import { Action } from '@warriorjs/core';\nimport { FORWARD, LEFT } from '@warriorjs/spatial';\nimport { beforeEach, descr"
},
{
"path": "libs/abilities/src/Bind.ts",
"chars": 844,
"preview": "import { Action } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport "
},
{
"path": "libs/abilities/src/Detonate.test.ts",
"chars": 3481,
"preview": "import { Action } from '@warriorjs/core';\nimport { FORWARD, LEFT } from '@warriorjs/spatial';\nimport { beforeEach, descr"
},
{
"path": "libs/abilities/src/Detonate.ts",
"chars": 2045,
"preview": "import { type AbilityBinding, Action } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection, type RelativeOf"
},
{
"path": "libs/abilities/src/DirectionOf.test.ts",
"chars": 1080,
"preview": "import { Sense } from '@warriorjs/core';\nimport { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial';\nimport { b"
},
{
"path": "libs/abilities/src/DirectionOf.ts",
"chars": 556,
"preview": "import { Sense, type SensedSpace } from '@warriorjs/core';\nimport { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/sp"
},
{
"path": "libs/abilities/src/DirectionOfStairs.test.ts",
"chars": 1123,
"preview": "import { Sense } from '@warriorjs/core';\nimport { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial';\nimport { b"
},
{
"path": "libs/abilities/src/DirectionOfStairs.ts",
"chars": 516,
"preview": "import { Sense } from '@warriorjs/core';\nimport { BACKWARD, FORWARD, LEFT, RIGHT } from '@warriorjs/spatial';\n\nimport { "
},
{
"path": "libs/abilities/src/DistanceOf.test.ts",
"chars": 962,
"preview": "import { Sense } from '@warriorjs/core';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Distan"
},
{
"path": "libs/abilities/src/DistanceOf.ts",
"chars": 453,
"preview": "import { Sense, type SensedSpace } from '@warriorjs/core';\n\nimport { type AbilityMeta } from './types.js';\n\nclass Distan"
},
{
"path": "libs/abilities/src/Feel.test.ts",
"chars": 1296,
"preview": "import { Sense } from '@warriorjs/core';\nimport { FORWARD, LEFT } from '@warriorjs/spatial';\nimport { beforeEach, descri"
},
{
"path": "libs/abilities/src/Feel.ts",
"chars": 616,
"preview": "import { Sense } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport {"
},
{
"path": "libs/abilities/src/Health.test.ts",
"chars": 777,
"preview": "import { Sense } from '@warriorjs/core';\nimport { beforeEach, describe, expect, test } from 'vitest';\n\nimport Health fro"
},
{
"path": "libs/abilities/src/Health.ts",
"chars": 343,
"preview": "import { Sense } from '@warriorjs/core';\n\nimport { type AbilityMeta } from './types.js';\n\nclass Health extends Sense {\n "
},
{
"path": "libs/abilities/src/Listen.test.ts",
"chars": 1403,
"preview": "import { Sense } from '@warriorjs/core';\nimport { FORWARD, NORTH } from '@warriorjs/spatial';\nimport { beforeEach, descr"
},
{
"path": "libs/abilities/src/Listen.ts",
"chars": 785,
"preview": "import { Sense } from '@warriorjs/core';\nimport { FORWARD, getRelativeOffset } from '@warriorjs/spatial';\n\nimport { type"
},
{
"path": "libs/abilities/src/Look.test.ts",
"chars": 2472,
"preview": "import { Sense } from '@warriorjs/core';\nimport { FORWARD, LEFT } from '@warriorjs/spatial';\nimport { beforeEach, descri"
},
{
"path": "libs/abilities/src/Look.ts",
"chars": 1216,
"preview": "import { type AbilityBinding, Sense } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection } from '@warriorj"
},
{
"path": "libs/abilities/src/MaxHealth.test.ts",
"chars": 819,
"preview": "import { Sense } from '@warriorjs/core';\nimport { beforeEach, describe, expect, test } from 'vitest';\n\nimport MaxHealth "
},
{
"path": "libs/abilities/src/MaxHealth.ts",
"chars": 360,
"preview": "import { Sense } from '@warriorjs/core';\n\nimport { type AbilityMeta } from './types.js';\n\nclass MaxHealth extends Sense "
},
{
"path": "libs/abilities/src/Pivot.test.ts",
"chars": 1275,
"preview": "import { Action } from '@warriorjs/core';\nimport { BACKWARD, RIGHT } from '@warriorjs/spatial';\nimport { beforeEach, des"
},
{
"path": "libs/abilities/src/Pivot.ts",
"chars": 629,
"preview": "import { Action } from '@warriorjs/core';\nimport { BACKWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport"
},
{
"path": "libs/abilities/src/Rescue.test.ts",
"chars": 2303,
"preview": "import { Action } from '@warriorjs/core';\nimport { FORWARD, RIGHT } from '@warriorjs/spatial';\nimport { beforeEach, desc"
},
{
"path": "libs/abilities/src/Rescue.ts",
"chars": 867,
"preview": "import { Action } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport "
},
{
"path": "libs/abilities/src/Rest.test.ts",
"chars": 1329,
"preview": "import { Action } from '@warriorjs/core';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Rest "
},
{
"path": "libs/abilities/src/Rest.ts",
"chars": 923,
"preview": "import { type AbilityBinding, Action } from '@warriorjs/core';\n\nimport { type AbilityMeta, type Unit } from './types.js'"
},
{
"path": "libs/abilities/src/Shoot.test.ts",
"chars": 2958,
"preview": "import { Action } from '@warriorjs/core';\nimport { FORWARD, LEFT } from '@warriorjs/spatial';\nimport { beforeEach, descr"
},
{
"path": "libs/abilities/src/Shoot.ts",
"chars": 1472,
"preview": "import { type AbilityBinding, Action } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection } from '@warrior"
},
{
"path": "libs/abilities/src/Think.test.ts",
"chars": 1204,
"preview": "import { Sense } from '@warriorjs/core';\nimport { beforeEach, describe, expect, test, vi } from 'vitest';\n\nimport Think "
},
{
"path": "libs/abilities/src/Think.ts",
"chars": 511,
"preview": "import util from 'node:util';\nimport { Sense } from '@warriorjs/core';\n\nimport { type AbilityMeta } from './types.js';\n\n"
},
{
"path": "libs/abilities/src/Walk.test.ts",
"chars": 1862,
"preview": "import { Action } from '@warriorjs/core';\nimport { FORWARD, RIGHT } from '@warriorjs/spatial';\nimport { beforeEach, desc"
},
{
"path": "libs/abilities/src/Walk.ts",
"chars": 802,
"preview": "import { Action } from '@warriorjs/core';\nimport { FORWARD, type RelativeDirection } from '@warriorjs/spatial';\n\nimport "
},
{
"path": "libs/abilities/src/index.ts",
"chars": 857,
"preview": "export { default as Attack } from './Attack.js';\nexport { default as Bind } from './Bind.js';\nexport { default as Detona"
},
{
"path": "libs/abilities/src/types.ts",
"chars": 1521,
"preview": "import { type SensedSpace } from '@warriorjs/core';\nimport { type AbsoluteDirection, type Location, type RelativeDirecti"
},
{
"path": "libs/abilities/tsconfig.build.json",
"chars": 115,
"preview": "{\n \"extends\": \"./tsconfig.json\",\n \"compilerOptions\": {\n \"outDir\": \"dist\"\n },\n \"exclude\": [\"**/*.test.ts\"]\n}\n"
},
{
"path": "libs/abilities/tsconfig.json",
"chars": 110,
"preview": "{\n \"extends\": \"../../tsconfig.json\",\n \"compilerOptions\": {\n \"rootDir\": \"src\"\n },\n \"include\": [\"src\"]\n}\n"
},
{
"path": "libs/core/README.md",
"chars": 478,
"preview": "# @warriorjs/core\n\n> WarriorJS core.\n\n## Install\n\n```sh\nnpm install @warriorjs/core\n```\n\n## Usage\n\n```js\nconst warriorjs"
},
{
"path": "libs/core/package.json",
"chars": 1000,
"preview": "{\n \"name\": \"@warriorjs/core\",\n \"version\": \"0.14.0\",\n \"description\": \"WarriorJS core\",\n \"author\": \"Matias Olivera <hi"
},
{
"path": "libs/core/src/Ability.test.ts",
"chars": 908,
"preview": "import { describe, expect, test, vi } from 'vitest';\n\nimport Ability, { type AbilityMeta } from './Ability.js';\nimport A"
},
{
"path": "libs/core/src/Ability.ts",
"chars": 763,
"preview": "export interface AbilityParam {\n name: string;\n type: 'Direction' | 'Space' | 'number' | 'any';\n optional?: boolean;\n"
},
{
"path": "libs/core/src/Action.test.ts",
"chars": 1353,
"preview": "import { describe, expect, test, vi } from 'vitest';\n\nimport Ability, { type AbilityMeta } from './Ability.js';\nimport A"
},
{
"path": "libs/core/src/Action.ts",
"chars": 149,
"preview": "import Ability from './Ability.js';\n\nabstract class Action extends Ability {\n abstract perform(...args: unknown[]): voi"
},
{
"path": "libs/core/src/Effect.test.ts",
"chars": 1133,
"preview": "import { describe, expect, test, vi } from 'vitest';\n\nimport Effect from './Effect.js';\n\nclass TestEffect extends Effect"
}
]
// ... and 105 more files (download for full content)
About this extraction
This page contains the full source code of the olistic/warriorjs GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 305 files (550.6 KB), approximately 151.2k tokens, and a symbol index with 452 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.
Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.