Compare commits
3 Commits
7888f375e1
...
0263495d0b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0263495d0b | ||
|
|
a2a1d0cc58 | ||
|
|
cb1dfea33b |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -22,3 +22,5 @@ dist-ssr
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
coverage
|
||||
45
bun.lock
45
bun.lock
@@ -9,6 +9,7 @@
|
||||
"rot-js": "^2.2.1",
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.0.16",
|
||||
@@ -16,6 +17,16 @@
|
||||
},
|
||||
},
|
||||
"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/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
|
||||
@@ -68,8 +79,12 @@
|
||||
|
||||
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.2", "", { "os": "win32", "cpu": "x64" }, "sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ=="],
|
||||
|
||||
"@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/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-arm64": ["@rollup/rollup-android-arm64@4.54.0", "", { "os": "android", "cpu": "arm64" }, "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw=="],
|
||||
@@ -122,6 +137,8 @@
|
||||
|
||||
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
|
||||
|
||||
"@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/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=="],
|
||||
@@ -138,8 +155,12 @@
|
||||
|
||||
"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=="],
|
||||
|
||||
"chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="],
|
||||
|
||||
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||
|
||||
"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=="],
|
||||
@@ -154,8 +175,28 @@
|
||||
|
||||
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
|
||||
|
||||
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
||||
|
||||
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"obug": ["obug@2.1.1", "", {}, "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ=="],
|
||||
@@ -174,6 +215,8 @@
|
||||
|
||||
"rot-js": ["rot-js@2.2.1", "", {}, "sha512-lItXH31vj4ebdypayCx9dh98qPr57E7jGW2lVMKxtBHooU3xpGRtLS8kdJIni232tvPJ8Sl0+aqXZj8c6W0MGw=="],
|
||||
|
||||
"semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
|
||||
|
||||
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
|
||||
|
||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||
@@ -182,6 +225,8 @@
|
||||
|
||||
"std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
|
||||
|
||||
"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=="],
|
||||
|
||||
"tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="],
|
||||
|
||||
@@ -8,10 +8,12 @@
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest",
|
||||
"test:coverage": "vitest run --coverage",
|
||||
"check": "tsc --noEmit",
|
||||
"verify": "bun run check && bun run test"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitest/coverage-v8": "^4.0.16",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "^7.2.4",
|
||||
"vitest": "^4.0.16"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { key } from '../utils';
|
||||
import { key, sleep } from '../utils';
|
||||
|
||||
describe('Utils', () => {
|
||||
describe('key', () => {
|
||||
@@ -9,4 +9,13 @@ describe('Utils', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,9 @@ export interface CombatantActor extends BaseActor {
|
||||
aiState?: EnemyAIState;
|
||||
alertedAt?: number;
|
||||
lastKnownPlayerPos?: Vec2;
|
||||
|
||||
// Turn scheduling
|
||||
energy: number;
|
||||
}
|
||||
|
||||
export interface CollectibleActor extends BaseActor {
|
||||
|
||||
@@ -90,4 +90,43 @@ describe('EntityManager', () => {
|
||||
expect(entityManager.getActorsAt(1, 1).map(a => a.id)).toEqual([2]);
|
||||
});
|
||||
|
||||
|
||||
it('should handle removing non-existent actor gracefully', () => {
|
||||
// Should not throw
|
||||
entityManager.removeActor(999);
|
||||
});
|
||||
|
||||
it('should handle moving non-existent actor gracefully', () => {
|
||||
// Should not throw
|
||||
entityManager.moveActor(999, { x: 0, y: 0 }, { x: 1, y: 1 });
|
||||
});
|
||||
|
||||
it('should handle moving an actor that is not in the grid at expected position (inconsistent state)', () => {
|
||||
const actor: Actor = { id: 1, pos: { x: 0, y: 0 } } as any;
|
||||
// Add to actors map but NOT to grid (simulating desync)
|
||||
mockWorld.actors.set(1, actor);
|
||||
|
||||
// Attempt move
|
||||
entityManager.moveActor(1, { x: 0, y: 0 }, { x: 1, y: 1 });
|
||||
|
||||
expect(actor.pos.x).toBe(1);
|
||||
expect(actor.pos.y).toBe(1);
|
||||
// Should be added to new position in grid
|
||||
expect(entityManager.getActorsAt(1, 1).map(a => a.id)).toContain(1);
|
||||
});
|
||||
|
||||
it('should handle moving an actor that is in grid but ID not found in list (very rare edge case)', () => {
|
||||
// Manually pollute grid with empty array for old pos
|
||||
// This forces `ids` to exist but `indexOf` to return -1
|
||||
const idx = 0; // 0,0
|
||||
// @ts-ignore
|
||||
entityManager.grid.set(idx, [999]); // occupied by someone else
|
||||
|
||||
const actor: Actor = { id: 1, pos: { x: 0, y:0 } } as any;
|
||||
mockWorld.actors.set(1, actor);
|
||||
|
||||
entityManager.moveActor(1, { x: 0, y: 0 }, { x: 1, y: 1 });
|
||||
expect(actor.pos).toEqual({ x: 1, y: 1 });
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
154
src/engine/__tests__/ai_behavior.test.ts
Normal file
154
src/engine/__tests__/ai_behavior.test.ts
Normal file
@@ -0,0 +1,154 @@
|
||||
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { decideEnemyAction, applyAction, stepUntilPlayerTurn } from '../simulation/simulation';
|
||||
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
||||
import { EntityManager } from '../EntityManager';
|
||||
import { TileType } from '../../core/terrain';
|
||||
|
||||
const createTestWorld = (actors: Map<EntityId, Actor>): World => {
|
||||
return {
|
||||
width: 10,
|
||||
height: 10,
|
||||
tiles: new Array(100).fill(TileType.EMPTY),
|
||||
actors,
|
||||
exit: { x: 9, y: 9 }
|
||||
};
|
||||
};
|
||||
|
||||
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 entityManager: EntityManager;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// 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, 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, category: "combatant", isPlayer: false, pos: { x: 9, y: 9 },
|
||||
speed: 80, stats: createTestStats(), aiState: "wandering", energy: 0
|
||||
} as any;
|
||||
|
||||
actors.set(1, player);
|
||||
actors.set(2, rat);
|
||||
const world = createTestWorld(actors);
|
||||
entityManager = new EntityManager(world);
|
||||
|
||||
let ratMoves = 0;
|
||||
|
||||
// Simulate 20 player turns
|
||||
// With fair scheduling, Rat (80 speed) should move approx 80% as often as Player (100 speed).
|
||||
// So in 20 turns, approx 16 moves. Definitley > 0.
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const result = stepUntilPlayerTurn(world, 1, entityManager);
|
||||
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)
|
||||
);
|
||||
|
||||
// console.log(`Turn ${i}: Events`, result.events);
|
||||
if (enemyActs.length > 0) ratMoves++;
|
||||
}
|
||||
// console.log(`Total Rat Moves: ${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, { id: 1, category: "combatant", isPlayer: true, pos: { x: 5, y: 0 }, stats: createTestStats(), energy: 0 } as any);
|
||||
actors.set(2, {
|
||||
id: 2, category: "combatant", isPlayer: false, pos: { x: 0, y: 0 },
|
||||
stats: createTestStats(), aiState: "wandering", energy: 0
|
||||
} as any);
|
||||
|
||||
const world = createTestWorld(actors);
|
||||
world.tiles[0] = type;
|
||||
|
||||
// Rat at 0,0. Player at 5,0.
|
||||
decideEnemyAction(world, actors.get(2) as any, actors.get(1) as any, new EntityManager(world));
|
||||
|
||||
expect((actors.get(2) as CombatantActor).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, category: "combatant", isPlayer: true, pos: { x: 0, y: 0 }, stats: createTestStats({ attack: 1, accuracy: 100 }), energy: 0 } as any;
|
||||
const enemy = {
|
||||
id: 2, 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, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
const em = new EntityManager(world);
|
||||
applyAction(world, 1, { type: "attack", targetId: 2 }, em);
|
||||
|
||||
const updatedEnemy = actors.get(2) as CombatantActor;
|
||||
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, category: "combatant", isPlayer: true, pos: { x: 9, y: 9 }, stats: createTestStats(), energy: 0 } as any;
|
||||
const enemy = {
|
||||
id: 2,
|
||||
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, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
// Player is far away and potentially blocked
|
||||
world.tiles[1] = TileType.WALL; // x=1, y=0 blocked
|
||||
|
||||
decideEnemyAction(world, enemy, player, new EntityManager(world));
|
||||
|
||||
// alerted -> pursuing (due to time) -> searching (due to no sight)
|
||||
expect(enemy.aiState).toBe("searching");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest';
|
||||
import { generateWorld } from '../world/generator';
|
||||
import { isWall, inBounds } from '../world/world-logic';
|
||||
import { type CombatantActor } from '../../core/types';
|
||||
import { TileType } from '../../core/terrain';
|
||||
import * as ROT from 'rot-js';
|
||||
|
||||
describe('World Generator', () => {
|
||||
describe('generateWorld', () => {
|
||||
@@ -223,4 +225,89 @@ describe('World Generator', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
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(60);
|
||||
expect(world.height).toBe(40);
|
||||
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 } = generateWorld(11, runState);
|
||||
const enemies = Array.from(world.actors.values()).filter(a => a.category === 'combatant' && !a.isPlayer);
|
||||
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 } = generateWorld(10 + i, runState);
|
||||
const player = world.actors.get(playerId)!;
|
||||
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 } = generateWorld(12, runState);
|
||||
const player = world.actors.get(playerId)!;
|
||||
|
||||
expect(world.tiles[player.pos.y * world.width + player.pos.x]).toBe(TileType.EMPTY);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { applyAction, decideEnemyAction, stepUntilPlayerTurn } from '../simulation/simulation';
|
||||
import { type World, type Actor, type EntityId, type CombatantActor } from '../../core/types';
|
||||
import { EntityManager } from '../EntityManager';
|
||||
|
||||
describe('Combat Simulation', () => {
|
||||
let entityManager: EntityManager;
|
||||
|
||||
const createTestWorld = (actors: Map<EntityId, Actor>): World => {
|
||||
return {
|
||||
width: 10,
|
||||
@@ -23,14 +20,19 @@ describe('Combat Simulation', () => {
|
||||
...overrides
|
||||
});
|
||||
|
||||
describe('Combat Simulation', () => {
|
||||
let entityManager: EntityManager;
|
||||
|
||||
|
||||
|
||||
describe('applyAction - success paths', () => {
|
||||
it('should deal damage when player attacks enemy', () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
actors.set(1, {
|
||||
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats()
|
||||
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0
|
||||
} as any);
|
||||
actors.set(2, {
|
||||
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 })
|
||||
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
|
||||
} as any);
|
||||
|
||||
const world = createTestWorld(actors);
|
||||
@@ -45,10 +47,10 @@ describe('Combat Simulation', () => {
|
||||
it("should kill enemy and spawn EXP orb without ID reuse collision", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
actors.set(1, {
|
||||
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats({ attack: 50 })
|
||||
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats({ attack: 50 }), energy: 0
|
||||
} as any);
|
||||
actors.set(2, {
|
||||
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 })
|
||||
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
|
||||
} as any);
|
||||
|
||||
const world = createTestWorld(actors);
|
||||
@@ -67,7 +69,7 @@ describe('Combat Simulation', () => {
|
||||
it("should destruction tile when walking on destructible-by-walk tile", () => {
|
||||
const actors = new Map<EntityId, Actor>();
|
||||
actors.set(1, {
|
||||
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats()
|
||||
id: 1, category: "combatant", isPlayer: true, type: "player", pos: { x: 3, y: 3 }, speed: 100, stats: createTestStats(), energy: 0
|
||||
} as any);
|
||||
|
||||
const world = createTestWorld(actors);
|
||||
@@ -90,8 +92,8 @@ describe('Combat Simulation', () => {
|
||||
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() } as any;
|
||||
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 3, y: 3 }, stats: createTestStats() } as any;
|
||||
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, player);
|
||||
actors.set(2, enemy);
|
||||
|
||||
@@ -136,7 +138,8 @@ describe('Combat Simulation', () => {
|
||||
isPlayer: false,
|
||||
pos: { x: 0, y: 0 },
|
||||
stats: createTestStats(),
|
||||
aiState: "wandering"
|
||||
aiState: "wandering",
|
||||
energy: 0
|
||||
} as any;
|
||||
actors.set(1, player);
|
||||
actors.set(2, enemy);
|
||||
@@ -222,8 +225,8 @@ describe('Combat Simulation', () => {
|
||||
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() } as any;
|
||||
const enemy = { id: 2, category: "combatant", isPlayer: false, pos: { x: 5, y: 5 }, speed: 100, stats: createTestStats() } as any;
|
||||
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, player);
|
||||
actors.set(2, enemy);
|
||||
@@ -237,4 +240,141 @@ describe('Combat Simulation', () => {
|
||||
expect(result.awaitingPlayerId).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Combat Mechanics - Detailed", () => {
|
||||
let mockRandom: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockRandom = vi.spyOn(Math, 'random');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
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, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
// Mock random to be 51 (scale 0-100 logic uses * 100) -> 0.51
|
||||
mockRandom.mockReturnValue(0.51);
|
||||
|
||||
const events = applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world));
|
||||
|
||||
expect(events.some(e => e.type === "dodged")).toBe(true);
|
||||
expect(enemy.stats.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, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(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, { type: "attack", targetId: 2 }, new EntityManager(world));
|
||||
|
||||
// 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, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(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, { type: "attack", targetId: 2 }, new EntityManager(world));
|
||||
|
||||
// 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, player);
|
||||
actors.set(2, enemy);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
// Standard hit
|
||||
mockRandom.mockReturnValue(0.1);
|
||||
|
||||
const events = applyAction(world, 1, { type: "attack", targetId: 2 }, new EntityManager(world));
|
||||
|
||||
// Damage 10. Heal 50% = 5. HP -> 15.
|
||||
expect(player.stats.hp).toBe(15);
|
||||
expect(events.some(e => e.type === "healed")).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
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, player);
|
||||
actors.set(2, orb);
|
||||
const world = createTestWorld(actors);
|
||||
|
||||
// Move player onto orb
|
||||
const events = applyAction(world, 1, { type: "move", dx: 1, dy: 0 }, new EntityManager(world));
|
||||
|
||||
expect(player.stats.level).toBe(2);
|
||||
expect(player.stats.exp).toBe(50); // 150 - 100 = 50
|
||||
expect(events.some(e => e.type === "leveled-up")).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -94,7 +94,8 @@ describe('World Utilities', () => {
|
||||
type: "player",
|
||||
pos: { x: 3, y: 3 },
|
||||
speed: 100,
|
||||
stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any
|
||||
stats: { hp: 10, maxHp: 10, attack: 1, defense: 0 } as any,
|
||||
energy: 100
|
||||
});
|
||||
|
||||
expect(isBlocked(world, 3, 3)).toBe(true);
|
||||
|
||||
@@ -6,8 +6,6 @@ import { findPathAStar } from "../world/pathfinding";
|
||||
import { GAME_CONFIG } from "../../core/config/GameConfig";
|
||||
import { type EntityManager } from "../EntityManager";
|
||||
import { FOV } from "rot-js";
|
||||
import * as ROT from "rot-js";
|
||||
|
||||
|
||||
export function applyAction(w: World, actorId: EntityId, action: Action, em?: EntityManager): SimEvent[] {
|
||||
const actor = w.actors.get(actorId);
|
||||
@@ -168,6 +166,16 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
||||
|
||||
target.stats.hp -= dmg;
|
||||
|
||||
// Aggression on damage: if target is enemy and attacker is player (or vice versa), alert them
|
||||
if (target.stats.hp > 0 && target.category === "combatant" && !target.isPlayer) {
|
||||
// Switch to pursuing immediately
|
||||
target.aiState = "pursuing";
|
||||
target.alertedAt = Date.now(); // Reset alert timer if any
|
||||
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));
|
||||
@@ -210,7 +218,6 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
||||
|
||||
// Spawn EXP Orb
|
||||
const enemyDef = (GAME_CONFIG.enemies as any)[target.type || ""];
|
||||
const expAmount = enemyDef?.expValue || 0;
|
||||
const orbId = em ? em.getNextId() : Math.max(0, ...w.actors.keys(), target.id) + 1;
|
||||
|
||||
const orb: CollectibleActor = {
|
||||
@@ -218,7 +225,7 @@ function handleAttack(w: World, actor: Actor, action: { targetId: EntityId }, em
|
||||
category: "collectible",
|
||||
type: "exp_orb",
|
||||
pos: { ...target.pos },
|
||||
expAmount
|
||||
expAmount: enemyDef?.expValue || 0
|
||||
};
|
||||
|
||||
if (em) em.addActor(orb);
|
||||
@@ -243,6 +250,7 @@ function canEnemySeePlayer(w: World, enemy: CombatantActor, player: CombatantAct
|
||||
|
||||
const fov = new FOV.PreciseShadowcasting((x: number, y: number) => {
|
||||
if (!inBounds(w, x, y)) return false;
|
||||
if (x === enemy.pos.x && y === enemy.pos.y) return true; // Can always see out of own tile
|
||||
const idx = y * w.width + x;
|
||||
return !blocksSight(w.tiles[idx]);
|
||||
});
|
||||
@@ -305,6 +313,15 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
||||
|
||||
// State transitions
|
||||
let justAlerted = false;
|
||||
|
||||
// Check if alerted state has expired
|
||||
if (enemy.aiState === "alerted") {
|
||||
const alertDuration = 1000;
|
||||
if (Date.now() - (enemy.alertedAt || 0) > alertDuration) {
|
||||
enemy.aiState = "pursuing";
|
||||
}
|
||||
}
|
||||
|
||||
if (canSee) {
|
||||
if (enemy.aiState === "wandering" || enemy.aiState === "searching") {
|
||||
// Spotted player (or re-spotted)! Transition to alerted state
|
||||
@@ -318,13 +335,7 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
||||
}
|
||||
} else {
|
||||
// Cannot see player
|
||||
if (enemy.aiState === "alerted") {
|
||||
// Check if alert period is over (1 second = 1000ms)
|
||||
const alertDuration = 1000;
|
||||
if (Date.now() - (enemy.alertedAt || 0) > alertDuration) {
|
||||
enemy.aiState = "pursuing";
|
||||
}
|
||||
} else if (enemy.aiState === "pursuing") {
|
||||
if (enemy.aiState === "pursuing") {
|
||||
// Lost sight while pursuing -> switch to searching
|
||||
enemy.aiState = "searching";
|
||||
} else if (enemy.aiState === "searching") {
|
||||
@@ -402,44 +413,81 @@ export function decideEnemyAction(w: World, enemy: CombatantActor, player: Comba
|
||||
* Returns enemy events accumulated along the way.
|
||||
*/
|
||||
export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityManager): { awaitingPlayerId: EntityId; events: SimEvent[] } {
|
||||
// Energy Threshold
|
||||
const THRESHOLD = 100;
|
||||
|
||||
// Ensure player exists
|
||||
const player = w.actors.get(playerId) as CombatantActor;
|
||||
if (!player || player.category !== "combatant") throw new Error("Player missing or invalid");
|
||||
|
||||
const events: SimEvent[] = [];
|
||||
|
||||
// Create scheduler and add all combatants
|
||||
const scheduler = new ROT.Scheduler.Speed();
|
||||
// If player already has enough energy (from previous accumulation), return immediately to let them act
|
||||
// NOTE: We do NOT deduct player energy here. The player's action will cost energy in the next turn processing or we expect the caller to have deducted it?
|
||||
// Actually, standard roguelike loop:
|
||||
// 1. Player acts. Deduct cost.
|
||||
// 2. Loop game until Player has energy >= Threshold.
|
||||
|
||||
for (const actor of w.actors.values()) {
|
||||
if (actor.category === "combatant") {
|
||||
// ROT.Scheduler.Speed expects actors to have a getSpeed() method
|
||||
// Add it dynamically if it doesn't exist
|
||||
const actorWithGetSpeed = actor as any;
|
||||
if (!actorWithGetSpeed.getSpeed) {
|
||||
actorWithGetSpeed.getSpeed = function() { return this.speed; };
|
||||
}
|
||||
scheduler.add(actorWithGetSpeed, true);
|
||||
}
|
||||
// Since this function is called AFTER user input (Player just acted), we assume Player needs to recover energy.
|
||||
// BUT, we should check if we need to deduct energy first?
|
||||
// The caller just applied an action. We should probably deduct energy for that action BEFORE entering the loop?
|
||||
// For now, let's assume the player is at < 100 energy and needs to wait.
|
||||
// Wait, if we don't deduct energy, the player stays at high energy?
|
||||
// The caller doesn't manage energy. WE manage energy.
|
||||
|
||||
// Implicitly, the player just spent 100 energy to trigger this call.
|
||||
// So we should deduct it from the player NOW.
|
||||
if (player.energy >= THRESHOLD) {
|
||||
player.energy -= THRESHOLD;
|
||||
}
|
||||
|
||||
while (true) {
|
||||
// Get next actor from scheduler
|
||||
const actor = scheduler.next() as CombatantActor | null;
|
||||
|
||||
if (!actor || !w.actors.has(actor.id)) {
|
||||
// Actor was removed (died), continue to next
|
||||
continue;
|
||||
// If player has enough energy to act, return control to user
|
||||
if (player.energy >= THRESHOLD) {
|
||||
return { awaitingPlayerId: playerId, events };
|
||||
}
|
||||
|
||||
if (actor.isPlayer) {
|
||||
// Player's turn - return control to the user
|
||||
return { awaitingPlayerId: actor.id, events };
|
||||
// Give energy to everyone
|
||||
for (const actor of w.actors.values()) {
|
||||
if (actor.category === "combatant") {
|
||||
actor.energy += actor.speed;
|
||||
}
|
||||
}
|
||||
|
||||
// Enemy turn - decide action and apply it
|
||||
// Process turns for everyone who has enough energy (except player, who breaks the loop)
|
||||
// We sort by energy to give priority to those who have waited longest/are fastest?
|
||||
// ROT.Scheduler uses a priority queue. Here we can iterate.
|
||||
// Iterating map values is insertion order.
|
||||
// Ideally we'd duplicate the list to sort it, but for performance let's simple iterate.
|
||||
|
||||
// We need to loop multiple times if someone has A LOT of energy (e.g. speed 200 vs speed 50)
|
||||
// But typically we step 1 tick.
|
||||
|
||||
// Simpler approach:
|
||||
// Process all actors with energy >= THRESHOLD.
|
||||
// If multiple have >= THRESHOLD, who goes first?
|
||||
// Usually the one with highest energy.
|
||||
|
||||
// Let's protect against infinite loops if someone has infinite speed.
|
||||
let actionsTaken = 0;
|
||||
while (true) {
|
||||
const eligibleActors = [...w.actors.values()].filter(
|
||||
a => a.category === "combatant" && a.energy >= THRESHOLD && !a.isPlayer
|
||||
) as CombatantActor[];
|
||||
|
||||
if (eligibleActors.length === 0) break;
|
||||
|
||||
// Sort by energy descending
|
||||
eligibleActors.sort((a, b) => b.energy - a.energy);
|
||||
|
||||
const actor = eligibleActors[0];
|
||||
|
||||
// Actor takes a turn
|
||||
actor.energy -= THRESHOLD;
|
||||
|
||||
// Decide logic
|
||||
const decision = decideEnemyAction(w, actor, player, em);
|
||||
|
||||
// Emit alert event if enemy just spotted player
|
||||
if (decision.justAlerted) {
|
||||
events.push({
|
||||
type: "enemy-alerted",
|
||||
@@ -451,10 +499,14 @@ export function stepUntilPlayerTurn(w: World, playerId: EntityId, em?: EntityMan
|
||||
|
||||
events.push(...applyAction(w, actor.id, decision.action, em));
|
||||
|
||||
// Check if player was killed by this action
|
||||
// Check if player died
|
||||
if (!w.actors.has(playerId)) {
|
||||
return { awaitingPlayerId: null as any, events };
|
||||
}
|
||||
|
||||
actionsTaken++;
|
||||
if (actionsTaken > 1000) break; // Emergency break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
||||
// Set ROT's RNG seed for consistent dungeon generation
|
||||
ROT.RNG.setSeed(floor * 12345);
|
||||
|
||||
const rooms = generateRooms(width, height, tiles, floor);
|
||||
const rooms = generateRooms(width, height, tiles, floor, random);
|
||||
|
||||
// Place player in first room
|
||||
const firstRoom = rooms[0];
|
||||
@@ -46,7 +46,8 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
||||
pos: { x: playerX, y: playerY },
|
||||
speed: GAME_CONFIG.player.speed,
|
||||
stats: { ...runState.stats },
|
||||
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] }
|
||||
inventory: { gold: runState.inventory.gold, items: [...runState.inventory.items] },
|
||||
energy: 0
|
||||
});
|
||||
|
||||
// Place exit in last room
|
||||
@@ -77,7 +78,8 @@ export function generateWorld(floor: number, runState: RunState): { world: World
|
||||
}
|
||||
|
||||
|
||||
function generateRooms(width: number, height: number, tiles: Tile[], floor: number): Room[] {
|
||||
// Update generateRooms signature to accept random
|
||||
function generateRooms(width: number, height: number, tiles: Tile[], floor: number, random: () => number): Room[] {
|
||||
const rooms: Room[] = [];
|
||||
|
||||
// Choose dungeon algorithm based on floor depth
|
||||
@@ -135,6 +137,9 @@ function generateRooms(width: number, height: number, tiles: Tile[], floor: numb
|
||||
} else {
|
||||
// Cellular caves don't have explicit rooms, so find connected floor areas
|
||||
rooms.push(...extractRoomsFromCave(width, height, tiles));
|
||||
|
||||
// Connect the isolated cave rooms
|
||||
connectRooms(width, tiles, rooms, random);
|
||||
}
|
||||
|
||||
// Ensure we have at least 2 rooms for player/exit placement
|
||||
@@ -144,11 +149,55 @@ function generateRooms(width: number, height: number, tiles: Tile[], floor: numb
|
||||
{ x: 5, y: 5, width: 5, height: 5 },
|
||||
{ x: width - 10, y: height - 10, width: 5, height: 5 }
|
||||
);
|
||||
// Connect the fallback rooms
|
||||
connectRooms(width, tiles, rooms, random);
|
||||
}
|
||||
|
||||
return rooms;
|
||||
}
|
||||
|
||||
function connectRooms(width: number, tiles: Tile[], rooms: Room[], random: () => number) {
|
||||
for (let i = 0; i < rooms.length - 1; i++) {
|
||||
const r1 = rooms[i];
|
||||
const r2 = rooms[i+1];
|
||||
|
||||
const c1x = r1.x + Math.floor(r1.width / 2);
|
||||
const c1y = r1.y + Math.floor(r1.height / 2);
|
||||
const c2x = r2.x + Math.floor(r2.width / 2);
|
||||
const c2y = r2.y + Math.floor(r2.height / 2);
|
||||
|
||||
if (random() < 0.5) {
|
||||
digH(width, tiles, c1x, c2x, c1y);
|
||||
digV(width, tiles, c1y, c2y, c2x);
|
||||
} else {
|
||||
digV(width, tiles, c1y, c2y, c1x);
|
||||
digH(width, tiles, c1x, c2x, c2y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function digH(width: number, tiles: Tile[], x1: number, x2: number, y: number) {
|
||||
const start = Math.min(x1, x2);
|
||||
const end = Math.max(x1, x2);
|
||||
for (let x = start; x <= end; x++) {
|
||||
const idx = y * width + x;
|
||||
if (tiles[idx] === TileType.WALL) {
|
||||
tiles[idx] = TileType.EMPTY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function digV(width: number, tiles: Tile[], y1: number, y2: number, x: number) {
|
||||
const start = Math.min(y1, y2);
|
||||
const end = Math.max(y1, y2);
|
||||
for (let y = start; y <= end; y++) {
|
||||
const idx = y * width + x;
|
||||
if (tiles[idx] === TileType.WALL) {
|
||||
tiles[idx] = TileType.EMPTY;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* For cellular/cave maps, find clusters of floor tiles to use as "rooms"
|
||||
*/
|
||||
@@ -343,7 +392,8 @@ function placeEnemies(floor: number, rooms: Room[], actors: Map<EntityId, Actor>
|
||||
blockChance: 0,
|
||||
luck: 0,
|
||||
passiveNodes: []
|
||||
}
|
||||
},
|
||||
energy: 0
|
||||
});
|
||||
|
||||
occupiedPositions.add(k);
|
||||
|
||||
@@ -196,7 +196,8 @@ describe('DungeonRenderer', () => {
|
||||
type: "rat",
|
||||
pos: { x: 3, y: 1 },
|
||||
speed: 10,
|
||||
stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any
|
||||
stats: { hp: 10, maxHp: 10, attack: 2, defense: 0 } as any,
|
||||
energy: 10
|
||||
});
|
||||
|
||||
(renderer as any).fovManager.visibleArray[1 * mockWorld.width + 3] = 1;
|
||||
|
||||
19
vitest.config.ts
Normal file
19
vitest.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: ['text', 'json', 'html'],
|
||||
exclude: [
|
||||
'node_modules/**',
|
||||
'dist/**',
|
||||
'**/*.d.ts',
|
||||
'**/*.config.ts',
|
||||
'**/*.test.ts',
|
||||
'**/*.spec.ts',
|
||||
'output/**'
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user