Compare commits

...

105 Commits

Author SHA1 Message Date
b0dd090a60 Multidirectional map generation 2026-02-07 15:24:11 +11:00
88017add92 Fixed zooming bug black lines again 2026-02-07 14:36:18 +11:00
72c4251fc4 Added more side rooms, 8-10 per level 2026-02-07 14:30:53 +11:00
319ce20b6a fixed vertical black line bug 2026-02-07 14:08:17 +11:00
72d0f5d576 Fixed door saying open bugs 2026-02-07 13:34:12 +11:00
da544438e1 Fixed bug with vision when standing in doorway 2026-02-07 13:04:49 +11:00
02f850da35 Fixed coprse bug 2026-02-07 12:54:25 +11:00
4b50e341a7 Half changes to switch to exit level, Ran out of credits, re added enemies 2026-01-31 14:56:53 +11:00
f6fc057e4f Double level size, fixed skitzo track path 2026-01-31 14:17:08 +11:00
43b33733e9 Added rail tracks, cart and camera movement with arrow keys, removed enemies... 4 now 2026-01-31 13:47:34 +11:00
b18e2d08ba Character sprite switching - directionality - added dragon head 2026-01-31 10:58:12 +11:00
58b3726d21 Merge branch 'master' of https://gitea.peterstockings.com/peterstockings/rogue 2026-01-30 17:49:25 +11:00
41909fd8e6 Added flamethrower with buring effects 2026-01-30 17:49:23 +11:00
Peter Stockings
3a656c46fc Fix broken tests 2026-01-28 20:18:44 +11:00
c06823e08b Provided WASD movement 2026-01-28 19:07:22 +11:00
80e82f68a0 changed visual movement speed to be slower and made diagonal movement with arrow keys work 2026-01-28 18:59:35 +11:00
f01d8de15c Auto reload last reloadble weapon when reload is triggered 2026-01-28 18:32:52 +11:00
90aebc892a Make it so you cant shoot yourself 2026-01-28 18:19:46 +11:00
5d33d0e660 Added pregress bar for reloading 2026-01-28 18:06:42 +11:00
fc18008656 Add Billys assets (WIP) 2026-01-28 17:38:46 +11:00
Peter Stockings
c105719e4a Remove use of any in PlayerInputHandler 2026-01-27 20:55:03 +11:00
Peter Stockings
34554aa051 refactor game scene 2026-01-27 20:38:48 +11:00
Peter Stockings
2493d37c7a fix: when applying or cancelling upgrade clear border effect on items 2026-01-27 18:03:11 +11:00
Peter Stockings
cdedf47e0d Ensure that damage takes into effect stat bonuses from equipment 2026-01-27 17:48:20 +11:00
Peter Stockings
165cde6ca3 Add reload logic for ranged weapons 2026-01-27 17:35:34 +11:00
Peter Stockings
7260781f38 Hide sprites of corpses when in fog of war 2026-01-27 15:56:32 +11:00
Peter Stockings
a15bb3675b Shot trap status/damage of affected entity rather then just player 2026-01-27 14:14:27 +11:00
Peter Stockings
ef7d85750f Begin refactoring GameScene 2026-01-27 13:46:19 +11:00
Peter Stockings
1d7be54fd9 Upgrading should increase all numeric stats by 1 2026-01-25 17:25:10 +11:00
Peter Stockings
1931482abd feat: Enemies drop loot on death 2026-01-25 17:01:19 +11:00
Peter Stockings
9552364a60 feat: Add traps 2026-01-25 16:37:46 +11:00
Peter Stockings
18d4f0cdd4 refactor inventory overlay 2026-01-23 23:45:41 +11:00
Peter Stockings
c415becc38 feat: add upgrade scrolls 2026-01-23 23:26:55 +11:00
Peter Stockings
e130e6d174 feat: create item variants 2026-01-23 08:29:39 +11:00
Peter Stockings
d2039df8c8 refactor items logic 2026-01-22 22:04:23 +11:00
Peter Stockings
4129f5390f feat: add tooltip for equipment stats 2026-01-21 22:57:32 +11:00
Peter Stockings
84f5624ed5 feat: make equipment slots equipable and ensure stat bonuses are granted 2026-01-21 22:41:00 +11:00
Peter Stockings
c4b0a16dd4 Add a chain of carts and a start/stop button 2026-01-21 22:05:08 +11:00
Peter Stockings
9832d3d6b9 Randomly generate large track loop with multiple curves in Track exploration scene 2026-01-21 20:31:04 +11:00
Peter Stockings
7aaadee3c5 feat: Add scene with track loop and mine cart 2026-01-21 20:18:02 +11:00
Peter Stockings
ff6b6bfb73 feat: make items in backpack draggable to and from quickslot 2026-01-21 15:18:42 +11:00
Peter Stockings
a11f86d23b feat: handle stacking in inventory and show item count and current/max ammo of ranged weapons 2026-01-21 14:52:08 +11:00
Peter Stockings
9196c49976 Feat: Add swap, move, & drop items in quick slots 2026-01-21 14:33:29 +11:00
Peter Stockings
219c1c8899 Update ranged weapon quickslot text to match stackable items 2026-01-21 14:00:55 +11:00
Peter Stockings
516bf6e3c9 refactor: introduce core ECS for movement and AI 2026-01-21 13:49:26 +11:00
Peter Stockings
01124e66a7 Add kennys dungeon asset pack which has track tracks 2026-01-21 13:43:26 +11:00
Peter Stockings
75df62db66 Add test coverage for TargetingSystem 2026-01-20 23:22:33 +11:00
Peter Stockings
59a84b97e0 Fix broken GameScene test 2026-01-20 23:07:31 +11:00
Peter Stockings
327b6aa0eb Change targetting line to dashed 2026-01-20 23:05:18 +11:00
Peter Stockings
1a91aa5274 Change crosshair targeting sprite 2026-01-20 22:56:16 +11:00
Peter Stockings
d4f763d1d0 Add ammo counter for ranged items in quickslot 2026-01-20 21:35:34 +11:00
Peter Stockings
bac2c130aa Add gun to inventory that fires bullets 2026-01-20 21:31:21 +11:00
Peter Stockings
1713ba76de Add in weapons (guns + cross hair) sprites 2026-01-20 18:20:03 +11:00
Peter Stockings
0d00e76d6b Fix broken test 2026-01-20 18:19:14 +11:00
064952f254 Ensure projectiles dont get embedded in walls or blocking tiles 2026-01-18 13:44:48 +11:00
6447f01c77 Changed quick bar from 4 to 10 item slots 2026-01-18 13:37:51 +11:00
Peter Stockings
f344213f55 Add temporary character outline in equipment overlay 2026-01-07 23:54:27 +11:00
Peter Stockings
a55661ccdf Start updating look of inventory overlay 2026-01-07 20:48:58 +11:00
Peter Stockings
dd85891831 Update hud 2026-01-07 18:30:39 +11:00
Peter Stockings
47e15ccf5c Update look of quick slot and action buttons 2026-01-07 18:30:27 +11:00
Peter Stockings
d01dd8a4fc Update look of quickslots 2026-01-07 16:57:54 +11:00
Peter Stockings
4ca932e11c Add workflow to check lines of code excluding tests 2026-01-07 16:52:07 +11:00
Peter Stockings
b503199ba9 Remove reference to soldier sprite 2026-01-07 09:28:40 +11:00
Peter Stockings
fcd31cce68 Further refactoring 2026-01-07 09:19:38 +11:00
Peter Stockings
f9b1abee6e Cancel targetting if player moves 2026-01-06 22:39:02 +11:00
Peter Stockings
505f62ac97 Highlight active item slot and activate when shortcut key is pressed 2026-01-06 22:33:51 +11:00
Peter Stockings
309ab19e8c Attempting to move into tile that blocks shouldnt result in wait action 2026-01-06 21:23:25 +11:00
Peter Stockings
6e060013f7 Throwing an item shouldnt trigger wait 2026-01-06 21:14:25 +11:00
Peter Stockings
7ae1fa6671 Fix bug where when starting the game hp/exp bar and quick slot items wouldnt be rendered until after first move 2026-01-06 21:04:40 +11:00
Peter Stockings
9b1fc78409 Add in throwable items (dagger) from pixel dungeon 2026-01-06 20:58:53 +11:00
Peter Stockings
3b29180a00 Add quick slot and consumables (health and mana potions) 2026-01-06 18:23:34 +11:00
Peter Stockings
57fb85f62e When enemy is comes into site dont tween sprite from (0,0) to correct location, instead just create at correct location 2026-01-06 17:59:56 +11:00
Peter Stockings
a6bcf24cd0 Add button in bottom right to wait 2026-01-06 16:46:47 +11:00
Peter Stockings
a9779348e9 Allow melee attacking diagonally as well 2026-01-06 10:59:05 +11:00
Peter Stockings
0263495d0b Fix bug where slower enemies (ie rat) would never get scheduled a turn 2026-01-06 10:38:03 +11:00
Peter Stockings
a2a1d0cc58 Add more tests 2026-01-06 10:01:59 +11:00
Peter Stockings
cb1dfea33b Add test coverage command 2026-01-06 10:01:26 +11:00
Peter Stockings
7888f375e1 Add zoom and drag to move camera 2026-01-05 22:44:04 +11:00
Peter Stockings
d9da9f69a5 Add link to deployed game to readme 2026-01-05 22:21:54 +11:00
Peter Stockings
4b9dfa98b5 Add readme 2026-01-05 22:18:20 +11:00
Peter Stockings
b3954a6408 Close door after walking through again, and add more test coverage 2026-01-05 22:14:10 +11:00
Peter Stockings
b35cf5a964 Add openable doors to generated rooms 2026-01-05 21:48:19 +11:00
Peter Stockings
a01d4abdf7 Make grass block vision 2026-01-05 21:32:18 +11:00
Peter Stockings
39528d297e Grass becomes grass saplings when walked over 2026-01-05 21:19:42 +11:00
Peter Stockings
ecf58dded1 Change black empty tile to grass and make it destructable 2026-01-05 20:59:33 +11:00
Peter Stockings
a7091c70c6 Add in mana and an asset viewer 2026-01-05 18:57:17 +11:00
Peter Stockings
43d5dce2e5 Use rot-js for scheduling & path finding 2026-01-05 15:41:27 +11:00
Peter Stockings
50a922ca85 Use rot-js to create dungeon layout 2026-01-05 14:58:18 +11:00
Peter Stockings
45a1ed2253 Ensure enemies only lock onto player once they have line of sight 2026-01-05 14:46:04 +11:00
Peter Stockings
dba0f054db Fix for bug where when switching levels the player would jump between entrance to exit locations 2026-01-05 14:17:12 +11:00
Peter Stockings
d638d1a821 Fix bug where clicking new game on death screen didnt actually start new game 2026-01-05 14:12:13 +11:00
Peter Stockings
f86daac9ac Add more test coverage 2026-01-05 14:03:25 +11:00
Peter Stockings
ce68470ab1 Another refactor 2026-01-05 13:24:56 +11:00
Peter Stockings
ac86d612e2 Rename tiles0 asset to dungeon 2026-01-05 13:01:38 +11:00
Peter Stockings
e223bf4b40 Create enemy type 2026-01-05 13:00:16 +11:00
Peter Stockings
161da3a64a Add scene solely dedicated to preloading assets 2026-01-05 12:47:09 +11:00
Peter Stockings
86a6afd1df Add more stats, crit/block/accuracy/dodge/lifesteal 2026-01-05 12:39:43 +11:00
Peter Stockings
171abb681a Add character overlay, where skills and passives (changing this) can be set 2026-01-04 21:12:07 +11:00
Peter Stockings
f67f488764 Add placeholder backpack and inventory UI 2026-01-04 20:02:11 +11:00
Peter Stockings
2ca51945fc Fix issue where killing an enemy resulted in orb being rendered with rat sprite on top 2026-01-04 19:02:51 +11:00
Peter Stockings
b5314986e3 Add command to ensure typescript is valid and tests pass, and ensure this is run after task completion by LLMs 2026-01-04 18:54:30 +11:00
Peter Stockings
64994887dc Merge splash and start screen in to menu screen 2026-01-04 18:53:57 +11:00
Peter Stockings
83b7f35e57 Fix typescript errors in tests 2026-01-04 18:43:19 +11:00
Peter Stockings
29e46093f5 Add levelling up mechanics through experience gained via killing enemies 2026-01-04 18:36:31 +11:00
Peter Stockings
42cd77998d Use wall + floor assets from Pixel dungeon 2026-01-04 16:46:49 +11:00
134 changed files with 17227 additions and 1542 deletions

7
.agent/workflows/loc.md Normal file
View File

@@ -0,0 +1,7 @@
---
description: Count lines of TypeScript source code excluding tests
---
// turbo-all
1. Count TypeScript lines excluding tests: `(Get-ChildItem -Recurse -Include *.ts -Exclude *.test.ts,*.spec.ts -Path . | Where-Object { $_.FullName -notmatch '\\node_modules\\' } | Get-Content |
Measure-Object -Line).Lines`

View File

@@ -0,0 +1,7 @@
---
description: Verify code quality by running TypeScript checks and tests
---
// turbo-all
1. Run the verification script: `bun run verify`
2. Ensure no errors were reported.

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ dist-ssr
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
coverage
.eslintcache

78
README.md Normal file
View File

@@ -0,0 +1,78 @@
# Rogue
**[Play Online](https://rogue.peterstockings.com/)**
A roguelike dungeon crawler built with TypeScript, Phaser, and Rot.js.
## Getting Started
Follow these instructions to get the project up and running on your local machine.
### Prerequisites
This project uses [Bun](https://bun.sh/) as its runtime and package manager. Ensure you have Bun installed on your system.
### Installation
Clone the repository and install the dependencies:
```bash
bun install
```
### Running the Game
Start the development server:
```bash
bun run dev
```
### Accessing the Game
Once the server is running, open your browser and navigate to the URL shown in the terminal. typically:
[http://localhost:5173](http://localhost:5173)
## Development Workflow
We strive to maintain a high quality of code. Please follow these guidelines when contributing.
### Making Changes
1. **Create a Feature Branch**: Always create a new branch for your changes.
```bash
git checkout -b feature/my-new-feature
```
2. **Add Tests**: Ensure that new functionality is covered by unit tests.
3. **Run Verification**: Before merging your changes, run the verification script to ensure type safety and pass all tests.
```bash
bun run verify
```
### Running Tests
To run the test suite manually:
```bash
bun run test
```
## Project Structure
The source code is organized as follows:
- `src/core`: Contains core game logic, configuration, and type definitions.
- `src/engine`: Handles game systems like simulation, turn management, and world generation.
- `src/rendering`: Manages visual aspects using Phaser, including the renderer and FOV.
- `src/scenes`: Defines the different Phaser scenes (e.g., Game, Menu, Preload).
- `src/ui`: Contains User Interface components and logic.
## Built With
- [Phaser](https://phaser.io/) - HTML5 Game Framework
- [Rot.js](https://ondras.github.io/rot.js/hp/) - Roguelike Toolkit
- [Vite](https://vitejs.dev/) - Frontend Tooling
- [TypeScript](https://www.typescriptlang.org/) - Typed JavaScript
- [Vitest](https://vitest.dev/) - Unit Testing Framework

BIN
assets/ArtStyleTesting.kra Normal file

Binary file not shown.

241
bun.lock
View File

@@ -9,6 +9,11 @@
"rot-js": "^2.2.1", "rot-js": "^2.2.1",
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0",
"@vitest/coverage-v8": "^4.0.16",
"eslint": "^9.39.2",
"globals": "^17.0.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.2.4", "vite": "^7.2.4",
"vitest": "^4.0.16", "vitest": "^4.0.16",
@@ -16,6 +21,16 @@
}, },
}, },
"packages": { "packages": {
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="],
"@babel/parser": ["@babel/parser@7.28.5", "", { "dependencies": { "@babel/types": "^7.28.5" }, "bin": "./bin/babel-parser.js" }, "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ=="],
"@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
@@ -68,8 +83,38 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
"@eslint/config-array": ["@eslint/config-array@0.21.1", "", { "dependencies": { "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA=="],
"@eslint/config-helpers": ["@eslint/config-helpers@0.4.2", "", { "dependencies": { "@eslint/core": "^0.17.0" } }, "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw=="],
"@eslint/core": ["@eslint/core@0.17.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ=="],
"@eslint/eslintrc": ["@eslint/eslintrc@3.3.3", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.1", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ=="],
"@eslint/js": ["@eslint/js@9.39.2", "", {}, "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA=="],
"@eslint/object-schema": ["@eslint/object-schema@2.1.7", "", {}, "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA=="],
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="], "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.54.0", "", { "os": "android", "cpu": "arm" }, "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng=="],
"@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="], "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
@@ -122,6 +167,30 @@
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.52.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/type-utils": "8.52.0", "@typescript-eslint/utils": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.52.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-okqtOgqu2qmZJ5iN4TWlgfF171dZmx2FzdOv2K/ixL2LZWDStL8+JgQerI2sa8eAEfoydG9+0V96m7V+P8yE1Q=="],
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.52.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-iIACsx8pxRnguSYhHiMn2PvhvfpopO9FXHyn1mG5txZIsAaB6F0KwbFnUQN3KCiG3Jcuad/Cao2FAs1Wp7vAyg=="],
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.52.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.52.0", "@typescript-eslint/types": "^8.52.0", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-xD0MfdSdEmeFa3OmVqonHi+Cciab96ls1UhIF/qX/O/gPu5KXD0bY9lu33jj04fjzrXHcuvjBcBC+D3SNSadaw=="],
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0" } }, "sha512-ixxqmmCcc1Nf8S0mS0TkJ/3LKcC8mruYJPOU6Ia2F/zUUR4pApW7LzrpU3JmtePbRUTes9bEqRc1Gg4iyRnDzA=="],
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.52.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-jl+8fzr/SdzdxWJznq5nvoI7qn2tNYV/ZBAEcaFMVXf+K6jmXvAFrgo/+5rxgnL152f//pDEAYAhhBAZGrVfwg=="],
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0", "@typescript-eslint/utils": "8.52.0", "debug": "^4.4.3", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-JD3wKBRWglYRQkAtsyGz1AewDu3mTc7NtRjR/ceTyGoPqmdS5oCdx/oZMWD5Zuqmo6/MpsYs0wp6axNt88/2EQ=="],
"@typescript-eslint/types": ["@typescript-eslint/types@8.52.0", "", {}, "sha512-LWQV1V4q9V4cT4H5JCIx3481iIFxH1UkVk+ZkGGAV1ZGcjGI9IoFOfg3O6ywz8QqCDEp7Inlg6kovMofsNRaGg=="],
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.52.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.52.0", "@typescript-eslint/tsconfig-utils": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/visitor-keys": "8.52.0", "debug": "^4.4.3", "minimatch": "^9.0.5", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.0.0" } }, "sha512-XP3LClsCc0FsTK5/frGjolyADTh3QmsLp6nKd476xNI9CsSsLnmn4f0jrzNoAulmxlmNIpeXuHYeEQv61Q6qeQ=="],
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.52.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.52.0", "@typescript-eslint/types": "8.52.0", "@typescript-eslint/typescript-estree": "8.52.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-wYndVMWkweqHpEpwPhwqE2lnD2DxC6WVLupU/DOt/0/v+/+iQbbzO3jOHjmBMnhu0DgLULvOaU4h4pwHYi2oRQ=="],
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.52.0", "", { "dependencies": { "@typescript-eslint/types": "8.52.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-ink3/Zofus34nmBsPjow63FP5M7IGff0RKAgqR6+CFpdk22M7aLwC9gOcLGYqr7MczLPzZVERW9hRog3O4n1sQ=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@4.0.16", "", { "dependencies": { "@bcoe/v8-coverage": "^1.0.2", "@vitest/utils": "4.0.16", "ast-v8-to-istanbul": "^0.3.8", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.2.0", "magicast": "^0.5.1", "obug": "^2.1.1", "std-env": "^3.10.0", "tinyrainbow": "^3.0.3" }, "peerDependencies": { "@vitest/browser": "4.0.16", "vitest": "4.0.16" }, "optionalPeers": ["@vitest/browser"] }, "sha512-2rNdjEIsPRzsdu6/9Eq0AYAzYdpP6Bx9cje9tL3FE5XzXRQF1fNU9pe/1yE8fCrS0HD+fBtt6gLPh6LI57tX7A=="],
"@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="], "@vitest/expect": ["@vitest/expect@4.0.16", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "@types/chai": "^5.2.2", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "chai": "^6.2.1", "tinyrainbow": "^3.0.3" } }, "sha512-eshqULT2It7McaJkQGLkPjPjNph+uevROGuIMJdG3V+0BSR2w9u6J9Lwu+E8cK5TETlfou8GRijhafIMhXsimA=="],
"@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="], "@vitest/mocker": ["@vitest/mocker@4.0.16", "", { "dependencies": { "@vitest/spy": "4.0.16", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0-0" }, "optionalPeers": ["msw", "vite"] }, "sha512-yb6k4AZxJTB+q9ycAvsoxGn+j/po0UaPgajllBgt1PzoMAAmJGYFdDk0uCcRcxb3BrME34I6u8gHZTQlkqSZpg=="],
@@ -136,30 +205,162 @@
"@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="], "@vitest/utils": ["@vitest/utils@4.0.16", "", { "dependencies": { "@vitest/pretty-format": "4.0.16", "tinyrainbow": "^3.0.3" } }, "sha512-h8z9yYhV3e1LEfaQ3zdypIrnAg/9hguReGZoS7Gl0aBG5xgA410zBqECqmaF/+RkTggRsfnzc1XaAHA6bmUufA=="],
"acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="],
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
"ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"ast-v8-to-istanbul": ["ast-v8-to-istanbul@0.3.10", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.31", "estree-walker": "^3.0.3", "js-tokens": "^9.0.1" } }, "sha512-p4K7vMz2ZSk3wN8l5o3y2bJAoZXT3VuJI5OLTATY/01CYWumWvwkUw0SqDBnNq6IiTO3qDa1eSQDibAV8g7XOQ=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="],
"callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="],
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="], "es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="], "esbuild": ["esbuild@0.27.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.2", "@esbuild/android-arm": "0.27.2", "@esbuild/android-arm64": "0.27.2", "@esbuild/android-x64": "0.27.2", "@esbuild/darwin-arm64": "0.27.2", "@esbuild/darwin-x64": "0.27.2", "@esbuild/freebsd-arm64": "0.27.2", "@esbuild/freebsd-x64": "0.27.2", "@esbuild/linux-arm": "0.27.2", "@esbuild/linux-arm64": "0.27.2", "@esbuild/linux-ia32": "0.27.2", "@esbuild/linux-loong64": "0.27.2", "@esbuild/linux-mips64el": "0.27.2", "@esbuild/linux-ppc64": "0.27.2", "@esbuild/linux-riscv64": "0.27.2", "@esbuild/linux-s390x": "0.27.2", "@esbuild/linux-x64": "0.27.2", "@esbuild/netbsd-arm64": "0.27.2", "@esbuild/netbsd-x64": "0.27.2", "@esbuild/openbsd-arm64": "0.27.2", "@esbuild/openbsd-x64": "0.27.2", "@esbuild/openharmony-arm64": "0.27.2", "@esbuild/sunos-x64": "0.27.2", "@esbuild/win32-arm64": "0.27.2", "@esbuild/win32-ia32": "0.27.2", "@esbuild/win32-x64": "0.27.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw=="],
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
"eslint": ["eslint@9.39.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.1", "@eslint/config-helpers": "^0.4.2", "@eslint/core": "^0.17.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.39.2", "@eslint/plugin-kit": "^0.4.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^8.4.0", "eslint-visitor-keys": "^4.2.1", "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw=="],
"eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="],
"eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="],
"espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="],
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
"estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
"eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="], "eventemitter3": ["eventemitter3@5.0.1", "", {}, "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="], "expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
"flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
"globals": ["globals@17.0.0", "", {}, "sha512-gv5BeD2EssA793rlFWVPMMCqefTlpusw6/2TbAVMy0FzcG8wKJn4O+NqJ4+XWmmwrayJgw5TzrmWjFgmz1XPqw=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
"istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"js-tokens": ["js-tokens@9.0.1", "", {}, "sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ=="],
"js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="],
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
"lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"magicast": ["magicast@0.5.1", "", { "dependencies": { "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "source-map-js": "^1.2.1" } }, "sha512-xrHS24IxaLrvuo613F719wvOIv9xPHFWQHuvGUBmPnCA/3MQxKI3b+r7n1jAoDHmsbC5bRhTZYR77invLAxVnw=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
"nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="],
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="], "obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
"parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="],
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="], "pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"phaser": ["phaser@3.90.0", "", { "dependencies": { "eventemitter3": "^5.0.1" } }, "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ=="], "phaser": ["phaser@3.90.0", "", { "dependencies": { "eventemitter3": "^5.0.1" } }, "sha512-/cziz/5ZIn02uDkC9RzN8VF9x3Gs3XdFFf9nkiMEQT3p7hQlWuyjy4QWosU802qqno2YSLn2BfqwOKLv/sSVfQ=="],
@@ -170,10 +371,22 @@
"postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
"resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="],
"rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="], "rollup": ["rollup@4.54.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.54.0", "@rollup/rollup-android-arm64": "4.54.0", "@rollup/rollup-darwin-arm64": "4.54.0", "@rollup/rollup-darwin-x64": "4.54.0", "@rollup/rollup-freebsd-arm64": "4.54.0", "@rollup/rollup-freebsd-x64": "4.54.0", "@rollup/rollup-linux-arm-gnueabihf": "4.54.0", "@rollup/rollup-linux-arm-musleabihf": "4.54.0", "@rollup/rollup-linux-arm64-gnu": "4.54.0", "@rollup/rollup-linux-arm64-musl": "4.54.0", "@rollup/rollup-linux-loong64-gnu": "4.54.0", "@rollup/rollup-linux-ppc64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-gnu": "4.54.0", "@rollup/rollup-linux-riscv64-musl": "4.54.0", "@rollup/rollup-linux-s390x-gnu": "4.54.0", "@rollup/rollup-linux-x64-gnu": "4.54.0", "@rollup/rollup-linux-x64-musl": "4.54.0", "@rollup/rollup-openharmony-arm64": "4.54.0", "@rollup/rollup-win32-arm64-msvc": "4.54.0", "@rollup/rollup-win32-ia32-msvc": "4.54.0", "@rollup/rollup-win32-x64-gnu": "4.54.0", "@rollup/rollup-win32-x64-msvc": "4.54.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw=="],
"rot-js": ["rot-js@2.2.1", "", {}, "sha512-lItXH31vj4ebdypayCx9dh98qPr57E7jGW2lVMKxtBHooU3xpGRtLS8kdJIni232tvPJ8Sl0+aqXZj8c6W0MGw=="], "rot-js": ["rot-js@2.2.1", "", {}, "sha512-lItXH31vj4ebdypayCx9dh98qPr57E7jGW2lVMKxtBHooU3xpGRtLS8kdJIni232tvPJ8Sl0+aqXZj8c6W0MGw=="],
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
@@ -182,6 +395,10 @@
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="], "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
@@ -190,12 +407,36 @@
"tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="], "tinyrainbow": ["tinyrainbow@3.0.3", "", {}, "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q=="],
"ts-api-utils": ["ts-api-utils@2.4.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-3TaVTaAv2gTiMB35i3FiGJaRfwb3Pyn/j3m/bfAvGe8FB7CF6u+LMYqYlDh7reQf7UNvoTvdfAqHGmPGOSsPmA=="],
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
"vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="], "vite": ["vite@7.3.0", "", { "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-dZwN5L1VlUBewiP6H9s2+B3e3Jg96D0vzN+Ry73sOefebhYr9f94wwkMNN/9ouoU8pV1BqA1d1zGk8928cx0rg=="],
"vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="], "vitest": ["vitest@4.0.16", "", { "dependencies": { "@vitest/expect": "4.0.16", "@vitest/mocker": "4.0.16", "@vitest/pretty-format": "4.0.16", "@vitest/runner": "4.0.16", "@vitest/snapshot": "4.0.16", "@vitest/spy": "4.0.16", "@vitest/utils": "4.0.16", "es-module-lexer": "^1.7.0", "expect-type": "^1.2.2", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", "std-env": "^3.10.0", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", "tinyrainbow": "^3.0.3", "vite": "^6.0.0 || ^7.0.0", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "4.0.16", "@vitest/browser-preview": "4.0.16", "@vitest/browser-webdriverio": "4.0.16", "@vitest/ui": "4.0.16", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-E4t7DJ9pESL6E3I8nFjPa4xGUd3PmiWDLsDztS2qXSJWfHtbQnwAWylaBvSNY48I3vr8PTqIZlyK8TE3V3CA4Q=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="],
"@eslint/eslintrc/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="],
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
"@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="],
} }
} }

54
eslint.config.js Normal file
View File

@@ -0,0 +1,54 @@
// @ts-check
import tseslint from "@typescript-eslint/eslint-plugin";
import tsparser from "@typescript-eslint/parser";
export default [
{
files: ["src/**/*.ts", "src/**/*.tsx"],
languageOptions: {
parser: tsparser,
parserOptions: {
ecmaVersion: "latest",
sourceType: "module",
},
},
plugins: {
"@typescript-eslint": tseslint,
},
rules: {
// TypeScript recommended rules (subset)
"@typescript-eslint/no-explicit-any": "warn", // Warn, not error for gradual migration
"@typescript-eslint/no-unused-vars": [
"error",
{
argsIgnorePattern: "^_",
varsIgnorePattern: "^_",
},
],
"@typescript-eslint/explicit-function-return-type": "off",
"@typescript-eslint/no-non-null-assertion": "warn",
// General code quality
"no-console": "off", // Game dev needs console
"prefer-const": "error",
"no-var": "error",
},
},
{
files: ["src/**/*.test.ts", "src/**/__tests__/**/*.ts"],
rules: {
// More lenient for tests
"@typescript-eslint/no-explicit-any": "off",
"@typescript-eslint/no-non-null-assertion": "off",
},
},
{
ignores: [
"node_modules/**",
"dist/**",
"coverage/**",
"*.config.js",
"*.config.ts",
],
},
];

View File

@@ -7,9 +7,19 @@
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest" "test": "vitest",
"test:coverage": "vitest run --coverage",
"check": "tsc --noEmit",
"lint": "eslint src --ext .ts,.tsx",
"lint:fix": "eslint src --ext .ts,.tsx --fix",
"verify": "bun run check && bun run test"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.52.0",
"@typescript-eslint/parser": "^8.52.0",
"@vitest/coverage-v8": "^4.0.16",
"eslint": "^9.39.2",
"globals": "^17.0.0",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "^7.2.4", "vite": "^7.2.4",
"vitest": "^4.0.16" "vitest": "^4.0.16"

View File

Before

Width:  |  Height:  |  Size: 3.4 KiB

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

Before

Width:  |  Height:  |  Size: 6.3 KiB

After

Width:  |  Height:  |  Size: 6.3 KiB

View File

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 624 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 896 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 414 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 181 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 192 B

View File

Before

Width:  |  Height:  |  Size: 616 KiB

After

Width:  |  Height:  |  Size: 616 KiB

View File

@@ -0,0 +1,26 @@
import { vi } from 'vitest';
// Stub global window for Phaser device detection
if (typeof window === 'undefined') {
(globalThis as any).window = {
location: { href: '', origin: '' },
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
cordova: undefined,
navigator: { userAgent: 'node' }
};
}
if (typeof document === 'undefined') {
(globalThis as any).document = {
createElement: vi.fn(() => ({
getContext: vi.fn(),
style: {}
})),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
};
}
if (typeof navigator === 'undefined') {
(globalThis as any).navigator = { userAgent: 'node' };
}

View File

@@ -0,0 +1,70 @@
import { describe, it, expect } from 'vitest';
import { seededRandom, manhattan, lerp, raycast } from '../math';
describe('Math Utilities', () => {
describe('seededRandom', () => {
it('should return consistent results for the same seed', () => {
const rng1 = seededRandom(12345);
const rng2 = seededRandom(12345);
expect(rng1()).toBe(rng2());
expect(rng1()).toBe(rng2());
expect(rng1()).toBe(rng2());
});
it('should return different results for different seeds', () => {
const rng1 = seededRandom(12345);
const rng2 = seededRandom(67890);
expect(rng1()).not.toBe(rng2());
});
});
describe('raycast', () => {
it('should return straight horizontal line', () => {
const points = raycast(0, 0, 3, 0);
expect(points).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 3, y: 0 }
]);
});
it('should return straight vertical line', () => {
const points = raycast(0, 0, 0, 3);
expect(points).toEqual([
{ x: 0, y: 0 },
{ x: 0, y: 1 },
{ x: 0, y: 2 },
{ x: 0, y: 3 }
]);
});
it('should return diagonal line', () => {
const points = raycast(0, 0, 2, 2);
expect(points).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 2 }
]);
});
});
describe('manhattan', () => {
it('should calculate correct distance', () => {
expect(manhattan({ x: 0, y: 0 }, { x: 3, y: 4 })).toBe(7);
expect(manhattan({ x: 1, y: 1 }, { x: 4, y: 5 })).toBe(7);
expect(manhattan({ x: -1, y: -1 }, { x: -2, y: -2 })).toBe(2);
});
});
describe('lerp', () => {
it('should interpolate correctly', () => {
expect(lerp(0, 10, 0.5)).toBe(5);
expect(lerp(0, 10, 0)).toBe(0);
expect(lerp(0, 10, 1)).toBe(10);
expect(lerp(10, 20, 0.5)).toBe(15);
});
});
});

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import {
TileType,
isBlocking,
isDestructible,
blocksSight,
getDestructionResult,
isDestructibleByWalk
} from '../terrain';
describe('Terrain', () => {
describe('Tile Definitions', () => {
it('should correctly identify blocking tiles', () => {
expect(isBlocking(TileType.WALL)).toBe(true);
expect(isBlocking(TileType.WALL_DECO)).toBe(true);
expect(isBlocking(TileType.WATER)).toBe(true);
expect(isBlocking(TileType.EMPTY)).toBe(false);
expect(isBlocking(TileType.GRASS)).toBe(false);
expect(isBlocking(TileType.EXIT)).toBe(false);
});
it('should correctly identify destructible tiles', () => {
expect(isDestructible(TileType.GRASS)).toBe(true);
expect(isDestructible(TileType.DOOR_CLOSED)).toBe(true);
expect(isDestructible(TileType.WALL)).toBe(false);
expect(isDestructible(TileType.EMPTY)).toBe(false);
});
it('should correctly identify tiles blocking sight', () => {
expect(blocksSight(TileType.WALL)).toBe(true);
expect(blocksSight(TileType.WALL_DECO)).toBe(true);
expect(blocksSight(TileType.DOOR_CLOSED)).toBe(true);
expect(blocksSight(TileType.GRASS)).toBe(true); // Grass blocks vision in this game logic
expect(blocksSight(TileType.EMPTY)).toBe(false);
expect(blocksSight(TileType.EXIT)).toBe(false);
expect(blocksSight(TileType.GRASS_SAPLINGS)).toBe(false);
});
it('should return correct destruction result', () => {
expect(getDestructionResult(TileType.GRASS)).toBe(TileType.GRASS_SAPLINGS);
expect(getDestructionResult(TileType.DOOR_CLOSED)).toBe(TileType.DOOR_OPEN);
expect(getDestructionResult(TileType.DOOR_OPEN)).toBe(TileType.DOOR_CLOSED);
expect(getDestructionResult(TileType.WALL)).toBeUndefined();
});
it('should correctly identify tiles destructible by walk', () => {
expect(isDestructibleByWalk(TileType.GRASS)).toBe(true);
expect(isDestructibleByWalk(TileType.DOOR_CLOSED)).toBe(true);
expect(isDestructibleByWalk(TileType.DOOR_OPEN)).toBe(true); // Should be closable by walk
expect(isDestructibleByWalk(TileType.WALL)).toBe(false);
});
it('should handle unknown tile types gracefully', () => {
const unknownTile = 999;
expect(isBlocking(unknownTile)).toBe(false);
expect(isDestructible(unknownTile)).toBe(false);
expect(blocksSight(unknownTile)).toBe(false);
expect(getDestructionResult(unknownTile)).toBeUndefined();
expect(isDestructibleByWalk(unknownTile)).toBe(false);
});
});
});

View File

@@ -0,0 +1,21 @@
import { describe, it, expect } from 'vitest';
import { key, sleep } from '../utils';
describe('Utils', () => {
describe('key', () => {
it('should generate correct key string', () => {
expect(key(1, 2)).toBe('1,2');
expect(key(0, 0)).toBe('0,0');
expect(key(-5, 10)).toBe('-5,10');
});
});
describe('sleep', () => {
it('should resolve after delay', async () => {
const start = Date.now();
await sleep(10);
const end = Date.now();
expect(end - start).toBeGreaterThanOrEqual(10);
});
});
});

View File

@@ -1,13 +1,50 @@
export interface AnimationConfig {
key: string;
textureKey: string;
frames: number[];
frameRate: number;
repeat: number;
}
export const GAME_CONFIG = { export const GAME_CONFIG = {
player: { player: {
initialStats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }, initialStats: {
maxHp: 20,
hp: 20,
maxMana: 20,
mana: 20,
attack: 5,
defense: 2,
level: 1,
exp: 0,
expToNextLevel: 10,
statPoints: 0,
skillPoints: 0,
strength: 10,
dexterity: 10,
intelligence: 10,
// Offensive
critChance: 5,
critMultiplier: 150,
accuracy: 90,
lifesteal: 0,
// Defensive
evasion: 5,
blockChance: 0,
// Utility
luck: 0,
passiveNodes: [] as string[]
},
speed: 100, speed: 100,
viewRadius: 8 viewRadius: 8,
reloadDuration: 3,
}, },
map: { map: {
width: 60, width: 120,
height: 40, height: 80,
minRooms: 8, minRooms: 8,
maxRooms: 13, maxRooms: 13,
roomMinWidth: 5, roomMinWidth: 5,
@@ -16,47 +53,166 @@ export const GAME_CONFIG = {
roomMaxHeight: 10 roomMaxHeight: 10
}, },
enemy: { enemies: {
baseHpPerLevel: 2, rat: {
baseHp: 8, baseHp: 8,
baseAttack: 3, baseAttack: 3,
attackPerTwoLevels: 1, baseDefense: 0,
minSpeed: 80, minSpeed: 80,
maxSpeed: 130, maxSpeed: 110,
maxDefense: 2, expValue: 5
baseCountPerLevel: 1, },
baseCount: 3, bat: {
randomBonus: 4 baseHp: 6,
baseAttack: 4,
baseDefense: 0,
minSpeed: 110,
maxSpeed: 140,
expValue: 8
}
},
enemyScaling: {
baseCount: 15,
baseCountPerFloor: 5,
hpPerFloor: 5,
attackPerTwoFloors: 1,
expMultiplier: 1.2
},
trapScaling: {
baseCount: 0,
baseCountPerFloor: 0.5
},
leveling: {
baseExpToNextLevel: 10,
expMultiplier: 1.5,
hpGainPerLevel: 5,
manaGainPerLevel: 3,
attackGainPerLevel: 1,
statPointsPerLevel: 5,
skillPointsPerLevel: 1
},
mana: {
regenPerTurn: 2,
regenInterval: 3 // Regenerate every 3 turns
}, },
rendering: { rendering: {
tileSize: 24, tileSize: 16,
cameraZoom: 2, cameraZoom: 2,
minZoom: 1,
maxZoom: 4,
zoomStep: 1,
wallColor: 0x2b2b2b, wallColor: 0x2b2b2b,
floorColor: 0x161616, floorColor: 0x161616,
exitColor: 0xffd166, exitColor: 0xffd166,
playerColor: 0x66ff66, playerColor: 0x66ff66,
enemyColor: 0xff6666, enemyColor: 0xff6666,
pathPreviewColor: 0x3355ff, pathPreviewColor: 0x3355ff,
expOrbColor: 0x33ccff,
expTextColor: 0x33ccff,
levelUpColor: 0xffff00,
fogAlphaFloor: 0.15, fogAlphaFloor: 0.15,
fogAlphaWall: 0.35, fogAlphaWall: 0.35,
visibleMinAlpha: 0.35, visibleMinAlpha: 0.35,
visibleMaxAlpha: 1.0, visibleMaxAlpha: 1.0,
visibleStrengthFactor: 0.65 visibleStrengthFactor: 0.65,
tracks: {
endTop: 67,
endBottom: 68,
cornerNE: 93,
horizontal: 70,
cornerSE: 69,
endLeft: 79,
endRight: 80,
vertical: 81,
cornerSW: 71,
cornerNW: 95
},
mineCarts: {
horizontal: 54,
vertical: 55,
turning: 56
},
moveDuration: 62 // Visual duration for movement in ms
}, },
ui: { ui: {
// ... rest of content ...
minimapPanelWidth: 340, minimapPanelWidth: 340,
minimapPanelHeight: 220, minimapPanelHeight: 220,
minimapPadding: 20, minimapPadding: 20,
menuPanelWidth: 340, menuPanelWidth: 340,
menuPanelHeight: 220 menuPanelHeight: 220,
// Targeting
targetingLineColor: 0xff0000,
targetingLineWidth: 2,
targetingLineAlpha: 0.7,
targetingLineDash: 6,
targetingLineGap: 4,
targetingLineShorten: 8
}, },
gameplay: { gameplay: {
energyThreshold: 100, energyThreshold: 100,
actionCost: 100 actionCost: 100,
ceramicDragonHead: {
range: 4,
initialDamage: 7,
burnDamage: 3,
burnDuration: 5,
rechargeTurns: 20,
maxCharges: 3
} }
},
assets: {
spritesheets: [
{ key: "warrior", path: "assets/sprites/actors/player/warrior.png", frameConfig: { frameWidth: 12, frameHeight: 15 } },
{ key: "rat", path: "assets/sprites/actors/enemies/rat.png", frameConfig: { frameWidth: 16, frameHeight: 15 } },
{ key: "bat", path: "assets/sprites/actors/enemies/bat.png", frameConfig: { frameWidth: 15, frameHeight: 15 } },
{ key: "dungeon", path: "assets/tilesets/dungeon.png", frameConfig: { frameWidth: 16, frameHeight: 16 } },
{ key: "kennys_dungeon", path: "assets/tilesets/kennys_dungeon.png", frameConfig: { frameWidth: 16, frameHeight: 16 } },
{ key: "items", path: "assets/sprites/items/items.png", frameConfig: { frameWidth: 16, frameHeight: 16 } },
{ key: "weapons", path: "assets/sprites/items/weapons.png", frameConfig: { frameWidth: 24, frameHeight: 24 } }
],
images: [
{ key: "splash_bg", path: "assets/ui/splash_bg.png" },
{ key: "character_outline", path: "assets/ui/character_outline.png" },
{ key: "ceramic_dragon_head", path: "assets/sprites/items/ceramic_dragon_head.png" },
{ key: "PriestessNorth", path: "assets/sprites/priestess/PriestessNorth.png" },
{ key: "PriestessSouth", path: "assets/sprites/priestess/PriestessSouth.png" },
{ key: "PriestessEast", path: "assets/sprites/priestess/PriestessEast.png" },
{ key: "PriestessWest", path: "assets/sprites/priestess/PriestessWest.png" },
{ key: "mine_cart", path: "assets/sprites/items/mine_cart.png" },
{ key: "track_straight", path: "assets/sprites/items/track_straight.png" },
{ key: "track_corner", path: "assets/sprites/items/track_corner.png" },
{ key: "track_vertical", path: "assets/sprites/items/track_vertical.png" },
{ key: "track_switch", path: "assets/sprites/items/track_switch.png" }
]
},
animations: [
// Warrior
{ key: "warrior-idle", textureKey: "warrior", frames: [0, 0, 0, 1, 0, 0, 1, 1], frameRate: 2, repeat: -1 },
{ key: "warrior-run", textureKey: "warrior", frames: [2, 3, 4, 5, 6, 7], frameRate: 15, repeat: -1 },
{ key: "warrior-die", textureKey: "warrior", frames: [8, 9, 10, 11, 12], frameRate: 10, repeat: 0 },
// Rat
{ key: "rat-idle", textureKey: "rat", frames: [0, 0, 0, 1], frameRate: 4, repeat: -1 },
{ key: "rat-run", textureKey: "rat", frames: [6, 7, 8, 9, 10], frameRate: 10, repeat: -1 },
{ key: "rat-die", textureKey: "rat", frames: [11, 12, 13, 14], frameRate: 10, repeat: 0 },
// Bat
{ key: "bat-idle", textureKey: "bat", frames: [0, 1], frameRate: 8, repeat: -1 },
{ key: "bat-run", textureKey: "bat", frames: [0, 1], frameRate: 12, repeat: -1 },
{ key: "bat-die", textureKey: "bat", frames: [4, 5, 6], frameRate: 10, repeat: 0 },
]
} as const; } as const;
export type GameConfig = typeof GAME_CONFIG; export type GameConfig = typeof GAME_CONFIG;

View File

@@ -0,0 +1,177 @@
import type { ItemType } from "../types";
// =============================================================================
// Variant Stat Modifiers
// =============================================================================
export type VariantStatModifiers = Partial<{
defense: number;
attack: number;
speed: number;
maxHp: number;
critChance: number;
critMultiplier: number;
accuracy: number;
evasion: number;
blockChance: number;
lifesteal: number;
luck: number;
// Consumable-specific multiplier
effectMultiplier: number;
}>;
export interface ItemVariant {
prefix: string;
glowColor: number;
statModifiers: VariantStatModifiers;
applicableTo: ItemType[];
}
// =============================================================================
// Armour Variants
// =============================================================================
export const ARMOUR_VARIANTS = {
heavy: {
prefix: "Heavy",
glowColor: 0x4488ff, // Blue
statModifiers: { defense: 2, speed: -1 },
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
},
light: {
prefix: "Light",
glowColor: 0x44ff88, // Green
statModifiers: { speed: 1, evasion: 5, defense: -1 },
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
},
reinforced: {
prefix: "Reinforced",
glowColor: 0xcccccc, // Silver
statModifiers: { defense: 1, blockChance: 5 },
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
},
blessed: {
prefix: "Blessed",
glowColor: 0xffd700, // Gold
statModifiers: { maxHp: 5, defense: 1 },
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
},
cursed: {
prefix: "Cursed",
glowColor: 0x8844ff, // Purple
statModifiers: { defense: 3, luck: -10 },
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
},
spiked: {
prefix: "Spiked",
glowColor: 0xff4444, // Red
statModifiers: { attack: 1, defense: 1 },
applicableTo: ["BodyArmour", "Helmet", "Gloves", "Boots"] as ItemType[],
},
} as const;
// =============================================================================
// Weapon Variants
// =============================================================================
export const WEAPON_VARIANTS = {
sharp: {
prefix: "Sharp",
glowColor: 0xffffff, // White
statModifiers: { attack: 2, critChance: 5 },
applicableTo: ["Weapon"] as ItemType[],
},
heavy_weapon: {
prefix: "Heavy",
glowColor: 0x4488ff, // Blue
statModifiers: { attack: 3, speed: -1 },
applicableTo: ["Weapon"] as ItemType[],
},
balanced: {
prefix: "Balanced",
glowColor: 0x44ffff, // Cyan
statModifiers: { attack: 1, accuracy: 10 },
applicableTo: ["Weapon"] as ItemType[],
},
venomous: {
prefix: "Venomous",
glowColor: 0x88ff44, // Toxic green
statModifiers: { attack: 1 },
applicableTo: ["Weapon"] as ItemType[],
},
vampiric: {
prefix: "Vampiric",
glowColor: 0xcc2222, // Crimson
statModifiers: { lifesteal: 5 },
applicableTo: ["Weapon"] as ItemType[],
},
brutal: {
prefix: "Brutal",
glowColor: 0xff8844, // Orange
statModifiers: { critMultiplier: 0.5 },
applicableTo: ["Weapon"] as ItemType[],
},
} as const;
// =============================================================================
// Consumable Variants
// =============================================================================
export const CONSUMABLE_VARIANTS = {
potent: {
prefix: "Potent",
glowColor: 0xff6644, // Red-orange
statModifiers: { effectMultiplier: 1.5 },
applicableTo: ["Consumable"] as ItemType[],
},
diluted: {
prefix: "Diluted",
glowColor: 0xaaaaaa, // Pale gray
statModifiers: { effectMultiplier: 0.5 },
applicableTo: ["Consumable"] as ItemType[],
},
enchanted: {
prefix: "Enchanted",
glowColor: 0xff44ff, // Magenta
statModifiers: { effectMultiplier: 2 },
applicableTo: ["Consumable"] as ItemType[],
},
} as const;
// =============================================================================
// Combined Variant Lookup
// =============================================================================
export const ALL_VARIANTS = {
...ARMOUR_VARIANTS,
...WEAPON_VARIANTS,
...CONSUMABLE_VARIANTS,
} as const;
export type ArmourVariantId = keyof typeof ARMOUR_VARIANTS;
export type WeaponVariantId = keyof typeof WEAPON_VARIANTS;
export type ConsumableVariantId = keyof typeof CONSUMABLE_VARIANTS;
export type ItemVariantId = keyof typeof ALL_VARIANTS;
// =============================================================================
// Helper Functions
// =============================================================================
export function getVariant(variantId: ItemVariantId): ItemVariant {
return ALL_VARIANTS[variantId];
}
export function getVariantGlowColor(variantId: ItemVariantId): number {
return ALL_VARIANTS[variantId].glowColor;
}
export function isVariantApplicable(variantId: ItemVariantId, itemType: ItemType): boolean {
const variant = ALL_VARIANTS[variantId];
return variant.applicableTo.includes(itemType);
}
export function getApplicableVariants(itemType: ItemType): ItemVariantId[] {
return (Object.keys(ALL_VARIANTS) as ItemVariantId[]).filter(
(id) => isVariantApplicable(id, itemType)
);
}

269
src/core/config/Items.ts Normal file
View File

@@ -0,0 +1,269 @@
import type {
ConsumableItem,
MeleeWeaponItem,
RangedWeaponItem,
ArmourItem,
AmmoItem,
CeramicDragonHeadItem
} from "../types";
import { GAME_CONFIG } from "../config/GameConfig";
// =============================================================================
// Per-Type Template Lists (Immutable)
// =============================================================================
export const CONSUMABLES = {
health_potion: {
name: "Health Potion",
textureKey: "items",
spriteIndex: 57,
healAmount: 5,
stackable: true,
},
throwing_dagger: {
name: "Throwing Dagger",
textureKey: "items",
spriteIndex: 15,
attack: 4,
throwable: true,
stackable: true,
},
upgrade_scroll: {
name: "Upgrade Scroll",
textureKey: "items",
spriteIndex: 79,
stackable: true,
},
} as const;
export const RANGED_WEAPONS = {
pistol: {
name: "Pistol",
textureKey: "weapons",
spriteIndex: 1,
attack: 10,
range: 8,
magazineSize: 6,
ammoType: "9mm",
projectileSpeed: 15,
fireSound: "shoot",
},
} as const;
export const MELEE_WEAPONS = {
iron_sword: {
name: "Iron Sword",
textureKey: "items",
spriteIndex: 2,
attack: 2,
},
} as const;
export const AMMO = {
ammo_9mm: {
name: "9mm Ammo",
textureKey: "weapons",
spriteIndex: 23,
ammoType: "9mm",
stackable: true,
},
} as const;
export const ARMOUR = {
leather_armor: {
name: "Leather Armor",
textureKey: "items",
spriteIndex: 25,
defense: 2,
},
} as const;
// Combined lookup for rendering (e.g., projectile sprites)
export const ALL_TEMPLATES = {
...CONSUMABLES,
...RANGED_WEAPONS,
...MELEE_WEAPONS,
...AMMO,
...ARMOUR,
} as const;
// =============================================================================
// Type-Safe IDs (derived from templates)
// =============================================================================
export type ConsumableId = keyof typeof CONSUMABLES;
export type RangedWeaponId = keyof typeof RANGED_WEAPONS;
export type MeleeWeaponId = keyof typeof MELEE_WEAPONS;
export type AmmoId = keyof typeof AMMO;
export type ArmourId = keyof typeof ARMOUR;
export type ItemTemplateId = keyof typeof ALL_TEMPLATES;
// =============================================================================
// Factory Functions
// =============================================================================
import {
ALL_VARIANTS,
type ArmourVariantId,
type WeaponVariantId,
type ConsumableVariantId
} from "./ItemVariants";
export function createConsumable(
id: ConsumableId,
quantity = 1,
variant?: ConsumableVariantId
): ConsumableItem {
const t = CONSUMABLES[id];
const v = variant ? ALL_VARIANTS[variant] : null;
// Apply effect multiplier for consumables
const effectMult = v?.statModifiers.effectMultiplier ?? 1;
const baseHealAmount = "healAmount" in t ? t.healAmount : undefined;
const finalHealAmount = baseHealAmount ? Math.floor(baseHealAmount * effectMult) : undefined;
const name = v ? `${v.prefix} ${t.name}` : t.name;
return {
id,
name,
type: "Consumable",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
stackable: t.stackable ?? false,
quantity,
variant,
stats: {
hp: finalHealAmount,
attack: "attack" in t ? t.attack : undefined,
},
throwable: "throwable" in t ? t.throwable : undefined,
};
}
export function createRangedWeapon(
id: RangedWeaponId,
variant?: WeaponVariantId
): RangedWeaponItem {
const t = RANGED_WEAPONS[id];
const v = variant ? ALL_VARIANTS[variant] : null;
const name = v ? `${v.prefix} ${t.name}` : t.name;
const attackBonus = (v?.statModifiers as { attack?: number })?.attack ?? 0;
return {
id,
name,
type: "Weapon",
weaponType: "ranged",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
currentAmmo: t.magazineSize,
reloadingTurnsLeft: 0,
variant,
stats: {
attack: t.attack + attackBonus,
range: t.range,
magazineSize: t.magazineSize,
ammoType: t.ammoType,
projectileSpeed: t.projectileSpeed,
fireSound: t.fireSound,
},
};
}
export function createMeleeWeapon(
id: MeleeWeaponId,
variant?: WeaponVariantId
): MeleeWeaponItem {
const t = MELEE_WEAPONS[id];
const v = variant ? ALL_VARIANTS[variant] : null;
const name = v ? `${v.prefix} ${t.name}` : t.name;
const attackBonus = (v?.statModifiers as { attack?: number })?.attack ?? 0;
return {
id,
name,
type: "Weapon",
weaponType: "melee",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
variant,
stats: {
attack: t.attack + attackBonus,
},
};
}
export function createAmmo(id: AmmoId, quantity = 10): AmmoItem {
const t = AMMO[id];
return {
id,
name: t.name,
type: "Ammo",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
ammoType: t.ammoType,
stackable: true,
quantity,
};
}
export function createArmour(
id: ArmourId,
variant?: ArmourVariantId
): ArmourItem {
const t = ARMOUR[id];
const v = variant ? ALL_VARIANTS[variant] : null;
const name = v ? `${v.prefix} ${t.name}` : t.name;
const defenseBonus = v?.statModifiers.defense ?? 0;
return {
id,
name,
type: "BodyArmour",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
variant,
stats: {
defense: t.defense + defenseBonus,
},
};
}
export function createUpgradeScroll(quantity = 1): ConsumableItem {
const t = CONSUMABLES["upgrade_scroll"];
return {
id: "upgrade_scroll",
name: t.name,
type: "Consumable",
textureKey: t.textureKey,
spriteIndex: t.spriteIndex,
stackable: true,
quantity,
};
}
export function createCeramicDragonHead(): CeramicDragonHeadItem {
const config = GAME_CONFIG.gameplay.ceramicDragonHead;
return {
id: "ceramic_dragon_head",
name: "Ceramic Dragon Head",
type: "Weapon",
weaponType: "ceramic_dragon_head",
textureKey: "ceramic_dragon_head",
spriteIndex: 0,
charges: config.maxCharges,
maxCharges: config.maxCharges,
lastRechargeTurn: 0,
stats: {
attack: config.initialDamage,
range: config.range,
},
};
}
// Legacy export for backward compatibility during migration
export const ITEMS = ALL_TEMPLATES;

8
src/core/config/ui.ts Normal file
View File

@@ -0,0 +1,8 @@
export const UI_CONFIG = {
targeting: {
crosshair: {
textureKey: "weapons",
frame: 35
}
}
};

View File

@@ -1,17 +1,42 @@
import type { Vec2 } from "./types"; import type { Vec2 } from "./types";
export function seededRandom(seed: number): () => number { export function seededRandom(seed: number) {
let state = seed; let s = seed % 2147483647;
if (s <= 0) s += 2147483646;
return () => { return () => {
state = (state * 1103515245 + 12345) & 0x7fffffff; s = (s * 16807) % 2147483647;
return state / 0x7fffffff; return (s - 1) / 2147483646;
}; };
} }
/**
* Bresenham's line algorithm to get all points between two coordinates.
*/
export function raycast(x0: number, y0: number, x1: number, y1: number): Vec2[] {
const points: Vec2[] = [];
let startX = x0;
let startY = y0;
const dx = Math.abs(x1 - x0);
const dy = Math.abs(y1 - y0);
const sx = (x0 < x1) ? 1 : -1;
const sy = (y0 < y1) ? 1 : -1;
let err = dx - dy;
while(true) {
points.push({ x: startX, y: startY });
if (startX === x1 && startY === y1) break;
const e2 = 2 * err;
if (e2 > -dy) { err -= dy; startX += sx; }
if (e2 < dx) { err += dx; startY += sy; }
}
return points;
}
export function manhattan(a: Vec2, b: Vec2): number { export function manhattan(a: Vec2, b: Vec2): number {
return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); return Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
} }
export function lerp(a: number, b: number, t: number): number { export function lerp(start: number, end: number, t: number): number {
return a + (b - a) * t; return start * (1 - t) + end * t;
} }

69
src/core/terrain.ts Normal file
View File

@@ -0,0 +1,69 @@
export const TileType = {
EMPTY: 1,
WALL: 4,
GRASS: 15,
GRASS_SAPLINGS: 2,
EMPTY_DECO: 24,
WALL_DECO: 12,
EXIT: 8,
WATER: 63, // Unused but kept for safety/legacy
DOOR_CLOSED: 5,
DOOR_OPEN: 6,
TRACK: 30, // Restored to 30 to fix duplicate key error
SWITCH_OFF: 31,
SWITCH_ON: 32
} as const;
export type TileType = typeof TileType[keyof typeof TileType];
export interface TileBehavior {
id: TileType;
isBlocking: boolean;
isDestructible: boolean;
isDestructibleByWalk?: boolean;
blocksVision?: boolean;
destructsTo?: TileType;
}
export const TILE_DEFINITIONS: Record<number, TileBehavior> = {
[TileType.EMPTY]: { id: TileType.EMPTY, isBlocking: false, isDestructible: false },
[TileType.WALL]: { id: TileType.WALL, isBlocking: true, isDestructible: false },
[TileType.GRASS]: { id: TileType.GRASS, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.GRASS_SAPLINGS },
[TileType.GRASS_SAPLINGS]: { id: TileType.GRASS_SAPLINGS, isBlocking: false, isDestructible: false },
[TileType.EMPTY_DECO]: { id: TileType.EMPTY_DECO, isBlocking: false, isDestructible: false },
[TileType.WALL_DECO]: { id: TileType.WALL_DECO, isBlocking: true, isDestructible: false },
[TileType.EXIT]: { id: TileType.EXIT, isBlocking: false, isDestructible: false },
[TileType.WATER]: { id: TileType.WATER, isBlocking: true, isDestructible: false },
[TileType.DOOR_CLOSED]: { id: TileType.DOOR_CLOSED, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: true, destructsTo: TileType.DOOR_OPEN },
[TileType.DOOR_OPEN]: { id: TileType.DOOR_OPEN, isBlocking: false, isDestructible: true, isDestructibleByWalk: true, blocksVision: false, destructsTo: TileType.DOOR_CLOSED },
[TileType.TRACK]: { id: TileType.TRACK, isBlocking: false, isDestructible: false },
[TileType.SWITCH_OFF]: { id: TileType.SWITCH_OFF, isBlocking: true, isDestructible: false },
[TileType.SWITCH_ON]: { id: TileType.SWITCH_ON, isBlocking: true, isDestructible: false }
};
export function isBlocking(tile: number): boolean {
const def = TILE_DEFINITIONS[tile];
return def ? def.isBlocking : false;
}
export function isDestructible(tile: number): boolean {
const def = TILE_DEFINITIONS[tile];
return def ? def.isDestructible : false;
}
export function isDestructibleByWalk(tile: number): boolean {
const def = TILE_DEFINITIONS[tile];
return def ? !!def.isDestructibleByWalk : false;
}
export function blocksSight(tile: number): boolean {
const def = TILE_DEFINITIONS[tile];
return def ? (def.isBlocking || !!def.blocksVision) : false;
}
export function getDestructionResult(tile: number): number | undefined {
const def = TILE_DEFINITIONS[tile];
return def ? def.destructsTo : undefined;
}

View File

@@ -2,53 +2,237 @@ export type EntityId = number;
export type Vec2 = { x: number; y: number }; export type Vec2 = { x: number; y: number };
export type Tile = 0 | 1; // 0 = floor, 1 = wall export type Tile = number;
export type EnemyType = "rat" | "bat";
export type ActorType = "player" | EnemyType;
export type EnemyAIState = "wandering" | "alerted" | "pursuing" | "searching";
export type Action = export type Action =
| { type: "move"; dx: number; dy: number } | { type: "move"; dx: number; dy: number }
| { type: "attack"; targetId: EntityId } | { type: "attack"; targetId: EntityId }
| { type: "throw" }
| { type: "wait" }; | { type: "wait" };
export type SimEvent = export type SimEvent =
| { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 } | { type: "moved"; actorId: EntityId; from: Vec2; to: Vec2 }
| { type: "attacked"; attackerId: EntityId; targetId: EntityId } | { type: "attacked"; attackerId: EntityId; targetId: EntityId }
| { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number } | { type: "damaged"; targetId: EntityId; amount: number; hp: number; x: number; y: number; isCrit?: boolean; isBlock?: boolean }
| { type: "killed"; targetId: EntityId; killerId: EntityId; x: number; y: number; victimType?: "player" | "rat" | "bat" } | { type: "dodged"; targetId: EntityId; x: number; y: number }
| { type: "waited"; actorId: EntityId }; | { type: "healed"; actorId: EntityId; amount: number; x: number; y: number }
| { type: "killed"; targetId: EntityId; killerId: EntityId; x: number; y: number; victimType?: ActorType }
| { type: "waited"; actorId: EntityId }
| { type: "exp-collected"; actorId: EntityId; amount: number; x: number; y: number }
| { type: "orb-spawned"; orbId: EntityId; x: number; y: number }
| { type: "leveled-up"; actorId: EntityId; level: number; x: number; y: number }
| { type: "enemy-alerted"; enemyId: EntityId; x: number; y: number }
| { type: "move-blocked"; actorId: EntityId; x: number; y: number }
| { type: "mission-complete" };
export type Stats = { export type Stats = {
maxHp: number; maxHp: number;
hp: number; hp: number;
maxMana: number;
mana: number;
attack: number; attack: number;
defense: number; defense: number;
level: number;
exp: number;
expToNextLevel: number;
// Offensive
critChance: number;
critMultiplier: number;
accuracy: number;
lifesteal: number;
// Defensive
evasion: number;
blockChance: number;
// Utility
luck: number;
// New Progression Fields
statPoints: number;
skillPoints: number;
strength: number;
dexterity: number;
intelligence: number;
passiveNodes: string[]; // List of IDs for allocated passive nodes
};
export type ItemType =
| "Weapon"
| "Offhand"
| "BodyArmour"
| "Helmet"
| "Gloves"
| "Boots"
| "Amulet"
| "Ring"
| "Belt"
| "Currency"
| "Consumable"
| "Ammo";
export interface BaseItem {
id: string;
name: string;
textureKey: string;
spriteIndex: number;
quantity?: number;
stackable?: boolean;
variant?: string; // ItemVariantId - stored as string to avoid circular imports
upgradeLevel?: number; // Enhancement level (+1, +2, etc.)
}
export interface MeleeWeaponItem extends BaseItem {
type: "Weapon";
weaponType: "melee";
stats: {
attack: number;
};
}
export interface RangedWeaponItem extends BaseItem {
type: "Weapon";
weaponType: "ranged";
currentAmmo: number; // Runtime state - moved to top level for easier access
reloadingTurnsLeft: number;
stats: {
attack: number;
range: number;
magazineSize: number;
ammoType: string;
projectileSpeed: number;
fireSound?: string;
};
}
export interface CeramicDragonHeadItem extends BaseItem {
type: "Weapon";
weaponType: "ceramic_dragon_head";
charges: number;
maxCharges: number;
lastRechargeTurn: number;
stats: {
attack: number;
range: number;
};
}
export type WeaponItem = MeleeWeaponItem | RangedWeaponItem | CeramicDragonHeadItem;
export interface ArmourItem extends BaseItem {
type: "BodyArmour" | "Helmet" | "Gloves" | "Boots";
stats: {
defense: number;
};
}
export interface ConsumableItem extends BaseItem {
type: "Consumable";
stats?: {
hp?: number;
attack?: number;
};
throwable?: boolean;
}
export interface AmmoItem extends BaseItem {
type: "Ammo";
ammoType: string;
}
export interface MiscItem extends BaseItem {
type: "Currency" | "Ring" | "Amulet" | "Belt" | "Offhand";
stats?: Partial<Stats>;
}
export type Item = WeaponItem | ArmourItem | ConsumableItem | AmmoItem | MiscItem;
export type Equipment = {
mainHand?: Item;
offHand?: Item;
bodyArmour?: Item;
helmet?: Item;
gloves?: Item;
boots?: Item;
amulet?: Item;
ringLeft?: Item;
ringRight?: Item;
belt?: Item;
}; };
export type Inventory = { export type Inventory = {
gold: number; gold: number;
items: string[]; items: Item[];
}; };
export type RunState = { export type RunState = {
stats: Stats; stats: Stats;
inventory: Inventory; inventory: Inventory;
seed: number;
lastReloadableWeaponId?: string | null;
}; };
export type Actor = { export interface BaseActor {
id: EntityId; id: EntityId;
isPlayer: boolean;
type?: "player" | "rat" | "bat";
pos: Vec2; pos: Vec2;
speed: number; type?: string;
energy: number; }
stats?: Stats; export interface CombatantActor extends BaseActor {
category: "combatant";
isPlayer: boolean;
type: ActorType;
speed: number;
stats: Stats;
inventory?: Inventory; inventory?: Inventory;
}; equipment?: Equipment;
// Enemy AI state
aiState?: EnemyAIState;
alertedAt?: number;
lastKnownPlayerPos?: Vec2;
// Turn scheduling
energy: number;
}
export interface CollectibleActor extends BaseActor {
category: "collectible";
type: "exp_orb";
expAmount: number;
}
export interface ItemDropActor extends BaseActor {
category: "item_drop";
// type: string; // "health_potion", etc. or reuse Item
item: Item;
}
export type Actor = CombatantActor | CollectibleActor | ItemDropActor;
export type World = { export type World = {
width: number; width: number;
height: number; height: number;
tiles: Tile[]; tiles: Tile[];
actors: Map<EntityId, Actor>;
exit: Vec2; exit: Vec2;
trackPath: Vec2[];
}; };
export interface UIUpdatePayload {
world: World;
playerId: EntityId;
player: CombatantActor | null; // Added for ECS Access
floorIndex: number;
uiState: {
targetingItemId: string | null;
};
}

View File

@@ -0,0 +1,348 @@
import type {
World,
EntityId,
Actor,
CombatantActor,
CollectibleActor,
ItemDropActor,
Vec2,
EnemyAIState
} from "../core/types";
import type { ECSWorld } from "./ecs/World";
/**
* Centralized accessor for game entities.
* Provides a unified interface for querying actors from the World.
*
* This facade:
* - Centralizes entity access patterns
* - Makes it easy to migrate to ECS later
* - Reduces scattered world.actors calls
*/
export class EntityAccessor {
private _playerId: EntityId;
private ecsWorld: ECSWorld;
private actorCache: Map<EntityId, Actor> = new Map();
constructor(
_world: World,
playerId: EntityId,
ecsWorld: ECSWorld
) {
this._playerId = playerId;
this.ecsWorld = ecsWorld;
}
/**
* Updates the world reference (called when loading new floors).
*/
updateWorld(_world: World, playerId: EntityId, ecsWorld: ECSWorld): void {
this._playerId = playerId;
this.ecsWorld = ecsWorld;
this.actorCache.clear();
}
private entityToActor(id: EntityId): Actor | null {
if (!this.ecsWorld) return null;
// Check cache first
const cached = this.actorCache.get(id);
if (cached) {
// Double check it still exists in ECS
if (!this.ecsWorld.hasEntity(id)) {
this.actorCache.delete(id);
return null;
}
return cached;
}
const pos = this.ecsWorld.getComponent(id, "position");
if (!pos) return null;
// Check for combatant
const stats = this.ecsWorld.getComponent(id, "stats");
const actorType = this.ecsWorld.getComponent(id, "actorType");
if (stats && actorType) {
const energyComp = this.ecsWorld.getComponent(id, "energy");
const playerComp = this.ecsWorld.getComponent(id, "player");
const ai = this.ecsWorld.getComponent(id, "ai");
const inventory = this.ecsWorld.getComponent(id, "inventory");
const equipment = this.ecsWorld.getComponent(id, "equipment");
// Create a proxy-like object to ensure writes persist to ECS components
let localEnergy = 0;
const actor = {
id,
// Pass Reference to PositionComponent so moves persist
pos: pos,
category: "combatant",
isPlayer: !!playerComp,
type: actorType.type,
// Pass Reference to StatsComponent
stats: stats,
// Speed defaults
speed: energyComp?.speed ?? 100,
// Pass Reference (or fallback)
inventory: inventory ?? { gold: 0, items: [] },
equipment: equipment
} as CombatantActor;
// Manually define 'energy' property to proxy to component
Object.defineProperty(actor, 'energy', {
get: () => energyComp ? energyComp.current : localEnergy,
set: (v: number) => {
if (energyComp) {
energyComp.current = v;
} else {
localEnergy = v;
}
},
enumerable: true,
configurable: true
});
// Proxy AI state properties
Object.defineProperty(actor, 'aiState', {
get: () => ai?.state,
set: (v: EnemyAIState) => { if (ai) ai.state = v; },
enumerable: true,
configurable: true
});
Object.defineProperty(actor, 'alertedAt', {
get: () => ai?.alertedAt,
set: (v: number) => { if (ai) ai.alertedAt = v; },
enumerable: true,
configurable: true
});
Object.defineProperty(actor, 'lastKnownPlayerPos', {
get: () => ai?.lastKnownPlayerPos,
set: (v: Vec2) => { if (ai) ai.lastKnownPlayerPos = v; },
enumerable: true,
configurable: true
});
this.actorCache.set(id, actor);
return actor;
}
// Check for collectible
const collectible = this.ecsWorld.getComponent(id, "collectible");
if (collectible) {
const actor = {
id,
pos: pos, // Reference
category: "collectible",
type: "exp_orb",
expAmount: collectible.amount
} as CollectibleActor;
this.actorCache.set(id, actor);
return actor;
}
// Check for Item Drop
const groundItem = this.ecsWorld.getComponent(id, "groundItem");
if (groundItem) {
const actor = {
id,
pos: pos,
category: "item_drop",
item: groundItem.item
} as ItemDropActor;
this.actorCache.set(id, actor);
return actor;
}
return null;
}
// ==========================================
// Player Access
// ==========================================
/**
* Gets the player's entity ID.
*/
get playerId(): EntityId {
return this._playerId;
}
/**
* Gets the player entity.
*/
getPlayer(): CombatantActor | null {
const actor = this.entityToActor(this._playerId);
if (actor?.category === "combatant") return actor as CombatantActor;
return null;
}
/**
* Gets the player's current position.
*/
getPlayerPos(): Vec2 | null {
const player = this.getPlayer();
return player ? { ...player.pos } : null;
}
/**
* Checks if the player exists (is alive).
*/
isPlayerAlive(): boolean {
return this.ecsWorld.hasEntity(this._playerId) && (this.ecsWorld.getComponent(this._playerId, "position") !== undefined);
}
// ==========================================
// Generic Actor Access
// ==========================================
/**
* Gets any actor by ID.
*/
getActor(id: EntityId): Actor | null {
return this.entityToActor(id);
}
/**
* Gets a combatant actor by ID.
*/
getCombatant(id: EntityId): CombatantActor | null {
const actor = this.entityToActor(id);
if (actor?.category === "combatant") return actor as CombatantActor;
return null;
}
/**
* Checks if an actor exists.
*/
hasActor(id: EntityId): boolean {
return this.ecsWorld.hasEntity(id) && (this.ecsWorld.getComponent(id, "position") !== undefined);
}
// ==========================================
// Spatial Queries
// ==========================================
/**
* Gets all actors at a specific position.
*/
getActorsAt(x: number, y: number): Actor[] {
// Query ECS
return [...this.getAllActors()].filter(a => a.pos.x === x && a.pos.y === y);
}
/**
* Finds an enemy combatant at a specific position.
*/
findEnemyAt(x: number, y: number): CombatantActor | null {
const actors = this.getActorsAt(x, y);
for (const actor of actors) {
if (actor.category === "combatant" && !actor.isPlayer) {
return actor;
}
}
return null;
}
/**
* Checks if there's any enemy at the given position.
*/
hasEnemyAt(x: number, y: number): boolean {
return this.findEnemyAt(x, y) !== null;
}
/**
* Finds a collectible at a specific position.
*/
findCollectibleAt(x: number, y: number): CollectibleActor | null {
const actors = this.getActorsAt(x, y);
for (const actor of actors) {
if (actor.category === "collectible") {
return actor;
}
}
return null;
}
/**
* Finds an item drop at a specific position.
*/
findItemDropAt(x: number, y: number): ItemDropActor | null {
const actors = this.getActorsAt(x, y);
for (const actor of actors) {
if (actor.category === "item_drop") {
return actor;
}
}
return null;
}
// ==========================================
// Collection Queries
// ==========================================
/**
* Gets all enemy combatants in the world.
*/
getEnemies(): CombatantActor[] {
return [...this.getAllActors()].filter(
(a): a is CombatantActor => a.category === "combatant" && !a.isPlayer
);
}
/**
* Gets all combatants (player + enemies).
*/
getCombatants(): CombatantActor[] {
return [...this.getAllActors()].filter(
(a): a is CombatantActor => a.category === "combatant"
);
}
/**
* Gets all collectibles (exp orbs, etc.).
*/
getCollectibles(): CollectibleActor[] {
return [...this.getAllActors()].filter(
(a): a is CollectibleActor => a.category === "collectible"
);
}
/**
* Gets all item drops.
*/
getItemDrops(): ItemDropActor[] {
return [...this.getAllActors()].filter(
(a): a is ItemDropActor => a.category === "item_drop"
);
}
/**
* Iterates over all actors (for rendering, etc.).
*/
getAllActors(): IterableIterator<Actor> {
const actors: Actor[] = [];
// Get all entities with position (candidates)
const entities = this.ecsWorld.getEntitiesWith("position");
for (const id of entities) {
const actor = this.entityToActor(id);
if (actor) actors.push(actor);
}
return actors.values();
}
/**
* Removes an actor from the world.
*/
removeActor(id: EntityId): void {
this.ecsWorld.destroyEntity(id);
}
/**
* Access to the raw ECS world if needed for specialized systems.
*/
get context(): ECSWorld | undefined {
return this.ecsWorld;
}
}

View File

@@ -0,0 +1,59 @@
import { type CombatantActor, type Stats } from "../core/types";
export class ProgressionManager {
allocateStat(player: CombatantActor, statName: string) {
if (!player.stats || player.stats.statPoints <= 0) return;
player.stats.statPoints--;
if (statName === "strength") {
player.stats.strength++;
player.stats.maxHp += 2;
player.stats.hp += 2;
player.stats.attack += 0.2;
} else if (statName === "dexterity") {
player.stats.dexterity++;
player.speed += 1;
} else if (statName === "intelligence") {
player.stats.intelligence++;
if (player.stats.intelligence % 5 === 0) {
player.stats.defense++;
}
}
}
allocatePassive(player: CombatantActor, nodeId: string) {
if (!player.stats || player.stats.skillPoints <= 0) return;
if (player.stats.passiveNodes.includes(nodeId)) return;
player.stats.skillPoints--;
player.stats.passiveNodes.push(nodeId);
// Apply bonuses
switch (nodeId) {
case "off_1":
player.stats.attack += 2;
break;
case "off_2":
player.stats.attack += 4;
break;
case "def_1":
player.stats.maxHp += 10;
player.stats.hp += 10;
break;
case "def_2":
player.stats.defense += 2;
break;
case "util_1":
player.speed += 5;
break;
case "util_2":
player.stats.expToNextLevel = Math.floor(player.stats.expToNextLevel * 0.9);
break;
}
}
calculateStats(baseStats: Stats): Stats {
return baseStats;
}
}

View File

@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { applyAction } from '../simulation/simulation';
import { type World, type Actor, type EntityId } from '../../core/types';
import { EntityAccessor } from '../EntityAccessor';
import { ECSWorld } from '../ecs/World';
import { TileType } from '../../core/terrain';
const createTestWorld = (): World => {
return {
width: 10,
height: 10,
tiles: new Array(100).fill(TileType.EMPTY),
exit: { x: 9, y: 9 },
trackPath: []
};
};
describe('Multi-step Door Walkthrough Bug', () => {
let ecsWorld: ECSWorld;
let world: World;
beforeEach(() => {
ecsWorld = new ECSWorld();
world = createTestWorld();
});
it('door should close after player walks through and moves away', () => {
const playerId = 1 as EntityId;
const player: Actor = {
id: playerId, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: { hp: 10, maxHp: 10 } as any, energy: 0
} as any;
ecsWorld.addComponent(playerId, "position", player.pos);
ecsWorld.addComponent(playerId, "player", {});
ecsWorld.addComponent(playerId, "stats", { hp: 10, maxHp: 10 } as any);
ecsWorld.addComponent(playerId, "actorType", { type: "player" });
ecsWorld.addComponent(playerId, "energy", { current: 0, speed: 100 });
const accessor = new EntityAccessor(world, playerId, ecsWorld);
// Place a closed door at (4,3)
const doorIdx = 3 * 10 + 4;
world.tiles[doorIdx] = TileType.DOOR_CLOSED;
// 1. Move onto the door
console.log("Step 1: Moving onto door at (4,3)");
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
expect(player.pos).toEqual({ x: 4, y: 3 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
// 2. Move off the door to (5,3)
console.log("Step 2: Moving off door to (5,3)");
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
expect(player.pos).toEqual({ x: 5, y: 3 });
// This is where it's reported to stay open sometimes
console.log("Door tile state after Step 2:", world.tiles[doorIdx]);
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_CLOSED);
// 3. Move further away to (6,3)
console.log("Step 3: Moving further away to (6,3)");
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
expect(player.pos).toEqual({ x: 6, y: 3 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_CLOSED);
});
it('door should close after player walks through it diagonally', () => {
const playerId = 1 as EntityId;
const player: Actor = {
id: playerId, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: { hp: 10, maxHp: 10 } as any, energy: 0
} as any;
ecsWorld.addComponent(playerId, "position", player.pos);
ecsWorld.addComponent(playerId, "player", {});
ecsWorld.addComponent(playerId, "stats", { hp: 10, maxHp: 10 } as any);
ecsWorld.addComponent(playerId, "actorType", { type: "player" });
ecsWorld.addComponent(playerId, "energy", { current: 0, speed: 100 });
const accessor = new EntityAccessor(world, playerId, ecsWorld);
// Place a closed door at (4,4)
const doorIdx = 4 * 10 + 4;
world.tiles[doorIdx] = TileType.DOOR_CLOSED;
// 1. Move onto the door diagonally
applyAction(world, playerId, { type: "move", dx: 1, dy: 1 }, accessor);
expect(player.pos).toEqual({ x: 4, y: 4 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
// 2. Move off the door diagonally to (5,5)
applyAction(world, playerId, { type: "move", dx: 1, dy: 1 }, accessor);
expect(player.pos).toEqual({ x: 5, y: 5 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_CLOSED);
});
it('door should stay open while player is standing on it (wait action)', () => {
const playerId = 1 as EntityId;
const player: Actor = {
id: playerId, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: { hp: 10, maxHp: 10 } as any, energy: 0
} as any;
ecsWorld.addComponent(playerId, "position", player.pos);
ecsWorld.addComponent(playerId, "player", {});
ecsWorld.addComponent(playerId, "stats", { hp: 10, maxHp: 10 } as any);
ecsWorld.addComponent(playerId, "actorType", { type: "player" });
ecsWorld.addComponent(playerId, "energy", { current: 0, speed: 100 });
const accessor = new EntityAccessor(world, playerId, ecsWorld);
// Place a closed door at (4,3)
const doorIdx = 3 * 10 + 4;
world.tiles[doorIdx] = TileType.DOOR_CLOSED;
// 1. Move onto the door
applyAction(world, playerId, { type: "move", dx: 1, dy: 0 }, accessor);
expect(player.pos).toEqual({ x: 4, y: 3 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
// 2. Wait on the door
applyAction(world, playerId, { type: "wait" }, accessor);
expect(player.pos).toEqual({ x: 4, y: 3 });
expect(world.tiles[doorIdx]).toBe(TileType.DOOR_OPEN);
});
});

View File

@@ -0,0 +1,276 @@
import { describe, it, expect, beforeEach } from "vitest";
import { EntityAccessor } from "../EntityAccessor";
import { ECSWorld } from "../ecs/World";
import type { World, CombatantActor, CollectibleActor, ItemDropActor, Actor, EntityId } from "../../core/types";
function createMockWorld(): World {
return {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 },
trackPath: []
};
}
function createPlayer(id: number, x: number, y: number): CombatantActor {
return {
id: id as EntityId,
pos: { x, y },
category: "combatant",
isPlayer: true,
type: "player",
speed: 100,
energy: 0,
stats: {
maxHp: 20, hp: 20, maxMana: 10, mana: 10,
attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0,
evasion: 5, blockChance: 0, luck: 0,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
passiveNodes: [],
},
};
}
function createEnemy(id: number, x: number, y: number, type: "rat" | "bat" = "rat"): CombatantActor {
return {
id: id as EntityId,
pos: { x, y },
category: "combatant",
isPlayer: false,
type,
speed: 80,
energy: 0,
stats: {
maxHp: 10, hp: 10, maxMana: 0, mana: 0,
attack: 3, defense: 1, level: 1, exp: 0, expToNextLevel: 10,
critChance: 0, critMultiplier: 100, accuracy: 80, lifesteal: 0,
evasion: 0, blockChance: 0, luck: 0,
statPoints: 0, skillPoints: 0, strength: 5, dexterity: 5, intelligence: 5,
passiveNodes: [],
},
};
}
function createExpOrb(id: number, x: number, y: number): CollectibleActor {
return {
id: id as EntityId,
pos: { x, y },
category: "collectible",
type: "exp_orb",
expAmount: 5,
};
}
function createItemDrop(id: number, x: number, y: number): ItemDropActor {
return {
id: id as EntityId,
pos: { x, y },
category: "item_drop",
item: {
id: "health_potion",
name: "Health Potion",
type: "Consumable",
textureKey: "items",
spriteIndex: 0,
},
};
}
describe("EntityAccessor", () => {
let world: World;
let ecsWorld: ECSWorld;
let accessor: EntityAccessor;
const PLAYER_ID = 1;
beforeEach(() => {
world = createMockWorld();
ecsWorld = new ECSWorld();
accessor = new EntityAccessor(world, PLAYER_ID as EntityId, ecsWorld);
});
function syncActor(actor: Actor) {
ecsWorld.addComponent(actor.id, "position", actor.pos);
ecsWorld.addComponent(actor.id, "name", { name: actor.id.toString() });
if (actor.category === "combatant") {
const c = actor as CombatantActor;
ecsWorld.addComponent(actor.id, "stats", c.stats);
ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed });
ecsWorld.addComponent(actor.id, "actorType", { type: c.type });
if (c.isPlayer) {
ecsWorld.addComponent(actor.id, "player", {});
} else {
ecsWorld.addComponent(actor.id, "ai", { state: "wandering" });
}
} else if (actor.category === "collectible") {
ecsWorld.addComponent(actor.id, "collectible", { type: "exp_orb", amount: (actor as CollectibleActor).expAmount });
} else if (actor.category === "item_drop") {
ecsWorld.addComponent(actor.id, "groundItem", { item: (actor as ItemDropActor).item });
}
}
describe("Player Access", () => {
it("getPlayer returns player when exists", () => {
const player = createPlayer(PLAYER_ID, 5, 5);
syncActor(player);
expect(accessor.getPlayer()?.id).toBe(player.id);
});
it("getPlayer returns null when player doesn't exist", () => {
expect(accessor.getPlayer()).toBeNull();
});
it("getPlayerPos returns position copy", () => {
const player = createPlayer(PLAYER_ID, 3, 4);
syncActor(player);
const pos = accessor.getPlayerPos();
expect(pos).toEqual({ x: 3, y: 4 });
// Verify it's a copy
if (pos) {
pos.x = 99;
const freshPlayer = accessor.getPlayer();
expect(freshPlayer?.pos.x).toBe(3);
}
});
it("isPlayerAlive returns true when player exists", () => {
syncActor(createPlayer(PLAYER_ID, 5, 5));
expect(accessor.isPlayerAlive()).toBe(true);
});
it("isPlayerAlive returns false when player is dead", () => {
expect(accessor.isPlayerAlive()).toBe(false);
});
});
describe("Generic Actor Access", () => {
it("getActor returns actor by ID", () => {
const enemy = createEnemy(2, 3, 3);
syncActor(enemy);
expect(accessor.getActor(2 as EntityId)?.id).toBe(enemy.id);
});
it("getActor returns null for non-existent ID", () => {
expect(accessor.getActor(999 as EntityId)).toBeNull();
});
it("getCombatant returns combatant by ID", () => {
const enemy = createEnemy(2, 3, 3);
syncActor(enemy);
expect(accessor.getCombatant(2 as EntityId)?.id).toBe(enemy.id);
});
it("getCombatant returns null for non-combatant", () => {
const orb = createExpOrb(3, 5, 5);
syncActor(orb);
expect(accessor.getCombatant(3 as EntityId)).toBeNull();
});
it("hasActor returns true for existing actor", () => {
syncActor(createEnemy(2, 3, 3));
expect(accessor.hasActor(2 as EntityId)).toBe(true);
});
it("hasActor returns false for non-existent ID", () => {
expect(accessor.hasActor(999 as EntityId)).toBe(false);
});
});
describe("Spatial Queries", () => {
it("findEnemyAt returns enemy at position", () => {
const enemy = createEnemy(2, 4, 4);
syncActor(enemy);
expect(accessor.findEnemyAt(4, 4)?.id).toBe(enemy.id);
});
it("findEnemyAt returns null when no enemy at position", () => {
syncActor(createPlayer(PLAYER_ID, 4, 4));
expect(accessor.findEnemyAt(4, 4)).toBeNull();
});
it("hasEnemyAt returns true when enemy exists at position", () => {
syncActor(createEnemy(2, 4, 4));
expect(accessor.hasEnemyAt(4, 4)).toBe(true);
});
it("findCollectibleAt returns collectible at position", () => {
const orb = createExpOrb(3, 6, 6);
syncActor(orb);
expect(accessor.findCollectibleAt(6, 6)?.id).toBe(orb.id);
});
it("findItemDropAt returns item drop at position", () => {
const drop = createItemDrop(4, 7, 7);
syncActor(drop);
expect(accessor.findItemDropAt(7, 7)?.id).toBe(drop.id);
});
});
describe("Collection Queries", () => {
beforeEach(() => {
syncActor(createPlayer(PLAYER_ID, 5, 5));
syncActor(createEnemy(2, 3, 3));
syncActor(createEnemy(3, 4, 4, "bat"));
syncActor(createExpOrb(4, 6, 6));
syncActor(createItemDrop(5, 7, 7));
});
it("getEnemies returns only non-player combatants", () => {
const enemies = accessor.getEnemies();
expect(enemies.length).toBe(2);
expect(enemies.every(e => !e.isPlayer)).toBe(true);
});
it("getCombatants returns player and enemies", () => {
const combatants = accessor.getCombatants();
expect(combatants.length).toBe(3);
});
it("getCollectibles returns only collectibles", () => {
const collectibles = accessor.getCollectibles();
expect(collectibles.length).toBe(1);
expect(collectibles[0].id).toBe(4);
});
it("getItemDrops returns only item drops", () => {
const drops = accessor.getItemDrops();
expect(drops.length).toBe(1);
expect(drops[0].id).toBe(5);
});
});
describe("updateWorld", () => {
it("updates references correctly", () => {
syncActor(createPlayer(PLAYER_ID, 1, 1));
const newWorld = createMockWorld();
const newEcsWorld = new ECSWorld();
const newPlayerId = 10;
const newPlayer = createPlayer(newPlayerId, 8, 8);
// Manually add to newEcsWorld
newEcsWorld.addComponent(newPlayer.id, "position", newPlayer.pos);
newEcsWorld.addComponent(newPlayer.id, "actorType", { type: "player" });
newEcsWorld.addComponent(newPlayer.id, "stats", newPlayer.stats);
newEcsWorld.addComponent(newPlayer.id, "player", {});
accessor.updateWorld(newWorld, newPlayerId as EntityId, newEcsWorld);
const player = accessor.getPlayer();
expect(player?.id).toBe(newPlayerId);
expect(player?.pos).toEqual({ x: 8, y: 8 });
});
});
});

View File

@@ -0,0 +1,64 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TriggerSystem } from '../ecs/systems/TriggerSystem';
import { ECSWorld } from '../ecs/World';
import { EventBus } from '../ecs/EventBus';
import { Prefabs } from '../ecs/Prefabs';
import type { EntityId } from '../../core/types';
describe('Prefab Trap Integration', () => {
let world: ECSWorld;
let eventBus: EventBus;
let system: TriggerSystem;
beforeEach(() => {
world = new ECSWorld();
eventBus = new EventBus();
system = new TriggerSystem();
system.setEventBus(eventBus);
});
it('should trigger poison trap when player moves onto it', () => {
// Setup Player (ID 1)
const playerId = 1 as EntityId;
world.addComponent(playerId, 'position', { x: 1, y: 1 });
world.addComponent(playerId, 'stats', { hp: 10, maxHp: 10 } as any);
world.addComponent(playerId, 'player', {});
// Setup Prefab Trap (ID 100) at (2, 1)
// Use a high ID to avoid collision (simulating generator fix)
world.setNextId(100);
const trapId = Prefabs.poisonTrap(world, 2, 1, 5, 2);
// Register system (initializes entity positions)
system.onRegister(world);
const spy = vi.spyOn(eventBus, 'emit');
// === MOVE PLAYER ===
// Update Player Position to (2, 1)
const pos = world.getComponent(playerId, 'position');
if (pos) pos.x = 2; // Move reference
// Update System
system.update([trapId], world);
// Expect trigger activated
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
type: 'trigger_activated',
triggerId: trapId,
activatorId: playerId
}));
// Expect damage (magnitude 2)
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
type: 'damage',
amount: 2
}));
// Expect status applied
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
type: 'status_applied',
status: 'poison'
}));
});
});

View File

@@ -0,0 +1,98 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { ProgressionManager } from '../ProgressionManager';
import { type CombatantActor } from '../../core/types';
describe('ProgressionManager', () => {
let progressionManager: ProgressionManager;
let mockPlayer: CombatantActor;
beforeEach(() => {
progressionManager = new ProgressionManager();
mockPlayer = {
id: 1,
category: 'combatant',
isPlayer: true,
pos: { x: 0, y: 0 },
speed: 100,
stats: {
maxHp: 20,
hp: 20,
maxMana: 0,
mana: 0,
level: 1,
exp: 0,
expToNextLevel: 100,
statPoints: 5,
skillPoints: 2,
strength: 10,
dexterity: 10,
intelligence: 10,
attack: 5,
defense: 2,
critChance: 5,
critMultiplier: 150,
accuracy: 90,
lifesteal: 0,
evasion: 5,
blockChance: 0,
luck: 0,
passiveNodes: []
}
} as any;
});
it('should allocate strength and increase maxHp and attack', () => {
progressionManager.allocateStat(mockPlayer, 'strength');
expect(mockPlayer.stats.strength).toBe(11);
expect(mockPlayer.stats.maxHp).toBe(22);
expect(mockPlayer.stats.hp).toBe(22);
expect(mockPlayer.stats.attack).toBeCloseTo(5.2);
expect(mockPlayer.stats.statPoints).toBe(4);
});
it('should allocate dexterity and increase speed', () => {
progressionManager.allocateStat(mockPlayer, 'dexterity');
expect(mockPlayer.stats.dexterity).toBe(11);
expect(mockPlayer.speed).toBe(101);
expect(mockPlayer.stats.statPoints).toBe(4);
});
it('should allocate intelligence and increase defense every 5 points', () => {
// Current INT is 10 (multiple of 5)
// Next multiple is 15
progressionManager.allocateStat(mockPlayer, 'intelligence');
expect(mockPlayer.stats.intelligence).toBe(11);
expect(mockPlayer.stats.defense).toBe(2); // No increase yet
mockPlayer.stats.intelligence = 14;
mockPlayer.stats.statPoints = 1;
progressionManager.allocateStat(mockPlayer, 'intelligence');
expect(mockPlayer.stats.intelligence).toBe(15);
expect(mockPlayer.stats.defense).toBe(3); // Increased!
});
it('should not allocate stats if statPoints are 0', () => {
mockPlayer.stats.statPoints = 0;
progressionManager.allocateStat(mockPlayer, 'strength');
expect(mockPlayer.stats.strength).toBe(10);
});
it('should apply passive node bonuses', () => {
progressionManager.allocatePassive(mockPlayer, 'off_1');
expect(mockPlayer.stats.attack).toBe(7);
expect(mockPlayer.stats.skillPoints).toBe(1);
expect(mockPlayer.stats.passiveNodes).toContain('off_1');
progressionManager.allocatePassive(mockPlayer, 'util_2');
expect(mockPlayer.stats.expToNextLevel).toBe(90);
expect(mockPlayer.stats.skillPoints).toBe(0);
});
it('should not apply the same passive twice', () => {
progressionManager.allocatePassive(mockPlayer, 'off_1');
const pointsAfterFirst = mockPlayer.stats.skillPoints;
progressionManager.allocatePassive(mockPlayer, 'off_1');
expect(mockPlayer.stats.skillPoints).toBe(pointsAfterFirst);
expect(mockPlayer.stats.attack).toBe(7); // Same as before
});
});

View File

@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TriggerSystem } from '../ecs/systems/TriggerSystem';
import { ECSWorld } from '../ecs/World';
import { EventBus } from '../ecs/EventBus';
import type { EntityId } from '../../core/types';
describe('TriggerSystem Integration', () => {
let world: ECSWorld;
let eventBus: EventBus;
let system: TriggerSystem;
beforeEach(() => {
world = new ECSWorld();
eventBus = new EventBus();
system = new TriggerSystem();
system.setEventBus(eventBus);
});
it('should trigger onEnter when player moves onto trap', () => {
// Setup Player (ID 1)
const playerId = 1 as EntityId;
const playerPos = { x: 1, y: 1 };
world.addComponent(playerId, 'position', playerPos);
world.addComponent(playerId, 'player', {});
// Setup Trap (ID 100) at (2, 1)
const trapId = 100 as EntityId;
world.addComponent(trapId, 'position', { x: 2, y: 1 });
world.addComponent(trapId, 'trigger', {
onEnter: true,
damage: 10
});
// Register system (initializes entity positions)
system.onRegister(world);
// Verify initial state: Player at (1,1), Trap at (2,1)
// System tracking: Player at (1,1)
const spy = vi.spyOn(eventBus, 'emit');
// === MOVE PLAYER ===
// Simulate MovementSystem update
playerPos.x = 2; // Move to (2,1) directly (reference update)
// System Update
system.update([trapId], world);
// Expect trigger activation
expect(spy).toHaveBeenCalledWith(expect.objectContaining({
type: 'trigger_activated',
triggerId: trapId,
activatorId: playerId
}));
});
});

View File

@@ -0,0 +1,188 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { decideEnemyAction, applyAction, stepUntilPlayerTurn } from '../simulation/simulation';
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
import { EntityAccessor } from '../EntityAccessor';
import { TileType } from '../../core/terrain';
import { ECSWorld } from '../ecs/World';
const createTestWorld = (): World => {
return {
width: 10,
height: 10,
tiles: new Array(100).fill(TileType.EMPTY),
exit: { x: 9, y: 9 },
trackPath: []
};
};
const createTestStats = (overrides: Partial<any> = {}) => ({
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [],
critChance: 0, critMultiplier: 100, accuracy: 100, lifesteal: 0, evasion: 0, blockChance: 0, luck: 0,
...overrides
});
describe('AI Behavior & Scheduling', () => {
let accessor: EntityAccessor;
let ecsWorld: ECSWorld;
beforeEach(() => {
ecsWorld = new ECSWorld();
});
const syncToECS = (actors: Map<EntityId, Actor>) => {
let maxId = 0;
for (const actor of actors.values()) {
if (actor.id > maxId) maxId = actor.id;
ecsWorld.addComponent(actor.id, "position", actor.pos);
ecsWorld.addComponent(actor.id, "name", { name: actor.id.toString() });
if (actor.category === "combatant") {
const c = actor as CombatantActor;
ecsWorld.addComponent(actor.id, "stats", c.stats || createTestStats());
ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed || 100 });
ecsWorld.addComponent(actor.id, "actorType", { type: c.type || "player" });
if (c.isPlayer) {
ecsWorld.addComponent(actor.id, "player", {});
} else {
ecsWorld.addComponent(actor.id, "ai", {
state: c.aiState || "wandering",
alertedAt: c.alertedAt,
lastKnownPlayerPos: c.lastKnownPlayerPos
});
}
}
}
ecsWorld.setNextId(maxId + 1);
};
// -------------------------------------------------------------------------
// Scheduling Fairness
// -------------------------------------------------------------------------
describe('Scheduler Fairness', () => {
it("should allow slower actors to act eventually", () => {
const actors = new Map<EntityId, Actor>();
// Player Speed 100
const player = {
id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 },
speed: 100, stats: createTestStats(), energy: 0
} as any;
// Rat Speed 80 (Slow)
const rat = {
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 9, y: 9 },
speed: 80, stats: createTestStats(), aiState: "wandering", energy: 0
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, rat);
const world = createTestWorld();
syncToECS(actors);
accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
let ratMoves = 0;
// Simulate 20 player turns
for (let i = 0; i < 20; i++) {
const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor);
const enemyActs = result.events.filter(e =>
(e.type === "moved" || e.type === "waited" || e.type === "enemy-alerted") &&
((e as any).actorId === 2 || (e as any).enemyId === 2)
);
if (enemyActs.length > 0) ratMoves++;
}
expect(ratMoves).toBeGreaterThan(0);
});
});
// -------------------------------------------------------------------------
// Vision & Perception
// -------------------------------------------------------------------------
describe('AI Vision', () => {
const terrainTypes = [
{ type: TileType.EMPTY, name: 'Empty' },
{ type: TileType.GRASS, name: 'Grass' }, // Blocks Vision normally
{ type: TileType.GRASS_SAPLINGS, name: 'Saplings' },
];
terrainTypes.forEach(({ type, name }) => {
it(`should see player when standing on ${name}`, () => {
const actors = new Map<EntityId, Actor>();
actors.set(1 as EntityId, { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats(), energy: 0 } as any);
actors.set(2 as EntityId, {
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 },
stats: createTestStats(), aiState: "wandering", energy: 0
} as any);
const world = createTestWorld();
world.tiles[0] = type;
syncToECS(actors);
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
// Rat at 0,0. Player at 5,0.
decideEnemyAction(world, testAccessor.getCombatant(2 as EntityId) as any, testAccessor.getCombatant(1 as EntityId) as any, testAccessor);
const updatedRat = testAccessor.getCombatant(2 as EntityId);
expect(updatedRat?.aiState).toBe("alerted");
});
});
});
// -------------------------------------------------------------------------
// Aggression & State Machine
// -------------------------------------------------------------------------
describe('AI Aggression State Machine', () => {
it('should become pursuing when damaged by player, even if not sighting player', () => {
const actors = new Map<EntityId, Actor>();
// Player far away/invisible (simulated logic)
const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats({ attack: 1, accuracy: 100 }), energy: 0 } as any;
const enemy = {
id: 2 as EntityId, category: "combatant", isPlayer: false, pos: { x: 0, y: 5 },
stats: createTestStats({ hp: 10, defense: 0, evasion: 0 }), aiState: "wandering", energy: 0
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, testAccessor);
const updatedEnemy = testAccessor.getCombatant(2 as EntityId);
expect(updatedEnemy?.aiState).toBe("pursuing");
expect(updatedEnemy?.lastKnownPlayerPos).toEqual(player.pos);
});
it("should transition from alerted to pursuing after delay even if sight is blocked", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1 as EntityId, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats(), energy: 0 } as any;
const enemy = {
id: 2 as EntityId,
category: "combatant",
isPlayer: false,
pos: { x: 0, y: 0 },
stats: createTestStats(),
aiState: "alerted",
alertedAt: Date.now() - 2000, // Alerted 2 seconds ago
lastKnownPlayerPos: { x: 9, y: 9 }, // Known position
energy: 0
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
// Player is far away and potentially blocked
world.tiles[1] = TileType.WALL; // x=1, y=0 blocked
syncToECS(actors);
const testAccessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
const rat = testAccessor.getCombatant(2 as EntityId)!;
decideEnemyAction(world, rat, testAccessor.getPlayer()!, testAccessor);
// alerted -> pursuing (due to time) -> searching (due to no sight)
expect(rat.aiState).toBe("searching");
});
});
});

View File

@@ -0,0 +1,175 @@
import { describe, it, expect, beforeEach } from "vitest";
import { getClosestVisibleEnemy } from "../gameplay/CombatLogic";
import type { World, CombatantActor, Actor, EntityId } from "../../core/types";
import { EntityAccessor } from "../EntityAccessor";
import { ECSWorld } from "../ecs/World";
describe("CombatLogic - getClosestVisibleEnemy", () => {
let ecsWorld: ECSWorld;
beforeEach(() => {
ecsWorld = new ECSWorld();
});
// Helper to create valid default stats for testing
const createMockStats = () => ({
hp: 10, maxHp: 10, attack: 1, defense: 0,
accuracy: 100, evasion: 0, critChance: 0, critMultiplier: 0,
blockChance: 0, lifesteal: 0, mana: 0, maxMana: 0,
level: 1, exp: 0, expToNextLevel: 100, luck: 0,
statPoints: 0, skillPoints: 0,
strength: 10, dexterity: 10, intelligence: 10,
passiveNodes: []
});
it("should return null if no enemies are visible", () => {
const world: World = {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 },
trackPath: []
};
const actors = new Map<EntityId, Actor>();
const player: CombatantActor = {
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
stats: createMockStats(),
inventory: { gold: 0, items: [] }, equipment: {},
speed: 1, energy: 0
};
actors.set(0 as EntityId, player);
const enemy: CombatantActor = {
id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false,
stats: createMockStats(),
speed: 1, energy: 0
};
actors.set(1 as EntityId, enemy);
for (const a of actors.values()) {
ecsWorld.addComponent(a.id, "position", a.pos);
ecsWorld.addComponent(a.id, "actorType", { type: a.type as any });
if (a.category === "combatant") {
ecsWorld.addComponent(a.id, "stats", a.stats);
if (a.isPlayer) ecsWorld.addComponent(a.id, "player", {});
}
}
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
// Mock seenArray where nothing is seen
const seenArray = new Uint8Array(100).fill(0);
const result = getClosestVisibleEnemy(player.pos, seenArray, 10, accessor);
expect(result).toBeNull();
});
it("should return the closest visible enemy", () => {
const world: World = {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 },
trackPath: []
};
const actors = new Map<EntityId, Actor>();
const player: CombatantActor = {
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
stats: createMockStats(),
inventory: { gold: 0, items: [] }, equipment: {},
speed: 1, energy: 0
};
actors.set(0 as EntityId, player);
// Enemy 1: Close (distance sqrt(2) ~= 1.41)
const enemy1: CombatantActor = {
id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false,
stats: createMockStats(),
speed: 1, energy: 0
};
actors.set(1 as EntityId, enemy1);
// Enemy 2: Farther (distance sqrt(8) ~= 2.82)
const enemy2: CombatantActor = {
id: 2, category: "combatant", type: "rat", pos: { x: 7, y: 7 }, isPlayer: false,
stats: createMockStats(),
speed: 1, energy: 0
};
actors.set(2 as EntityId, enemy2);
for (const a of actors.values()) {
ecsWorld.addComponent(a.id, "position", a.pos);
ecsWorld.addComponent(a.id, "actorType", { type: a.type as any });
if (a.category === "combatant") {
ecsWorld.addComponent(a.id, "stats", a.stats);
if (a.isPlayer) ecsWorld.addComponent(a.id, "player", {});
}
}
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
// Mock seenArray where both are seen
const seenArray = new Uint8Array(100).fill(0);
seenArray[6 * 10 + 6] = 1; // Enemy 1 visible
seenArray[7 * 10 + 7] = 1; // Enemy 2 visible
const result = getClosestVisibleEnemy(player.pos, seenArray, 10, accessor);
expect(result).toEqual({ x: 6, y: 6 });
});
it("should ignore invisible closer enemies and select visible farther ones", () => {
const world: World = {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 },
trackPath: []
};
const actors = new Map<EntityId, Actor>();
const player: CombatantActor = {
id: 0, category: "combatant", type: "player", pos: { x: 5, y: 5 }, isPlayer: true,
stats: createMockStats(),
inventory: { gold: 0, items: [] }, equipment: {},
speed: 1, energy: 0
};
actors.set(0 as EntityId, player);
// Enemy 1: Close but invisible
const enemy1: CombatantActor = {
id: 1, category: "combatant", type: "rat", pos: { x: 6, y: 6 }, isPlayer: false,
stats: createMockStats(),
speed: 1, energy: 0
};
actors.set(1 as EntityId, enemy1);
// Enemy 2: Farther but visible
const enemy2: CombatantActor = {
id: 2, category: "combatant", type: "rat", pos: { x: 8, y: 5 }, isPlayer: false,
stats: createMockStats(),
speed: 1, energy: 0
};
actors.set(2 as EntityId, enemy2);
for (const a of actors.values()) {
ecsWorld.addComponent(a.id, "position", a.pos);
ecsWorld.addComponent(a.id, "actorType", { type: a.type as any });
if (a.category === "combatant") {
ecsWorld.addComponent(a.id, "stats", a.stats);
if (a.isPlayer) ecsWorld.addComponent(a.id, "player", {});
}
}
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
// Mock seenArray where only Enemy 2 is seen
const seenArray = new Uint8Array(100).fill(0);
seenArray[5 * 10 + 8] = 1; // Enemy 2 visible at (8,5)
const result = getClosestVisibleEnemy(player.pos, seenArray, 10, accessor);
expect(result).toEqual({ x: 8, y: 5 });
});
});

View File

@@ -1,45 +1,70 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { generateWorld } from '../world/generator'; import { generateWorld } from '../world/generator';
import { isWall, inBounds } from '../world/world-logic'; import { isWall, inBounds } from '../world/world-logic';
import { TileType } from '../../core/terrain';
import { EntityAccessor } from '../EntityAccessor';
import * as ROT from 'rot-js';
describe('World Generator', () => { describe('World Generator', () => {
describe('generateWorld', () => { describe('generateWorld', () => {
it('should generate a world with correct dimensions', () => { it('should generate a world with correct dimensions', () => {
const runState = { const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }, stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
const { world } = generateWorld(1, runState); const { world } = generateWorld(1, runState);
expect(world.width).toBe(60); expect(world.width).toBe(120);
expect(world.height).toBe(40); expect(world.height).toBe(80);
expect(world.tiles.length).toBe(60 * 40); expect(world.tiles.length).toBe(120 * 80);
}); });
it('should place player actor', () => { it('should place player actor', () => {
const runState = { const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }, stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
const { world, playerId } = generateWorld(1, runState); const { world, playerId, ecsWorld } = generateWorld(1, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
expect(playerId).toBe(1); expect(playerId).toBeGreaterThan(0);
const player = world.actors.get(playerId); const player = accessor.getPlayer();
expect(player).toBeDefined(); expect(player).toBeDefined();
expect(player?.category).toBe("combatant");
expect(player?.isPlayer).toBe(true); expect(player?.isPlayer).toBe(true);
expect(player?.stats).toEqual(runState.stats); // We expect the stats to be the same, but they are proxies now
expect(player?.stats.hp).toEqual(runState.stats.hp);
expect(player?.stats.attack).toEqual(runState.stats.attack);
}); });
it('should create walkable rooms', () => { it('should create walkable rooms', () => {
const runState = { const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }, stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
const { world, playerId } = generateWorld(1, runState); const { world, playerId, ecsWorld } = generateWorld(1, runState);
const player = world.actors.get(playerId)!; const accessor = new EntityAccessor(world, playerId, ecsWorld);
const player = accessor.getPlayer()!;
// Player should spawn in a walkable area // Player should spawn in a walkable area
expect(isWall(world, player.pos.x, player.pos.y)).toBe(false); expect(isWall(world, player.pos.x, player.pos.y)).toBe(false);
@@ -47,7 +72,12 @@ describe('World Generator', () => {
it('should place exit in valid location', () => { it('should place exit in valid location', () => {
const runState = { const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }, stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
@@ -60,48 +90,63 @@ describe('World Generator', () => {
it('should create enemies', () => { it('should create enemies', () => {
const runState = { const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }, stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
const { world } = generateWorld(1, runState); const { world, playerId, ecsWorld } = generateWorld(1, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
// Should have player + enemies const enemies = accessor.getEnemies();
expect(world.actors.size).toBeGreaterThan(1);
// All non-player actors should be enemies
const enemies = Array.from(world.actors.values()).filter(a => !a.isPlayer);
expect(enemies.length).toBeGreaterThan(0); expect(enemies.length).toBeGreaterThan(0);
// Enemies should have stats // Enemies should have stats
enemies.forEach(enemy => { enemies.forEach(enemy => {
expect(enemy.stats).toBeDefined(); expect(enemy.stats).toBeDefined();
expect(enemy.stats!.hp).toBeGreaterThan(0); expect(enemy.stats.hp).toBeGreaterThan(0);
expect(enemy.stats!.attack).toBeGreaterThan(0); expect(enemy.stats.attack).toBeGreaterThan(0);
}); });
}); });
it('should generate deterministic maps for same level', () => { it('should generate deterministic maps for same level', () => {
const runState = { const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }, stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
const { world: world1, playerId: player1 } = generateWorld(1, runState); const { world: world1, playerId: player1, ecsWorld: ecs1 } = generateWorld(1, runState);
const { world: world2, playerId: player2 } = generateWorld(1, runState); const { world: world2, playerId: player2, ecsWorld: ecs2 } = generateWorld(1, runState);
// Same level should generate identical layouts // Same level should generate identical layouts
expect(world1.tiles).toEqual(world2.tiles); expect(world1.tiles).toEqual(world2.tiles);
expect(world1.exit).toEqual(world2.exit); expect(world1.exit).toEqual(world2.exit);
const player1Pos = world1.actors.get(player1)!.pos; const accessor1 = new EntityAccessor(world1, player1, ecs1);
const player2Pos = world2.actors.get(player2)!.pos; const accessor2 = new EntityAccessor(world2, player2, ecs2);
const player1Pos = accessor1.getPlayer()!.pos;
const player2Pos = accessor2.getPlayer()!.pos;
expect(player1Pos).toEqual(player2Pos); expect(player1Pos).toEqual(player2Pos);
}); });
it('should generate different maps for different levels', () => { it('should generate different maps for different levels', () => {
const runState = { const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }, stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
@@ -114,23 +159,168 @@ describe('World Generator', () => {
it('should scale enemy difficulty with level', () => { it('should scale enemy difficulty with level', () => {
const runState = { const runState = {
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }, stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] } inventory: { gold: 0, items: [] }
}; };
const { world: world1 } = generateWorld(1, runState); const { world: world1, playerId: p1, ecsWorld: ecs1 } = generateWorld(1, runState);
const { world: world5 } = generateWorld(5, runState); const { world: world5, playerId: p5, ecsWorld: ecs5 } = generateWorld(5, runState);
const enemies1 = Array.from(world1.actors.values()).filter(a => !a.isPlayer); const accessor1 = new EntityAccessor(world1, p1, ecs1);
const enemies5 = Array.from(world5.actors.values()).filter(a => !a.isPlayer); const accessor5 = new EntityAccessor(world5, p5, ecs5);
const enemies1 = accessor1.getEnemies();
const enemies5 = accessor5.getEnemies();
// Higher level should have more enemies // Higher level should have more enemies
expect(enemies5.length).toBeGreaterThan(enemies1.length); expect(enemies5.length).toBeGreaterThan(enemies1.length);
// Higher level enemies should have higher stats // Higher level enemies should have higher stats
const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies1.length; const avgHp1 = enemies1.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies1.length;
const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats?.hp || 0), 0) / enemies5.length; const avgHp5 = enemies5.reduce((sum, e) => sum + (e.stats.hp || 0), 0) / enemies5.length;
expect(avgHp5).toBeGreaterThan(avgHp1); expect(avgHp5).toBeGreaterThan(avgHp1);
}); });
it('should generate doors on dungeon floors', () => {
const runState = {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
// Generate a few worlds to ensure we hit the 50% door chance at least once
let foundDoor = false;
for (let i = 0; i < 5; i++) {
const { world } = generateWorld(1, runState); // Floor 1 is Uniform (Dungeon)
if (world.tiles.some(t => t === 5 || t === 6)) { // 5=DOOR_CLOSED, 6=DOOR_OPEN
foundDoor = true;
break;
}
}
expect(foundDoor).toBe(true);
});
it('should ensure player spawns on safe tile (not grass)', () => {
const runState = {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
// Generate multiple worlds to stress test spawn placement
for (let i = 0; i < 10; i++) {
const { world, playerId, ecsWorld } = generateWorld(1, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const player = accessor.getPlayer()!;
// Check tile under player
const tileIdx = player.pos.y * world.width + player.pos.x;
const tile = world.tiles[tileIdx];
// Should be EMPTY (1), specifically NOT GRASS (15) which blocks vision
expect(tile).toBe(1); // TileType.EMPTY
}
});
});
describe('Cave Generation (Floors 10+)', () => {
it('should generate cellular automata style maps', () => {
const runState = {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world } = generateWorld(10, runState);
// Basic validity checks
expect(world.width).toBe(120);
expect(world.height).toBe(80);
expect(world.tiles.some(t => t === TileType.EMPTY)).toBe(true);
expect(world.tiles.some(t => t === TileType.WALL)).toBe(true);
});
it('should place enemies in caves', () => {
const runState = {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId, ecsWorld } = generateWorld(11, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const enemies = accessor.getEnemies();
expect(enemies.length).toBeGreaterThan(0);
});
it('should ensure the map is connected (Player can reach Exit)', () => {
const runState = {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
for (let i = 0; i < 5; i++) {
const { world, playerId, ecsWorld } = generateWorld(10 + i, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const player = accessor.getPlayer()!;
const exit = world.exit;
const pathfinder = new ROT.Path.AStar(exit.x, exit.y, (x, y) => {
if (!inBounds(world, x, y)) return false;
return !isWall(world, x, y);
});
const path: Array<[number, number]> = [];
pathfinder.compute(player.pos.x, player.pos.y, (x, y) => {
path.push([x, y]);
});
expect(path.length).toBeGreaterThan(0);
}
});
it('should verify safe spawn logic on caves', () => {
const runState = {
stats: {
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10, maxMana: 20, mana: 20,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
critChance: 5, critMultiplier: 150, accuracy: 90, lifesteal: 0, evasion: 5, blockChance: 0, luck: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] }
};
const { world, playerId, ecsWorld } = generateWorld(12, runState);
const accessor = new EntityAccessor(world, playerId, ecsWorld);
const player = accessor.getPlayer()!;
expect(world.tiles[player.pos.y * world.width + player.pos.x]).toBe(TileType.EMPTY);
});
}); });
}); });

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach } from "vitest";
import { ItemManager } from "../../scenes/systems/ItemManager";
import type { World, CombatantActor, Item, EntityId } from "../../core/types";
import { EntityAccessor } from "../../engine/EntityAccessor";
import { ECSWorld } from "../../engine/ecs/World";
describe("ItemManager - Stacking Logic", () => {
let itemManager: ItemManager;
let accessor: EntityAccessor;
let world: World;
let player: CombatantActor;
let ecsWorld: ECSWorld;
beforeEach(() => {
world = {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 }
} as any;
ecsWorld = new ECSWorld();
accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
itemManager = new ItemManager(world, accessor, ecsWorld);
player = {
id: 0 as EntityId,
pos: { x: 1, y: 1 },
category: "combatant",
isPlayer: true,
type: "player",
inventory: { gold: 0, items: [] },
stats: { hp: 10, maxHp: 10 } as any,
equipment: {} as any,
speed: 100,
energy: 0
};
// Sync player to ECS
ecsWorld.addComponent(player.id, "position", player.pos);
ecsWorld.addComponent(player.id, "player", {});
ecsWorld.addComponent(player.id, "stats", player.stats);
ecsWorld.addComponent(player.id, "actorType", { type: "player" });
ecsWorld.addComponent(player.id, "inventory", player.inventory!);
ecsWorld.addComponent(player.id, "energy", { current: 0, speed: 100 });
});
it("should stack stackable items when picked up", () => {
const potion: Item = {
id: "potion",
name: "Potion",
type: "Consumable",
textureKey: "items",
spriteIndex: 0,
stackable: true,
quantity: 1
};
const playerActor = accessor.getPlayer()!;
// First potion
itemManager.spawnItem(potion, { x: 1, y: 1 });
itemManager.tryPickup(playerActor);
expect(playerActor.inventory!.items.length).toBe(1);
expect(playerActor.inventory!.items[0].quantity).toBe(1);
// Second potion
itemManager.spawnItem(potion, { x: 1, y: 1 });
itemManager.tryPickup(playerActor);
expect(playerActor.inventory!.items.length).toBe(1);
expect(playerActor.inventory!.items[0].quantity).toBe(2);
});
it("should NOT stack non-stackable items", () => {
const sword: Item = {
id: "iron_sword",
name: "Iron Sword",
type: "Weapon",
weaponType: "melee",
textureKey: "items",
spriteIndex: 1,
stackable: false,
stats: { attack: 1 }
} as any;
const playerActor = accessor.getPlayer()!;
// First sword
itemManager.spawnItem(sword, { x: 1, y: 1 });
itemManager.tryPickup(playerActor);
expect(playerActor.inventory!.items.length).toBe(1);
// Second sword
itemManager.spawnItem(sword, { x: 1, y: 1 });
itemManager.tryPickup(playerActor);
expect(playerActor.inventory!.items.length).toBe(2);
});
it("should sum quantities of stackable items correctly", () => {
const ammo: Item = {
id: "9mm_ammo",
name: "9mm Ammo",
type: "Ammo",
textureKey: "items",
spriteIndex: 2,
stackable: true,
quantity: 10,
ammoType: "9mm"
} as any;
const playerActor = accessor.getPlayer()!;
itemManager.spawnItem(ammo, { x: 1, y: 1 });
itemManager.tryPickup(playerActor);
expect(playerActor.inventory!.items[0].quantity).toBe(10);
const moreAmmo = { ...ammo, quantity: 5 };
itemManager.spawnItem(moreAmmo, { x: 1, y: 1 });
itemManager.tryPickup(playerActor);
expect(playerActor.inventory!.items[0].quantity).toBe(15);
});
});

View File

@@ -0,0 +1,85 @@
import { describe, it, expect } from 'vitest';
import { findPathAStar } from '../world/pathfinding';
import type { World, EntityId } from '../../core/types';
import { TileType } from '../../core/terrain';
import { ECSWorld } from '../ecs/World';
import { EntityAccessor } from '../EntityAccessor';
describe('Pathfinding', () => {
const createTestWorld = (width: number, height: number, tileType: number = TileType.EMPTY): World => ({
width,
height,
tiles: new Array(width * height).fill(tileType),
exit: { x: 0, y: 0 },
trackPath: []
});
it('should find a path between two reachable points', () => {
const world = createTestWorld(10, 10);
const seen = new Uint8Array(100).fill(1);
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
expect(path.length).toBe(4); // 0,0 -> 0,1 -> 0,2 -> 0,3
expect(path[0]).toEqual({ x: 0, y: 0 });
expect(path[3]).toEqual({ x: 0, y: 3 });
});
it('should return empty array if target is a wall', () => {
const world = createTestWorld(10, 10);
world.tiles[30] = TileType.WALL; // Wall at 0,3
const seen = new Uint8Array(100).fill(1);
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
expect(path).toEqual([]);
});
it('should return empty array if no path exists', () => {
const world = createTestWorld(10, 10);
// Create a wall blockage
for (let x = 0; x < 10; x++) world.tiles[10 + x] = TileType.WALL;
const seen = new Uint8Array(100).fill(1);
const path = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 5 });
expect(path).toEqual([]);
});
it('should respect ignoreBlockedTarget option', () => {
const world = createTestWorld(10, 10);
const ecsWorld = new ECSWorld();
// Place an actor at target
ecsWorld.addComponent(1 as EntityId, "position", { x: 0, y: 3 });
ecsWorld.addComponent(1 as EntityId, "actorType", { type: "rat" });
ecsWorld.addComponent(1 as EntityId, "stats", { hp: 10 } as any);
const seen = new Uint8Array(100).fill(1);
const accessor = new EntityAccessor(world, 0 as EntityId, ecsWorld);
// With accessor, it should be blocked
const pathBlocked = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { accessor });
expect(pathBlocked).toEqual([]);
// With ignoreBlockedTarget, it should succeed
const pathIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreBlockedTarget: true, accessor });
expect(pathIgnored.length).toBeGreaterThan(0);
expect(pathIgnored[pathIgnored.length - 1]).toEqual({ x: 0, y: 3 });
});
it('should respect ignoreSeen option', () => {
const world = createTestWorld(10, 10);
const seen = new Uint8Array(100).fill(0); // Nothing seen
// Without ignoreSeen, should fail because target/path is unseen
const pathUnseen = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 });
expect(pathUnseen).toEqual([]);
// With ignoreSeen, should succeed
const pathSeenIgnored = findPathAStar(world, seen, { x: 0, y: 0 }, { x: 0, y: 3 }, { ignoreSeen: true });
expect(pathSeenIgnored.length).toBe(4);
});
});

View File

@@ -1,125 +1,629 @@
import { describe, it, expect } from 'vitest';
import { applyAction } from '../simulation/simulation';
import { type World, type Actor, type EntityId } from '../../core/types';
describe('Combat Simulation', () => { import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
const createTestWorld = (actors: Map<EntityId, Actor>): World => ({ import { applyAction, decideEnemyAction, stepUntilPlayerTurn } from '../simulation/simulation';
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
import { EntityAccessor } from '../EntityAccessor';
import { ECSWorld } from '../ecs/World';
const createTestWorld = (): World => {
return {
width: 10, width: 10,
height: 10, height: 10,
tiles: new Array(100).fill(0), tiles: new Array(100).fill(0),
actors, exit: { x: 9, y: 9 },
exit: { x: 9, y: 9 } trackPath: []
};
};
const createTestStats = (overrides: Partial<any> = {}) => ({
maxHp: 20, hp: 20, attack: 5, defense: 2, level: 1, exp: 0, expToNextLevel: 10,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10, passiveNodes: [],
critChance: 0, critMultiplier: 100, accuracy: 100, lifesteal: 0, evasion: 0, blockChance: 0, luck: 0,
...overrides
}); });
describe('applyAction - attack', () => { describe('Combat Simulation', () => {
let ecsWorld: ECSWorld;
beforeEach(() => {
ecsWorld = new ECSWorld();
});
const syncToECS = (actors: Map<EntityId, Actor>) => {
let maxId = 0;
for (const actor of actors.values()) {
if (actor.id > maxId) maxId = actor.id;
ecsWorld.addComponent(actor.id, "position", actor.pos);
ecsWorld.addComponent(actor.id, "name", { name: actor.id.toString() });
if (actor.category === "combatant") {
const c = actor as CombatantActor;
ecsWorld.addComponent(actor.id, "stats", c.stats || createTestStats());
ecsWorld.addComponent(actor.id, "energy", { current: c.energy, speed: c.speed || 100 });
ecsWorld.addComponent(actor.id, "actorType", { type: c.type || "player" });
if (c.isPlayer) {
ecsWorld.addComponent(actor.id, "player", {});
} else {
ecsWorld.addComponent(actor.id, "ai", {
state: c.aiState || "wandering",
alertedAt: c.alertedAt,
lastKnownPlayerPos: c.lastKnownPlayerPos
});
}
} else if (actor.category === "collectible") {
ecsWorld.addComponent(actor.id, "collectible", { type: "exp_orb", amount: actor.expAmount });
}
}
ecsWorld.setNextId(maxId + 1);
};
describe('applyAction', () => {
it('should return empty events if actor does not exist', () => {
const world = createTestWorld();
const events = applyAction(world, 999 as EntityId, { type: "wait" }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
expect(events).toEqual([]);
});
});
describe('applyAction - success paths', () => {
it('should deal damage when player attacks enemy', () => { it('should deal damage when player attacks enemy', () => {
const actors = new Map<EntityId, Actor>(); const actors = new Map<EntityId, Actor>();
actors.set(1, { actors.set(1 as EntityId, {
id: 1, id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0
isPlayer: true, } as any);
pos: { x: 3, y: 3 }, actors.set(2 as EntityId, {
speed: 100, id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }), energy: 0
energy: 0, } as any);
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 }
}); const world = createTestWorld();
actors.set(2, { syncToECS(actors);
id: 2, const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
isPlayer: false, const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, accessor);
pos: { x: 4, y: 3 },
speed: 100, const enemy = accessor.getCombatant(2 as EntityId);
energy: 0, expect(enemy?.stats.hp).toBeLessThan(10);
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 } expect(events.some(e => e.type === "attacked")).toBe(true);
}); });
const world = createTestWorld(actors); it("should kill enemy and spawn EXP orb without ID reuse collision", () => {
const events = applyAction(world, 1, { type: 'attack', targetId: 2 });
const enemy = world.actors.get(2)!;
expect(enemy.stats!.hp).toBeLessThan(10);
// Should have attack event
expect(events.some(e => e.type === 'attacked')).toBe(true);
});
it('should kill enemy when damage exceeds hp', () => {
const actors = new Map<EntityId, Actor>(); const actors = new Map<EntityId, Actor>();
actors.set(1, { actors.set(1 as EntityId, {
id: 1, id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats({ attack: 50 }), energy: 0
isPlayer: true, } as any);
pos: { x: 3, y: 3 }, actors.set(2 as EntityId, {
speed: 100, id: 2, category: "combatant", isPlayer: false, type: "rat", pos: { x: 4, y: 3 }, speed: 100, stats: createTestStats({ maxHp: 10, hp: 10, attack: 3, defense: 1 }), energy: 0
energy: 0, } as any);
stats: { maxHp: 20, hp: 20, attack: 50, defense: 2 }
}); const world = createTestWorld();
actors.set(2, { syncToECS(actors);
id: 2, const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
isPlayer: false, applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, accessor);
pos: { x: 4, y: 3 },
speed: 100, // Enemy (id 2) should be gone
energy: 0, expect(accessor.hasActor(2 as EntityId)).toBe(false);
stats: { maxHp: 10, hp: 10, attack: 3, defense: 1 }
// A new ID should be generated for the orb (should be 3)
const orb = accessor.getCollectibles().find(a => a.type === "exp_orb");
expect(orb).toBeDefined();
expect(orb!.id).toBe(3);
}); });
const world = createTestWorld(actors); it("should destruction tile when walking on destructible-by-walk tile", () => {
const events = applyAction(world, 1, { type: 'attack', targetId: 2 });
// Enemy should be removed from world
expect(world.actors.has(2)).toBe(false);
// Should have killed event
expect(events.some(e => e.type === 'killed')).toBe(true);
});
it('should apply defense to reduce damage', () => {
const actors = new Map<EntityId, Actor>(); const actors = new Map<EntityId, Actor>();
actors.set(1, { actors.set(1 as EntityId, {
id: 1, id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0
isPlayer: true, } as any);
pos: { x: 3, y: 3 },
speed: 100, const world = createTestWorld();
energy: 0, // tile at 4,3 is grass (15) which is destructible by walk
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 } const grassIdx = 3 * 10 + 4;
}); world.tiles[grassIdx] = 15; // TileType.GRASS
actors.set(2, {
id: 2, syncToECS(actors);
isPlayer: false, const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
pos: { x: 4, y: 3 }, applyAction(world, 1 as EntityId, { type: "move", dx: 1, dy: 0 }, accessor);
speed: 100,
energy: 0, // Player moved to 4,3
stats: { maxHp: 10, hp: 10, attack: 3, defense: 3 } const player = accessor.getActor(1 as EntityId);
expect(player!.pos).toEqual({ x: 4, y: 3 });
// Tile should effectively be destroyed (turned to saplings/2)
expect(world.tiles[grassIdx]).toBe(2); // TileType.GRASS_SAPLINGS
}); });
const world = createTestWorld(actors); it("should handle wait action", () => {
applyAction(world, 1, { type: 'attack', targetId: 2 });
const enemy = world.actors.get(2)!;
const damage = 10 - enemy.stats!. hp;
// Damage should be reduced by defense (5 attack - 3 defense = 2 damage)
expect(damage).toBe(2);
});
});
describe('applyAction - move', () => {
it('should move actor to new position', () => {
const actors = new Map<EntityId, Actor>(); const actors = new Map<EntityId, Actor>();
actors.set(1, { actors.set(1 as EntityId, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats(), type: "player" } as any);
id: 1, const world = createTestWorld();
isPlayer: true, syncToECS(actors);
const events = applyAction(world, 1 as EntityId, { type: "wait" }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
expect(events).toEqual([{ type: "waited", actorId: 1 }]);
});
it("should default to wait for unknown action type", () => {
const actors = new Map<EntityId, Actor>();
actors.set(1 as EntityId, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats(), type: "player" } as any);
const world = createTestWorld();
syncToECS(actors);
const events = applyAction(world, 1 as EntityId, { type: "unknown_hack" } as any, new EntityAccessor(world, 1 as EntityId, ecsWorld));
expect(events).toEqual([{ type: "waited", actorId: 1 }]);
});
it("should NOT emit wait event for throw action", () => {
const actors = new Map<EntityId, Actor>();
actors.set(1 as EntityId, { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats(), type: "player" } as any);
const world = createTestWorld();
syncToECS(actors);
const events = applyAction(world, 1 as EntityId, { type: "throw" }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
expect(events).toEqual([]);
});
});
describe("decideEnemyAction - AI Logic", () => {
it("should path around walls", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 3 }, stats: createTestStats(), energy: 0 } as any;
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats(), energy: 0 } as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
world.tiles[3 * 10 + 4] = 4; // Wall
syncToECS(actors);
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
const decision = decideEnemyAction(world, enemy, player, accessor);
expect(decision.action.type).toBe("move");
});
it("should attack if player is adjacent", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 4, y: 3 }, stats: createTestStats() } as any;
const enemy = {
id: 2,
category: "combatant",
isPlayer: false,
pos: { x: 3, y: 3 }, pos: { x: 3, y: 3 },
stats: createTestStats(),
// Set AI state to pursuing so the enemy will attack when adjacent
aiState: "pursuing",
lastKnownPlayerPos: { x: 4, y: 3 }
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
const decision = decideEnemyAction(world, enemy, player, accessor);
expect(decision.action).toEqual({ type: "attack", targetId: 1 });
});
it("should transition to alerted when spotting player", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats() } as any;
const enemy = {
id: 2,
category: "combatant",
isPlayer: false,
pos: { x: 0, y: 0 },
stats: createTestStats(),
aiState: "wandering",
energy: 0
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld));
const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai");
expect(updatedEnemy?.state).toBe("alerted");
expect(decision.justAlerted).toBe(true);
});
it("should transition from pursuing to searching when sight is lost", () => {
const actors = new Map<EntityId, Actor>();
// Player far away (unseen)
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats() } as any;
const enemy = {
id: 2,
category: "combatant",
isPlayer: false,
pos: { x: 0, y: 0 },
stats: createTestStats(),
aiState: "pursuing", // Currently pursuing
lastKnownPlayerPos: { x: 5, y: 5 }
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
// Should switch to searching because can't see player
decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld));
const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai");
expect(updatedEnemy?.state).toBe("searching");
});
it("should transition from searching to alerted when sight regained", () => {
const actors = new Map<EntityId, Actor>();
// Player adjacent (visible)
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 1, y: 0 }, stats: createTestStats() } as any;
const enemy = {
id: 2,
category: "combatant",
isPlayer: false,
pos: { x: 0, y: 0 },
stats: createTestStats(),
aiState: "searching",
lastKnownPlayerPos: { x: 5, y: 5 }
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld));
const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai");
expect(updatedEnemy?.state).toBe("alerted");
expect(decision.justAlerted).toBe(true);
});
it("should transition from searching to wandering when reached target", () => {
const actors = new Map<EntityId, Actor>();
// Player far away (unseen) - Manhattan dist > 8
// Enemy at 9,9. Player at 0,0. Dist = 18.
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats() } as any;
const enemy = {
id: 2,
category: "combatant",
isPlayer: false,
pos: { x: 9, y: 9 }, // At target
stats: createTestStats(),
aiState: "searching",
lastKnownPlayerPos: { x: 9, y: 9 }
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld));
const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "ai");
expect(updatedEnemy?.state).toBe("wandering");
});
});
describe("stepUntilPlayerTurn", () => {
it("should process enemy turns", () => {
const actors = new Map<EntityId, Actor>();
// Player is slow, enemy is fast. Enemy should move before player returns.
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats(), energy: 0 } as any;
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100, stats: createTestStats(), energy: 0 } as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor);
// Enemy should have taken at least one action
expect(result.events.length).toBeGreaterThan(0);
expect(result.awaitingPlayerId).toBe(1);
});
it("should handle player death during enemy turn", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats({ hp: 1 }), energy: 0 } as any;
// Enemy that will kill player
const enemy = {
id: 2,
category: "combatant",
isPlayer: false,
pos: { x: 1, y: 0 },
speed: 100, speed: 100,
energy: 0, stats: createTestStats({ attack: 100 }),
stats: { maxHp: 20, hp: 20, attack: 5, defense: 2 } aiState: "pursuing",
energy: 100
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor);
expect(accessor.hasActor(1 as EntityId)).toBe(false); // Player dead
expect(result.events.some((e: any) => e.type === "killed" && e.targetId === 1)).toBe(true);
});
}); });
const world = createTestWorld(actors); describe("Combat Mechanics - Detailed", () => {
const events = applyAction(world, 1, { type: 'move', dx: 1, dy: 0 }); let mockRandom: ReturnType<typeof vi.spyOn>;
const player = world.actors.get(1)!; beforeEach(() => {
expect(player.pos).toEqual({ x: 4, y: 3 }); mockRandom = vi.spyOn(Math, 'random');
});
// Should have moved event afterEach(() => {
expect(events.some(e => e.type === 'moved')).toBe(true); mockRandom.mockRestore();
});
it("should dodge attack when roll > hit chance", () => {
const actors = new Map<EntityId, Actor>();
// Acc 100, Eva 50. Hit Chance = 50.
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 }, stats: createTestStats({ accuracy: 100 }), energy: 0 } as any;
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 50, hp: 10 }), energy: 0 } as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
// Mock random to be 51 (scale 0-100 logic uses * 100) -> 0.51
mockRandom.mockReturnValue(0.1); // Hit roll
// Wait, hitChance is Acc (100) - Eva (50) = 50.
// Roll 0.51 * 100 = 51. 51 > 50 -> Dodge.
mockRandom.mockReturnValue(0.51);
const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
expect(events.some(e => e.type === "dodged")).toBe(true);
const updatedEnemy = ecsWorld.getComponent(2 as EntityId, "stats");
expect(updatedEnemy?.hp).toBe(10); // No damage
});
it("should crit when roll < crit chance", () => {
const actors = new Map<EntityId, Actor>();
// Acc 100, Eva 0. Hit Chance = 100.
// Crit Chance 50%.
const player = {
id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 },
stats: createTestStats({ accuracy: 100, critChance: 50, critMultiplier: 200, attack: 10 }),
energy: 0
} as any;
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ evasion: 0, defense: 0, hp: 50 }), energy: 0 } as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
// Mock random:
// 1. Hit roll: 0.1 (Hit)
// 2. Crit roll: 0.4 (Crit, since < 0.5)
// 3. Block roll: 0.9 (No block)
mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.4).mockReturnValueOnce(0.9);
const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
// Damage = 10 * 2 = 20
const dmgEvent = events.find(e => e.type === "damaged") as any;
expect(dmgEvent).toBeDefined();
expect(dmgEvent.amount).toBe(20);
expect(dmgEvent.isCrit).toBe(true);
});
it("should block when roll < block chance", () => {
const actors = new Map<EntityId, Actor>();
const player = {
id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 },
stats: createTestStats({ accuracy: 100, critChance: 0, attack: 10 })
} as any;
const enemy = {
id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 },
stats: createTestStats({ evasion: 0, defense: 0, hp: 50, blockChance: 50 }), energy: 0
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
// Mock random:
// 1. Hit roll: 0.1
// 2. Crit roll: 0.9
// 3. Block roll: 0.4 (Block, since < 0.5)
mockRandom.mockReturnValueOnce(0.1).mockReturnValueOnce(0.9).mockReturnValueOnce(0.4);
const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
// Damage = 10 * 0.5 = 5
const dmgEvent = events.find(e => e.type === "damaged") as any;
expect(dmgEvent.amount).toBe(5);
expect(dmgEvent.isBlock).toBe(true);
});
it("should lifesteal on hit", () => {
const actors = new Map<EntityId, Actor>();
const player = {
id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 },
stats: createTestStats({ accuracy: 100, attack: 10, lifesteal: 50, hp: 10, maxHp: 20 })
} as any;
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
// Standard hit
mockRandom.mockReturnValue(0.1);
const events = applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
// Damage 10. Heal 50% = 5. HP -> 15.
const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats");
expect(updatedPlayer?.hp).toBe(15);
expect(events.some(e => e.type === "healed")).toBe(true);
});
it("should not lifesteal beyond maxHp", () => {
const actors = new Map<EntityId, Actor>();
const player = {
id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 },
stats: createTestStats({ accuracy: 100, attack: 10, lifesteal: 100, hp: 19, maxHp: 20 })
} as any;
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 4, y: 3 }, stats: createTestStats({ hp: 20, defense: 0 }) } as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
mockRandom.mockReturnValue(0.1);
applyAction(world, 1 as EntityId, { type: "attack", targetId: 2 as EntityId }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
// Damage 10. Heal 10. HP 19+10 = 29 > 20. Should be 20.
const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats");
expect(updatedPlayer?.hp).toBe(20);
});
});
describe("Level Up Logic", () => {
it("should level up when collecting ample experience", () => {
const actors = new Map<EntityId, Actor>();
const player = {
id: 1, category: "combatant", isPlayer: true, pos: { x: 3, y: 3 },
stats: createTestStats({ level: 1, exp: 0, expToNextLevel: 100 })
} as any;
// Orb with 150 exp
const orb = {
id: 2, category: "collectible", type: "exp_orb", pos: { x: 4, y: 3 }, expAmount: 150
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, orb);
const world = createTestWorld();
syncToECS(actors);
// Move player onto orb
const events = applyAction(world, 1 as EntityId, { type: "move", dx: 1, dy: 0 }, new EntityAccessor(world, 1 as EntityId, ecsWorld));
const updatedPlayer = ecsWorld.getComponent(1 as EntityId, "stats");
expect(updatedPlayer?.level).toBe(2);
expect(updatedPlayer?.exp).toBe(50); // 150 - 100 = 50
expect(events.some(e => e.type === "leveled-up")).toBe(true);
});
});
describe("Diagonal Mechanics", () => {
it("should allow enemy to attack player diagonally", () => {
const actors = new Map<EntityId, Actor>();
// Enemy at 4,4. Player at 5,5 (diagonal)
const enemy = {
id: 1,
category: "combatant",
isPlayer: false,
pos: { x: 4, y: 4 },
stats: createTestStats(),
aiState: "pursuing", // Skip alert phase
energy: 0
} as any;
const player = {
id: 2,
category: "combatant",
isPlayer: true,
pos: { x: 5, y: 5 },
stats: createTestStats(),
energy: 0
} as any;
actors.set(1 as EntityId, enemy);
actors.set(2 as EntityId, player);
const world = createTestWorld();
syncToECS(actors);
// Enemy should decide to attack
const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld));
expect(decision.action.type).toBe("attack");
if (decision.action.type === "attack") {
expect(decision.action.targetId).toBe(player.id);
}
});
it("should allow player to attack enemy diagonally via applyAction", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 4, y: 4 }, stats: createTestStats(), energy: 0 } as any;
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, stats: createTestStats(), energy: 0 } as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
const action: any = { type: "attack", targetId: 2 };
const events = applyAction(world, 1 as EntityId, action, new EntityAccessor(world, 1 as EntityId, ecsWorld));
const attackEvent = events.find(e => e.type === "attacked");
expect(attackEvent).toBeDefined();
expect(attackEvent?.targetId).toBe(2);
});
it("should NOT generate diagonal move for enemy", () => {
const actors = new Map<EntityId, Actor>();
// Enemy at 4,4. Player at 4,6. Dist 2.
const enemy = {
id: 1,
category: "combatant",
isPlayer: false,
pos: { x: 4, y: 4 },
stats: createTestStats(),
aiState: "pursuing",
energy: 0
} as any;
const player = { id: 2, category: "combatant", isPlayer: true, pos: { x: 4, y: 6 }, stats: createTestStats(), energy: 0 } as any;
actors.set(1 as EntityId, enemy);
actors.set(2 as EntityId, player);
const world = createTestWorld();
syncToECS(actors);
const decision = decideEnemyAction(world, enemy, player, new EntityAccessor(world, 1 as EntityId, ecsWorld));
if (decision.action.type === "move") {
const { dx, dy } = decision.action;
// Should be (0, 1) or cardinal, sum of abs should be 1
expect(Math.abs(dx) + Math.abs(dy)).toBe(1);
}
});
});
describe("Death Cleanup", () => {
it("should remove combatants with 0 HP during turn processing", () => {
const actors = new Map<EntityId, Actor>();
const player = { id: 1, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, speed: 10, stats: createTestStats(), energy: 0 } as any;
// Enemy with 0 HP (e.g. killed by status effect prior to turn)
const enemy = {
id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100,
stats: createTestStats({ hp: 0 }), energy: 0, type: "rat"
} as any;
actors.set(1 as EntityId, player);
actors.set(2 as EntityId, enemy);
const world = createTestWorld();
syncToECS(actors);
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
// This should trigger checkDeaths
const result = stepUntilPlayerTurn(world, 1 as EntityId, accessor);
expect(accessor.hasActor(2 as EntityId)).toBe(false);
expect(result.events.some(e => e.type === "killed" && e.targetId === 2)).toBe(true);
}); });
}); });
}); });

View File

@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { traceProjectile } from '../gameplay/CombatLogic';
import { EntityAccessor } from '../EntityAccessor';
import { ECSWorld } from '../ecs/World';
import type { World, EntityId } from '../../core/types';
const createTestWorld = (): World => {
return {
width: 10,
height: 10,
tiles: new Array(100).fill(0), // 0 = Floor
exit: { x: 9, y: 9 },
trackPath: []
};
};
describe('Throwing Mechanics', () => {
it('should land ON the wall currently (demonstrating the bug)', () => {
const world = createTestWorld();
const ecsWorld = new ECSWorld();
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
// Wall at (5, 0)
world.tiles[5] = 4; // Wall
const start = { x: 0, y: 0 };
const target = { x: 5, y: 0 }; // Target the wall directly
const result = traceProjectile(world, start, target, accessor);
// NEW BEHAVIOR: blockedPos is the tile BEFORE the wall (4, 0)
expect(result.blockedPos).toEqual({ x: 4, y: 0 });
});
it('should land ON the wall when throwing PAST a wall (demonstrating the bug)', () => {
const world = createTestWorld();
const ecsWorld = new ECSWorld();
const accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
// Wall at (3, 0)
world.tiles[3] = 4; // Wall
const start = { x: 0, y: 0 };
const target = { x: 5, y: 0 }; // Target past the wall
const result = traceProjectile(world, start, target, accessor);
// NEW BEHAVIOR: Hits the wall at 3,0, stops at 2,0
expect(result.blockedPos).toEqual({ x: 2, y: 0 });
});
});

View File

@@ -1,14 +1,16 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { idx, inBounds, isWall, isBlocked } from '../world/world-logic'; import { idx, inBounds, isWall, isBlocked, tryDestructTile } from '../world/world-logic';
import { type World, type Tile } from '../../core/types'; import { type World, type Tile } from '../../core/types';
import { TileType } from '../../core/terrain';
describe('World Utilities', () => { describe('World Utilities', () => {
const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({ const createTestWorld = (width: number, height: number, tiles: Tile[]): World => ({
width, width,
height, height,
tiles, tiles,
actors: new Map(), exit: { x: 0, y: 0 },
exit: { x: 0, y: 0 } trackPath: []
}); });
describe('idx', () => { describe('idx', () => {
@@ -44,9 +46,10 @@ describe('World Utilities', () => {
describe('isWall', () => { describe('isWall', () => {
it('should return true for wall tiles', () => { it('should return true for wall tiles', () => {
const tiles: Tile[] = new Array(100).fill(0); const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
tiles[0] = 1; // wall at 0,0 tiles[0] = TileType.WALL; // wall at 0,0
tiles[55] = 1; // wall at 5,5 tiles[55] = TileType.WALL; // wall at 5,5
const world = createTestWorld(10, 10, tiles); const world = createTestWorld(10, 10, tiles);
@@ -55,11 +58,12 @@ describe('World Utilities', () => {
}); });
it('should return false for floor tiles', () => { it('should return false for floor tiles', () => {
const tiles: Tile[] = new Array(100).fill(0); const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
const world = createTestWorld(10, 10, tiles); const world = createTestWorld(10, 10, tiles);
expect(isWall(world, 3, 3)).toBe(false); expect(isWall(world, 3, 3)).toBe(false);
expect(isWall(world, 7, 7)).toBe(false); expect(isWall(world, 7, 7)).toBe(false);
}); });
it('should return false for out of bounds coordinates', () => { it('should return false for out of bounds coordinates', () => {
@@ -72,39 +76,70 @@ describe('World Utilities', () => {
describe('isBlocked', () => { describe('isBlocked', () => {
it('should return true for walls', () => { it('should return true for walls', () => {
const tiles: Tile[] = new Array(100).fill(0); const tiles: Tile[] = new Array(100).fill(TileType.EMPTY);
tiles[55] = 1; // wall at 5,5 tiles[55] = TileType.WALL; // wall at 5,5
const world = createTestWorld(10, 10, tiles); const world = createTestWorld(10, 10, tiles);
const mockAccessor = { getActorsAt: () => [] } as any;
expect(isBlocked(world, 5, 5)).toBe(true); expect(isBlocked(world, 5, 5, mockAccessor)).toBe(true);
}); });
it('should return true for actor positions', () => { it('should return true for actor positions', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0)); const world = createTestWorld(10, 10, new Array(100).fill(0));
world.actors.set(1, { const mockAccessor = {
id: 1, getActorsAt: (x: number, y: number) => {
isPlayer: true, if (x === 3 && y === 3) return [{ category: "combatant" }];
pos: { x: 3, y: 3 }, return [];
speed: 100, }
energy: 0 } as any;
});
expect(isBlocked(world, 3, 3)).toBe(true); expect(isBlocked(world, 3, 3, mockAccessor)).toBe(true);
}); });
it('should return false for empty floor tiles', () => { it('should return false for empty floor tiles', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0)); const world = createTestWorld(10, 10, new Array(100).fill(0));
const mockAccessor = { getActorsAt: () => [] } as any;
expect(isBlocked(world, 3, 3)).toBe(false); expect(isBlocked(world, 3, 3, mockAccessor)).toBe(false);
expect(isBlocked(world, 7, 7)).toBe(false); expect(isBlocked(world, 7, 7, mockAccessor)).toBe(false);
}); });
it('should return true for out of bounds', () => { it('should return true for out of bounds', () => {
const world = createTestWorld(10, 10, new Array(100).fill(0)); const world = createTestWorld(10, 10, new Array(100).fill(0));
const mockAccessor = { getActorsAt: () => [] } as any;
expect(isBlocked(world, -1, 0)).toBe(true); expect(isBlocked(world, -1, 0, mockAccessor)).toBe(true);
expect(isBlocked(world, 10, 10)).toBe(true); expect(isBlocked(world, 10, 10, mockAccessor)).toBe(true);
});
});
describe('tryDestructTile', () => {
it('should destruct a destructible tile', () => {
const tiles = new Array(100).fill(TileType.EMPTY);
tiles[0] = TileType.GRASS;
const world = createTestWorld(10, 10, tiles);
const result = tryDestructTile(world, 0, 0);
expect(result).toBe(true);
expect(world.tiles[0]).toBe(TileType.GRASS_SAPLINGS);
});
it('should not destruct a non-destructible tile', () => {
const tiles = new Array(100).fill(TileType.EMPTY);
tiles[0] = TileType.WALL;
const world = createTestWorld(10, 10, tiles);
const result = tryDestructTile(world, 0, 0);
expect(result).toBe(false);
expect(world.tiles[0]).toBe(TileType.WALL);
});
it('should return false for out of bounds', () => {
const world = createTestWorld(10, 10, new Array(100).fill(TileType.EMPTY));
expect(tryDestructTile(world, -1, 0)).toBe(false);
}); });
}); });
}); });

124
src/engine/ecs/AISystem.ts Normal file
View File

@@ -0,0 +1,124 @@
import { type ECSWorld } from "./World";
import { type World as GameWorld, type EntityId, type Vec2, type Action } from "../../core/types";
import { type EntityAccessor } from "../EntityAccessor";
import { findPathAStar } from "../world/pathfinding";
import { isBlocked, inBounds } from "../world/world-logic";
import { blocksSight } from "../../core/terrain";
import { FOV } from "rot-js";
export class AISystem {
private ecsWorld: ECSWorld;
private gameWorld: GameWorld;
private accessor: EntityAccessor;
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, accessor: EntityAccessor) {
this.ecsWorld = ecsWorld;
this.gameWorld = gameWorld;
this.accessor = accessor;
}
update(enemyId: EntityId, playerId: EntityId): { action: Action; justAlerted: boolean } {
const ai = this.ecsWorld.getComponent(enemyId, "ai");
const pos = this.ecsWorld.getComponent(enemyId, "position");
const playerPos = this.ecsWorld.getComponent(playerId, "position");
if (!ai || !pos || !playerPos) {
return { action: { type: "wait" }, justAlerted: false };
}
const canSee = this.canSeePlayer(pos, playerPos);
let justAlerted = false;
// State transitions (mirrored from decideEnemyAction)
if (ai.state === "alerted") {
const alertDuration = 1000;
if (Date.now() - (ai.alertedAt || 0) > alertDuration) {
ai.state = "pursuing";
}
}
if (canSee) {
if (ai.state === "wandering" || ai.state === "searching") {
ai.state = "alerted";
ai.alertedAt = Date.now();
ai.lastKnownPlayerPos = { ...playerPos };
justAlerted = true;
} else if (ai.state === "pursuing") {
ai.lastKnownPlayerPos = { ...playerPos };
}
} else {
if (ai.state === "pursuing") {
ai.state = "searching";
} else if (ai.state === "searching") {
if (ai.lastKnownPlayerPos) {
const dist = Math.abs(pos.x - ai.lastKnownPlayerPos.x) + Math.abs(pos.y - ai.lastKnownPlayerPos.y);
if (dist <= 1) {
ai.state = "wandering";
ai.lastKnownPlayerPos = undefined;
}
} else {
ai.state = "wandering";
}
}
}
// Behavior logic
if (ai.state === "wandering") {
return { action: this.getRandomWanderMove(pos), justAlerted };
}
if (ai.state === "alerted") {
return { action: { type: "wait" }, justAlerted };
}
const targetPos = canSee ? playerPos : (ai.lastKnownPlayerPos || playerPos);
const dx = playerPos.x - pos.x;
const dy = playerPos.y - pos.y;
const chebyshevDist = Math.max(Math.abs(dx), Math.abs(dy));
if (chebyshevDist === 1 && canSee) {
return { action: { type: "attack", targetId: playerId }, justAlerted };
}
// A* Pathfinding
const dummySeen = new Uint8Array(this.gameWorld.width * this.gameWorld.height).fill(1);
const path = findPathAStar(this.gameWorld, dummySeen, pos, targetPos, {
ignoreBlockedTarget: true,
ignoreSeen: true,
accessor: this.accessor
});
if (path.length >= 2) {
const next = path[1];
return { action: { type: "move", dx: next.x - pos.x, dy: next.y - pos.y }, justAlerted };
}
return { action: { type: "wait" }, justAlerted };
}
private canSeePlayer(enemyPos: Vec2, playerPos: Vec2): boolean {
const viewRadius = 8;
let canSee = false;
const fov = new FOV.PreciseShadowcasting((x, y) => {
if (!inBounds(this.gameWorld, x, y)) return false;
if (x === enemyPos.x && y === enemyPos.y) return true;
const idx = y * this.gameWorld.width + x;
return !blocksSight(this.gameWorld.tiles[idx]);
});
fov.compute(enemyPos.x, enemyPos.y, viewRadius, (x, y) => {
if (x === playerPos.x && y === playerPos.y) canSee = true;
});
return canSee;
}
private getRandomWanderMove(pos: Vec2): Action {
const directions = [{ dx: 0, dy: -1 }, { dx: 0, dy: 1 }, { dx: -1, dy: 0 }, { dx: 1, dy: 0 }];
// Simple shuffle and try
for (const dir of directions.sort(() => Math.random() - 0.5)) {
if (!isBlocked(this.gameWorld, pos.x + dir.dx, pos.y + dir.dy, this.accessor)) {
return { type: "move", ...dir };
}
}
return { type: "wait" };
}
}

View File

@@ -0,0 +1,279 @@
import { type ECSWorld } from "./World";
import { type ComponentMap } from "./components";
import { type EntityId, type Stats, type EnemyAIState, type ActorType, type Inventory, type Equipment, type Item, type Vec2 } from "../../core/types";
import { GAME_CONFIG } from "../../core/config/GameConfig";
/**
* Fluent builder for creating ECS entities with components.
* Makes entity creation declarative and easy to extend.
*
* @example
* // Create a simple trap
* EntityBuilder.create(world)
* .withPosition(5, 10)
* .asTrap(15)
* .build();
*
* @example
* // Create an enemy
* EntityBuilder.create(world)
* .withPosition(3, 7)
* .asEnemy("rat")
* .build();
*/
export class EntityBuilder {
private world: ECSWorld;
private entityId: EntityId;
private components: Partial<{ [K in keyof ComponentMap]: ComponentMap[K] }> = {};
private constructor(world: ECSWorld) {
this.world = world;
this.entityId = world.createEntity();
}
/**
* Start building a new entity.
*/
static create(world: ECSWorld): EntityBuilder {
return new EntityBuilder(world);
}
/**
* Add a position component.
*/
withPosition(x: number, y: number): this {
this.components.position = { x, y };
return this;
}
/**
* Add a name component.
*/
withName(name: string): this {
this.components.name = { name };
return this;
}
/**
* Add a sprite component.
*/
withSprite(texture: string, index: number): this {
this.components.sprite = { texture, index };
return this;
}
/**
* Add stats component with partial stats (fills defaults).
*/
withStats(stats: Partial<Stats>): this {
const defaultStats: Stats = {
maxHp: 10, hp: 10,
maxMana: 0, mana: 0,
attack: 1, defense: 0,
level: 1, exp: 0, expToNextLevel: 10,
critChance: 0, critMultiplier: 100, accuracy: 100, lifesteal: 0,
evasion: 0, blockChance: 0, luck: 0,
statPoints: 0, skillPoints: 0,
strength: 10, dexterity: 10, intelligence: 10,
passiveNodes: []
};
this.components.stats = { ...defaultStats, ...stats };
return this;
}
/**
* Add energy component for turn scheduling.
*/
withEnergy(speed: number, current: number = 0): this {
this.components.energy = { current, speed };
return this;
}
/**
* Add inventory component.
*/
withInventory(inventory: Inventory): this {
this.components.inventory = inventory;
return this;
}
/**
* Add equipment component.
*/
withEquipment(equipment: Equipment): this {
this.components.equipment = equipment;
return this;
}
/**
* Add AI component for enemy behavior.
*/
withAI(state: EnemyAIState = "wandering"): this {
this.components.ai = { state };
return this;
}
/**
* Add player tag component.
*/
asPlayer(): this {
this.components.player = {};
this.components.actorType = { type: "player" };
return this;
}
/**
* Configure as an enemy with stats from GameConfig.
*/
asEnemy(type: ActorType): this {
if (type === "player") {
throw new Error("Use asPlayer() for player entities");
}
this.components.actorType = { type };
this.withAI("wandering");
// Apply enemy stats from config
const config = GAME_CONFIG.enemies[type as keyof typeof GAME_CONFIG.enemies];
if (config) {
const speed = config.minSpeed + Math.random() * (config.maxSpeed - config.minSpeed);
this.withStats({
maxHp: config.baseHp,
hp: config.baseHp,
attack: config.baseAttack,
defense: config.baseDefense
});
this.withEnergy(speed);
}
return this;
}
/**
* Configure as a trap that deals damage when stepped on.
*/
asTrap(damage: number, oneShot: boolean = false): this {
this.components.trigger = {
onEnter: true,
oneShot,
damage
};
return this;
}
/**
* Configure as a trigger zone (pressure plate, etc).
*/
asTrigger(options: {
onEnter?: boolean;
onExit?: boolean;
onInteract?: boolean;
oneShot?: boolean;
targetId?: EntityId;
effect?: string;
effectDuration?: number;
}): this {
this.components.trigger = {
onEnter: options.onEnter ?? false,
onExit: options.onExit,
onInteract: options.onInteract,
oneShot: options.oneShot,
targetId: options.targetId,
effect: options.effect,
effectDuration: options.effectDuration
};
return this;
}
/**
* Configure as a destructible object.
*/
asDestructible(hp: number, maxHp?: number, options?: {
destroyedTile?: number;
lootTable?: string;
}): this {
this.components.destructible = {
hp,
maxHp: maxHp ?? hp,
destroyedTile: options?.destroyedTile,
lootTable: options?.lootTable
};
return this;
}
/**
* Configure as a collectible (exp orb, etc).
*/
asCollectible(type: "exp_orb", amount: number): this {
this.components.collectible = { type, amount };
return this;
}
/**
* Configure as an item on the ground.
*/
asGroundItem(item: Item): this {
this.components.groundItem = { item };
return this;
}
/**
* Add initial status effects.
*/
withStatusEffects(effects: ComponentMap["statusEffects"]["effects"]): this {
this.components.statusEffects = { effects };
return this;
}
/**
* Add combat tracking component.
*/
withCombat(): this {
this.components.combat = {};
return this;
}
/**
* Add a raw component directly.
*/
with<K extends keyof ComponentMap>(type: K, data: ComponentMap[K]): this {
this.components[type] = data;
return this;
}
/**
* Configure as a mine cart.
*/
asMineCart(path: Vec2[]): this {
this.components.mineCart = {
isMoving: false,
path,
pathIndex: 0
};
this.withSprite("mine_cart", 0);
this.withName("Mine Cart");
return this;
}
/**
* Finalize and register all components with the ECS world.
* @returns The created entity ID
*/
build(): EntityId {
for (const [type, data] of Object.entries(this.components)) {
if (data !== undefined) {
this.world.addComponent(this.entityId, type as keyof ComponentMap, data as any);
}
}
return this.entityId;
}
/**
* Get the entity ID (even before build is called).
*/
getId(): EntityId {
return this.entityId;
}
}

158
src/engine/ecs/EventBus.ts Normal file
View File

@@ -0,0 +1,158 @@
import { type EntityId } from "../../core/types";
import { type ComponentType } from "./components";
/**
* Game events for cross-system communication.
* Systems can emit and subscribe to these events to react to gameplay changes.
*/
export type GameEvent =
// Combat events
| { type: "damage"; entityId: EntityId; amount: number; source?: EntityId }
| { type: "heal"; entityId: EntityId; amount: number; source?: EntityId }
| { type: "death"; entityId: EntityId; killedBy?: EntityId }
// Component lifecycle events
| { type: "component_added"; entityId: EntityId; componentType: ComponentType }
| { type: "component_removed"; entityId: EntityId; componentType: ComponentType }
| { type: "entity_created"; entityId: EntityId }
| { type: "entity_destroyed"; entityId: EntityId }
// Movement & trigger events
| { type: "stepped_on"; entityId: EntityId; x: number; y: number }
| { type: "entity_moved"; entityId: EntityId; from: { x: number; y: number }; to: { x: number; y: number } }
| { type: "trigger_activated"; triggerId: EntityId; activatorId: EntityId }
// Status effect events
| { type: "status_applied"; entityId: EntityId; status: string; duration: number }
| { type: "status_expired"; entityId: EntityId; status: string }
| { type: "status_tick"; entityId: EntityId; status: string; remaining: number }
// World events
| { type: "tile_changed"; x: number; y: number }
| { type: "mission_complete" };
export type GameEventType = GameEvent["type"];
type EventHandler<T extends GameEvent = GameEvent> = (event: T) => void;
/**
* Lightweight event bus for cross-system communication.
* Enables reactive gameplay like status effects, triggers, and combat feedback.
*/
export class EventBus {
private listeners: Map<string, Set<EventHandler>> = new Map();
private onceListeners: Map<string, Set<EventHandler>> = new Map();
private eventQueue: GameEvent[] = [];
/**
* Subscribe to events of a specific type.
* @returns Unsubscribe function
*/
on<T extends GameEventType>(
eventType: T,
handler: EventHandler<Extract<GameEvent, { type: T }>>
): () => void {
if (!this.listeners.has(eventType)) {
this.listeners.set(eventType, new Set());
}
this.listeners.get(eventType)!.add(handler as EventHandler);
// Return unsubscribe function
return () => {
this.listeners.get(eventType)?.delete(handler as EventHandler);
};
}
/**
* Subscribe to a single occurrence of an event type.
* The handler is automatically removed after being called once.
*/
once<T extends GameEventType>(
eventType: T,
handler: EventHandler<Extract<GameEvent, { type: T }>>
): void {
if (!this.onceListeners.has(eventType)) {
this.onceListeners.set(eventType, new Set());
}
this.onceListeners.get(eventType)!.add(handler as EventHandler);
}
/**
* Emit an event to all registered listeners.
*/
emit(event: GameEvent): void {
const eventType = event.type;
// Call regular listeners
const handlers = this.listeners.get(eventType);
if (handlers) {
for (const handler of handlers) {
handler(event);
}
}
// Call once listeners and remove them
const onceHandlers = this.onceListeners.get(eventType);
if (onceHandlers) {
for (const handler of onceHandlers) {
handler(event);
}
this.onceListeners.delete(eventType);
}
// Add to queue for drain()
this.eventQueue.push(event);
}
/**
* Drain all queued events and return them.
* Clears the internal queue.
*/
drain(): GameEvent[] {
const events = [...this.eventQueue];
this.eventQueue = [];
return events;
}
/**
* Remove all listeners for a specific event type.
*/
off(eventType: GameEventType): void {
this.listeners.delete(eventType);
this.onceListeners.delete(eventType);
}
/**
* Remove all listeners for all event types.
*/
clear(): void {
this.listeners.clear();
this.onceListeners.clear();
}
/**
* Check if there are any listeners for a specific event type.
*/
hasListeners(eventType: GameEventType): boolean {
return (
(this.listeners.get(eventType)?.size ?? 0) > 0 ||
(this.onceListeners.get(eventType)?.size ?? 0) > 0
);
}
}
// Singleton instance for global event bus (optional - can also create instances per world)
let globalEventBus: EventBus | null = null;
export function getEventBus(): EventBus {
if (!globalEventBus) {
globalEventBus = new EventBus();
}
return globalEventBus;
}
export function resetEventBus(): void {
globalEventBus?.clear();
globalEventBus = null;
}

View File

@@ -0,0 +1,34 @@
import { type ECSWorld } from "./World";
import { type World as GameWorld, type EntityId } from "../../core/types";
import { isBlocked } from "../world/world-logic";
import { type EntityAccessor } from "../EntityAccessor";
export class MovementSystem {
private ecsWorld: ECSWorld;
private gameWorld: GameWorld;
private accessor: EntityAccessor;
constructor(ecsWorld: ECSWorld, gameWorld: GameWorld, accessor: EntityAccessor) {
this.ecsWorld = ecsWorld;
this.gameWorld = gameWorld;
this.accessor = accessor;
}
move(entityId: EntityId, dx: number, dy: number): boolean {
const pos = this.ecsWorld.getComponent(entityId, "position");
if (!pos) return false;
const nx = pos.x + dx;
const ny = pos.y + dy;
if (!isBlocked(this.gameWorld, nx, ny, this.accessor)) {
// Update ECS Position
pos.x = nx;
pos.y = ny;
return true;
}
return false;
}
}

259
src/engine/ecs/Prefabs.ts Normal file
View File

@@ -0,0 +1,259 @@
import { type ECSWorld } from "./World";
import { EntityBuilder } from "./EntityBuilder";
import { type EntityId, type Item, type Vec2 } from "../../core/types";
import { GAME_CONFIG } from "../../core/config/GameConfig";
/**
* Pre-defined entity templates for common entity types.
* Use these for quick spawning of standard game entities.
*
* @example
* const ratId = Prefabs.rat(world, 5, 10);
* const trapId = Prefabs.spikeTrap(world, 3, 7, 15);
*/
export const Prefabs = {
/**
* Create a rat enemy at the given position.
*/
rat(world: ECSWorld, x: number, y: number, floorBonus: number = 0): EntityId {
const config = GAME_CONFIG.enemies.rat;
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Rat")
.asEnemy("rat")
.withStats({
maxHp: config.baseHp + floorBonus,
hp: config.baseHp + floorBonus,
attack: config.baseAttack + Math.floor(floorBonus / 2),
defense: config.baseDefense
})
.withCombat()
.build();
},
/**
* Create a bat enemy at the given position.
*/
bat(world: ECSWorld, x: number, y: number, floorBonus: number = 0): EntityId {
const config = GAME_CONFIG.enemies.bat;
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Bat")
.asEnemy("bat")
.withStats({
maxHp: config.baseHp + floorBonus,
hp: config.baseHp + floorBonus,
attack: config.baseAttack + Math.floor(floorBonus / 2),
defense: config.baseDefense
})
.withCombat()
.build();
},
/**
* Create an experience orb collectible.
*/
expOrb(world: ECSWorld, x: number, y: number, amount: number): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Experience Orb")
.withSprite("items", 0) // Adjust sprite index as needed
.asCollectible("exp_orb", amount)
.build();
},
/**
* Create a poison trap (sprite 17 - green).
* Applies poison status effect when stepped on.
*/
poisonTrap(world: ECSWorld, x: number, y: number, duration: number = 5, magnitude: number = 2): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Poison Trap")
.withSprite("dungeon", 17)
.asTrigger({
onEnter: true,
oneShot: true,
effect: "poison",
effectDuration: duration
})
.with("trigger", {
onEnter: true,
oneShot: true,
effect: "poison",
effectDuration: duration,
damage: magnitude // Store magnitude as damage for effect processing
})
.build();
},
/**
* Create a fire trap (sprite 19 - orange).
* Applies burning status effect when stepped on.
*/
fireTrap(world: ECSWorld, x: number, y: number, duration: number = 3, magnitude: number = 4): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Fire Trap")
.withSprite("dungeon", 19)
.asTrigger({
onEnter: true,
oneShot: true,
effect: "burning",
effectDuration: duration
})
.with("trigger", {
onEnter: true,
oneShot: true,
effect: "burning",
effectDuration: duration,
damage: magnitude
})
.build();
},
/**
* Create a paralysis trap (sprite 21 - yellow).
* Applies frozen status effect when stepped on.
*/
paralysisTrap(world: ECSWorld, x: number, y: number, duration: number = 2): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Paralysis Trap")
.withSprite("dungeon", 21)
.asTrigger({
onEnter: true,
oneShot: true,
effect: "frozen",
effectDuration: duration
})
.build();
},
/**
* Create a pressure plate trigger.
*/
pressurePlate(world: ECSWorld, x: number, y: number, effect?: string, duration?: number): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Pressure Plate")
.withSprite("dungeon", 34) // Adjust sprite index as needed
.asTrigger({
onEnter: true,
onExit: true,
effect,
effectDuration: duration
})
.build();
},
/**
* Create a destructible barrel.
*/
barrel(world: ECSWorld, x: number, y: number, lootTable?: string): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Barrel")
.withSprite("dungeon", 48) // Adjust sprite index as needed
.asDestructible(5, 5, { lootTable })
.build();
},
/**
* Create a destructible crate.
*/
crate(world: ECSWorld, x: number, y: number, lootTable?: string): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Crate")
.withSprite("dungeon", 49) // Adjust sprite index as needed
.asDestructible(8, 8, { lootTable })
.build();
},
/**
* Create an item drop on the ground.
*/
itemDrop(world: ECSWorld, x: number, y: number, item: Item, spriteIndex: number = 0): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName(item.name)
.withSprite("items", spriteIndex)
.asGroundItem(item)
.build();
},
/**
* Create a fire entity on a tile.
*/
fire(world: ECSWorld, x: number, y: number, duration: number = 4): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Fire")
.withSprite("dungeon", 19) // Reuse fire trap sprite index for fire
.with("lifeSpan", { remainingTurns: duration })
.asTrigger({
onEnter: true,
effect: "burning",
effectDuration: 5
})
.with("trigger", {
onEnter: true,
effect: "burning",
effectDuration: 5,
damage: 3
})
.build();
},
/**
* Create a player entity at the given position.
*/
player(world: ECSWorld, x: number, y: number): EntityId {
const config = GAME_CONFIG.player;
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Player")
.asPlayer()
.withStats(config.initialStats)
.withEnergy(config.speed)
.withCombat()
.build();
},
/**
* Create a mine cart at the start of a path.
*/
mineCart(world: ECSWorld, path: Vec2[]): EntityId {
const start = path[0];
return EntityBuilder.create(world)
.withPosition(start.x, start.y)
.asMineCart(path)
.build();
},
/**
* Create a switch that triggers the mine cart.
*/
trackSwitch(world: ECSWorld, x: number, y: number, cartId: EntityId): EntityId {
return EntityBuilder.create(world)
.withPosition(x, y)
.withName("Track Switch")
.withSprite("track_switch", 0)
.asTrigger({
onEnter: false,
onInteract: true,
oneShot: true,
targetId: cartId
})
.build();
}
};
/**
* Type for prefab factory functions.
* Useful for creating maps of spawnable entities.
*/
export type PrefabFactory = (world: ECSWorld, x: number, y: number, ...args: any[]) => EntityId;

259
src/engine/ecs/System.ts Normal file
View File

@@ -0,0 +1,259 @@
import { type ECSWorld } from "./World";
import { type ComponentType } from "./components";
import { type EntityId } from "../../core/types";
import { type EventBus } from "./EventBus";
/**
* Abstract base class for all ECS systems.
* Systems operate on entities that have specific component combinations.
*
* @example
* class StatusEffectSystem extends System {
* readonly name = "StatusEffect";
* readonly requiredComponents = ["statusEffects", "stats"] as const;
*
* update(entities: EntityId[], world: ECSWorld) {
* for (const id of entities) {
* // Process status effects...
* }
* }
* }
*/
export abstract class System {
/**
* Human-readable name for debugging and logging.
*/
abstract readonly name: string;
/**
* Components required for this system to operate on an entity.
* Only entities with ALL these components will be passed to update().
*/
abstract readonly requiredComponents: readonly ComponentType[];
/**
* Priority for execution order (lower = earlier).
* Default is 0. Use negative for early systems, positive for late.
*/
readonly priority: number = 0;
/**
* Whether this system is currently enabled.
*/
enabled: boolean = true;
/**
* Optional reference to the event bus for emitting/subscribing to events.
*/
protected eventBus?: EventBus;
/**
* Called by the registry to inject the event bus.
*/
setEventBus(eventBus: EventBus): void {
this.eventBus = eventBus;
}
/**
* Main update method called each game tick.
* @param entities - All entities that have the required components
* @param world - The ECS world for component access
* @param dt - Delta time since last update (optional)
*/
abstract update(entities: EntityId[], world: ECSWorld, dt?: number): void;
/**
* Optional: Called when a matching entity is added to the world.
*/
onEntityAdded?(entityId: EntityId, world: ECSWorld): void;
/**
* Optional: Called when a matching entity is removed from the world.
*/
onEntityRemoved?(entityId: EntityId, world: ECSWorld): void;
/**
* Optional: Called once when the system is registered.
*/
onRegister?(world: ECSWorld): void;
/**
* Optional: Called once when the system is unregistered.
*/
onUnregister?(world: ECSWorld): void;
}
/**
* Manages registration and execution of all ECS systems.
* Handles entity queries, execution order, and lifecycle hooks.
*
* @example
* const registry = new SystemRegistry(world, eventBus);
* registry.register(new StatusEffectSystem());
* registry.register(new TriggerSystem());
*
* // In game loop:
* registry.updateAll(deltaTime);
*/
export class SystemRegistry {
private systems: System[] = [];
private world: ECSWorld;
private eventBus?: EventBus;
private queryCache: Map<string, EntityId[]> = new Map();
private queryCacheDirty: boolean = true;
constructor(world: ECSWorld, eventBus?: EventBus) {
this.world = world;
this.eventBus = eventBus;
}
/**
* Register a new system.
* Systems are sorted by priority (lower = earlier execution).
*/
register(system: System): void {
if (this.eventBus) {
system.setEventBus(this.eventBus);
}
this.systems.push(system);
this.systems.sort((a, b) => a.priority - b.priority);
system.onRegister?.(this.world);
this.invalidateCache();
}
/**
* Unregister a system by instance or name.
*/
unregister(systemOrName: System | string): boolean {
const index = typeof systemOrName === "string"
? this.systems.findIndex(s => s.name === systemOrName)
: this.systems.indexOf(systemOrName);
if (index !== -1) {
const [removed] = this.systems.splice(index, 1);
removed.onUnregister?.(this.world);
this.invalidateCache();
return true;
}
return false;
}
/**
* Get a system by name.
*/
get<T extends System>(name: string): T | undefined {
return this.systems.find(s => s.name === name) as T | undefined;
}
/**
* Check if a system is registered.
*/
has(name: string): boolean {
return this.systems.some(s => s.name === name);
}
/**
* Update all enabled systems in priority order.
*/
updateAll(dt?: number): void {
for (const system of this.systems) {
if (!system.enabled) continue;
const entities = this.getEntitiesForSystem(system);
system.update(entities, this.world, dt);
}
}
/**
* Update a specific system by name.
*/
updateSystem(name: string, dt?: number): boolean {
const system = this.get(name);
if (!system || !system.enabled) return false;
const entities = this.getEntitiesForSystem(system);
system.update(entities, this.world, dt);
return true;
}
/**
* Enable or disable a system by name.
*/
setEnabled(name: string, enabled: boolean): boolean {
const system = this.get(name);
if (system) {
system.enabled = enabled;
return true;
}
return false;
}
/**
* Get all registered systems.
*/
getSystems(): readonly System[] {
return this.systems;
}
/**
* Mark the entity cache as dirty (call after entity changes).
*/
invalidateCache(): void {
this.queryCacheDirty = true;
this.queryCache.clear();
}
/**
* Notify systems that an entity was added.
*/
notifyEntityAdded(entityId: EntityId): void {
this.invalidateCache();
for (const system of this.systems) {
if (system.onEntityAdded && this.entityMatchesSystem(entityId, system)) {
system.onEntityAdded(entityId, this.world);
}
}
}
/**
* Notify systems that an entity was removed.
*/
notifyEntityRemoved(entityId: EntityId): void {
for (const system of this.systems) {
if (system.onEntityRemoved) {
system.onEntityRemoved(entityId, this.world);
}
}
this.invalidateCache();
}
/**
* Get entities matching a system's required components.
*/
private getEntitiesForSystem(system: System): EntityId[] {
const cacheKey = system.requiredComponents.join(",");
if (!this.queryCacheDirty && this.queryCache.has(cacheKey)) {
return this.queryCache.get(cacheKey)!;
}
const entities = this.world.getEntitiesWith(...system.requiredComponents);
this.queryCache.set(cacheKey, entities);
return entities;
}
/**
* Check if an entity has all components required by a system.
*/
private entityMatchesSystem(entityId: EntityId, system: System): boolean {
for (const component of system.requiredComponents) {
if (!this.world.hasComponent(entityId, component)) {
return false;
}
}
return true;
}
}

83
src/engine/ecs/World.ts Normal file
View File

@@ -0,0 +1,83 @@
import { type ComponentMap, type ComponentType } from "./components";
import { type EntityId } from "../../core/types";
export class ECSWorld {
private nextId: number = 1;
private entities: Set<EntityId> = new Set();
private components: { [K in ComponentType]?: Map<EntityId, ComponentMap[K]> } = {};
createEntity(): EntityId {
const id = this.nextId++ as EntityId;
this.entities.add(id);
return id;
}
hasEntity(id: EntityId): boolean {
return this.entities.has(id);
}
destroyEntity(id: EntityId) {
this.entities.delete(id);
for (const type in this.components) {
this.components[type as ComponentType]?.delete(id);
}
}
addComponent<K extends ComponentType>(id: EntityId, type: K, data: ComponentMap[K]) {
this.entities.add(id); // Ensure entity is registered
if (!this.components[type]) {
this.components[type] = new Map();
}
this.components[type]!.set(id, data);
}
removeComponent(id: EntityId, type: ComponentType) {
this.components[type]?.delete(id);
}
getComponent<K extends ComponentType>(id: EntityId, type: K): ComponentMap[K] | undefined {
return this.components[type]?.get(id) as ComponentMap[K] | undefined;
}
hasComponent(id: EntityId, type: ComponentType): boolean {
return this.components[type]?.has(id) ?? false;
}
getEntitiesWith<K extends ComponentType>(...types: K[]): EntityId[] {
if (types.length === 0) return Array.from(this.entities);
// Start with the smallest set to optimize
const sortedTypes = [...types].sort((a, b) => {
const sizeA = this.components[a]?.size ?? 0;
const sizeB = this.components[b]?.size ?? 0;
return sizeA - sizeB;
});
const firstType = sortedTypes[0];
const firstMap = this.components[firstType];
if (!firstMap) return [];
const result: EntityId[] = [];
for (const id of firstMap.keys()) {
let hasAll = true;
for (let i = 1; i < sortedTypes.length; i++) {
if (!this.components[sortedTypes[i]]?.has(id)) {
hasAll = false;
break;
}
}
if (hasAll) result.push(id);
}
return result;
}
// Helper for existing systems that use the lastId logic
setNextId(id: number) {
this.nextId = id;
}
get currentNextId(): number {
return this.nextId;
}
}

View File

@@ -0,0 +1,39 @@
import { describe, it, expect } from 'vitest';
import { ECSWorld } from '../World';
import { EntityAccessor } from '../../EntityAccessor';
import { EntityBuilder } from '../EntityBuilder';
import type { World as GameWorld, EntityId } from '../../../core/types';
describe('ECS Removal and Accessor', () => {
it('should not report destroyed entities in getAllActors', () => {
const ecsWorld = new ECSWorld();
const gameWorld: GameWorld = {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 0, y: 0 },
trackPath: []
};
const accessor = new EntityAccessor(gameWorld, 999 as EntityId, ecsWorld);
// Create Entity
const id = EntityBuilder.create(ecsWorld)
.asEnemy("rat")
.withPosition(5, 5)
.withStats({ hp: 10, maxHp: 10 } as any)
.build();
// Verify it exists
let actors = [...accessor.getAllActors()];
expect(actors.length).toBe(1);
expect(actors[0].id).toBe(id);
// Destroy it
ecsWorld.destroyEntity(id);
// Verify it is gone
actors = [...accessor.getAllActors()];
expect(actors.length).toBe(0);
});
});

View File

@@ -0,0 +1,231 @@
import { describe, it, expect, beforeEach } from "vitest";
import { EntityBuilder } from "../EntityBuilder";
import { Prefabs } from "../Prefabs";
import { ECSWorld } from "../World";
describe("EntityBuilder", () => {
let world: ECSWorld;
beforeEach(() => {
world = new ECSWorld();
});
describe("basic entity creation", () => {
it("should create an entity with position", () => {
const id = EntityBuilder.create(world)
.withPosition(5, 10)
.build();
const pos = world.getComponent(id, "position");
expect(pos).toEqual({ x: 5, y: 10 });
});
it("should create an entity with name", () => {
const id = EntityBuilder.create(world)
.withName("TestEntity")
.build();
const name = world.getComponent(id, "name");
expect(name).toEqual({ name: "TestEntity" });
});
it("should create an entity with sprite", () => {
const id = EntityBuilder.create(world)
.withSprite("items", 5)
.build();
const sprite = world.getComponent(id, "sprite");
expect(sprite).toEqual({ texture: "items", index: 5 });
});
it("should chain multiple components", () => {
const id = EntityBuilder.create(world)
.withPosition(3, 7)
.withName("ChainedEntity")
.withSprite("dungeon", 10)
.build();
expect(world.getComponent(id, "position")).toEqual({ x: 3, y: 7 });
expect(world.getComponent(id, "name")).toEqual({ name: "ChainedEntity" });
expect(world.getComponent(id, "sprite")).toEqual({ texture: "dungeon", index: 10 });
});
});
describe("withStats", () => {
it("should create stats with defaults and overrides", () => {
const id = EntityBuilder.create(world)
.withStats({ maxHp: 50, hp: 50, attack: 10 })
.build();
const stats = world.getComponent(id, "stats");
expect(stats?.maxHp).toBe(50);
expect(stats?.hp).toBe(50);
expect(stats?.attack).toBe(10);
// Check defaults are applied
expect(stats?.defense).toBe(0);
expect(stats?.level).toBe(1);
});
});
describe("asPlayer", () => {
it("should add player tag and actorType", () => {
const id = EntityBuilder.create(world)
.asPlayer()
.build();
expect(world.hasComponent(id, "player")).toBe(true);
expect(world.getComponent(id, "actorType")).toEqual({ type: "player" });
});
});
describe("asEnemy", () => {
it("should configure as enemy with AI", () => {
const id = EntityBuilder.create(world)
.withPosition(0, 0)
.asEnemy("rat")
.build();
expect(world.getComponent(id, "actorType")).toEqual({ type: "rat" });
expect(world.hasComponent(id, "ai")).toBe(true);
expect(world.getComponent(id, "ai")?.state).toBe("wandering");
});
it("should throw for player type", () => {
expect(() => {
EntityBuilder.create(world).asEnemy("player" as any);
}).toThrow();
});
});
describe("asTrap", () => {
it("should create a trap with damage", () => {
const id = EntityBuilder.create(world)
.withPosition(5, 5)
.asTrap(15)
.build();
const trigger = world.getComponent(id, "trigger");
expect(trigger?.onEnter).toBe(true);
expect(trigger?.damage).toBe(15);
expect(trigger?.oneShot).toBe(false);
});
it("should create a one-shot trap", () => {
const id = EntityBuilder.create(world)
.asTrap(10, true)
.build();
const trigger = world.getComponent(id, "trigger");
expect(trigger?.oneShot).toBe(true);
});
});
describe("asDestructible", () => {
it("should create destructible with hp", () => {
const id = EntityBuilder.create(world)
.asDestructible(20)
.build();
const destructible = world.getComponent(id, "destructible");
expect(destructible?.hp).toBe(20);
expect(destructible?.maxHp).toBe(20);
});
it("should create destructible with loot table", () => {
const id = EntityBuilder.create(world)
.asDestructible(10, 10, { lootTable: "barrel_loot" })
.build();
const destructible = world.getComponent(id, "destructible");
expect(destructible?.lootTable).toBe("barrel_loot");
});
});
describe("asCollectible", () => {
it("should create an exp orb collectible", () => {
const id = EntityBuilder.create(world)
.asCollectible("exp_orb", 25)
.build();
const collectible = world.getComponent(id, "collectible");
expect(collectible?.type).toBe("exp_orb");
expect(collectible?.amount).toBe(25);
});
});
describe("withCombat", () => {
it("should add combat tracking component", () => {
const id = EntityBuilder.create(world)
.withCombat()
.build();
expect(world.hasComponent(id, "combat")).toBe(true);
});
});
describe("getId", () => {
it("should return entity id before build", () => {
const builder = EntityBuilder.create(world);
const id = builder.getId();
expect(typeof id).toBe("number");
expect(id).toBeGreaterThan(0);
});
});
});
describe("Prefabs", () => {
let world: ECSWorld;
beforeEach(() => {
world = new ECSWorld();
});
it("should create a rat with all required components", () => {
const id = Prefabs.rat(world, 5, 10);
expect(world.getComponent(id, "position")).toEqual({ x: 5, y: 10 });
expect(world.getComponent(id, "name")).toEqual({ name: "Rat" });
expect(world.getComponent(id, "actorType")).toEqual({ type: "rat" });
expect(world.hasComponent(id, "ai")).toBe(true);
expect(world.hasComponent(id, "stats")).toBe(true);
expect(world.hasComponent(id, "combat")).toBe(true);
});
it("should create a bat with all required components", () => {
const id = Prefabs.bat(world, 3, 7);
expect(world.getComponent(id, "position")).toEqual({ x: 3, y: 7 });
expect(world.getComponent(id, "actorType")).toEqual({ type: "bat" });
});
it("should create poison trap", () => {
const id = Prefabs.poisonTrap(world, 2, 4, 5, 3);
expect(world.getComponent(id, "position")).toEqual({ x: 2, y: 4 });
expect(world.getComponent(id, "trigger")?.effect).toBe("poison");
expect(world.getComponent(id, "trigger")?.onEnter).toBe(true);
});
it("should create barrel", () => {
const id = Prefabs.barrel(world, 1, 1, "gold_loot");
expect(world.getComponent(id, "destructible")?.hp).toBe(5);
expect(world.getComponent(id, "destructible")?.lootTable).toBe("gold_loot");
});
it("should create exp orb", () => {
const id = Prefabs.expOrb(world, 0, 0, 50);
expect(world.getComponent(id, "collectible")).toEqual({ type: "exp_orb", amount: 50 });
});
it("should create player", () => {
const id = Prefabs.player(world, 10, 10);
expect(world.hasComponent(id, "player")).toBe(true);
expect(world.getComponent(id, "actorType")).toEqual({ type: "player" });
expect(world.hasComponent(id, "stats")).toBe(true);
expect(world.hasComponent(id, "energy")).toBe(true);
});
});

View File

@@ -0,0 +1,192 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { EventBus, type GameEvent } from "../EventBus";
describe("EventBus", () => {
let eventBus: EventBus;
beforeEach(() => {
eventBus = new EventBus();
});
describe("on() and emit()", () => {
it("should call handler when matching event is emitted", () => {
const handler = vi.fn();
eventBus.on("damage", handler);
const event: GameEvent = { type: "damage", entityId: 1, amount: 10 };
eventBus.emit(event);
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenCalledWith(event);
});
it("should not call handler for non-matching event types", () => {
const damageHandler = vi.fn();
const healHandler = vi.fn();
eventBus.on("damage", damageHandler);
eventBus.on("heal", healHandler);
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
expect(damageHandler).toHaveBeenCalledTimes(1);
expect(healHandler).not.toHaveBeenCalled();
});
it("should call multiple handlers for the same event type", () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
eventBus.on("death", handler1);
eventBus.on("death", handler2);
eventBus.emit({ type: "death", entityId: 5 });
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);
});
it("should allow handler to be called multiple times", () => {
const handler = vi.fn();
eventBus.on("damage", handler);
eventBus.emit({ type: "damage", entityId: 1, amount: 5 });
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
eventBus.emit({ type: "damage", entityId: 1, amount: 15 });
expect(handler).toHaveBeenCalledTimes(3);
});
});
describe("unsubscribe", () => {
it("should return unsubscribe function that removes handler", () => {
const handler = vi.fn();
const unsubscribe = eventBus.on("damage", handler);
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
expect(handler).toHaveBeenCalledTimes(1);
unsubscribe();
eventBus.emit({ type: "damage", entityId: 1, amount: 20 });
expect(handler).toHaveBeenCalledTimes(1); // Still 1, not called again
});
});
describe("once()", () => {
it("should call handler only once then auto-remove", () => {
const handler = vi.fn();
eventBus.once("status_applied", handler);
eventBus.emit({ type: "status_applied", entityId: 1, status: "poison", duration: 5 });
eventBus.emit({ type: "status_applied", entityId: 1, status: "poison", duration: 5 });
eventBus.emit({ type: "status_applied", entityId: 1, status: "poison", duration: 5 });
expect(handler).toHaveBeenCalledTimes(1);
});
it("should call all once handlers for the same event", () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
eventBus.once("death", handler1);
eventBus.once("death", handler2);
eventBus.emit({ type: "death", entityId: 1 });
expect(handler1).toHaveBeenCalledTimes(1);
expect(handler2).toHaveBeenCalledTimes(1);
});
});
describe("off()", () => {
it("should remove all listeners for a specific event type", () => {
const handler1 = vi.fn();
const handler2 = vi.fn();
eventBus.on("damage", handler1);
eventBus.on("damage", handler2);
eventBus.once("damage", vi.fn());
eventBus.off("damage");
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
expect(handler1).not.toHaveBeenCalled();
expect(handler2).not.toHaveBeenCalled();
});
});
describe("clear()", () => {
it("should remove all listeners for all event types", () => {
const damageHandler = vi.fn();
const healHandler = vi.fn();
eventBus.on("damage", damageHandler);
eventBus.on("heal", healHandler);
eventBus.clear();
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
eventBus.emit({ type: "heal", entityId: 1, amount: 10 });
expect(damageHandler).not.toHaveBeenCalled();
expect(healHandler).not.toHaveBeenCalled();
});
});
describe("hasListeners()", () => {
it("should return true when listeners exist", () => {
eventBus.on("damage", vi.fn());
expect(eventBus.hasListeners("damage")).toBe(true);
});
it("should return false when no listeners exist", () => {
expect(eventBus.hasListeners("damage")).toBe(false);
});
it("should return true for once listeners", () => {
eventBus.once("death", vi.fn());
expect(eventBus.hasListeners("death")).toBe(true);
});
it("should return false after unsubscribe", () => {
const unsubscribe = eventBus.on("damage", vi.fn());
expect(eventBus.hasListeners("damage")).toBe(true);
unsubscribe();
expect(eventBus.hasListeners("damage")).toBe(false);
});
});
describe("event types", () => {
it("should handle all defined event types", () => {
const handlers = {
damage: vi.fn(),
heal: vi.fn(),
death: vi.fn(),
component_added: vi.fn(),
stepped_on: vi.fn(),
status_applied: vi.fn(),
trigger_activated: vi.fn(),
};
Object.entries(handlers).forEach(([type, handler]) => {
eventBus.on(type as any, handler);
});
// Emit various events
eventBus.emit({ type: "damage", entityId: 1, amount: 10 });
eventBus.emit({ type: "heal", entityId: 1, amount: 5 });
eventBus.emit({ type: "death", entityId: 1 });
eventBus.emit({ type: "component_added", entityId: 1, componentType: "stats" });
eventBus.emit({ type: "stepped_on", entityId: 1, x: 5, y: 10 });
eventBus.emit({ type: "status_applied", entityId: 1, status: "poison", duration: 3 });
eventBus.emit({ type: "trigger_activated", triggerId: 1, activatorId: 2 });
Object.values(handlers).forEach((handler) => {
expect(handler).toHaveBeenCalledTimes(1);
});
});
});
});

View File

@@ -0,0 +1,288 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { System, SystemRegistry } from "../System";
import { ECSWorld } from "../World";
import { EventBus } from "../EventBus";
import { type EntityId } from "../../../core/types";
import { type ComponentType } from "../components";
// Test system implementations
class TestSystemA extends System {
readonly name = "TestA";
readonly requiredComponents: readonly ComponentType[] = ["position"];
readonly priority = 0;
updateCalls: EntityId[][] = [];
update(entities: EntityId[], _world: ECSWorld): void {
this.updateCalls.push([...entities]);
}
}
class TestSystemB extends System {
readonly name = "TestB";
readonly requiredComponents: readonly ComponentType[] = ["position", "stats"];
readonly priority = 10; // Lower priority = runs later
updateCalls: EntityId[][] = [];
update(entities: EntityId[], _world: ECSWorld): void {
this.updateCalls.push([...entities]);
}
}
class TestSystemWithHooks extends System {
readonly name = "TestWithHooks";
readonly requiredComponents: readonly ComponentType[] = ["position"];
registered = false;
unregistered = false;
addedEntities: EntityId[] = [];
removedEntities: EntityId[] = [];
update(_entities: EntityId[], _world: ECSWorld): void {}
onRegister(_world: ECSWorld): void {
this.registered = true;
}
onUnregister(_world: ECSWorld): void {
this.unregistered = true;
}
onEntityAdded(entityId: EntityId, _world: ECSWorld): void {
this.addedEntities.push(entityId);
}
onEntityRemoved(entityId: EntityId, _world: ECSWorld): void {
this.removedEntities.push(entityId);
}
}
describe("SystemRegistry", () => {
let world: ECSWorld;
let registry: SystemRegistry;
beforeEach(() => {
world = new ECSWorld();
registry = new SystemRegistry(world);
});
describe("register()", () => {
it("should register a system", () => {
const system = new TestSystemA();
registry.register(system);
expect(registry.has("TestA")).toBe(true);
});
it("should call onRegister when registering", () => {
const system = new TestSystemWithHooks();
registry.register(system);
expect(system.registered).toBe(true);
});
it("should inject event bus into system", () => {
const eventBus = new EventBus();
const registryWithEvents = new SystemRegistry(world, eventBus);
const system = new TestSystemA();
const setEventBusSpy = vi.spyOn(system, "setEventBus");
registryWithEvents.register(system);
expect(setEventBusSpy).toHaveBeenCalledWith(eventBus);
});
});
describe("unregister()", () => {
it("should unregister by instance", () => {
const system = new TestSystemA();
registry.register(system);
const result = registry.unregister(system);
expect(result).toBe(true);
expect(registry.has("TestA")).toBe(false);
});
it("should unregister by name", () => {
registry.register(new TestSystemA());
const result = registry.unregister("TestA");
expect(result).toBe(true);
expect(registry.has("TestA")).toBe(false);
});
it("should call onUnregister when unregistering", () => {
const system = new TestSystemWithHooks();
registry.register(system);
registry.unregister(system);
expect(system.unregistered).toBe(true);
});
it("should return false for unknown system", () => {
const result = registry.unregister("Unknown");
expect(result).toBe(false);
});
});
describe("get()", () => {
it("should return system by name", () => {
const system = new TestSystemA();
registry.register(system);
expect(registry.get("TestA")).toBe(system);
});
it("should return undefined for unknown system", () => {
expect(registry.get("Unknown")).toBeUndefined();
});
});
describe("updateAll()", () => {
it("should update all systems", () => {
const systemA = new TestSystemA();
const systemB = new TestSystemB();
registry.register(systemA);
registry.register(systemB);
// Create entity with position
const id1 = world.createEntity();
world.addComponent(id1, "position", { x: 0, y: 0 });
registry.updateAll();
expect(systemA.updateCalls.length).toBe(1);
expect(systemA.updateCalls[0]).toContain(id1);
});
it("should pass only matching entities to each system", () => {
const systemA = new TestSystemA(); // needs position
const systemB = new TestSystemB(); // needs position + stats
registry.register(systemA);
registry.register(systemB);
// Entity with only position
const id1 = world.createEntity();
world.addComponent(id1, "position", { x: 0, y: 0 });
// Entity with position and stats
const id2 = world.createEntity();
world.addComponent(id2, "position", { x: 1, y: 1 });
world.addComponent(id2, "stats", { hp: 10, maxHp: 10 } as any);
registry.updateAll();
// SystemA should get both entities
expect(systemA.updateCalls[0]).toContain(id1);
expect(systemA.updateCalls[0]).toContain(id2);
// SystemB should only get entity with both components
expect(systemB.updateCalls[0]).not.toContain(id1);
expect(systemB.updateCalls[0]).toContain(id2);
});
it("should respect priority order", () => {
const callOrder: string[] = [];
class PrioritySystemLow extends System {
readonly name = "Low";
readonly requiredComponents: readonly ComponentType[] = ["position"];
readonly priority = 100;
update() { callOrder.push("Low"); }
}
class PrioritySystemHigh extends System {
readonly name = "High";
readonly requiredComponents: readonly ComponentType[] = ["position"];
readonly priority = -10;
update() { callOrder.push("High"); }
}
// Register in reverse order
registry.register(new PrioritySystemLow());
registry.register(new PrioritySystemHigh());
const id = world.createEntity();
world.addComponent(id, "position", { x: 0, y: 0 });
registry.updateAll();
expect(callOrder).toEqual(["High", "Low"]);
});
it("should skip disabled systems", () => {
const system = new TestSystemA();
registry.register(system);
const id = world.createEntity();
world.addComponent(id, "position", { x: 0, y: 0 });
system.enabled = false;
registry.updateAll();
expect(system.updateCalls.length).toBe(0);
});
});
describe("setEnabled()", () => {
it("should enable/disable system by name", () => {
const system = new TestSystemA();
registry.register(system);
registry.setEnabled("TestA", false);
expect(system.enabled).toBe(false);
registry.setEnabled("TestA", true);
expect(system.enabled).toBe(true);
});
it("should return false for unknown system", () => {
expect(registry.setEnabled("Unknown", false)).toBe(false);
});
});
describe("entity notifications", () => {
it("should notify systems when entity is added", () => {
const system = new TestSystemWithHooks();
registry.register(system);
const id = world.createEntity();
world.addComponent(id, "position", { x: 0, y: 0 });
registry.notifyEntityAdded(id);
expect(system.addedEntities).toContain(id);
});
it("should notify systems when entity is removed", () => {
const system = new TestSystemWithHooks();
registry.register(system);
const id = world.createEntity();
world.addComponent(id, "position", { x: 0, y: 0 });
registry.notifyEntityRemoved(id);
expect(system.removedEntities).toContain(id);
});
});
describe("getSystems()", () => {
it("should return all registered systems", () => {
registry.register(new TestSystemA());
registry.register(new TestSystemB());
const systems = registry.getSystems();
expect(systems.length).toBe(2);
expect(systems.map(s => s.name)).toContain("TestA");
expect(systems.map(s => s.name)).toContain("TestB");
});
});
});

View File

@@ -0,0 +1,153 @@
import { type Vec2, type Stats, type ActorType, type EnemyAIState, type EntityId, type Inventory, type Equipment, type Item } from "../../core/types";
export interface PositionComponent extends Vec2 { }
export interface StatsComponent extends Stats { }
export interface EnergyComponent {
current: number;
speed: number;
}
export interface AIComponent {
state: EnemyAIState;
alertedAt?: number;
lastKnownPlayerPos?: Vec2;
}
export interface PlayerTagComponent { }
export interface CollectibleComponent {
type: "exp_orb";
amount: number;
}
export interface SpriteComponent {
texture: string;
index: number;
}
export interface NameComponent {
name: string;
}
export interface ActorTypeComponent {
type: ActorType;
}
// ============================================
// New Components for Extended Gameplay
// ============================================
/**
* For traps, pressure plates, AOE zones, etc.
* Entities with this component react when other entities step on/off them.
*/
export interface TriggerComponent {
onEnter?: boolean; // Trigger when entity steps on this tile
onExit?: boolean; // Trigger when entity leaves this tile
onInteract?: boolean; // Trigger when entity interacts with this
oneShot?: boolean; // Destroy/disable after triggering once
triggered?: boolean; // Is currently triggered/active
spent?: boolean; // Has already triggered (for oneShot triggers)
targetId?: EntityId; // Target entity for this trigger (e.g., mine cart for a switch)
damage?: number; // Damage to deal on trigger (for traps)
effect?: string; // Status effect to apply (e.g., "poison", "slow")
effectDuration?: number; // Duration of applied effect
}
/**
* For the Mine Cart.
*/
export interface MineCartComponent {
isMoving: boolean;
path: Vec2[];
pathIndex: number;
}
/**
* Status effect instance applied to an entity.
*/
export interface StatusEffect {
type: string; // "poison", "burning", "frozen", "slow", "regen", etc.
duration: number; // Remaining turns
magnitude?: number; // Damage per turn, slow %, heal per turn, etc.
source?: EntityId; // Who/what applied this effect
}
/**
* Container for multiple status effects on an entity.
* Systems can iterate through effects each turn to apply them.
*/
export interface StatusEffectsComponent {
effects: StatusEffect[];
}
/**
* Combat-specific tracking data.
* Separates combat state from general stats for cleaner systems.
*/
export interface CombatComponent {
lastAttackTurn?: number; // Turn when entity last attacked
lastDamageTurn?: number; // Turn when entity last took damage
damageTakenThisTurn?: number; // Accumulated damage this turn
damageDealtThisTurn?: number; // Accumulated damage dealt this turn
killCount?: number; // Total kills by this entity
comboCount?: number; // Consecutive hits for combo systems
}
/**
* For destructible objects like barrels, crates, doors, etc.
*/
export interface DestructibleComponent {
hp: number;
maxHp: number;
destroyedTile?: number; // Tile type to become when destroyed (e.g., rubble)
lootTable?: string; // ID of loot table to roll from on destruction
}
/**
* For items laying on the ground that can be picked up.
*/
export interface GroundItemComponent {
item: Item;
}
export interface InventoryComponent extends Inventory { }
export interface EquipmentComponent extends Equipment { }
/**
* For entities that should be destroyed after a certain amount of time/turns.
*/
export interface LifeSpanComponent {
remainingTurns: number;
}
export type ComponentMap = {
// Core components
position: PositionComponent;
stats: StatsComponent;
energy: EnergyComponent;
ai: AIComponent;
player: PlayerTagComponent;
collectible: CollectibleComponent;
sprite: SpriteComponent;
name: NameComponent;
actorType: ActorTypeComponent;
// Extended gameplay components
trigger: TriggerComponent;
statusEffects: StatusEffectsComponent;
combat: CombatComponent;
destructible: DestructibleComponent;
groundItem: GroundItemComponent;
inventory: InventoryComponent;
equipment: EquipmentComponent;
lifeSpan: LifeSpanComponent;
mineCart: MineCartComponent;
};
export type ComponentType = keyof ComponentMap;

View File

@@ -0,0 +1,103 @@
import { System } from "../System";
import { type ECSWorld } from "../World";
import { type ComponentType } from "../components";
import { type EntityId, type World } from "../../../core/types";
import { TileType, getDestructionResult } from "../../../core/terrain";
import { idx, inBounds } from "../../world/world-logic";
import { Prefabs } from "../Prefabs";
export class FireSystem extends System {
readonly name = "Fire";
readonly requiredComponents: readonly ComponentType[] = ["position"];
readonly priority = 15; // Run after status effects
private world: World;
constructor(world: World) {
super();
this.world = world;
}
update(entities: EntityId[], ecsWorld: ECSWorld, _dt?: number): void {
const fireEntities = entities.filter(id => ecsWorld.getComponent(id, "name")?.name === "Fire");
const spreadTargets: { x: number; y: number; duration: number }[] = [];
const entitiesToRemove: EntityId[] = [];
// Get all combatant positions to avoid spreading onto them
const combatantEntities = ecsWorld.getEntitiesWith("position").filter(id =>
ecsWorld.hasComponent(id, "player") || ecsWorld.hasComponent(id, "stats")
);
const combatantPosSet = new Set(combatantEntities.map(id => {
const p = ecsWorld.getComponent(id, "position")!;
return `${p.x},${p.y}`;
}));
// 1. Process existing fire entities
for (const fireId of fireEntities) {
const pos = ecsWorld.getComponent(fireId, "position");
const lifeSpan = ecsWorld.getComponent(fireId, "lifeSpan");
if (!pos) continue;
// Decrement lifespan
if (lifeSpan) {
lifeSpan.remainingTurns--;
// If fire expires, destroy it and the tile below it
if (lifeSpan.remainingTurns <= 0) {
entitiesToRemove.push(fireId);
const tileIdx = idx(this.world, pos.x, pos.y);
const tile = this.world.tiles[tileIdx];
const nextTile = getDestructionResult(tile);
if (nextTile !== undefined) {
this.world.tiles[tileIdx] = nextTile;
this.eventBus?.emit({ type: "tile_changed", x: pos.x, y: pos.y });
}
continue; // Fire is gone, don't spread from it anymore
}
}
// 2. Spreading logic (only if fire is still active)
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx = pos.x + dx;
const ny = pos.y + dy;
if (!inBounds(this.world, nx, ny)) continue;
// Skip tiles occupied by any combatant
if (combatantPosSet.has(`${nx},${ny}`)) continue;
const tileIdx = idx(this.world, nx, ny);
const tile = this.world.tiles[tileIdx];
// Fire ONLY spreads to GRASS
if (tile === TileType.GRASS) {
spreadTargets.push({ x: nx, y: ny, duration: 2 });
}
}
}
}
// Cleanup expired fires
for (const id of entitiesToRemove) {
ecsWorld.destroyEntity(id);
}
// 3. Apply spreading
for (const target of spreadTargets) {
// Check if fire already there
const existing = ecsWorld.getEntitiesWith("position").find(id => {
const p = ecsWorld.getComponent(id, "position");
const n = ecsWorld.getComponent(id, "name");
return p?.x === target.x && p?.y === target.y && n?.name === "Fire";
});
if (!existing) {
Prefabs.fire(ecsWorld, target.x, target.y, target.duration);
}
}
}
}

View File

@@ -0,0 +1,47 @@
import { System } from "../System";
import { type ECSWorld } from "../World";
import { type EntityId } from "../../../core/types";
/**
* System that moves the mine cart along its fixed path.
* Moves 1 tile per update (tick).
*/
export class MineCartSystem extends System {
readonly name = "MineCart";
readonly requiredComponents = ["mineCart", "position", "sprite"] as const;
update(entities: EntityId[], world: ECSWorld) {
for (const id of entities) {
const mineCart = world.getComponent(id, "mineCart");
const pos = world.getComponent(id, "position");
if (!mineCart || !pos || !mineCart.isMoving) continue;
// Move to next path node if available
if (mineCart.pathIndex < mineCart.path.length - 1) {
mineCart.pathIndex++;
const nextPos = mineCart.path[mineCart.pathIndex];
// Update position component
pos.x = nextPos.x;
pos.y = nextPos.y;
// Emit event for visual feedback
this.eventBus?.emit({
type: "entity_moved",
entityId: id,
from: { x: pos.x, y: pos.y },
to: nextPos
});
} else {
// Reached the end
if (mineCart.isMoving) {
mineCart.isMoving = false;
this.eventBus?.emit({ type: "mission_complete" });
}
}
}
}
}

View File

@@ -0,0 +1,183 @@
import { System } from "../System";
import { type ECSWorld } from "../World";
import { type ComponentType, type StatusEffect } from "../components";
import { type EntityId } from "../../../core/types";
/**
* Processes status effects on entities each turn.
* Applies damage/healing, decrements durations, and removes expired effects.
*
* @example
* registry.register(new StatusEffectSystem());
*
* // Apply poison to an entity
* world.addComponent(entityId, "statusEffects", {
* effects: [{ type: "poison", duration: 5, magnitude: 3 }]
* });
*/
export class StatusEffectSystem extends System {
readonly name = "StatusEffect";
readonly requiredComponents: readonly ComponentType[] = ["statusEffects", "stats"];
readonly priority = 10; // Run after movement/triggers
update(entities: EntityId[], world: ECSWorld, _dt?: number): void {
for (const entityId of entities) {
const statusEffects = world.getComponent(entityId, "statusEffects");
const stats = world.getComponent(entityId, "stats");
if (!statusEffects || !stats) continue;
const expiredEffects: StatusEffect[] = [];
for (const effect of statusEffects.effects) {
this.processEffect(entityId, effect, stats);
effect.duration--;
if (effect.duration <= 0) {
expiredEffects.push(effect);
}
}
// Remove expired effects
if (expiredEffects.length > 0) {
statusEffects.effects = statusEffects.effects.filter(
e => !expiredEffects.includes(e)
);
// Emit events for expired effects
for (const expired of expiredEffects) {
this.eventBus?.emit({
type: "status_expired",
entityId,
status: expired.type
});
}
}
// Emit tick events for remaining effects
for (const effect of statusEffects.effects) {
this.eventBus?.emit({
type: "status_tick",
entityId,
status: effect.type,
remaining: effect.duration
});
}
}
}
/**
* Apply the effect of a single status effect.
*/
private processEffect(
entityId: EntityId,
effect: StatusEffect,
stats: { hp: number; maxHp: number; [key: string]: any }
): void {
const magnitude = effect.magnitude ?? 1;
switch (effect.type) {
case "poison":
case "burning":
// Damage over time
const damage = magnitude;
stats.hp = Math.max(0, stats.hp - damage);
this.eventBus?.emit({
type: "damage",
entityId,
amount: damage,
source: effect.source
});
break;
case "regen":
case "healing":
// Heal over time
const heal = magnitude;
stats.hp = Math.min(stats.maxHp, stats.hp + heal);
this.eventBus?.emit({
type: "heal",
entityId,
amount: heal
});
break;
case "slow":
// Slow is typically checked elsewhere (movement system)
// This just maintains the effect tracking
break;
case "frozen":
// Frozen prevents actions (checked by AI/input systems)
break;
default:
// Unknown effect type - custom handlers can subscribe to status_tick
break;
}
}
}
/**
* Helper function to apply a status effect to an entity.
*/
export function applyStatusEffect(
world: ECSWorld,
entityId: EntityId,
effect: StatusEffect
): void {
let statusEffects = world.getComponent(entityId, "statusEffects");
if (!statusEffects) {
statusEffects = { effects: [] };
world.addComponent(entityId, "statusEffects", statusEffects);
}
// Check for existing effect of same type
const existing = statusEffects.effects.find(e => e.type === effect.type);
if (existing) {
// Refresh duration and update magnitude if higher
existing.duration = Math.max(existing.duration, effect.duration);
if (effect.magnitude !== undefined) {
existing.magnitude = Math.max(existing.magnitude ?? 0, effect.magnitude);
}
} else {
statusEffects.effects.push({ ...effect });
}
}
/**
* Helper function to remove a status effect from an entity.
*/
export function removeStatusEffect(
world: ECSWorld,
entityId: EntityId,
effectType: string
): boolean {
const statusEffects = world.getComponent(entityId, "statusEffects");
if (!statusEffects) return false;
const index = statusEffects.effects.findIndex(e => e.type === effectType);
if (index !== -1) {
statusEffects.effects.splice(index, 1);
return true;
}
return false;
}
/**
* Helper function to check if an entity has a specific status effect.
*/
export function hasStatusEffect(
world: ECSWorld,
entityId: EntityId,
effectType: string
): boolean {
const statusEffects = world.getComponent(entityId, "statusEffects");
return statusEffects?.effects.some(e => e.type === effectType) ?? false;
}

View File

@@ -0,0 +1,190 @@
import { System } from "../System";
import { type ECSWorld } from "../World";
import { type ComponentType } from "../components";
import { type EntityId } from "../../../core/types";
import { applyStatusEffect } from "./StatusEffectSystem";
/**
* Processes trigger entities when other entities step on them.
* Handles traps (damage), status effects, and one-shot triggers.
*
* @example
* registry.register(new TriggerSystem());
*
* // Create a spike trap
* world.addComponent(trapId, "trigger", {
* onEnter: true,
* damage: 15
* });
*/
export class TriggerSystem extends System {
readonly name = "Trigger";
readonly requiredComponents: readonly ComponentType[] = ["trigger", "position"];
readonly priority = 5; // Run before status effects
/**
* Track which entities are currently on which triggers.
* Used to detect enter/exit events.
*/
private entityPositions: Map<EntityId, { x: number; y: number }> = new Map();
update(entities: EntityId[], world: ECSWorld, _dt?: number): void {
// Get all entities with positions (potential activators)
const allWithPosition = world.getEntitiesWith("position");
for (const triggerId of entities) {
const trigger = world.getComponent(triggerId, "trigger");
const triggerPos = world.getComponent(triggerId, "position");
if (!trigger || !triggerPos) continue;
if (trigger.spent && trigger.oneShot) continue; // Already spent one-shot
// Check for entities at this trigger's position
for (const entityId of allWithPosition) {
if (entityId === triggerId) continue; // Skip self
const entityPos = world.getComponent(entityId, "position");
if (!entityPos) continue;
const isOnTrigger = entityPos.x === triggerPos.x && entityPos.y === triggerPos.y;
const wasOnTrigger = this.wasEntityOnTrigger(entityId, triggerPos);
// Handle enter or manual trigger
if ((trigger.onEnter && isOnTrigger && !wasOnTrigger) || trigger.triggered) {
this.activateTrigger(triggerId, entityId, trigger, world);
// If it was manually triggered, we should reset the flag
if (trigger.triggered) {
trigger.triggered = false;
}
}
// Handle exit
if (trigger.onExit && !isOnTrigger && wasOnTrigger) {
this.eventBus?.emit({
type: "trigger_activated",
triggerId,
activatorId: entityId
});
}
}
}
// Update entity positions for next frame
this.updateEntityPositions(allWithPosition, world);
}
/**
* Activate a trigger on an entity.
*/
private activateTrigger(
triggerId: EntityId,
activatorId: EntityId,
trigger: {
damage?: number;
effect?: string;
effectDuration?: number;
oneShot?: boolean;
triggered?: boolean;
targetId?: EntityId;
onInteract?: boolean;
spent?: boolean;
},
world: ECSWorld
): void {
// Emit trigger event
this.eventBus?.emit({
type: "trigger_activated",
triggerId,
activatorId
});
// Handle Mine Cart activation
if (trigger.targetId) {
const mineCart = world.getComponent(trigger.targetId, "mineCart");
if (mineCart) {
mineCart.isMoving = true;
// Change switch sprite if applicable (optional for now as we only have one frame)
const sprite = world.getComponent(triggerId, "sprite");
if (sprite && sprite.texture === "dungeon") {
sprite.index = 32;
}
}
}
// Apply damage if trap
if (trigger.damage && trigger.damage > 0) {
const stats = world.getComponent(activatorId, "stats");
if (stats) {
stats.hp = Math.max(0, stats.hp - trigger.damage);
this.eventBus?.emit({
type: "damage",
entityId: activatorId,
amount: trigger.damage,
source: triggerId
});
}
}
// Apply status effect if specified
if (trigger.effect) {
applyStatusEffect(world, activatorId, {
type: trigger.effect,
duration: trigger.effectDuration ?? 3,
source: triggerId
});
this.eventBus?.emit({
type: "status_applied",
entityId: activatorId,
status: trigger.effect,
duration: trigger.effectDuration ?? 3
});
}
// Mark as triggered for one-shot triggers and update sprite
if (trigger.oneShot) {
trigger.spent = true;
trigger.triggered = false;
// Change sprite to triggered appearance if it's a dungeon sprite
const sprite = world.getComponent(triggerId, "sprite");
if (sprite && sprite.texture === "dungeon") {
sprite.index = 23; // Triggered/spent trap appearance
}
}
}
/**
* Check if an entity was previously on a trigger position.
*/
private wasEntityOnTrigger(entityId: EntityId, triggerPos: { x: number; y: number }): boolean {
const lastPos = this.entityPositions.get(entityId);
if (!lastPos) return false;
return lastPos.x === triggerPos.x && lastPos.y === triggerPos.y;
}
/**
* Update cached entity positions for next frame comparison.
*/
private updateEntityPositions(entities: EntityId[], world: ECSWorld): void {
this.entityPositions.clear();
for (const entityId of entities) {
const pos = world.getComponent(entityId, "position");
if (pos) {
this.entityPositions.set(entityId, { x: pos.x, y: pos.y });
}
}
}
/**
* Called when the system is registered - initialize position tracking.
*/
onRegister(world: ECSWorld): void {
const allWithPosition = world.getEntitiesWith("position");
this.updateEntityPositions(allWithPosition, world);
}
}

View File

@@ -0,0 +1,198 @@
import { type World, type Vec2, type EntityId, type Stats, type Item } from "../../core/types";
import { isBlocked } from "../world/world-logic";
import { raycast } from "../../core/math";
import { type EntityAccessor } from "../EntityAccessor";
export interface ProjectileResult {
path: Vec2[];
blockedPos: Vec2;
hitActorId?: EntityId;
}
export interface DamageResult {
dmg: number;
hit: boolean;
isCrit: boolean;
isBlock: boolean;
}
/**
* Centralized damage calculation for both melee and ranged attacks.
*/
export function calculateDamage(attackerStats: Stats, targetStats: Stats, item?: Item): DamageResult {
const result: DamageResult = {
dmg: 0,
hit: false,
isCrit: false,
isBlock: false
};
// 1. Accuracy vs Evasion Check
const hitChance = attackerStats.accuracy - targetStats.evasion;
const hitRoll = Math.random() * 100;
if (hitRoll > hitChance) {
return result; // Miss
}
result.hit = true;
// 2. Base Damage Calculation
// Use player attack as base, add item attack if it's a weapon
let baseAttack = attackerStats.attack;
if (item && "stats" in item && item.stats && "attack" in item.stats) {
// For weapons, the item stats are already added to player stats in EquipmentService
// However, if we want to support 'thrown' items having their own base damage, we can add it here.
// For ranged weapons, executeThrow was using item.stats.attack.
// If it's a weapon, we assume the item.stats.attack is what should be used (or added).
// Actually, equipmentService adds item.stats.attack to player.stats.attack.
// So baseAttack is already "player + weapon".
// BUT for projectiles/thrown, we might want to ensure we're using the right value.
// If it's a weapon item, it's likely already factored in.
// If it's a CONSUMABLE (thrown), it might NOT be.
if (item.type === "Consumable") {
baseAttack += (item.stats as any).attack || 0;
}
}
let dmg = Math.max(1, baseAttack - targetStats.defense);
// 3. Critical Strike Check
const critRoll = Math.random() * 100;
const isCrit = critRoll < attackerStats.critChance;
if (isCrit) {
dmg = Math.floor(dmg * (attackerStats.critMultiplier / 100));
result.isCrit = true;
}
// 4. Block Chance Check
const blockRoll = Math.random() * 100;
if (blockRoll < targetStats.blockChance) {
dmg = Math.floor(dmg * 0.5);
result.isBlock = true;
}
result.dmg = dmg;
return result;
}
/**
* Calculates the path and impact of a projectile.
*/
export function traceProjectile(
world: World,
start: Vec2,
target: Vec2,
accessor: EntityAccessor | undefined,
shooterId?: EntityId
): ProjectileResult {
const points = raycast(start.x, start.y, target.x, target.y);
let blockedPos = target;
let hitActorId: EntityId | undefined;
// Iterate points (skip start)
for (let i = 1; i < points.length; i++) {
const p = points[i];
// Check for blocking
if (accessor && isBlocked(world, p.x, p.y, accessor)) {
// Check if we hit a combatant
let actors: any[] = [];
if (accessor) {
actors = accessor.getActorsAt(p.x, p.y);
}
const enemy = actors.find(a => a.category === "combatant" && a.id !== shooterId);
if (enemy) {
hitActorId = enemy.id;
blockedPos = p;
} else {
// Hit wall or other obstacle
blockedPos = points[i - 1];
}
break;
}
blockedPos = p;
}
return {
path: points,
blockedPos,
hitActorId
};
}
/**
* Calculates tiles within a cone for area of effect attacks.
*/
export function getConeTiles(origin: Vec2, target: Vec2, range: number): Vec2[] {
const tiles: Vec2[] = [];
const angle = Math.atan2(target.y - origin.y, target.x - origin.x);
const halfSpread = Math.PI / 4; // 90 degree cone
for (let dy = -range; dy <= range; dy++) {
for (let dx = -range; dx <= range; dx++) {
if (dx === 0 && dy === 0) continue;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist > range + 0.5) continue;
const tilePos = { x: origin.x + dx, y: origin.y + dy };
const tileAngle = Math.atan2(dy, dx);
// Normalize angle difference to [-PI, PI]
let angleDiff = tileAngle - angle;
while (angleDiff > Math.PI) angleDiff -= 2 * Math.PI;
while (angleDiff < -Math.PI) angleDiff += 2 * Math.PI;
if (Math.abs(angleDiff) <= halfSpread) {
tiles.push(tilePos);
}
}
}
return tiles;
}
export function getClosestVisibleEnemy(
origin: Vec2,
seenTiles: Set<string> | boolean[] | Uint8Array, // Support various visibility structures
width?: number, // Required if seenTiles is a flat array
accessor?: EntityAccessor
): Vec2 | null {
let closestDistSq = Infinity;
let closestPos: Vec2 | null = null;
// Helper to check visibility
const isVisible = (x: number, y: number) => {
if (Array.isArray(seenTiles) || seenTiles instanceof Uint8Array || seenTiles instanceof Int8Array) {
// Flat array
if (!width) return false;
return (seenTiles as any)[y * width + x];
} else {
// Set<string>
return (seenTiles as Set<string>).has(`${x},${y}`);
}
};
const enemies = accessor ? accessor.getEnemies() : [];
for (const actor of enemies) {
if (actor.category !== "combatant" || actor.isPlayer) continue;
// Check visibility
if (!isVisible(actor.pos.x, actor.pos.y)) continue;
const dx = actor.pos.x - origin.x;
const dy = actor.pos.y - origin.y;
const distSq = dx * dx + dy * dy;
if (distSq < closestDistSq) {
closestDistSq = distSq;
closestPos = { x: actor.pos.x, y: actor.pos.y };
}
}
return closestPos;
}

View File

@@ -0,0 +1,133 @@
import { describe, it, expect, vi } from "vitest";
import { calculateDamage } from "../CombatLogic";
import { type Stats, type Item } from "../../../core/types";
describe("CombatLogic - calculateDamage", () => {
const createStats = (overrides: Partial<Stats> = {}): Stats => ({
hp: 100, maxHp: 100, attack: 10, defense: 5,
accuracy: 100, evasion: 0, critChance: 0, critMultiplier: 200,
blockChance: 0, lifesteal: 0, mana: 50, maxMana: 50,
level: 1, exp: 0, expToNextLevel: 100, luck: 0,
statPoints: 0, skillPoints: 0, strength: 10, dexterity: 10, intelligence: 10,
passiveNodes: [],
...overrides
});
it("should calculate base damage correctly (attack - defense)", () => {
const attacker = createStats({ attack: 15 });
const target = createStats({ defense: 5 });
// Mock Math.random to ensure hit and no crit/block
vi.spyOn(Math, 'random').mockReturnValue(0.5);
const result = calculateDamage(attacker, target);
expect(result.hit).toBe(true);
expect(result.dmg).toBe(10); // 15 - 5
expect(result.isCrit).toBe(false);
expect(result.isBlock).toBe(false);
vi.restoreAllMocks();
});
it("should ensure minimum damage of 1", () => {
const attacker = createStats({ attack: 5 });
const target = createStats({ defense: 10 });
vi.spyOn(Math, 'random').mockReturnValue(0.5);
const result = calculateDamage(attacker, target);
expect(result.dmg).toBe(1);
vi.restoreAllMocks();
});
it("should handle misses (accuracy vs evasion)", () => {
const attacker = createStats({ accuracy: 50 });
const target = createStats({ evasion: 0 });
// Mock random to be > 50 (miss)
vi.spyOn(Math, 'random').mockReturnValue(0.6);
const result = calculateDamage(attacker, target);
expect(result.hit).toBe(false);
expect(result.dmg).toBe(0);
vi.restoreAllMocks();
});
it("should handle critical hits", () => {
const attacker = createStats({ attack: 10, critChance: 100, critMultiplier: 200 });
const target = createStats({ defense: 0 });
vi.spyOn(Math, 'random').mockReturnValue(0.5);
const result = calculateDamage(attacker, target);
expect(result.isCrit).toBe(true);
expect(result.dmg).toBe(20); // 10 * 2.0
vi.restoreAllMocks();
});
it("should handle blocking", () => {
const attacker = createStats({ attack: 20 });
const target = createStats({ defense: 0, blockChance: 100 });
// We need multiple random calls or a smarter mock if calculateDamage calls random multiple times.
// 1. Hit check
// 2. Crit check
// 3. Block check
const mockRandom = vi.fn()
.mockReturnValueOnce(0.1) // Hit (chance 100)
.mockReturnValueOnce(0.9) // No Crit (chance 0)
.mockReturnValueOnce(0.1); // Block (chance 100)
vi.spyOn(Math, 'random').mockImplementation(mockRandom);
const result = calculateDamage(attacker, target);
expect(result.isBlock).toBe(true);
expect(result.dmg).toBe(10); // (20-0) * 0.5
vi.restoreAllMocks();
});
it("should consider item attack for consumables (thrown items)", () => {
const attacker = createStats({ attack: 10 });
const target = createStats({ defense: 0 });
const item: Item = {
id: "bomb",
name: "Bomb",
type: "Consumable",
textureKey: "items",
spriteIndex: 0,
stats: { attack: 20 }
} as any;
vi.spyOn(Math, 'random').mockReturnValue(0.1);
const result = calculateDamage(attacker, target, item);
expect(result.dmg).toBe(30); // 10 (player) + 20 (item)
vi.restoreAllMocks();
});
it("should NOT add weapon attack twice (assumes it's already in player stats)", () => {
const attacker = createStats({ attack: 30 }); // Player 10 + Weapon 20
const target = createStats({ defense: 0 });
const item: Item = {
id: "pistol",
name: "Pistol",
type: "Weapon",
weaponType: "ranged",
textureKey: "items",
spriteIndex: 0,
stats: { attack: 20 }
} as any;
vi.spyOn(Math, 'random').mockReturnValue(0.1);
const result = calculateDamage(attacker, target, item);
expect(result.dmg).toBe(30); // Should remain 30, not 50
vi.restoreAllMocks();
});
});

View File

@@ -0,0 +1,129 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { traceProjectile } from '../CombatLogic';
import type { World, EntityId } from '../../../core/types';
import { EntityAccessor } from '../../EntityAccessor';
import { TileType } from '../../../core/terrain';
import { ECSWorld } from '../../ecs/World';
describe('CombatLogic', () => {
// Mock World
let mockWorld: World;
let ecsWorld: ECSWorld;
let accessor: EntityAccessor;
// Helper to set wall
const setWall = (x: number, y: number) => {
mockWorld.tiles[y * mockWorld.width + x] = TileType.WALL;
};
beforeEach(() => {
mockWorld = {
width: 10,
height: 10,
tiles: new Array(100).fill(TileType.EMPTY),
exit: { x: 9, y: 9 },
trackPath: []
};
ecsWorld = new ECSWorld();
// Shooter ID 1
accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld);
});
function syncActor(actor: any) {
ecsWorld.addComponent(actor.id as EntityId, "position", actor.pos);
if (actor.category === 'combatant') {
ecsWorld.addComponent(actor.id as EntityId, "actorType", { type: actor.type });
ecsWorld.addComponent(actor.id as EntityId, "stats", { hp: 10 } as any);
if (actor.isPlayer) ecsWorld.addComponent(actor.id as EntityId, "player", {});
} else if (actor.category === 'item_drop') {
ecsWorld.addComponent(actor.id as EntityId, "groundItem", { item: actor.item || {} });
}
}
describe('traceProjectile', () => {
it('should travel full path if no obstacles', () => {
const start = { x: 0, y: 0 };
const end = { x: 5, y: 0 };
const result = traceProjectile(mockWorld, start, end, accessor);
expect(result.blockedPos).toEqual(end);
expect(result.hitActorId).toBeUndefined();
expect(result.path).toHaveLength(6);
});
it('should stop at wall', () => {
const start = { x: 0, y: 0 };
const end = { x: 5, y: 0 };
setWall(3, 0); // Wall at (3,0)
const result = traceProjectile(mockWorld, start, end, accessor);
expect(result.blockedPos).toEqual({ x: 2, y: 0 });
expect(result.hitActorId).toBeUndefined();
});
it('should stop at enemy', () => {
const start = { x: 0, y: 0 };
const end = { x: 5, y: 0 };
// Place enemy at (3,0)
const enemyId = 2 as EntityId;
const enemy = {
id: enemyId,
type: 'rat',
category: 'combatant',
pos: { x: 3, y: 0 },
isPlayer: false
};
syncActor(enemy);
const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId); // Shooter 1
expect(result.blockedPos).toEqual({ x: 3, y: 0 });
expect(result.hitActorId).toBe(enemyId);
});
it('should ignore shooter position', () => {
const start = { x: 0, y: 0 };
const end = { x: 5, y: 0 };
// Shooter at start
const shooter = {
id: 1 as EntityId,
type: 'player',
category: 'combatant',
pos: { x: 0, y: 0 },
isPlayer: true
};
syncActor(shooter);
const result = traceProjectile(mockWorld, start, end, accessor, 1 as EntityId);
// Should not hit self
expect(result.hitActorId).toBeUndefined();
expect(result.blockedPos).toEqual(end);
});
it('should ignore non-combatant actors (e.g. items)', () => {
const start = { x: 0, y: 0 };
const end = { x: 5, y: 0 };
// Item at (3,0)
const item = {
id: 99 as EntityId,
category: 'item_drop',
pos: { x: 3, y: 0 },
item: { name: 'Test Item' }
};
syncActor(item);
const result = traceProjectile(mockWorld, start, end, accessor);
// Should pass through item
expect(result.blockedPos).toEqual(end);
expect(result.hitActorId).toBeUndefined();
});
});
});

View File

@@ -0,0 +1,162 @@
import { describe, it, expect, beforeEach } from "vitest";
import { ItemManager } from "../../../scenes/systems/ItemManager";
import type { World, CombatantActor, RangedWeaponItem, EntityId } from "../../../core/types";
import { EntityAccessor } from "../../EntityAccessor";
import { ECSWorld } from "../../ecs/World";
import { createRangedWeapon, createAmmo } from "../../../core/config/Items";
describe("Fireable Weapons & Ammo System", () => {
let accessor: EntityAccessor;
let itemManager: ItemManager;
let player: CombatantActor;
let ecsWorld: ECSWorld;
let world: World;
beforeEach(() => {
world = {
width: 10,
height: 10,
tiles: new Array(100).fill(0),
exit: { x: 9, y: 9 },
trackPath: []
};
ecsWorld = new ECSWorld();
accessor = new EntityAccessor(world, 1 as EntityId, ecsWorld);
itemManager = new ItemManager(world, accessor, ecsWorld);
player = {
id: 1 as EntityId,
pos: { x: 0, y: 0 },
category: "combatant",
type: "player",
isPlayer: true,
speed: 100,
energy: 0,
stats: {
maxHp: 100, hp: 100,
maxMana: 50, mana: 50,
attack: 1, defense: 0,
level: 1, exp: 0, expToNextLevel: 100,
critChance: 0, critMultiplier: 0, accuracy: 0, lifesteal: 0,
evasion: 0, blockChance: 0, luck: 0,
statPoints: 0, skillPoints: 0, strength: 0, dexterity: 0, intelligence: 0,
passiveNodes: []
},
inventory: { gold: 0, items: [] },
equipment: {}
} as any;
// Sync player to ECS
ecsWorld.addComponent(player.id, "position", player.pos);
ecsWorld.addComponent(player.id, "player", {});
ecsWorld.addComponent(player.id, "stats", player.stats);
ecsWorld.addComponent(player.id, "actorType", { type: "player" });
ecsWorld.addComponent(player.id, "inventory", player.inventory!);
ecsWorld.addComponent(player.id, "energy", { current: 0, speed: 100 });
// Avoid ID collisions between manually added player (ID 1) and spawned entities
ecsWorld.setNextId(10);
});
it("should stack ammo correctly", () => {
const playerActor = accessor.getPlayer()!;
// Spawn Ammo pack 1
const ammo1 = createAmmo("ammo_9mm", 10);
itemManager.spawnItem(ammo1, { x: 0, y: 0 });
// Pickup
itemManager.tryPickup(playerActor);
expect(playerActor.inventory!.items.length).toBe(1);
expect(playerActor.inventory!.items[0].quantity).toBe(10);
// Spawn Ammo pack 2
const ammo2 = createAmmo("ammo_9mm", 5);
itemManager.spawnItem(ammo2, { x: 0, y: 0 });
// Pickup (should merge)
itemManager.tryPickup(playerActor);
expect(playerActor.inventory!.items.length).toBe(1); // Still 1 stack
expect(playerActor.inventory!.items[0].quantity).toBe(15);
});
it("should consume ammo from weapon when fired", () => {
const playerActor = accessor.getPlayer()!;
// Create pistol using factory (already has currentAmmo initialized)
const pistol = createRangedWeapon("pistol");
playerActor.inventory!.items.push(pistol);
// Sanity Check - currentAmmo is now top-level
expect(pistol.currentAmmo).toBe(6);
expect(pistol.stats.magazineSize).toBe(6);
// Simulate Firing (logic mimic from GameScene)
if (pistol.currentAmmo! > 0) {
pistol.currentAmmo!--;
}
expect(pistol.currentAmmo).toBe(5);
});
it("should reload weapon using inventory ammo", () => {
const playerActor = accessor.getPlayer()!;
const pistol = createRangedWeapon("pistol");
pistol.currentAmmo = 0; // Empty
playerActor.inventory!.items.push(pistol);
const ammo = createAmmo("ammo_9mm", 10);
playerActor.inventory!.items.push(ammo);
// Logic mimic from GameScene
const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
const toTake = Math.min(needed, ammo.quantity!); // 6
pistol.currentAmmo += toTake;
ammo.quantity! -= toTake;
expect(pistol.currentAmmo).toBe(6);
expect(ammo.quantity).toBe(4);
});
it("should handle partial reload if not enough ammo", () => {
const playerActor = accessor.getPlayer()!;
const pistol = createRangedWeapon("pistol");
pistol.currentAmmo = 0;
playerActor.inventory!.items.push(pistol);
const ammo = createAmmo("ammo_9mm", 3); // Only 3 bullets
playerActor.inventory!.items.push(ammo);
// Logic mimic
const needed = pistol.stats.magazineSize - pistol.currentAmmo; // 6
const toTake = Math.min(needed, ammo.quantity!); // 3
pistol.currentAmmo += toTake;
ammo.quantity! -= toTake;
expect(pistol.currentAmmo).toBe(3);
expect(ammo.quantity).toBe(0);
});
it("should deep clone on spawn so pistols remain independent", () => {
const playerActor = accessor.getPlayer()!;
const pistol1 = createRangedWeapon("pistol");
// Spawn 1
itemManager.spawnItem(pistol1, { x: 0, y: 0 });
const picked1 = itemManager.tryPickup(playerActor)! as RangedWeaponItem;
// Spawn 2
const pistol2 = createRangedWeapon("pistol");
itemManager.spawnItem(pistol2, { x: 0, y: 0 });
const picked2 = itemManager.tryPickup(playerActor)! as RangedWeaponItem;
expect(picked1).not.toBe(picked2);
expect(picked1.stats).not.toBe(picked2.stats); // Critical!
// Modifying one should not affect other
picked1.currentAmmo = 0;
expect(picked2.currentAmmo).toBe(6);
});
});

View File

@@ -0,0 +1,147 @@
import Phaser from "phaser";
import { TILE_SIZE } from "../../core/constants";
export interface GameInputEvents {
"toggle-menu": () => void;
"close-menu": () => void;
"toggle-inventory": () => void;
"toggle-character": () => void;
"toggle-minimap": () => void;
"reload": () => void;
"wait": () => void;
"zoom": (deltaY: number) => void;
"pan": (dx: number, dy: number) => void;
"cancel-target": () => void;
"confirm-target": () => void; // Left click while targeting
"tile-click": (tileX: number, tileY: number, button: number) => void;
"cursor-move": (worldX: number, worldY: number) => void;
}
export class GameInput extends Phaser.Events.EventEmitter {
private scene: Phaser.Scene;
private cursors: Phaser.Types.Input.Keyboard.CursorKeys;
private wasd: {
W: Phaser.Input.Keyboard.Key;
A: Phaser.Input.Keyboard.Key;
S: Phaser.Input.Keyboard.Key;
D: Phaser.Input.Keyboard.Key;
};
constructor(scene: Phaser.Scene) {
super();
this.scene = scene;
this.cursors = this.scene.input.keyboard!.createCursorKeys();
this.wasd = this.scene.input.keyboard!.addKeys("W,A,S,D") as any;
this.setupKeyboard();
this.setupMouse();
}
private setupKeyboard() {
if (!this.scene.input.keyboard) return;
this.scene.input.keyboard.on("keydown-I", () => this.emit("toggle-menu"));
this.scene.input.keyboard.on("keydown-ESC", () => this.emit("close-menu"));
this.scene.input.keyboard.on("keydown-M", () => this.emit("toggle-minimap"));
this.scene.input.keyboard.on("keydown-B", () => this.emit("toggle-inventory"));
this.scene.input.keyboard.on("keydown-C", () => this.emit("toggle-character"));
this.scene.input.keyboard.on("keydown-R", () => this.emit("reload"));
this.scene.input.keyboard.on("keydown-SPACE", () => this.emit("wait"));
}
private setupMouse() {
this.scene.input.on("wheel", (_p: any, _g: any, _x: any, deltaY: number) => {
this.emit("zoom", deltaY);
});
this.scene.input.mouse?.disableContextMenu();
this.scene.input.on("pointerdown", (p: Phaser.Input.Pointer) => {
if (p.rightButtonDown()) {
this.emit("cancel-target");
}
// For general clicks, we emit tile-click
// Logic for "confirm-target" vs "move" happens in Scene for now,
// or we can distinguish based on internal state if we moved targeting here.
// For now, let's just emit generic events or specific if clear.
// Actually, GameScene has specific logic:
// "If targeting active -> Left Click = throw"
// "Else -> Left Click = move/attack"
// To keep GameInput "dumb", we just emit the click details.
// EXCEPT: Panning logic is computed from pointer movement.
const tx = Math.floor(p.worldX / TILE_SIZE);
const ty = Math.floor(p.worldY / TILE_SIZE);
this.emit("tile-click", tx, ty, p.button);
});
this.scene.input.on("pointermove", (p: Phaser.Input.Pointer) => {
this.emit("cursor-move", p.worldX, p.worldY);
// Panning logic
if (p.isDown) {
const isRightDrag = p.rightButtonDown();
const isMiddleDrag = p.middleButtonDown();
const isShiftDrag = p.isDown && p.event.shiftKey;
if (isRightDrag || isMiddleDrag || isShiftDrag) {
const { x, y } = p.position;
const { x: prevX, y: prevY } = p.prevPosition;
const dx = (x - prevX); // Zoom factor needs to be handled by receiver or passed here
const dy = (y - prevY);
this.emit("pan", dx, dy);
}
}
});
}
public getCursorState() {
// Return simplified WASD state for movement
let dx = 0;
let dy = 0;
const left = this.wasd.A.isDown;
const right = this.wasd.D.isDown;
const up = this.wasd.W.isDown;
const down = this.wasd.S.isDown;
if (left) dx -= 1;
if (right) dx += 1;
if (up) dy -= 1;
if (down) dy += 1;
const anyJustDown = Phaser.Input.Keyboard.JustDown(this.wasd.W) ||
Phaser.Input.Keyboard.JustDown(this.wasd.A) ||
Phaser.Input.Keyboard.JustDown(this.wasd.S) ||
Phaser.Input.Keyboard.JustDown(this.wasd.D);
return {
dx, dy, anyJustDown,
isLeft: !!left,
isRight: !!right,
isUp: !!up,
isDown: !!down
};
}
public getCameraPanState() {
// Return Arrow key state for camera panning
let dx = 0;
let dy = 0;
if (this.cursors.left?.isDown) dx -= 1;
if (this.cursors.right?.isDown) dx += 1;
if (this.cursors.up?.isDown) dy -= 1;
if (this.cursors.down?.isDown) dy += 1;
return { dx, dy };
}
public cleanup() {
this.removeAllListeners();
// Determine is scene specific cleanup is needed for inputs
}
}

View File

@@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { applyAction } from '../simulation';
import type { World, CombatantActor, Action, EntityId } from '../../../core/types';
import { TileType } from '../../../core/terrain';
import { GAME_CONFIG } from '../../../core/config/GameConfig';
import { EntityAccessor } from '../../EntityAccessor';
import { ECSWorld } from '../../ecs/World';
describe('Movement Blocking Behavior', () => {
let world: World;
let player: CombatantActor;
let accessor: EntityAccessor;
let ecsWorld: ECSWorld;
beforeEach(() => {
// minimalist world setup
world = {
width: 3,
height: 3,
tiles: new Array(9).fill(TileType.GRASS),
exit: { x: 2, y: 2 },
trackPath: []
};
// Blocking wall at (1, 0)
world.tiles[1] = TileType.WALL;
player = {
id: 1 as EntityId,
type: 'player',
category: 'combatant',
isPlayer: true,
pos: { x: 0, y: 0 },
speed: 100,
energy: 0,
stats: { ...GAME_CONFIG.player.initialStats }
};
ecsWorld = new ECSWorld();
ecsWorld.addComponent(player.id, "position", player.pos);
ecsWorld.addComponent(player.id, "stats", player.stats);
ecsWorld.addComponent(player.id, "actorType", { type: player.type });
ecsWorld.addComponent(player.id, "player", {});
ecsWorld.addComponent(player.id, "energy", { current: player.energy, speed: player.speed });
accessor = new EntityAccessor(world, player.id, ecsWorld);
});
it('should return move-blocked event when moving into a wall', () => {
const action: Action = { type: 'move', dx: 1, dy: 0 }; // Try to move right into wall at (1,0)
const events = applyAction(world, player.id, action, accessor);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: 'move-blocked',
actorId: player.id,
x: 1,
y: 0
});
});
it('should return moved event when moving into empty space', () => {
const action: Action = { type: 'move', dx: 0, dy: 1 }; // Move down to (0,1) - valid
const events = applyAction(world, player.id, action, accessor);
expect(events).toHaveLength(1);
expect(events[0]).toMatchObject({
type: 'moved',
actorId: player.id,
from: { x: 0, y: 0 },
to: { x: 0, y: 1 }
});
});
});

View File

@@ -1,19 +1,27 @@
import { ACTION_COST, ENERGY_THRESHOLD } from "../../core/constants"; import type { World, EntityId, Action, SimEvent, Actor, CombatantActor, CollectibleActor, ActorType } from "../../core/types";
import type { World, EntityId, Action, SimEvent, Actor } from "../../core/types"; import { calculateDamage } from "../gameplay/CombatLogic";
import { isBlocked } from "../world/world-logic";
export function applyAction(w: World, actorId: EntityId, action: Action): SimEvent[] { import { isBlocked, tryDestructTile } from "../world/world-logic";
const actor = w.actors.get(actorId); import { isDestructibleByWalk, TileType } from "../../core/terrain";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { type EntityAccessor } from "../EntityAccessor";
import { AISystem } from "../ecs/AISystem";
import { Prefabs } from "../ecs/Prefabs";
export function applyAction(w: World, actorId: EntityId, action: Action, accessor: EntityAccessor): SimEvent[] {
const actor = accessor.getActor(actorId);
if (!actor) return []; if (!actor) return [];
const events: SimEvent[] = []; const events: SimEvent[] = [];
switch (action.type) { switch (action.type) {
case "move": case "move":
events.push(...handleMove(w, actor, action)); events.push(...handleMove(w, actor, action, accessor));
break; break;
case "attack": case "attack":
events.push(...handleAttack(w, actor, action)); events.push(...handleAttack(w, actor, action, accessor));
break;
case "throw":
break; break;
case "wait": case "wait":
default: default:
@@ -21,123 +29,328 @@ export function applyAction(w: World, actorId: EntityId, action: Action): SimEve
break; break;
} }
// Spend energy for any action (move/wait/attack) checkDeaths(events, accessor);
actor.energy -= ACTION_COST;
return events; return events;
} }
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }): SimEvent[] { function handleExpCollection(player: Actor, events: SimEvent[], accessor: EntityAccessor) {
if (player.category !== "combatant") return;
const actorsAtPos = accessor.getActorsAt(player.pos.x, player.pos.y);
const orbs = actorsAtPos.filter(a =>
a.category === "collectible" &&
a.type === "exp_orb"
) as CollectibleActor[];
for (const orb of orbs) {
const amount = orb.expAmount || 0;
player.stats.exp += amount;
events.push({
type: "exp-collected",
actorId: player.id,
amount,
x: player.pos.x,
y: player.pos.y
});
checkLevelUp(player, events);
accessor.removeActor(orb.id);
}
}
function checkLevelUp(player: CombatantActor, events: SimEvent[]) {
const s = player.stats;
while (s.exp >= s.expToNextLevel) {
s.level++;
s.exp -= s.expToNextLevel;
// Growth
s.maxHp += GAME_CONFIG.leveling.hpGainPerLevel;
s.hp = s.maxHp; // Heal on level up
s.maxMana += GAME_CONFIG.leveling.manaGainPerLevel;
s.mana = s.maxMana; // Restore mana on level up
s.attack += GAME_CONFIG.leveling.attackGainPerLevel;
s.statPoints += GAME_CONFIG.leveling.statPointsPerLevel;
s.skillPoints += GAME_CONFIG.leveling.skillPointsPerLevel;
// Scale requirement
s.expToNextLevel = Math.floor(s.expToNextLevel * GAME_CONFIG.leveling.expMultiplier);
events.push({
type: "leveled-up",
actorId: player.id,
level: s.level,
x: player.pos.x,
y: player.pos.y
});
}
}
function handleMove(w: World, actor: Actor, action: { dx: number; dy: number }, accessor: EntityAccessor): SimEvent[] {
const from = { ...actor.pos }; const from = { ...actor.pos };
const nx = actor.pos.x + action.dx; const nx = actor.pos.x + action.dx;
const ny = actor.pos.y + action.dy; const ny = actor.pos.y + action.dy;
if (!isBlocked(w, nx, ny)) { if (!isBlocked(w, nx, ny, accessor)) {
actor.pos.x = nx; actor.pos.x = nx;
actor.pos.y = ny; actor.pos.y = ny;
const to = { ...actor.pos }; const to = { ...actor.pos };
return [{ type: "moved", actorId: actor.id, from, to }]; const events: SimEvent[] = [{ type: "moved", actorId: actor.id, from, to }];
} else {
return [{ type: "waited", actorId: actor.id }]; const tileIdx = ny * w.width + nx;
const tile = w.tiles[tileIdx];
if (isDestructibleByWalk(tile)) {
// Only open if it's currently closed.
// tryDestructTile toggles, so we must be specific for doors.
if (tile === TileType.DOOR_CLOSED) {
tryDestructTile(w, nx, ny);
} else if (tile !== TileType.DOOR_OPEN) {
// For other destructibles like grass
tryDestructTile(w, nx, ny);
} }
} }
function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }): SimEvent[] { // Handle "from" tile - Close door if we just left it and no one else is there
const target = w.actors.get(action.targetId); const fromIdx = from.y * w.width + from.x;
if (target && target.stats && actor.stats) { if (w.tiles[fromIdx] === TileType.DOOR_OPEN) {
const actorsLeft = accessor.getActorsAt(from.x, from.y);
if (actorsLeft.length === 0) {
console.log(`[simulation] Closing door at ${from.x},${from.y} - Actor ${actor.id} left`);
w.tiles[fromIdx] = TileType.DOOR_CLOSED;
} else {
console.log(`[simulation] Door at ${from.x},${from.y} stays open - ${actorsLeft.length} actors remain`);
}
}
if (actor.category === "combatant" && actor.isPlayer) {
handleExpCollection(actor, events, accessor);
}
return events;
} else {
// If blocked, check if we can interact with an entity at the target position
if (actor.category === "combatant" && actor.isPlayer && accessor?.context) {
const ecsWorld = accessor.context;
const interactables = ecsWorld.getEntitiesWith("position", "trigger").filter(id => {
const p = ecsWorld.getComponent(id, "position");
const t = ecsWorld.getComponent(id, "trigger");
return p?.x === nx && p?.y === ny && t?.onInteract;
});
if (interactables.length > 0) {
// Trigger interaction by marking it as triggered
// The TriggerSystem will pick this up on the next update
ecsWorld.getComponent(interactables[0], "trigger")!.triggered = true;
}
}
}
return [{ type: "move-blocked", actorId: actor.id, x: nx, y: ny }];
}
function handleAttack(_w: World, actor: Actor, action: { targetId: EntityId }, accessor: EntityAccessor): SimEvent[] {
const target = accessor.getActor(action.targetId);
if (target && target.category === "combatant" && actor.category === "combatant") {
const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }]; const events: SimEvent[] = [{ type: "attacked", attackerId: actor.id, targetId: action.targetId }];
const dmg = Math.max(1, actor.stats.attack - target.stats.defense); // 1. Calculate Damage
const result = calculateDamage(actor.stats, target.stats);
if (!result.hit) {
events.push({
type: "dodged",
targetId: action.targetId,
x: target.pos.x,
y: target.pos.y
});
return events;
}
const dmg = result.dmg;
const isCrit = result.isCrit;
const isBlock = result.isBlock;
target.stats.hp -= dmg; target.stats.hp -= dmg;
if (target.stats.hp > 0 && target.category === "combatant" && !target.isPlayer) {
target.aiState = "pursuing";
target.alertedAt = Date.now();
if (actor.pos) {
target.lastKnownPlayerPos = { ...actor.pos };
}
}
// 5. Lifesteal Logic
if (actor.stats.lifesteal > 0 && dmg > 0) {
const healAmount = Math.floor(dmg * (actor.stats.lifesteal / 100));
if (healAmount > 0) {
actor.stats.hp = Math.min(actor.stats.maxHp, actor.stats.hp + healAmount);
events.push({
type: "healed",
actorId: actor.id,
amount: healAmount,
x: actor.pos.x,
y: actor.pos.y
});
}
}
events.push({ events.push({
type: "damaged", type: "damaged",
targetId: action.targetId, targetId: action.targetId,
amount: dmg, amount: dmg,
hp: target.stats.hp, hp: target.stats.hp,
x: target.pos.x, x: target.pos.x,
y: target.pos.y y: target.pos.y,
isCrit,
isBlock
}); });
if (target.stats.hp <= 0) { if (target.stats.hp <= 0) {
events.push({ killActor(target, events, accessor, actor.id);
type: "killed",
targetId: target.id,
killerId: actor.id,
x: target.pos.x,
y: target.pos.y,
victimType: target.type
});
w.actors.delete(target.id);
} }
return events; return events;
} }
return [{ type: "waited", actorId: actor.id }]; return [{ type: "waited", actorId: actor.id }];
} }
export function killActor(target: CombatantActor, events: SimEvent[], accessor: EntityAccessor, killerId?: EntityId): void {
events.push({
type: "killed",
targetId: target.id,
killerId: killerId ?? (0 as EntityId),
x: target.pos.x,
y: target.pos.y,
victimType: target.type as ActorType
});
accessor.removeActor(target.id);
// Extinguish fire at the death position
const ecsWorld = accessor.context;
if (ecsWorld) {
const firesAtPos = ecsWorld.getEntitiesWith("position", "name").filter(id => {
const p = ecsWorld.getComponent(id, "position");
const n = ecsWorld.getComponent(id, "name");
return p?.x === target.pos.x && p?.y === target.pos.y && n?.name === "Fire";
});
for (const fireId of firesAtPos) {
ecsWorld.destroyEntity(fireId);
}
// Spawn EXP Orb
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
const expAmount = enemyDef?.expValue || 0;
const orbId = Prefabs.expOrb(ecsWorld, target.pos.x, target.pos.y, expAmount);
events.push({ type: "orb-spawned", orbId, x: target.pos.x, y: target.pos.y });
}
}
export function checkDeaths(events: SimEvent[], accessor: EntityAccessor): void {
const combatants = accessor.getCombatants();
for (const c of combatants) {
if (c.stats.hp <= 0) {
killActor(c, events, accessor);
}
}
}
/** /**
* Very basic enemy AI: * Enemy AI with state machine:
* - if adjacent to player, attack * - Wandering: Random movement when can't see player
* - else step toward player using greedy Manhattan * - Alerted: Brief period after spotting player (shows "!")
* - Pursuing: Chase player while in FOV or toward last known position
*/ */
export function decideEnemyAction(w: World, enemy: Actor, player: Actor): Action { export function decideEnemyAction(w: World, enemy: CombatantActor, player: CombatantActor, accessor: EntityAccessor): { action: Action; justAlerted: boolean } {
const dx = player.pos.x - enemy.pos.x; const ecsWorld = accessor.context;
const dy = player.pos.y - enemy.pos.y; if (ecsWorld) {
const dist = Math.abs(dx) + Math.abs(dy); const aiSystem = new AISystem(ecsWorld, w, accessor);
const result = aiSystem.update(enemy.id, player.id);
if (dist === 1) { const aiComp = ecsWorld.getComponent(enemy.id, "ai");
return { type: "attack", targetId: player.id }; if (aiComp) {
enemy.aiState = aiComp.state;
enemy.alertedAt = aiComp.alertedAt;
enemy.lastKnownPlayerPos = aiComp.lastKnownPlayerPos;
} }
const options: { dx: number; dy: number }[] = []; return result;
if (Math.abs(dx) >= Math.abs(dy)) {
options.push({ dx: Math.sign(dx), dy: 0 });
options.push({ dx: 0, dy: Math.sign(dy) });
} else {
options.push({ dx: 0, dy: Math.sign(dy) });
options.push({ dx: Math.sign(dx), dy: 0 });
} }
options.push({ dx: -options[0].dx, dy: -options[0].dy }); return { action: { type: "wait" }, justAlerted: false };
for (const o of options) {
if (o.dx === 0 && o.dy === 0) continue;
const nx = enemy.pos.x + o.dx;
const ny = enemy.pos.y + o.dy;
if (!isBlocked(w, nx, ny)) return { type: "move", dx: o.dx, dy: o.dy };
}
return { type: "wait" };
} }
/** /**
* Energy/speed scheduler: runs until it's the player's turn and the game needs input. * Speed-based scheduler using rot-js: runs until it's the player's turn and the game needs input.
* Returns enemy events accumulated along the way. * Returns enemy events accumulated along the way.
*/ */
export function stepUntilPlayerTurn(w: World, playerId: EntityId): { awaitingPlayerId: EntityId; events: SimEvent[] } { export function stepUntilPlayerTurn(w: World, playerId: EntityId, accessor: EntityAccessor): { awaitingPlayerId: EntityId; events: SimEvent[] } {
const player = w.actors.get(playerId); const THRESHOLD = 100;
if (!player) throw new Error("Player missing");
const player = accessor.getCombatant(playerId);
if (!player) throw new Error("Player missing or invalid");
const events: SimEvent[] = []; const events: SimEvent[] = [];
if (player.energy >= THRESHOLD) {
player.energy -= THRESHOLD;
}
while (true) { while (true) {
while (![...w.actors.values()].some(a => a.energy >= ENERGY_THRESHOLD)) { if (player.energy >= THRESHOLD) {
for (const a of w.actors.values()) a.energy += a.speed; return { awaitingPlayerId: playerId, events };
} }
const ready = [...w.actors.values()].filter(a => a.energy >= ENERGY_THRESHOLD); const actors = [...accessor.getAllActors()];
ready.sort((a, b) => (b.energy - a.energy) || (a.id - b.id)); for (const actor of actors) {
const actor = ready[0]; if (actor.category === "combatant") {
actor.energy += actor.speed;
if (actor.isPlayer) { }
return { awaitingPlayerId: actor.id, events };
} }
const action = decideEnemyAction(w, actor, player); let actionsTaken = 0;
events.push(...applyAction(w, actor.id, action)); while (true) {
const eligibleActors = accessor.getEnemies().filter(a => a.energy >= THRESHOLD);
// Check if player was killed by this action if (eligibleActors.length === 0) break;
if (!w.actors.has(playerId)) {
eligibleActors.sort((a, b) => b.energy - a.energy);
const actor = eligibleActors[0];
actor.energy -= THRESHOLD;
const decision = decideEnemyAction(w, actor, player, accessor);
if (decision.justAlerted) {
events.push({
type: "enemy-alerted",
enemyId: actor.id,
x: actor.pos.x,
y: actor.pos.y
});
}
events.push(...applyAction(w, actor.id, decision.action, accessor));
checkDeaths(events, accessor);
if (!accessor.isPlayerAlive()) {
return { awaitingPlayerId: null as any, events }; return { awaitingPlayerId: null as any, events };
} }
actionsTaken++;
if (actionsTaken > 1000) break;
} }
} }
}

View File

@@ -0,0 +1,134 @@
import { type CombatantActor, type Item, type Equipment } from "../../core/types";
/**
* Equipment slot keys matching the Equipment interface.
*/
export type EquipmentSlotKey = keyof Equipment;
/**
* Map of item types to valid equipment slot keys.
*/
const ITEM_TYPE_TO_SLOTS: Record<string, EquipmentSlotKey[]> = {
Weapon: ["mainHand", "offHand"],
BodyArmour: ["bodyArmour"],
Helmet: ["helmet"],
Gloves: ["gloves"],
Boots: ["boots"],
Ring: ["ringLeft", "ringRight"],
Belt: ["belt"],
Amulet: ["amulet"],
Offhand: ["offHand"],
};
/**
* Checks if an item can be equipped in the specified slot.
*/
export function isItemValidForSlot(item: Item | undefined, slotKey: string): boolean {
if (!item || !item.type) return false;
const validSlots = ITEM_TYPE_TO_SLOTS[item.type];
return validSlots?.includes(slotKey as EquipmentSlotKey) ?? false;
}
/**
* Applies or removes item stats to/from a player.
* @param player - The player actor to modify
* @param item - The item with stats to apply
* @param isAdding - True to add stats, false to remove
*/
export function applyItemStats(player: CombatantActor, item: Item, isAdding: boolean): void {
if (!("stats" in item) || !item.stats) return;
const modifier = isAdding ? 1 : -1;
const stats = item.stats as Record<string, number | undefined>;
// Primary stats
if (stats.defense) player.stats.defense += stats.defense * modifier;
if (stats.attack) player.stats.attack += stats.attack * modifier;
// Max HP with current HP adjustment
if (stats.maxHp) {
const diff = stats.maxHp * modifier;
player.stats.maxHp += diff;
player.stats.hp = Math.min(player.stats.maxHp, player.stats.hp + (isAdding ? diff : 0));
}
// Max Mana with current mana adjustment
if (stats.maxMana) {
const diff = stats.maxMana * modifier;
player.stats.maxMana += diff;
player.stats.mana = Math.min(player.stats.maxMana, player.stats.mana + (isAdding ? diff : 0));
}
// Secondary stats
if (stats.critChance) player.stats.critChance += stats.critChance * modifier;
if (stats.accuracy) player.stats.accuracy += stats.accuracy * modifier;
if (stats.evasion) player.stats.evasion += stats.evasion * modifier;
if (stats.blockChance) player.stats.blockChance += stats.blockChance * modifier;
}
/**
* De-equips an item from the specified slot, removing stats and returning to inventory.
* @returns The de-equipped item, or null if slot was empty
*/
export function deEquipItem(
player: CombatantActor,
slotKey: EquipmentSlotKey
): Item | null {
if (!player.equipment) return null;
const item = (player.equipment as Record<string, Item | undefined>)[slotKey];
if (!item) return null;
// Remove from equipment
delete (player.equipment as Record<string, Item | undefined>)[slotKey];
// Remove stats
applyItemStats(player, item, false);
// Add back to inventory
if (!player.inventory) player.inventory = { gold: 0, items: [] };
player.inventory.items.push(item);
return item;
}
/**
* Equips an item to the specified slot, handling swaps if needed.
* @returns Object with success status and optional message
*/
export function equipItem(
player: CombatantActor,
item: Item,
slotKey: EquipmentSlotKey
): { success: boolean; swappedItem?: Item; message?: string } {
// Validate slot
if (!isItemValidForSlot(item, slotKey)) {
return { success: false, message: "Cannot equip there!" };
}
// Remove from inventory
if (!player.inventory) return { success: false, message: "No inventory" };
const itemIdx = player.inventory.items.findIndex(it => it.id === item.id);
if (itemIdx === -1) return { success: false, message: "Item not in inventory" };
// Handle swap if slot is occupied
if (!player.equipment) player.equipment = {};
const oldItem = (player.equipment as Record<string, Item | undefined>)[slotKey];
let swappedItem: Item | undefined;
if (oldItem) {
swappedItem = deEquipItem(player, slotKey) ?? undefined;
}
// Move to equipment (re-find index after potential swap)
const newIdx = player.inventory.items.findIndex(it => it.id === item.id);
if (newIdx !== -1) {
player.inventory.items.splice(newIdx, 1);
}
(player.equipment as Record<string, Item | undefined>)[slotKey] = item;
// Apply stats
applyItemStats(player, item, true);
return { success: true, swappedItem };
}

View File

@@ -0,0 +1,166 @@
import { type Item, type ActorType } from "../../core/types";
import {
createMeleeWeapon,
createArmour,
createConsumable,
createAmmo,
MELEE_WEAPONS,
ARMOUR
} from "../../core/config/Items";
import {
WEAPON_VARIANTS,
ARMOUR_VARIANTS,
type WeaponVariantId,
type ArmourVariantId
} from "../../core/config/ItemVariants";
import { UpgradeManager } from "../systems/UpgradeManager";
/**
* Loot drop configuration.
* Chances are cumulative (checked in order).
*/
export const LOOT_CONFIG = {
// Base chance any item drops at all (per enemy)
baseDropChance: 0.25,
// Type weights (what kind of item drops)
typeWeights: {
weapon: 30,
armour: 25,
consumable: 35,
ammo: 10,
},
// Rarity chances (applied after type is chosen)
rarityChances: {
base: 0.60, // 60% just base item
variant: 0.30, // 30% has a variant
upgraded: 0.10, // 10% has upgrade applied
},
// Per-enemy type drop chance modifiers
enemyDropModifiers: {
rat: 0.8,
bat: 0.9,
// Add more enemy types as needed
} as Record<ActorType, number>,
};
/**
* Generate a random loot item based on the loot configuration.
* Returns null if no item drops.
*/
export function generateLoot(
random: () => number,
enemyType?: ActorType,
floorLevel: number = 1
): Item | null {
// Check base drop chance (modified by enemy type)
let dropChance: number = LOOT_CONFIG.baseDropChance;
if (enemyType && LOOT_CONFIG.enemyDropModifiers[enemyType]) {
dropChance *= LOOT_CONFIG.enemyDropModifiers[enemyType];
}
// Higher floor = slightly more drops
dropChance += floorLevel * 0.02;
dropChance = Math.min(dropChance, 0.6); // Cap at 60%
if (random() > dropChance) {
return null;
}
// Determine item type
const itemType = pickWeightedRandom(LOOT_CONFIG.typeWeights, random);
// Determine rarity tier
const rarityRoll = random();
let hasVariant = false;
let hasUpgrade = false;
if (rarityRoll >= (1 - LOOT_CONFIG.rarityChances.upgraded)) {
// Top 10%: upgraded (implies has variant too)
hasVariant = true;
hasUpgrade = true;
} else if (rarityRoll >= (1 - LOOT_CONFIG.rarityChances.upgraded - LOOT_CONFIG.rarityChances.variant)) {
// Next 30%: variant only
hasVariant = true;
}
// Otherwise: base item (60%)
// Generate the item
let item: Item | null = null;
switch (itemType) {
case "weapon": {
const weaponIds = Object.keys(MELEE_WEAPONS) as (keyof typeof MELEE_WEAPONS)[];
const weaponId = weaponIds[Math.floor(random() * weaponIds.length)];
let variant: WeaponVariantId | undefined;
if (hasVariant) {
const variantIds = Object.keys(WEAPON_VARIANTS) as WeaponVariantId[];
variant = variantIds[Math.floor(random() * variantIds.length)];
}
item = createMeleeWeapon(weaponId, variant);
break;
}
case "armour": {
const armourIds = Object.keys(ARMOUR) as (keyof typeof ARMOUR)[];
const armourId = armourIds[Math.floor(random() * armourIds.length)];
let variant: ArmourVariantId | undefined;
if (hasVariant) {
const variantIds = Object.keys(ARMOUR_VARIANTS) as ArmourVariantId[];
variant = variantIds[Math.floor(random() * variantIds.length)];
}
item = createArmour(armourId, variant);
break;
}
case "consumable": {
// Only drop health potions and throwing daggers, not upgrade scrolls
const droppableConsumables = ["health_potion", "throwing_dagger"] as const;
const consumableId = droppableConsumables[Math.floor(random() * droppableConsumables.length)];
const quantity = 1 + Math.floor(random() * 2); // 1-2
item = createConsumable(consumableId, quantity);
break;
}
case "ammo": {
const quantity = 5 + Math.floor(random() * 10); // 5-14
item = createAmmo("ammo_9mm", quantity);
break;
}
}
// Apply upgrade if rolled
if (item && hasUpgrade) {
UpgradeManager.applyUpgrade(item);
}
return item;
}
/**
* Pick from weighted options.
*/
function pickWeightedRandom(
weights: Record<string, number>,
random: () => number
): string {
const entries = Object.entries(weights);
const total = entries.reduce((sum, [, w]) => sum + w, 0);
let roll = random() * total;
for (const [key, weight] of entries) {
roll -= weight;
if (roll <= 0) {
return key;
}
}
return entries[entries.length - 1][0];
}

View File

@@ -0,0 +1,62 @@
import Phaser from "phaser";
import { GAME_CONFIG } from "../../core/config/GameConfig";
import { TrackDirection } from "./TrackSystem";
export interface MineCartState {
x: number;
y: number;
facing: { dx: number, dy: number };
}
export class MineCartSystem {
static updateOrientation(sprite: Phaser.GameObjects.Sprite, dx: number, dy: number, _connections: TrackDirection) {
const { mineCarts } = GAME_CONFIG.rendering;
// Horizontal movement
if (dx !== 0 && dy === 0) {
sprite.setFrame(mineCarts.horizontal);
sprite.setFlipX(dx < 0);
sprite.setAngle(0);
}
// Vertical movement
else if (dy !== 0 && dx === 0) {
sprite.setFrame(mineCarts.vertical);
sprite.setFlipY(false);
sprite.setAngle(0);
}
// Turning (Corner case)
else {
sprite.setFrame(mineCarts.turning);
// Logic for 56 (turned from right to down by default)
// We need to rotate/flip to match the actual turn.
// This is a bit complex without seeing the sprite, but we'll approximate:
if (dx > 0 && dy > 0) sprite.setAngle(0); // Right to Down
if (dx < 0 && dy < 0) sprite.setAngle(180); // Left to Up
if (dx > 0 && dy < 0) sprite.setAngle(-90); // Right to Up
if (dx < 0 && dy > 0) sprite.setAngle(90); // Left to Down
}
}
static getNextPosition(current: { x: number, y: number }, dx: number, dy: number, isTrack: (x: number, y: number) => boolean): { x: number, y: number, dx: number, dy: number } | null {
const nextX = current.x + dx;
const nextY = current.y + dy;
if (isTrack(nextX, nextY)) {
return { x: nextX, y: nextY, dx, dy };
}
// Try turning if blocked
const possibleTurns = [
{ tdx: dy, tdy: -dx }, // Left turn
{ tdx: -dy, tdy: dx } // Right turn
];
for (const turn of possibleTurns) {
if (isTrack(current.x + turn.tdx, current.y + turn.tdy)) {
return { x: current.x + turn.tdx, y: current.y + turn.tdy, dx: turn.tdx, dy: turn.tdy };
}
}
return null;
}
}

View File

@@ -0,0 +1,45 @@
import { GAME_CONFIG } from "../../core/config/GameConfig";
export const TrackDirection = {
NONE: 0,
NORTH: 1 << 0,
SOUTH: 1 << 1,
EAST: 1 << 2,
WEST: 1 << 3
} as const;
export type TrackDirection = number;
export class TrackSystem {
static getTrackFrame(connections: TrackDirection): number {
const { tracks } = GAME_CONFIG.rendering;
// Dead Ends
if (connections === TrackDirection.SOUTH) return tracks.endTop;
if (connections === TrackDirection.NORTH) return tracks.endBottom;
if (connections === TrackDirection.EAST) return tracks.endLeft;
if (connections === TrackDirection.WEST) return tracks.endRight;
// Straights
if (connections === (TrackDirection.NORTH | TrackDirection.SOUTH)) return tracks.vertical;
if (connections === (TrackDirection.EAST | TrackDirection.WEST)) return tracks.horizontal;
// Corners
if (connections === (TrackDirection.NORTH | TrackDirection.EAST)) return tracks.cornerNE;
if (connections === (TrackDirection.SOUTH | TrackDirection.EAST)) return tracks.cornerSE;
if (connections === (TrackDirection.SOUTH | TrackDirection.WEST)) return tracks.cornerSW;
if (connections === (TrackDirection.NORTH | TrackDirection.WEST)) return tracks.cornerNW;
// Fallback to horizontal
return tracks.horizontal;
}
static getConnectionsFromNeighbors(x: number, y: number, isTrack: (x: number, y: number) => boolean): TrackDirection {
let connections = TrackDirection.NONE;
if (isTrack(x, y - 1)) connections |= TrackDirection.NORTH;
if (isTrack(x, y + 1)) connections |= TrackDirection.SOUTH;
if (isTrack(x + 1, y)) connections |= TrackDirection.EAST;
if (isTrack(x - 1, y)) connections |= TrackDirection.WEST;
return connections;
}
}

View File

@@ -0,0 +1,59 @@
import type { Item, WeaponItem, ArmourItem } from "../../core/types";
/**
* Manages item upgrade logic for applying upgrade scrolls.
*/
export class UpgradeManager {
/**
* Checks if an item can be upgraded (weapons and armour only).
*/
static canUpgrade(item: Item): boolean {
return item.type === "Weapon" ||
item.type === "BodyArmour" ||
item.type === "Helmet" ||
item.type === "Gloves" ||
item.type === "Boots";
}
/**
* Applies an upgrade to an item, increasing all stats by +1.
* Returns true if successful.
*/
static applyUpgrade(item: Item): boolean {
if (!this.canUpgrade(item)) {
return false;
}
// Increment upgrade level
const currentLevel = item.upgradeLevel ?? 0;
item.upgradeLevel = currentLevel + 1;
// Update item name with level suffix
// Remove any existing upgrade suffix first
const baseName = item.name.replace(/\s*\+\d+$/, "");
item.name = `${baseName} +${item.upgradeLevel}`;
// Increase all numeric stats by +1
const stats = (item as WeaponItem | ArmourItem).stats;
for (const key of Object.keys(stats)) {
const value = stats[key as keyof typeof stats];
if (typeof value === "number") {
(stats as Record<string, unknown>)[key] = value + 1;
}
}
return true;
}
/**
* Gets the display name for an item including upgrade level.
*/
static getDisplayName(item: Item): string {
if (item.upgradeLevel && item.upgradeLevel > 0) {
const baseName = item.name.replace(/\s*\+\d+$/, "");
return `${baseName} +${item.upgradeLevel}`;
}
return item.name;
}
}

View File

@@ -0,0 +1,231 @@
import { describe, it, expect, beforeEach } from "vitest";
import {
isItemValidForSlot,
applyItemStats,
deEquipItem,
equipItem,
} from "../EquipmentService";
import type { CombatantActor, Item, WeaponItem, ArmourItem } from "../../../core/types";
// Helper to create a mock player
function createMockPlayer(overrides: Partial<CombatantActor> = {}): CombatantActor {
return {
id: 1,
pos: { x: 0, y: 0 },
category: "combatant",
isPlayer: true,
type: "player",
speed: 100,
energy: 0,
stats: {
maxHp: 20,
hp: 20,
maxMana: 10,
mana: 10,
attack: 5,
defense: 2,
level: 1,
exp: 0,
expToNextLevel: 10,
critChance: 5,
critMultiplier: 150,
accuracy: 90,
lifesteal: 0,
evasion: 5,
blockChance: 0,
luck: 0,
statPoints: 0,
skillPoints: 0,
strength: 10,
dexterity: 10,
intelligence: 10,
passiveNodes: [],
},
inventory: { gold: 0, items: [] },
equipment: {},
...overrides,
};
}
function createSword(): WeaponItem {
return {
id: "sword_1",
name: "Iron Sword",
type: "Weapon",
weaponType: "melee",
textureKey: "items",
spriteIndex: 0,
stats: { attack: 3 },
};
}
function createArmour(): ArmourItem {
return {
id: "armour_1",
name: "Leather Armor",
type: "BodyArmour",
textureKey: "items",
spriteIndex: 1,
stats: { defense: 2 },
};
}
describe("EquipmentService", () => {
describe("isItemValidForSlot", () => {
it("returns true for weapon in mainHand", () => {
expect(isItemValidForSlot(createSword(), "mainHand")).toBe(true);
});
it("returns true for weapon in offHand", () => {
expect(isItemValidForSlot(createSword(), "offHand")).toBe(true);
});
it("returns false for weapon in bodyArmour slot", () => {
expect(isItemValidForSlot(createSword(), "bodyArmour")).toBe(false);
});
it("returns true for BodyArmour in bodyArmour slot", () => {
expect(isItemValidForSlot(createArmour(), "bodyArmour")).toBe(true);
});
it("returns false for undefined item", () => {
expect(isItemValidForSlot(undefined, "mainHand")).toBe(false);
});
it("returns false for unknown slot", () => {
expect(isItemValidForSlot(createSword(), "unknownSlot")).toBe(false);
});
});
describe("applyItemStats", () => {
let player: CombatantActor;
beforeEach(() => {
player = createMockPlayer();
});
it("adds attack stat when isAdding is true", () => {
const sword = createSword();
applyItemStats(player, sword, true);
expect(player.stats.attack).toBe(8); // 5 + 3
});
it("removes attack stat when isAdding is false", () => {
const sword = createSword();
player.stats.attack = 8;
applyItemStats(player, sword, false);
expect(player.stats.attack).toBe(5);
});
it("adds defense stat when isAdding is true", () => {
const armour = createArmour();
applyItemStats(player, armour, true);
expect(player.stats.defense).toBe(4); // 2 + 2
});
it("handles items without stats", () => {
const itemWithoutStats = { id: "coin", name: "Coin", type: "Currency" } as Item;
applyItemStats(player, itemWithoutStats, true);
expect(player.stats.attack).toBe(5); // unchanged
});
});
describe("deEquipItem", () => {
let player: CombatantActor;
let sword: WeaponItem;
beforeEach(() => {
sword = createSword();
player = createMockPlayer({
equipment: { mainHand: sword },
inventory: { gold: 0, items: [] },
});
player.stats.attack = 8; // Sword already equipped
});
it("removes item from equipment slot", () => {
deEquipItem(player, "mainHand");
expect(player.equipment?.mainHand).toBeUndefined();
});
it("returns the de-equipped item", () => {
const result = deEquipItem(player, "mainHand");
expect(result?.id).toBe("sword_1");
});
it("adds item back to inventory", () => {
deEquipItem(player, "mainHand");
expect(player.inventory?.items.length).toBe(1);
expect(player.inventory?.items[0].id).toBe("sword_1");
});
it("removes item stats from player", () => {
deEquipItem(player, "mainHand");
expect(player.stats.attack).toBe(5); // Back to base
});
it("returns null for empty slot", () => {
const result = deEquipItem(player, "offHand");
expect(result).toBeNull();
});
});
describe("equipItem", () => {
let player: CombatantActor;
let sword: WeaponItem;
beforeEach(() => {
sword = createSword();
player = createMockPlayer({
inventory: { gold: 0, items: [sword] },
equipment: {},
});
});
it("equips item to valid slot", () => {
const result = equipItem(player, sword, "mainHand");
expect(result.success).toBe(true);
expect(player.equipment?.mainHand?.id).toBe("sword_1");
});
it("removes item from inventory", () => {
equipItem(player, sword, "mainHand");
expect(player.inventory?.items.length).toBe(0);
});
it("applies item stats", () => {
equipItem(player, sword, "mainHand");
expect(player.stats.attack).toBe(8); // 5 + 3
});
it("fails for invalid slot", () => {
const result = equipItem(player, sword, "bodyArmour");
expect(result.success).toBe(false);
expect(result.message).toBe("Cannot equip there!");
});
it("swaps existing item", () => {
const sword2: WeaponItem = {
id: "sword_2",
name: "Steel Sword",
type: "Weapon",
weaponType: "melee",
textureKey: "items",
spriteIndex: 0,
stats: { attack: 5 },
};
player.inventory!.items.push(sword2);
// Equip first sword
equipItem(player, sword, "mainHand");
expect(player.stats.attack).toBe(8);
// Equip second sword (should swap)
const result = equipItem(player, sword2, "mainHand");
expect(result.success).toBe(true);
expect(result.swappedItem?.id).toBe("sword_1");
expect(player.equipment?.mainHand?.id).toBe("sword_2");
expect(player.stats.attack).toBe(10); // 5 base + 5 new sword
});
});
});

View File

@@ -0,0 +1,66 @@
import { describe, it, expect } from 'vitest';
import { UpgradeManager } from '../UpgradeManager';
import { createMeleeWeapon, createArmour, createConsumable } from '../../../core/config/Items';
import type { WeaponItem, ArmourItem } from '../../../core/types';
describe('UpgradeManager', () => {
it('should correctly identify upgradeable items', () => {
const sword = createMeleeWeapon("iron_sword");
const armor = createArmour("leather_armor");
const potion = createConsumable("health_potion");
expect(UpgradeManager.canUpgrade(sword)).toBe(true);
expect(UpgradeManager.canUpgrade(armor)).toBe(true);
expect(UpgradeManager.canUpgrade(potion)).toBe(false);
});
it('should upgrade weapon stats and name', () => {
const sword = createMeleeWeapon("iron_sword") as WeaponItem;
const initialAttack = sword.stats.attack!;
const initialName = sword.name;
const success = UpgradeManager.applyUpgrade(sword);
expect(success).toBe(true);
expect(sword.stats.attack).toBe(initialAttack + 1);
expect(sword.upgradeLevel).toBe(1);
expect(sword.name).toBe(`${initialName} +1`);
});
it('should upgrade armour stats and name', () => {
const armor = createArmour("leather_armor") as ArmourItem;
const initialDefense = armor.stats.defense!;
const initialName = armor.name;
const success = UpgradeManager.applyUpgrade(armor);
expect(success).toBe(true);
expect(armor.stats.defense).toBe(initialDefense + 1);
expect(armor.upgradeLevel).toBe(1);
expect(armor.name).toBe(`${initialName} +1`);
});
it('should handle sequential upgrades', () => {
const sword = createMeleeWeapon("iron_sword") as WeaponItem;
const initialAttack = sword.stats.attack!;
const initialName = sword.name;
UpgradeManager.applyUpgrade(sword); // +1
UpgradeManager.applyUpgrade(sword); // +2
expect(sword.stats.attack).toBe(initialAttack + 2);
expect(sword.upgradeLevel).toBe(2);
expect(sword.name).toBe(`${initialName} +2`);
});
it('should not upgrade non-upgradeable items', () => {
const potion = createConsumable("health_potion");
const initialName = potion.name;
const success = UpgradeManager.applyUpgrade(potion);
expect(success).toBe(false);
expect(potion.upgradeLevel).toBeUndefined();
expect(potion.name).toBe(initialName);
});
});

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import { generateWorld } from '../generator';
import { GAME_CONFIG } from '../../../core/config/GameConfig';
describe('World Generator Stacking Debug', () => {
it('should not spawn multiple enemies on the same tile', () => {
const runState = {
stats: { ...GAME_CONFIG.player.initialStats },
inventory: { gold: 0, items: [] }
};
// Run multiple times to catch sporadic rng issues
for (let i = 0; i < 50; i++) {
const floor = 1 + (i % 10);
const { ecsWorld } = generateWorld(floor, runState);
// Get all enemies
const aiEntities = ecsWorld.getEntitiesWith("ai");
const positions = new Set<string>();
const duplicates: string[] = [];
for (const entityId of aiEntities) {
const pos = ecsWorld.getComponent(entityId, "position");
if (pos) {
const key = `${pos.x},${pos.y}`;
if (positions.has(key)) {
duplicates.push(key);
}
positions.add(key);
}
}
if (duplicates.length > 0) {
console.error(`Found duplicates on iteration ${i} (floor ${floor}):`, duplicates);
}
expect(duplicates.length).toBe(0);
}
});
});

View File

@@ -1,7 +1,24 @@
import { type World, type EntityId, type RunState, type Tile, type Actor, type Vec2 } from "../../core/types"; import { type World, type EntityId, type RunState, type Tile, type Vec2 } from "../../core/types";
import { TileType } from "../../core/terrain";
import { idx } from "./world-logic"; import { idx } from "./world-logic";
import { GAME_CONFIG } from "../../core/config/GameConfig"; import { GAME_CONFIG } from "../../core/config/GameConfig";
import {
createConsumable,
createMeleeWeapon,
createRangedWeapon,
createArmour,
createUpgradeScroll,
createAmmo,
createCeramicDragonHead
} from "../../core/config/Items";
import { seededRandom } from "../../core/math"; import { seededRandom } from "../../core/math";
import * as ROT from "rot-js";
import { ECSWorld } from "../ecs/World";
import { Prefabs } from "../ecs/Prefabs";
import { EntityBuilder } from "../ecs/EntityBuilder";
interface Room { interface Room {
x: number; x: number;
@@ -11,154 +28,545 @@ interface Room {
} }
/** /**
* Generates a procedural dungeon world with rooms and corridors * Generates a procedural dungeon world with rooms and corridors using rot-js Uniform algorithm
* @param level The level number (affects difficulty and randomness seed) * @param floor The floor number (affects difficulty)
* @param runState Player's persistent state across levels * @param runState Player's persistent state across floors
* @returns Generated world and player ID * @returns Generated world, player ID, and ECS world with traps
*/ */
export function generateWorld(level: number, runState: RunState): { world: World; playerId: EntityId } { export function generateWorld(floor: number, runState: RunState): { world: World; playerId: EntityId; ecsWorld: ECSWorld } {
const width = GAME_CONFIG.map.width; const width = GAME_CONFIG.map.width;
const height = GAME_CONFIG.map.height; const height = GAME_CONFIG.map.height;
const tiles: Tile[] = new Array(width * height).fill(1); // Start with all walls const tiles: Tile[] = new Array(width * height).fill(TileType.WALL);
const random = seededRandom(level * 12345); const random = seededRandom(runState.seed + floor * 12345);
const rooms = generateRooms(width, height, tiles, random); // Create ECS World first
const ecsWorld = new ECSWorld(); // Starts at ID 1 by default
// Place player in first room // Set ROT's RNG seed for consistent dungeon generation
const firstRoom = rooms[0]; ROT.RNG.setSeed(runState.seed + floor * 12345);
const playerX = firstRoom.x + Math.floor(firstRoom.width / 2);
const playerY = firstRoom.y + Math.floor(firstRoom.height / 2);
// Place exit in last room // Replace generateRooms call with track-first logic for mine cart mechanic
const lastRoom = rooms[rooms.length - 1]; const { rooms, trackPath } = generateTrackLevel(width, height, tiles, floor, random);
const exitX = lastRoom.x + Math.floor(lastRoom.width / 2);
const exitY = lastRoom.y + Math.floor(lastRoom.height / 2);
const exit: Vec2 = { x: exitX, y: exitY };
const actors = new Map<EntityId, Actor>(); console.log(`[generator] Track generated with ${trackPath.length} nodes.`);
console.log(`[generator] Rooms generated: ${rooms.length}`);
const playerId = 1; if (!trackPath || trackPath.length === 0) {
actors.set(playerId, { throw new Error("Failed to generate track path");
id: playerId,
isPlayer: true,
type: "player",
pos: { x: playerX, y: playerY },
speed: GAME_CONFIG.player.speed,
energy: 0,
stats: { ...runState.stats },
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] }
});
placeEnemies(level, rooms, actors, random);
return { world: { width, height, tiles, actors, exit }, playerId };
} }
function generateRooms(width: number, height: number, tiles: Tile[], random: () => number): Room[] { // Place player at start of track
const playerX = trackPath[0].x;
const playerY = trackPath[0].y;
// Clear track path
for (const pos of trackPath) {
tiles[pos.y * width + pos.x] = TileType.TRACK;
}
// Create Player Entity in ECS
const runInventory = {
gold: runState.inventory.gold,
items: [
...runState.inventory.items,
// Add starting items for testing if empty
...(runState.inventory.items.length === 0 ? [
createConsumable("health_potion", 2),
createMeleeWeapon("iron_sword", "sharp"),
createConsumable("throwing_dagger", 3),
createRangedWeapon("pistol"),
createAmmo("ammo_9mm", 10),
createCeramicDragonHead(),
createArmour("leather_armor", "heavy"),
createUpgradeScroll(2)
] : [])
]
};
const playerId = EntityBuilder.create(ecsWorld)
.asPlayer()
.withPosition(playerX, playerY)
// RunState stats override default player stats
.withStats(runState.stats)
.withInventory(runInventory)
.withEnergy(GAME_CONFIG.player.speed)
.build();
// Create Mine Cart at start of track
const cartId = Prefabs.mineCart(ecsWorld, trackPath);
const exit = { ...trackPath[trackPath.length - 1] };
// Place Switch adjacent to the end of the track
let switchPos = { x: exit.x, y: exit.y };
const neighbors = [
{ x: exit.x + 1, y: exit.y },
{ x: exit.x - 1, y: exit.y },
{ x: exit.x, y: exit.y + 1 },
{ x: exit.x, y: exit.y - 1 },
];
for (const n of neighbors) {
if (n.x >= 1 && n.x < width - 1 && n.y >= 1 && n.y < height - 1) {
const t = tiles[n.y * width + n.x];
if (t === TileType.EMPTY || t === TileType.EMPTY_DECO || t === TileType.GRASS || t === TileType.TRACK) {
switchPos = n;
// Don't break if it's track, try to find a real empty spot first
if (t !== TileType.TRACK) break;
}
}
}
Prefabs.trackSwitch(ecsWorld, switchPos.x, switchPos.y, cartId);
// Mark all track and room tiles as occupied for objects
const occupiedPositions = new Set<string>();
occupiedPositions.add(`${playerX},${playerY}`);
occupiedPositions.add(`${exit.x},${exit.y}`);
for (const pos of trackPath) {
occupiedPositions.add(`${pos.x},${pos.y}`);
}
// Place enemies
placeEnemies(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
// Place traps
placeTraps(floor, rooms, ecsWorld, tiles, width, random, occupiedPositions);
// Decorate and finalize tiles
decorate(width, height, tiles, random, exit);
// Ensure start and end are walkable and marked
tiles[playerY * width + playerX] = TileType.EMPTY;
tiles[exit.y * width + exit.x] = TileType.EXIT;
return {
world: { width, height, tiles, exit, trackPath },
playerId,
ecsWorld
};
}
/**
* Generates a level with a central rail track from start to end.
*/
function generateTrackLevel(width: number, height: number, tiles: Tile[], _floor: number, random: () => number): { rooms: Room[], trackPath: Vec2[] } {
const rooms: Room[] = []; const rooms: Room[] = [];
const numRooms = GAME_CONFIG.map.minRooms + Math.floor(random() * (GAME_CONFIG.map.maxRooms - GAME_CONFIG.map.minRooms + 1)); const trackPath: Vec2[] = [];
const fakeWorldForIdx = { width, height }; // 1. Generate a winding path of "Anchor Points" for rooms
const anchors: Vec2[] = [];
const startDir = Math.floor(random() * 4); // 0: East, 1: West, 2: South, 3: North
for (let i = 0; i < numRooms; i++) { let currA: Vec2;
const roomWidth = GAME_CONFIG.map.roomMinWidth + Math.floor(random() * (GAME_CONFIG.map.roomMaxWidth - GAME_CONFIG.map.roomMinWidth + 1)); const margin = 10;
const roomHeight = GAME_CONFIG.map.roomMinHeight + Math.floor(random() * (GAME_CONFIG.map.roomMaxHeight - GAME_CONFIG.map.roomMinHeight + 1)); const stepSize = 12;
const roomX = 1 + Math.floor(random() * (width - roomWidth - 2));
const roomY = 1 + Math.floor(random() * (height - roomHeight - 2));
const newRoom: Room = { x: roomX, y: roomY, width: roomWidth, height: roomHeight }; if (startDir === 0) { // East (Left to Right)
currA = { x: margin, y: margin + Math.floor(random() * (height - margin * 2)) };
if (!doesOverlap(newRoom, rooms)) { } else if (startDir === 1) { // West (Right to Left)
carveRoom(newRoom, tiles, fakeWorldForIdx); currA = { x: width - margin, y: margin + Math.floor(random() * (height - margin * 2)) };
} else if (startDir === 2) { // South (Top to Bottom)
if (rooms.length > 0) { currA = { x: margin + Math.floor(random() * (width - margin * 2)), y: margin };
carveCorridor(rooms[rooms.length - 1], newRoom, tiles, fakeWorldForIdx, random); } else { // North (Bottom to Top)
currA = { x: margin + Math.floor(random() * (width - margin * 2)), y: height - margin };
} }
rooms.push(newRoom); anchors.push({ ...currA });
}
} const isFinished = () => {
return rooms; if (startDir === 0) return currA.x >= width - margin;
if (startDir === 1) return currA.x <= margin;
if (startDir === 2) return currA.y >= height - margin;
return currA.y <= margin;
};
while (!isFinished()) {
let nextX = currA.x;
let nextY = currA.y;
if (startDir === 0) { // East
nextX += Math.floor(stepSize * (0.8 + random() * 0.4));
nextY += Math.floor((random() - 0.5) * height * 0.4);
} else if (startDir === 1) { // West
nextX -= Math.floor(stepSize * (0.8 + random() * 0.4));
nextY += Math.floor((random() - 0.5) * height * 0.4);
} else if (startDir === 2) { // South
nextY += Math.floor(stepSize * (0.8 + random() * 0.4));
nextX += Math.floor((random() - 0.5) * width * 0.4);
} else { // North
nextY -= Math.floor(stepSize * (0.8 + random() * 0.4));
nextX += Math.floor((random() - 0.5) * width * 0.4);
} }
function doesOverlap(newRoom: Room, rooms: Room[]): boolean { currA = {
for (const room of rooms) { x: Math.max(margin / 2, Math.min(width - margin / 2, nextX)),
if ( y: Math.max(margin / 2, Math.min(height - margin / 2, nextY))
newRoom.x < room.x + room.width + 1 && };
newRoom.x + newRoom.width + 1 > room.x && anchors.push({ ...currA });
newRoom.y < room.y + room.height + 1 &&
newRoom.y + newRoom.height + 1 > room.y
) {
return true;
}
}
return false;
} }
function carveRoom(room: Room, tiles: Tile[], world: any): void { // 2. Place Primary Rooms at anchors and connect them
for (let x = room.x; x < room.x + room.width; x++) { let prevCenter: Vec2 | null = null;
for (let y = room.y; y < room.y + room.height; y++) {
tiles[idx(world, x, y)] = 0;
}
}
}
function carveCorridor(room1: Room, room2: Room, tiles: Tile[], world: any, random: () => number): void { for (const anchor of anchors) {
const x1 = Math.floor(room1.x + room1.width / 2); const rw = 7 + Math.floor(random() * 6);
const y1 = Math.floor(room1.y + room1.height / 2); const rh = 6 + Math.floor(random() * 6);
const x2 = Math.floor(room2.x + room2.width / 2); const rx = Math.floor(anchor.x - rw / 2);
const y2 = Math.floor(room2.y + room2.height / 2); const ry = Math.floor(anchor.y - rh / 2);
if (random() < 0.5) { const room: Room = { x: rx, y: ry, width: rw, height: rh };
// Horizontal then vertical
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) { // Dig room interior
tiles[idx(world, x, y1)] = 0; for (let y = ry + 1; y < ry + rh - 1; y++) {
for (let x = rx + 1; x < rx + rw - 1; x++) {
if (x >= 0 && x < width && y >= 0 && y < height) {
tiles[y * width + x] = TileType.EMPTY;
} }
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
tiles[idx(world, x2, y)] = 0;
} }
}
rooms.push(room);
const currCenter = { x: rx + Math.floor(rw / 2), y: ry + Math.floor(rh / 2) };
// 3. Connect to previous room and lay track
if (prevCenter) {
// Connect path
const segment: Vec2[] = [];
let tx = prevCenter.x;
let ty = prevCenter.y;
const dig = (x: number, y: number) => {
for (let dy = 0; dy <= 1; dy++) {
for (let dx = 0; dx <= 1; dx++) {
const nx = x + dx;
const ny = y + dy;
if (nx >= 0 && nx < width && ny >= 0 && ny < height) {
tiles[ny * width + nx] = TileType.EMPTY;
}
}
}
if (!segment.find(p => p.x === x && p.y === y)) {
segment.push({ x, y });
}
};
// Simple L-shape for tracks within/between rooms
while (tx !== currCenter.x) {
tx += currCenter.x > tx ? 1 : -1;
dig(tx, ty);
}
while (ty !== currCenter.y) {
ty += currCenter.y > ty ? 1 : -1;
dig(tx, ty);
}
trackPath.push(...segment);
} else { } else {
// Vertical then horizontal trackPath.push(currCenter);
for (let y = Math.min(y1, y2); y <= Math.max(y1, y2); y++) {
tiles[idx(world, x1, y)] = 0;
} }
for (let x = Math.min(x1, x2); x <= Math.max(x1, x2); x++) {
tiles[idx(world, x, y2)] = 0; prevCenter = currCenter;
}
// 4. Branch Side Rooms off the main path
const targetSideRooms = 10;
let attempts = 0;
const maxAttempts = 300;
while (rooms.length < targetSideRooms + anchors.length && attempts < maxAttempts) {
attempts++;
const sourcePathIdx = Math.floor(random() * trackPath.length);
const source = trackPath[sourcePathIdx];
const rw = 5 + Math.floor(random() * 5); // Slightly smaller rooms to fit better
const rh = 4 + Math.floor(random() * 5);
// Try multiple offsets to find a gap
const distances = [5, 6, 7, 8];
const sides = [-1, 1];
let placed = false;
for (const dist of distances) {
for (const side of sides) {
let rx, ry;
if (random() < 0.5) { // Try horizontal offset
rx = source.x + (side * dist);
ry = source.y - Math.floor(rh / 2);
} else { // Try vertical offset
rx = source.x - Math.floor(rw / 2);
ry = source.y + (side * dist);
}
rx = Math.max(1, Math.min(width - rw - 1, rx));
ry = Math.max(1, Math.min(height - rh - 1, ry));
const room = { x: rx, y: ry, width: rw, height: rh };
// 1. Check overlap with existing rooms (strict padding)
const overlapRooms = rooms.some(r => !(room.x + room.width < r.x - 1 || room.x > r.x + r.width + 1 || room.y + room.height < r.y - 1 || room.y > r.y + r.height + 1));
if (overlapRooms) continue;
// 2. Check overlap with existing core structures (EMPTY tiles)
let overlapEmpty = false;
for (let y = ry - 1; y < ry + rh + 1; y++) {
for (let x = rx - 1; x < rx + rw + 1; x++) {
if (tiles[y * width + x] === TileType.EMPTY) {
overlapEmpty = true;
break;
}
}
if (overlapEmpty) break;
}
if (overlapEmpty) continue;
// Valid spot found!
for (let y = ry + 1; y < ry + rh - 1; y++) {
for (let x = rx + 1; x < rx + rw - 1; x++) {
tiles[y * width + x] = TileType.EMPTY;
}
}
digCorridor(width, tiles, source.x, source.y, rx + Math.floor(rw / 2), ry + Math.floor(rh / 2));
// Place door at room boundary
let ex = rx + Math.floor(rw / 2);
let ey = ry + (source.y <= ry ? 0 : rh - 1);
if (source.x < rx) {
ex = rx; ey = ry + Math.floor(rh / 2);
} else if (source.x >= rx + rw) {
ex = rx + rw - 1; ey = ry + Math.floor(rh / 2);
} else if (source.y < ry) {
ex = rx + Math.floor(rw / 2); ey = ry;
} else if (source.y >= ry + rh) {
ex = rx + Math.floor(rw / 2); ey = ry + rh - 1;
}
tiles[ey * width + ex] = TileType.DOOR_CLOSED;
rooms.push(room);
placed = true;
break;
}
if (placed) break;
}
}
console.log(`[generator] Final side rooms placed: ${rooms.length - anchors.length} after ${attempts} attempts.`);
// Place visual exit at track end
const lastNode = trackPath[trackPath.length - 1];
tiles[lastNode.y * width + lastNode.x] = TileType.EXIT;
return { rooms, trackPath };
}
function digCorridor(width: number, tiles: Tile[], x1: number, y1: number, x2: number, y2: number) {
let currX = x1;
let currY = y1;
while (currX !== x2 || currY !== y2) {
if (currX !== x2) {
currX += x2 > currX ? 1 : -1;
} else if (currY !== y2) {
currY += y2 > currY ? 1 : -1;
}
// Only dig if it's currently a wall
if (tiles[currY * width + currX] === TileType.WALL) {
tiles[currY * width + currX] = TileType.EMPTY;
} }
} }
} }
function placeEnemies(level: number, rooms: Room[], actors: Map<EntityId, Actor>, random: () => number): void {
let enemyId = 2;
const numEnemies = GAME_CONFIG.enemy.baseCount + level * GAME_CONFIG.enemy.baseCountPerLevel + Math.floor(random() * GAME_CONFIG.enemy.randomBonus);
for (let i = 0; i < numEnemies && i < rooms.length - 1; i++) {
function decorate(width: number, height: number, tiles: Tile[], random: () => number, _exit: Vec2): void {
const world = { width, height };
// Stairs removed as per user request
// Use Simplex noise for natural-looking grass distribution
const grassNoise = new ROT.Noise.Simplex();
const decorationNoise = new ROT.Noise.Simplex();
// Offset noise to get different patterns for grass vs decorations
const grassOffset = random() * 1000;
const decorOffset = random() * 1000;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const i = idx(world as any, x, y);
if (tiles[i] === TileType.EMPTY) {
// Grass patches: use noise to create organic shapes
const grassValue = grassNoise.get((x + grassOffset) / 15, (y + grassOffset) / 15);
// Create grass patches where noise is above threshold
if (grassValue > 0.35) {
tiles[i] = TileType.GRASS;
} else if (grassValue > 0.25) {
// Transition zone: Saplings around grass clumps
tiles[i] = TileType.GRASS_SAPLINGS;
} else {
// Floor decorations (moss/rocks): clustered distribution
const decoValue = decorationNoise.get((x + decorOffset) / 8, (y + decorOffset) / 8);
// Dense clusters where noise is high
if (decoValue > 0.5) {
tiles[i] = TileType.EMPTY_DECO;
} else if (decoValue > 0.3 && random() < 0.3) {
tiles[i] = TileType.EMPTY_DECO;
}
}
}
}
}
// Wall decorations (moss near grass)
for (let y = 0; y < height - 1; y++) {
for (let x = 0; x < width; x++) {
const i = idx(world as any, x, y);
const nextY = idx(world as any, x, y + 1);
if (tiles[i] === TileType.WALL &&
tiles[nextY] === TileType.GRASS &&
random() < 0.25) {
tiles[i] = TileType.WALL_DECO;
}
}
}
}
function placeEnemies(
floor: number,
rooms: Room[],
ecsWorld: ECSWorld,
tiles: Tile[],
width: number,
random: () => number,
occupiedPositions: Set<string>
): void {
const numEnemies = GAME_CONFIG.enemyScaling.baseCount + floor * GAME_CONFIG.enemyScaling.baseCountPerFloor;
const enemyTypes = Object.keys(GAME_CONFIG.enemies);
if (rooms.length < 2) return;
for (let i = 0; i < numEnemies; i++) {
// Pick a random room (not the starting room 0)
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1)); const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
const room = rooms[roomIdx]; const room = rooms[roomIdx];
const enemyX = room.x + 1 + Math.floor(random() * (room.width - 2)); // Try to find an empty spot in the room
const enemyY = room.y + 1 + Math.floor(random() * (room.height - 2)); for (let attempts = 0; attempts < 20; attempts++) {
const baseHp = GAME_CONFIG.enemy.baseHp + level * GAME_CONFIG.enemy.baseHpPerLevel; const ex = room.x + 1 + Math.floor(random() * (room.width - 2));
const baseAttack = GAME_CONFIG.enemy.baseAttack + Math.floor(level / 2) * GAME_CONFIG.enemy.attackPerTwoLevels; const ey = room.y + 1 + Math.floor(random() * (room.height - 2));
const k = `${ex},${ey}`;
const tileIdx = ey * width + ex;
const isFloor = tiles[tileIdx] === TileType.EMPTY ||
tiles[tileIdx] === TileType.EMPTY_DECO ||
tiles[tileIdx] === TileType.GRASS_SAPLINGS;
actors.set(enemyId, { if (isFloor && !occupiedPositions.has(k)) {
id: enemyId, const type = enemyTypes[Math.floor(random() * enemyTypes.length)] as keyof typeof GAME_CONFIG.enemies;
isPlayer: false, const enemyDef = GAME_CONFIG.enemies[type];
type: random() < 0.5 ? "rat" : "bat",
pos: { x: enemyX, y: enemyY }, const scaledHp = enemyDef.baseHp + floor * GAME_CONFIG.enemyScaling.hpPerFloor;
speed: GAME_CONFIG.enemy.minSpeed + Math.floor(random() * (GAME_CONFIG.enemy.maxSpeed - GAME_CONFIG.enemy.minSpeed)), const scaledAttack = enemyDef.baseAttack + Math.floor(floor / 2) * GAME_CONFIG.enemyScaling.attackPerTwoFloors;
energy: 0,
stats: { const speed = enemyDef.minSpeed + Math.floor(random() * (enemyDef.maxSpeed - enemyDef.minSpeed));
maxHp: baseHp + Math.floor(random() * 4),
hp: baseHp + Math.floor(random() * 4), // Create Enemy in ECS
attack: baseAttack + Math.floor(random() * 2), EntityBuilder.create(ecsWorld)
defense: Math.floor(random() * (GAME_CONFIG.enemy.maxDefense + 1)) .asEnemy(type)
} .withPosition(ex, ey)
}); .withSprite(type, 0)
enemyId++; .withName(type.charAt(0).toUpperCase() + type.slice(1))
.withCombat()
.withStats({
maxHp: scaledHp + Math.floor(random() * 4),
hp: scaledHp + Math.floor(random() * 4),
attack: scaledAttack + Math.floor(random() * 2),
defense: enemyDef.baseDefense,
})
.withEnergy(speed) // Configured speed
.build();
occupiedPositions.add(k);
break;
} }
} }
}
}
/**
* Place traps randomly in dungeon rooms.
* Trap density increases with floor depth.
*/
function placeTraps(
floor: number,
rooms: Room[],
ecsWorld: ECSWorld,
tiles: Tile[],
width: number,
random: () => number,
occupiedPositions: Set<string>
): void {
// Trap configuration
const trapTypes = ["poison", "fire", "paralysis"] as const;
// Number of traps scales with floor (1-2 on floor 1, up to 5-6 on floor 10)
const minTraps = 1 + Math.floor(floor / 3);
const maxTraps = minTraps + 2;
const numTraps = minTraps + Math.floor(random() * (maxTraps - minTraps + 1));
if (rooms.length < 2) return;
for (let i = 0; i < numTraps; i++) {
// Pick a random room (not the starting room)
const roomIdx = 1 + Math.floor(random() * (rooms.length - 1));
const room = rooms[roomIdx];
// Try to find a valid position
for (let attempts = 0; attempts < 10; attempts++) {
const tx = room.x + 1 + Math.floor(random() * (room.width - 2));
const ty = room.y + 1 + Math.floor(random() * (room.height - 2));
const key = `${tx},${ty}`;
// Check if position is valid (floor tile, not occupied)
const tileIdx = ty * width + tx;
const isFloor = tiles[tileIdx] === TileType.EMPTY ||
tiles[tileIdx] === TileType.EMPTY_DECO ||
tiles[tileIdx] === TileType.GRASS_SAPLINGS;
if (isFloor && !occupiedPositions.has(key)) {
// Pick a random trap type
const trapType = trapTypes[Math.floor(random() * trapTypes.length)];
// Scale effect duration/magnitude with floor
const duration = 3 + Math.floor(floor / 3);
const magnitude = 2 + Math.floor(floor / 2);
switch (trapType) {
case "poison":
Prefabs.poisonTrap(ecsWorld, tx, ty, duration, magnitude);
break;
case "fire":
Prefabs.fireTrap(ecsWorld, tx, ty, Math.ceil(duration / 2), magnitude + 2);
break;
case "paralysis":
Prefabs.paralysisTrap(ecsWorld, tx, ty, Math.max(2, Math.ceil(duration / 2)));
break;
}
occupiedPositions.add(key);
break;
}
}
}
}
export const makeTestWorld = generateWorld; export const makeTestWorld = generateWorld;

View File

@@ -1,103 +1,63 @@
import type { World, Vec2 } from "../../core/types"; import type { World, Vec2 } from "../../core/types";
import { key } from "../../core/utils";
import { manhattan } from "../../core/math";
import { inBounds, isWall, isBlocked, idx } from "./world-logic"; import { inBounds, isWall, isBlocked, idx } from "./world-logic";
import { type EntityAccessor } from "../EntityAccessor";
import * as ROT from "rot-js";
/** /**
* Simple 4-dir A* pathfinding. * 4-dir A* pathfinding using rot-js.
* Returns an array of positions INCLUDING start and end. If no path, returns []. * Returns an array of positions INCLUDING start and end. If no path, returns [].
* *
* Exploration rule: * Exploration rule:
* - You cannot path THROUGH unseen tiles. * - You cannot path THROUGH unseen tiles.
* - You cannot path TO an unseen target tile. * - You cannot path TO an unseen target tile.
*/ */
export function findPathAStar(w: World, seen: Uint8Array, start: Vec2, end: Vec2, options: { ignoreBlockedTarget?: boolean } = {}): Vec2[] { export function findPathAStar(
w: World,
seen: Uint8Array,
start: Vec2,
end: Vec2,
options: { ignoreBlockedTarget?: boolean; ignoreSeen?: boolean; accessor?: EntityAccessor } = {}
): Vec2[] {
// Validate target
if (!inBounds(w, end.x, end.y)) return []; if (!inBounds(w, end.x, end.y)) return [];
if (isWall(w, end.x, end.y)) return []; if (isWall(w, end.x, end.y)) return [];
// If not ignoring target block, fail if blocked // Check if target is blocked (unless ignoring)
if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y)) return []; if (!options.ignoreBlockedTarget && isBlocked(w, end.x, end.y, options.accessor)) return [];
if (seen[idx(w, end.x, end.y)] !== 1) return []; // Check if target is unseen (unless ignoring)
if (!options.ignoreSeen && seen[idx(w, end.x, end.y)] !== 1) return [];
const open: Vec2[] = [start]; // Create passable callback for rot-js
const cameFrom = new Map<string, string>(); const passableCallback = (x: number, y: number): boolean => {
// Out of bounds or wall = not passable
if (!inBounds(w, x, y)) return false;
if (isWall(w, x, y)) return false;
const gScore = new Map<string, number>(); // Start position is always passable
const fScore = new Map<string, number>(); if (x === start.x && y === start.y) return true;
const startK = key(start.x, start.y); // Target position is passable (we already validated it above)
gScore.set(startK, 0); if (x === end.x && y === end.y) return true;
fScore.set(startK, manhattan(start, end));
const inOpen = new Set<string>([startK]); // Check seen requirement
if (!options.ignoreSeen && seen[idx(w, x, y)] !== 1) return false;
const dirs = [ // Check actor blocking
{ x: 1, y: 0 }, if (options.accessor && isBlocked(w, x, y, options.accessor)) return false;
{ x: -1, y: 0 },
{ x: 0, y: 1 },
{ x: 0, y: -1 }
];
while (open.length > 0) { return true;
// Pick node with lowest fScore };
let bestIdx = 0;
let bestF = Infinity;
for (let i = 0; i < open.length; i++) {
const k = key(open[i].x, open[i].y);
const f = fScore.get(k) ?? Infinity;
if (f < bestF) {
bestF = f;
bestIdx = i;
}
}
const current = open.splice(bestIdx, 1)[0]; // Use rot-js A* pathfinding with 8-directional topology
const currentK = key(current.x, current.y); const astar = new ROT.Path.AStar(end.x, end.y, passableCallback, { topology: 8 });
inOpen.delete(currentK);
const path: Vec2[] = [];
// Compute path from start to end
astar.compute(start.x, start.y, (x: number, y: number) => {
path.push({ x, y });
});
if (current.x === end.x && current.y === end.y) {
// Reconstruct path
const path: Vec2[] = [end];
let k = currentK;
while (cameFrom.has(k)) {
const prevK = cameFrom.get(k)!;
const [px, py] = prevK.split(",").map(Number);
path.push({ x: px, y: py });
k = prevK;
}
path.reverse();
return path; return path;
} }
for (const d of dirs) {
const nx = current.x + d.x;
const ny = current.y + d.y;
if (!inBounds(w, nx, ny)) continue;
if (isWall(w, nx, ny)) continue;
// Exploration rule: cannot path through unseen (except start)
if (!(nx === start.x && ny === start.y) && seen[idx(w, nx, ny)] !== 1) continue;
// Avoid walking through other actors (except standing on start, OR if it is the target and we ignore block)
const isTarget = nx === end.x && ny === end.y;
if (!isTarget && !(nx === start.x && ny === start.y) && isBlocked(w, nx, ny)) continue;
const nK = key(nx, ny);
const tentativeG = (gScore.get(currentK) ?? Infinity) + 1;
if (tentativeG < (gScore.get(nK) ?? Infinity)) {
cameFrom.set(nK, currentK);
gScore.set(nK, tentativeG);
fScore.set(nK, tentativeG + manhattan({ x: nx, y: ny }, end));
if (!inOpen.has(nK)) {
open.push({ x: nx, y: ny });
inOpen.add(nK);
}
}
}
}
return [];
}

View File

@@ -1,4 +1,7 @@
import type { World, EntityId } from "../../core/types"; import type { World } from "../../core/types";
import { isBlocking, isDestructible, getDestructionResult } from "../../core/terrain";
import { type EntityAccessor } from "../EntityAccessor";
export function inBounds(w: World, x: number, y: number): boolean { export function inBounds(w: World, x: number, y: number): boolean {
return x >= 0 && y >= 0 && x < w.width && y < w.height; return x >= 0 && y >= 0 && x < w.width && y < w.height;
@@ -9,21 +12,52 @@ export function idx(w: World, x: number, y: number): number {
} }
export function isWall(w: World, x: number, y: number): boolean { export function isWall(w: World, x: number, y: number): boolean {
return w.tiles[idx(w, x, y)] === 1; // Alias for isBlocking for backward compatibility
return isBlockingTile(w, x, y);
} }
export function isBlocked(w: World, x: number, y: number): boolean { export function isBlockingTile(w: World, x: number, y: number): boolean {
if (!inBounds(w, x, y)) return true; const tile = w.tiles[idx(w, x, y)];
if (isWall(w, x, y)) return true; return isBlocking(tile);
}
for (const a of w.actors.values()) { export function tryDestructTile(w: World, x: number, y: number): boolean {
if (a.pos.x === x && a.pos.y === y) return true; if (!inBounds(w, x, y)) return false;
const i = idx(w, x, y);
const tile = w.tiles[i];
if (isDestructible(tile)) {
const nextTile = getDestructionResult(tile);
if (nextTile !== undefined) {
w.tiles[i] = nextTile;
return true;
}
} }
return false; return false;
} }
export function isPlayerOnExit(w: World, playerId: EntityId): boolean { export function isBlocked(w: World, x: number, y: number, accessor: EntityAccessor | undefined): boolean {
const p = w.actors.get(playerId); if (!inBounds(w, x, y)) return true;
if (!p) return false; if (isBlockingTile(w, x, y)) return true;
return p.pos.x === w.exit.x && p.pos.y === w.exit.y;
if (!accessor) return false;
const actors = accessor.getActorsAt(x, y);
if (actors.some(a => a.category === "combatant")) return true;
// Check for interactable entities (switches, etc.) that should block movement
if (accessor.context) {
const ecs = accessor.context;
const isInteractable = ecs.getEntitiesWith("position", "trigger").some(id => {
const p = ecs.getComponent(id, "position");
const t = ecs.getComponent(id, "trigger");
return p?.x === x && p?.y === y && t?.onInteract;
});
if (isInteractable) return true;
} }
return false;
}

View File

@@ -1,8 +1,10 @@
import Phaser from "phaser"; import Phaser from "phaser";
import GameUI from "./ui/GameUI"; import GameUI from "./ui/GameUI";
import { GameScene } from "./scenes/GameScene"; import { GameScene } from "./scenes/GameScene";
import { SplashScene } from "./scenes/SplashScene"; import { MenuScene } from "./scenes/MenuScene";
import { StartScene } from "./scenes/StartScene"; import { PreloadScene } from "./scenes/PreloadScene";
import { AssetViewerScene } from "./scenes/AssetViewerScene";
import { TrackExplorationScene } from "./scenes/TrackExplorationScene";
new Phaser.Game({ new Phaser.Game({
type: Phaser.AUTO, type: Phaser.AUTO,
@@ -15,5 +17,8 @@ new Phaser.Game({
backgroundColor: "#111", backgroundColor: "#111",
pixelArt: true, pixelArt: true,
roundPixels: true, roundPixels: true,
scene: [SplashScene, StartScene, GameScene, GameUI] dom: {
createContainer: true
},
scene: [PreloadScene, MenuScene, AssetViewerScene, TrackExplorationScene, GameScene, GameUI]
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,76 @@
import { FOV } from "rot-js";
import type ROT from "rot-js";
import { type World } from "../core/types";
import { idx, inBounds } from "../engine/world/world-logic";
import { blocksSight } from "../core/terrain";
import { GAME_CONFIG } from "../core/config/GameConfig";
import Phaser from "phaser";
export class FovManager {
private fov!: InstanceType<typeof ROT.FOV.PreciseShadowcasting>;
private seen!: Uint8Array;
private visible!: Uint8Array;
private visibleStrength!: Float32Array;
private worldWidth: number = 0;
private worldHeight: number = 0;
private currentOrigin: { x: number; y: number } = { x: 0, y: 0 };
initialize(world: World) {
this.worldWidth = world.width;
this.worldHeight = world.height;
this.seen = new Uint8Array(world.width * world.height);
this.visible = new Uint8Array(world.width * world.height);
this.visibleStrength = new Float32Array(world.width * world.height);
this.fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
// Best practice: Origin is always transparent to itself,
// otherwise vision is blocked if standing on an opaque tile (like a doorway).
if (x === this.currentOrigin.x && y === this.currentOrigin.y) return true;
if (!inBounds(world, x, y)) return false;
const idx = y * world.width + x;
return !blocksSight(world.tiles[idx]);
});
}
compute(world: World, origin: { x: number; y: number }) {
this.currentOrigin = origin;
this.visible.fill(0);
this.visibleStrength.fill(0);
const ox = origin.x;
const oy = origin.y;
this.fov.compute(ox, oy, GAME_CONFIG.player.viewRadius, (x: number, y: number, r: number, v: number) => {
if (!inBounds(world, x, y)) return;
const i = idx(world, x, y);
this.visible[i] = 1;
this.seen[i] = 1;
const radiusT = Phaser.Math.Clamp(r / GAME_CONFIG.player.viewRadius, 0, 1);
const falloff = 1 - radiusT * 0.6;
const strength = Phaser.Math.Clamp(v * falloff, 0, 1);
if (strength > this.visibleStrength[i]) this.visibleStrength[i] = strength;
});
}
isSeen(x: number, y: number): boolean {
if (x < 0 || x >= this.worldWidth || y < 0 || y >= this.worldHeight) return false;
return this.seen[y * this.worldWidth + x] === 1;
}
isVisible(x: number, y: number): boolean {
if (x < 0 || x >= this.worldWidth || y < 0 || y >= this.worldHeight) return false;
return this.visible[y * this.worldWidth + x] === 1;
}
get seenArray() {
return this.seen;
}
get visibleArray() {
return this.visible;
}
}

274
src/rendering/FxRenderer.ts Normal file
View File

@@ -0,0 +1,274 @@
import Phaser from "phaser";
import { type EntityId, type ActorType } from "../core/types";
import { TILE_SIZE } from "../core/constants";
import { GAME_CONFIG } from "../core/config/GameConfig";
export class FxRenderer {
private scene: Phaser.Scene;
private corpseSprites: { sprite: Phaser.GameObjects.Sprite; x: number; y: number }[] = [];
constructor(scene: Phaser.Scene) {
this.scene = scene;
}
showFloatingText(x: number, y: number, message: string, color: string) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY, message, {
fontSize: "14px",
color: color,
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 30,
alpha: 0,
duration: 1000,
ease: "Power1",
onComplete: () => text.destroy()
});
}
clearCorpses() {
for (const entry of this.corpseSprites) {
entry.sprite.destroy();
}
this.corpseSprites = [];
}
showDamage(x: number, y: number, amount: number, isCrit = false, isBlock = false) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
let textStr = amount.toString();
let color = "#ff3333";
let fontSize = "16px";
if (isCrit) {
textStr += "!";
color = "#ffff00";
fontSize = "22px";
}
const text = this.scene.add.text(screenX, screenY, textStr, {
fontSize,
color,
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
if (isBlock) {
const blockText = this.scene.add.text(screenX + 10, screenY - 10, "Blocked", {
fontSize: "10px",
color: "#888888",
fontStyle: "bold"
}).setOrigin(0, 1).setDepth(200);
this.scene.tweens.add({
targets: blockText,
y: screenY - 34,
alpha: 0,
duration: 800,
onComplete: () => blockText.destroy()
});
}
this.scene.tweens.add({
targets: text,
y: screenY - 24,
alpha: 0,
duration: isCrit ? 1200 : 800,
ease: isCrit ? "Bounce.out" : "Power1",
onComplete: () => text.destroy()
});
}
showDodge(x: number, y: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY, "Dodge", {
fontSize: "14px",
color: "#ffffff",
stroke: "#000",
strokeThickness: 2,
fontStyle: "italic"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
x: screenX + (Math.random() > 0.5 ? 20 : -20),
y: screenY - 20,
alpha: 0,
duration: 600,
onComplete: () => text.destroy()
});
}
showHeal(x: number, y: number, amount: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY, `+${amount}`, {
fontSize: "16px",
color: "#33ff33",
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 30,
alpha: 0,
duration: 1000,
onComplete: () => text.destroy()
});
}
spawnCorpse(x: number, y: number, type: ActorType) {
const textureKey = type === "player" ? "PriestessSouth" : type;
const corpse = this.scene.add.sprite(
x * TILE_SIZE + TILE_SIZE / 2,
y * TILE_SIZE + TILE_SIZE / 2,
textureKey,
0
);
corpse.setDepth(50);
corpse.setDisplaySize(TILE_SIZE, TILE_SIZE); // All corpses should be tile-sized
// Only play animation if it's not a priestess sprite
if (!textureKey.startsWith("Priestess")) {
corpse.play(`${textureKey}-die`);
} else {
// Maybe rotate or fade for visual interest since there's no animation
corpse.setAngle(90);
}
this.corpseSprites.push({ sprite: corpse, x, y });
}
updateVisibility(seen: Uint8Array, visible: Uint8Array, worldWidth: number) {
for (const entry of this.corpseSprites) {
const idx = entry.y * worldWidth + entry.x;
const isSeen = seen[idx] === 1;
const isVisible = visible[idx] === 1;
entry.sprite.setVisible(isSeen);
if (isSeen) {
if (isVisible) {
entry.sprite.setAlpha(1);
entry.sprite.clearTint();
} else {
entry.sprite.setAlpha(0.4);
entry.sprite.setTint(0x888888);
}
}
}
}
showWait(x: number, y: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY, "zZz", {
fontSize: "14px",
color: "#aaaaff",
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 20,
alpha: 0,
duration: 600,
ease: "Power1",
onComplete: () => text.destroy()
});
}
collectOrb(_actorId: EntityId, amount: number, x: number, y: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY, `+${amount} EXP`, {
fontSize: "14px",
color: "#" + GAME_CONFIG.rendering.expTextColor.toString(16),
stroke: "#000",
strokeThickness: 2,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(200);
this.scene.tweens.add({
targets: text,
y: screenY - 32,
alpha: 0,
duration: 1000,
ease: "Power1",
onComplete: () => text.destroy()
});
}
showLevelUp(x: number, y: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE;
const text = this.scene.add.text(screenX, screenY - 16, "+1 LVL", {
fontSize: "20px",
color: "#" + GAME_CONFIG.rendering.levelUpColor.toString(16),
stroke: "#000",
strokeThickness: 3,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(210);
this.scene.tweens.add({
targets: text,
y: screenY - 60,
alpha: 0,
duration: 1500,
ease: "Cubic.out",
onComplete: () => text.destroy()
});
}
showAlert(x: number, y: number) {
const screenX = x * TILE_SIZE + TILE_SIZE / 2;
const screenY = y * TILE_SIZE - 8;
const text = this.scene.add.text(screenX, screenY, "!", {
fontSize: "24px",
color: "#ffaa00",
stroke: "#000",
strokeThickness: 3,
fontStyle: "bold"
}).setOrigin(0.5, 1).setDepth(210);
// Exclamation mark stays visible for alert duration
this.scene.tweens.add({
targets: text,
y: screenY - 8,
duration: 200,
yoyo: true,
repeat: 3, // Bounce a few times
ease: "Sine.inOut"
});
this.scene.tweens.add({
targets: text,
alpha: 0,
delay: 900, // Start fading out near end of alert period
duration: 300,
onComplete: () => text.destroy()
});
}
}

View File

@@ -0,0 +1,170 @@
import Phaser from "phaser";
import type { Item } from "../core/types";
import { ALL_VARIANTS, type ItemVariantId } from "../core/config/ItemVariants";
/**
* Factory for creating item sprites with optional variant glow effects.
* Centralizes item rendering logic to ensure consistent glow styling across
* inventory, quick slots, and world drops.
*/
export class ItemSpriteFactory {
/**
* Creates an item sprite with optional glow effect for variants.
* Returns a container with the glow (if applicable) and main sprite.
*/
static createItemSprite(
scene: Phaser.Scene,
item: Item,
x: number,
y: number,
scale: number = 1
): Phaser.GameObjects.Container {
const container = scene.add.container(x, y);
// Create glow effect if item has a variant
if (item.variant) {
const glowColor = this.getGlowColor(item.variant as ItemVariantId);
if (glowColor !== null) {
const glow = this.createGlow(scene, item, scale, glowColor);
container.add(glow);
}
}
// Create main item sprite
// Standalone images don't use frame indices
const isStandalone = item.spriteIndex === undefined || item.spriteIndex === 0;
const sprite = isStandalone
? scene.add.sprite(0, 0, item.textureKey)
: scene.add.sprite(0, 0, item.textureKey, item.spriteIndex);
if (isStandalone) {
sprite.setDisplaySize(16 * scale, 16 * scale);
} else {
sprite.setScale(scale);
}
container.add(sprite);
// Add upgrade level badge if item has been upgraded
if (item.upgradeLevel && item.upgradeLevel > 0) {
const badge = this.createUpgradeBadge(scene, item.upgradeLevel, scale);
container.add(badge);
}
return container;
}
/**
* Creates just a sprite (no container) for simpler use cases like drag icons.
* Does not include glow - use createItemSprite for full effect.
*/
static createSimpleSprite(
scene: Phaser.Scene,
item: Item,
x: number,
y: number,
scale: number = 1
): Phaser.GameObjects.Sprite {
const isStandalone = item.spriteIndex === undefined || item.spriteIndex === 0;
const sprite = isStandalone
? scene.add.sprite(x, y, item.textureKey)
: scene.add.sprite(x, y, item.textureKey, item.spriteIndex);
if (isStandalone) {
sprite.setDisplaySize(16 * scale, 16 * scale);
} else {
sprite.setScale(scale);
}
return sprite;
}
/**
* Creates a soft glow effect behind the item using graphics.
* Uses a radial gradient-like effect with multiple circles.
*/
private static createGlow(
scene: Phaser.Scene,
_item: Item,
scale: number,
color: number
): Phaser.GameObjects.Graphics {
const glow = scene.add.graphics();
// Base size for the glow (16x16 sprite scaled)
const baseSize = 16 * scale;
const glowRadius = baseSize * 0.8;
// Extract RGB from hex color
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = color & 0xff;
// Draw multiple circles with decreasing alpha for soft glow effect
const layers = 5;
for (let i = layers; i >= 1; i--) {
const layerRadius = glowRadius * (i / layers) * 1.2;
const layerAlpha = 0.15 * (1 - (i - 1) / layers);
glow.fillStyle(Phaser.Display.Color.GetColor(r, g, b), layerAlpha);
glow.fillCircle(0, 0, layerRadius);
}
// Add pulsing animation to the glow
scene.tweens.add({
targets: glow,
alpha: { from: 0.7, to: 1.0 },
scaleX: { from: 0.9, to: 1.1 },
scaleY: { from: 0.9, to: 1.1 },
duration: 800,
yoyo: true,
repeat: -1,
ease: 'Sine.easeInOut'
});
return glow;
}
/**
* Gets the glow color for a variant.
*/
private static getGlowColor(variantId: ItemVariantId): number | null {
const variant = ALL_VARIANTS[variantId];
return variant?.glowColor ?? null;
}
/**
* Creates a badge displaying the upgrade level (e.g., "+1").
*/
private static createUpgradeBadge(
scene: Phaser.Scene,
level: number,
scale: number
): Phaser.GameObjects.Text {
// Position at top-right corner, slightly inset
const offset = 5 * scale;
// Level text with strong outline for readability without background
const text = scene.add.text(offset, -offset, `+${level}`, {
fontSize: `${9 * scale}px`,
color: "#ffd700",
fontStyle: "bold",
fontFamily: "monospace",
stroke: "#000000",
strokeThickness: 3
});
text.setOrigin(0.5);
return text;
}
/**
* Checks if an item has a variant with a glow.
*/
static hasGlow(item: Item): boolean {
return !!item.variant && !!ALL_VARIANTS[item.variant as ItemVariantId];
}
}

View File

@@ -0,0 +1,102 @@
import Phaser from "phaser";
import { type World } from "../core/types";
import { type EntityAccessor } from "../engine/EntityAccessor";
import { idx, isWall } from "../engine/world/world-logic";
import { GAME_CONFIG } from "../core/config/GameConfig";
export class MinimapRenderer {
private scene: Phaser.Scene;
private minimapGfx!: Phaser.GameObjects.Graphics;
private minimapContainer!: Phaser.GameObjects.Container;
private minimapBg!: Phaser.GameObjects.Rectangle;
private minimapVisible = false;
constructor(scene: Phaser.Scene) {
this.scene = scene;
this.initMinimap();
}
private initMinimap() {
this.minimapContainer = this.scene.add.container(0, 0);
this.minimapContainer.setScrollFactor(0);
this.minimapContainer.setDepth(1001);
this.minimapBg = this.scene.add
.rectangle(0, 0, GAME_CONFIG.ui.minimapPanelWidth, GAME_CONFIG.ui.minimapPanelHeight, 0x000000, 0.8)
.setStrokeStyle(1, 0xffffff, 0.9)
.setInteractive();
this.minimapGfx = this.scene.add.graphics();
this.minimapContainer.add(this.minimapBg);
this.minimapContainer.add(this.minimapGfx);
this.positionMinimap();
this.minimapContainer.setVisible(false);
}
positionMinimap() {
const cam = this.scene.cameras.main;
this.minimapContainer.setPosition(cam.width / 2, cam.height / 2);
}
toggle() {
this.minimapVisible = !this.minimapVisible;
this.minimapContainer.setVisible(this.minimapVisible);
}
isVisible(): boolean {
return this.minimapVisible;
}
render(world: World, seen: Uint8Array, visible: Uint8Array, accessor: EntityAccessor) {
this.minimapGfx.clear();
if (!world) return;
const padding = GAME_CONFIG.ui.minimapPadding;
const availableWidth = GAME_CONFIG.ui.minimapPanelWidth - padding * 2;
const availableHeight = GAME_CONFIG.ui.minimapPanelHeight - padding * 2;
const scaleX = availableWidth / world.width;
const scaleY = availableHeight / world.height;
const tileSize = Math.floor(Math.min(scaleX, scaleY));
const mapPixelWidth = world.width * tileSize;
const mapPixelHeight = world.height * tileSize;
const offsetX = -mapPixelWidth / 2;
const offsetY = -mapPixelHeight / 2;
for (let y = 0; y < world.height; y++) {
for (let x = 0; x < world.width; x++) {
const i = idx(world, x, y);
if (seen[i] !== 1) continue;
const wall = isWall(world, x, y);
const color = wall ? 0x666666 : 0x333333;
this.minimapGfx.fillStyle(color, 1);
this.minimapGfx.fillRect(offsetX + x * tileSize, offsetY + y * tileSize, tileSize, tileSize);
}
}
const ex = world.exit.x;
const ey = world.exit.y;
if (seen[idx(world, ex, ey)] === 1) {
this.minimapGfx.fillStyle(0xffd166, 1);
this.minimapGfx.fillRect(offsetX + ex * tileSize, offsetY + ey * tileSize, tileSize, tileSize);
}
const player = accessor.getPlayer();
if (player) {
this.minimapGfx.fillStyle(0x66ff66, 1);
this.minimapGfx.fillRect(offsetX + player.pos.x * tileSize, offsetY + player.pos.y * tileSize, tileSize, tileSize);
}
for (const a of accessor.getEnemies()) {
const i = idx(world, a.pos.x, a.pos.y);
if (visible[i] === 1) {
this.minimapGfx.fillStyle(0xff6666, 1);
this.minimapGfx.fillRect(offsetX + a.pos.x * tileSize, offsetY + a.pos.y * tileSize, tileSize, tileSize);
}
}
}
}

View File

@@ -1,8 +1,7 @@
import '../../__tests__/test-setup';
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import { DungeonRenderer } from '../DungeonRenderer';
import { type World } from '../../core/types';
// Mock Phaser // Mock Phaser - must be before imports that use it
vi.mock('phaser', () => { vi.mock('phaser', () => {
const mockSprite = { const mockSprite = {
setDepth: vi.fn().mockReturnThis(), setDepth: vi.fn().mockReturnThis(),
@@ -10,7 +9,13 @@ vi.mock('phaser', () => {
play: vi.fn().mockReturnThis(), play: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(), setPosition: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(), setVisible: vi.fn().mockReturnThis(),
setDisplaySize: vi.fn().mockReturnThis(),
destroy: vi.fn(), destroy: vi.fn(),
frame: { name: '0' },
setFrame: vi.fn(),
setAlpha: vi.fn(),
setAngle: vi.fn(),
clearTint: vi.fn(),
}; };
const mockGraphics = { const mockGraphics = {
@@ -27,6 +32,7 @@ vi.mock('phaser', () => {
setVisible: vi.fn().mockReturnThis(), setVisible: vi.fn().mockReturnThis(),
setScrollFactor: vi.fn().mockReturnThis(), setScrollFactor: vi.fn().mockReturnThis(),
setDepth: vi.fn().mockReturnThis(), setDepth: vi.fn().mockReturnThis(),
y: 0
}; };
const mockRectangle = { const mockRectangle = {
@@ -41,6 +47,13 @@ vi.mock('phaser', () => {
Graphics: vi.fn(() => mockGraphics), Graphics: vi.fn(() => mockGraphics),
Container: vi.fn(() => mockContainer), Container: vi.fn(() => mockContainer),
Rectangle: vi.fn(() => mockRectangle), Rectangle: vi.fn(() => mockRectangle),
Arc: vi.fn(() => ({
setStrokeStyle: vi.fn().mockReturnThis(),
setDepth: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
destroy: vi.fn(),
})),
}, },
Scene: vi.fn(), Scene: vi.fn(),
Math: { Math: {
@@ -50,10 +63,17 @@ vi.mock('phaser', () => {
}; };
}); });
import { DungeonRenderer } from '../DungeonRenderer';
import type { World, EntityId } from '../../core/types';
import { ECSWorld } from '../../engine/ecs/World';
import { EntityAccessor } from '../../engine/EntityAccessor';
describe('DungeonRenderer', () => { describe('DungeonRenderer', () => {
let mockScene: any; let mockScene: any;
let renderer: DungeonRenderer; let renderer: DungeonRenderer;
let mockWorld: World; let mockWorld: World;
let ecsWorld: ECSWorld;
let accessor: EntityAccessor;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
@@ -69,14 +89,30 @@ describe('DungeonRenderer', () => {
setDepth: vi.fn().mockReturnThis(), setDepth: vi.fn().mockReturnThis(),
setScale: vi.fn().mockReturnThis(), setScale: vi.fn().mockReturnThis(),
play: vi.fn().mockReturnThis(), play: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
setDisplaySize: vi.fn().mockReturnThis(),
destroy: vi.fn(), destroy: vi.fn(),
frame: { name: '0' },
setFrame: vi.fn(),
setAlpha: vi.fn(),
setAngle: vi.fn(),
clearTint: vi.fn(),
})), })),
circle: vi.fn().mockReturnValue({
setStrokeStyle: vi.fn().mockReturnThis(),
setDepth: vi.fn().mockReturnThis(),
setPosition: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
destroy: vi.fn(),
}),
container: vi.fn().mockReturnValue({ container: vi.fn().mockReturnValue({
add: vi.fn(), add: vi.fn(),
setPosition: vi.fn(), setPosition: vi.fn(),
setVisible: vi.fn(), setVisible: vi.fn(),
setScrollFactor: vi.fn(), setScrollFactor: vi.fn(),
setDepth: vi.fn(), setDepth: vi.fn(),
y: 0
}), }),
rectangle: vi.fn().mockReturnValue({ rectangle: vi.fn().mockReturnValue({
setStrokeStyle: vi.fn().mockReturnThis(), setStrokeStyle: vi.fn().mockReturnThis(),
@@ -87,6 +123,7 @@ describe('DungeonRenderer', () => {
main: { main: {
width: 800, width: 800,
height: 600, height: 600,
shake: vi.fn(),
}, },
}, },
anims: { anims: {
@@ -94,37 +131,171 @@ describe('DungeonRenderer', () => {
exists: vi.fn().mockReturnValue(true), exists: vi.fn().mockReturnValue(true),
generateFrameNumbers: vi.fn(), generateFrameNumbers: vi.fn(),
}, },
make: {
tilemap: vi.fn().mockReturnValue({
addTilesetImage: vi.fn().mockReturnValue({}),
createLayer: vi.fn().mockReturnValue({
setDepth: vi.fn(),
forEachTile: vi.fn(),
}),
createBlankLayer: vi.fn().mockReturnValue({
setDepth: vi.fn().mockReturnThis(),
forEachTile: vi.fn().mockReturnThis(),
putTileAt: vi.fn(),
setScale: vi.fn().mockReturnThis(),
setScrollFactor: vi.fn().mockReturnThis(),
setVisible: vi.fn().mockReturnThis(),
}),
destroy: vi.fn(),
}),
},
tweens: {
add: vi.fn(),
killTweensOf: vi.fn(),
},
time: {
now: 0
}
}; };
mockWorld = { mockWorld = {
width: 10, width: 10,
height: 10, height: 10,
tiles: new Array(100).fill(0), tiles: new Array(100).fill(0),
actors: new Map(),
exit: { x: 9, y: 9 }, exit: { x: 9, y: 9 },
trackPath: []
}; };
ecsWorld = new ECSWorld();
accessor = new EntityAccessor(mockWorld, 1 as EntityId, ecsWorld);
renderer = new DungeonRenderer(mockScene); renderer = new DungeonRenderer(mockScene);
}); });
it('should track and clear corpse sprites on level initialization', () => { it('should track and clear corpse sprites on floor initialization', () => {
renderer.initializeLevel(mockWorld); renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Spawn a couple of corpses // Spawn a couple of corpses
renderer.spawnCorpse(1, 1, 'rat'); renderer.spawnCorpse(1, 1, 'rat');
renderer.spawnCorpse(2, 2, 'bat'); renderer.spawnCorpse(2, 2, 'bat');
// Get the mock sprites that were returned by scene.add.sprite // Get the mock sprites that were returned by scene.add.sprite
// The player sprite is created first in initializeFloor if it doesn't exist
// Then the two corpses
const corpse1 = mockScene.add.sprite.mock.results[1].value; const corpse1 = mockScene.add.sprite.mock.results[1].value;
const corpse2 = mockScene.add.sprite.mock.results[2].value; const corpse2 = mockScene.add.sprite.mock.results[2].value;
expect(mockScene.add.sprite).toHaveBeenCalledTimes(3); expect(mockScene.add.sprite).toHaveBeenCalledTimes(3); // Player + 2 corpses
// Initialize level again (changing level) // Initialize floor again (changing level)
renderer.initializeLevel(mockWorld); renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Verify destroy was called on both corpse sprites
// Verify destroy was called on both corpse sprites (via fxRenderer.clearCorpses)
expect(corpse1.destroy).toHaveBeenCalledTimes(1); expect(corpse1.destroy).toHaveBeenCalledTimes(1);
expect(corpse2.destroy).toHaveBeenCalledTimes(1); expect(corpse2.destroy).toHaveBeenCalledTimes(1);
}); });
it('should render exp_orb correctly', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Add an exp_orb to the ECS world
ecsWorld.addComponent(2 as EntityId, "position", { x: 2, y: 1 });
ecsWorld.addComponent(2 as EntityId, "collectible", { type: "exp_orb", amount: 10 });
ecsWorld.addComponent(2 as EntityId, "actorType", { type: "exp_orb" as any });
// Make the tile visible for it to render
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 2] = 1;
// Reset mocks
mockScene.add.sprite.mockClear();
renderer.render([]);
// Should HAVE added a circle for the orb
expect(mockScene.add.circle).toHaveBeenCalled();
});
it('should render any enemy type as a sprite', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Add a rat
ecsWorld.addComponent(3 as EntityId, "position", { x: 3, y: 1 });
ecsWorld.addComponent(3 as EntityId, "actorType", { type: "rat" });
ecsWorld.addComponent(3 as EntityId, "stats", { hp: 10, maxHp: 10 } as any);
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
mockScene.add.sprite.mockClear();
renderer.render([]);
// Should have added a sprite for the rat
const ratSpriteCall = mockScene.add.sprite.mock.calls.find((call: any) => call[2] === 'rat');
expect(ratSpriteCall).toBeDefined();
});
it('should initialize new enemy sprites at target position and not tween them', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Position 5,5 -> 5*16 + 8 = 88
const TILE_SIZE = 16;
const targetX = 5 * TILE_SIZE + TILE_SIZE / 2;
const targetY = 5 * TILE_SIZE + TILE_SIZE / 2;
ecsWorld.addComponent(999 as EntityId, "position", { x: 5, y: 5 });
ecsWorld.addComponent(999 as EntityId, "actorType", { type: "rat" });
ecsWorld.addComponent(999 as EntityId, "stats", { hp: 10, maxHp: 10 } as any);
(renderer as any).fovManager.visibleArray[5 * mockWorld.width + 5] = 1;
mockScene.add.sprite.mockClear();
mockScene.tweens.add.mockClear();
renderer.render([]);
// Check spawn position
expect(mockScene.add.sprite).toHaveBeenCalledWith(targetX, targetY, 'rat', 0);
// Should NOT tween because it's the first spawn
expect(mockScene.tweens.add).not.toHaveBeenCalled();
});
it('should hide the original sprite when spawnCorpse is called with targetId', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Add a rat
const enemyId = 100 as EntityId;
ecsWorld.addComponent(enemyId, "position", { x: 3, y: 1 });
ecsWorld.addComponent(enemyId, "actorType", { type: "rat" });
ecsWorld.addComponent(enemyId, "stats", { hp: 10, maxHp: 10 } as any);
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
renderer.render([]);
// Verify sprite was created and is visible
const sprite = (renderer as any).enemySprites.get(enemyId);
expect(sprite).toBeDefined();
expect(sprite.setVisible).toHaveBeenCalledWith(true);
// Call spawnCorpse with targetId
renderer.spawnCorpse(3, 1, 'rat', enemyId);
// Verify original sprite was hidden
expect(sprite.setVisible).toHaveBeenCalledWith(false);
});
it('should hide the player sprite when spawnCorpse is called with playerId', () => {
renderer.initializeFloor(mockWorld, ecsWorld, accessor);
// Verify player sprite was created and is visible
const playerSprite = (renderer as any).playerSprite;
expect(playerSprite).toBeDefined();
playerSprite.setVisible(true); // Force visible for test
// Call spawnCorpse with playerId
renderer.spawnCorpse(1, 1, 'player', accessor.playerId);
// Verify player sprite was hidden
expect(playerSprite.setVisible).toHaveBeenCalledWith(false);
});
}); });

Some files were not shown because too many files have changed in this diff Show More