Compare commits
142
Commits
ef9557cba7
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9ed23295ae | ||
|
|
ca86bfc427 | ||
|
|
09c3fc443b | ||
|
|
a0b2003c65 | ||
|
|
03db1e8a06 | ||
|
|
20993d3b72 | ||
|
|
59391c74d9 | ||
|
|
fb3c98b1ef | ||
|
|
5c1ef232fd | ||
|
|
f5df1c26dc | ||
|
|
2014162819 | ||
|
|
d932c2f5e2 | ||
|
|
1bd49c32e0 | ||
|
|
b009189b9a | ||
|
|
59b99d2042 | ||
|
|
32408c3cee | ||
|
|
23ac09ff21 | ||
|
|
e8c995f74f | ||
|
|
bbab3cc43b | ||
|
|
2e2ddebec4 | ||
|
|
0d7a84edee | ||
|
|
bedde1ef4b | ||
|
|
359e7b7485 | ||
|
|
63e55a828e | ||
|
|
8ec95cbf81 | ||
|
|
f4c96a4a72 | ||
|
|
a5e2e4888e | ||
|
|
38fd46618e | ||
|
|
72647a8268 | ||
|
|
dbbbba14e2 | ||
|
|
888b7b822d | ||
|
|
bb4528cd27 | ||
|
|
877cc9d779 | ||
|
|
2eec668851 | ||
|
|
5235ba7def | ||
|
|
6cb0626f05 | ||
|
|
270b9b744e | ||
|
|
113d240f71 | ||
|
|
0547180074 | ||
|
|
d1605a9ee3 | ||
|
|
093738cd42 | ||
|
|
97ff61985a | ||
|
|
03d367c7b0 | ||
|
|
b509284bc6 | ||
|
|
9b20f7ad6f | ||
|
|
2823089f06 | ||
|
|
f834e15412 | ||
|
|
2ec0e323c4 | ||
|
|
140b7aed86 | ||
|
|
ca9912a5f5 | ||
|
|
34cb828f60 | ||
|
|
974c1a828c | ||
|
|
b83ff28f60 | ||
|
|
0c94e6923a | ||
|
|
d5ec099540 | ||
|
|
52b6cecd64 | ||
|
|
f336a989e2 | ||
|
|
423cc7c841 | ||
|
|
88d31430a6 | ||
|
|
90cb97e0ae | ||
|
|
5e172c61bb | ||
|
|
5019bc6324 | ||
|
|
9bed2ca13e | ||
|
|
08c6a67d16 | ||
|
|
dda8f4cfe9 | ||
|
|
2f2e4e56b5 | ||
|
|
3840c3d739 | ||
|
|
43bb246ab9 | ||
|
|
25b44fd6d1 | ||
|
|
601eb0f417 | ||
|
|
a82b6b0685 | ||
|
|
1e1d04777f | ||
|
|
8142fbfa60 | ||
|
|
89d96d838b | ||
|
|
6b50e9014a | ||
|
|
39ac37b5f2 | ||
|
|
b264cea305 | ||
|
|
6577897a42 | ||
|
|
4e9e426519 | ||
|
|
4f509da373 | ||
|
|
63733bc62d | ||
|
|
3aa92b2d2a | ||
|
|
fa92b5d865 | ||
|
|
e46822b45b | ||
|
|
03add589c6 | ||
|
|
4f5747d81d | ||
|
|
0222b66bed | ||
|
|
90ca8fa761 | ||
|
|
131af2c0bb | ||
|
|
02c159f8ae | ||
|
|
e09e24e274 | ||
|
|
a80852bc59 | ||
|
|
af0906561c | ||
|
|
aedf82d264 | ||
|
|
2f085cc0b6 | ||
|
|
fb66ec55c4 | ||
|
|
8155747cac | ||
|
|
7601a97ec9 | ||
|
|
f775d51a58 | ||
|
|
aa36f3ea67 | ||
|
|
0f04af2c6e | ||
|
|
c11bceeb44 | ||
|
|
1d749f59a6 | ||
|
|
65afe6dc8f | ||
|
|
3a135a4ad1 | ||
|
|
4deebf67c3 | ||
|
|
1c238aec3a | ||
|
|
a469b4024a | ||
|
|
7d8684a16f | ||
|
|
3dc566c2fd | ||
|
|
3f3490fad8 | ||
|
|
5e55b58c43 | ||
|
|
f8c008b67d | ||
|
|
c0fa0e91b2 | ||
|
|
e3014e47a8 | ||
|
|
f7b59a1790 | ||
|
|
b28ec823a7 | ||
|
|
b18e232cc8 | ||
|
|
51da1e5308 | ||
|
|
8c783da857 | ||
|
|
a010a0bca6 | ||
|
|
693c279e0f | ||
|
|
94c7c91745 | ||
|
|
976ee43ed3 | ||
|
|
6984e54bdf | ||
|
|
7b954bb5a5 | ||
|
|
7472095822 | ||
|
|
e5da41c1cb | ||
|
|
509e121275 | ||
|
|
e77e2d0737 | ||
|
|
09148f5b13 | ||
|
|
7e98e5e546 | ||
|
|
33095d5226 | ||
|
|
726856af35 | ||
|
|
630e35ad4e | ||
|
|
5860f2a247 | ||
|
|
8f34104332 | ||
|
|
760cfc9954 | ||
|
|
4fbd65e98c | ||
|
|
204198b10f | ||
|
|
6b724df7e7 | ||
|
|
e35871accd |
@@ -2,92 +2,92 @@
|
|||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
Prefer `bash.exe`(C:\Users\Administrator\AppData\Local\Microsoft\WindowsApps\bash.exe) over Powershell.
|
Shell environment: git bash (bash.exe) on Windows.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # Build ESM bundle + declarations to dist/ (via tsup)
|
npm run build # ESM bundle + declarations to dist/ (tsup)
|
||||||
npm run test # Run vitest in watch mode
|
npm run build:samples # Build samples separately (tsup.samples.config.ts)
|
||||||
npm run test:run # Run vitest once (no watch)
|
npm run test # vitest in watch mode
|
||||||
npm run typecheck # Type-check without emitting (tsc --noEmit)
|
npm run test:run # vitest once (no watch)
|
||||||
|
npm run typecheck # tsc --noEmit
|
||||||
```
|
```
|
||||||
|
|
||||||
### Running a single test file
|
Run a single test file: `npx vitest run tests/core/game.test.ts`
|
||||||
```bash
|
Run tests matching a name: `npx vitest run -t "should create"`
|
||||||
npx vitest run tests/samples/tic-tac-toe.test.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running a single test by name
|
`npm run prepare` runs `npm run build` on install — if `inline-schema` isn't available, installs will fail.
|
||||||
```bash
|
|
||||||
npx vitest run -t "should detect horizontal win for X"
|
## Local Dependency
|
||||||
```
|
|
||||||
|
`inline-schema` is a **local peer dependency** at `file:../inline-schema` — must exist as a sibling directory before `npm install`. Both vitest and tsup load custom plugins from it (`inline-schema/csv-loader/rollup` and `inline-schema/csv-loader/esbuild`). `yarn-spinner-loader` is also a local dependency at `file:../yarn-spinner-loader` — it can be changed and published if needed. It provides vitest and tsup/esbuild plugins for `.yarnproject` dialogue files.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **MutableSignal\<T\>**: extends Preact Signal — `.value` to read, `.produce(draft => ...)` to mutate (uses `mutative`). `.produceAsync()` awaits interruption promises first, then mutates.
|
||||||
|
- **GameHost**: lifecycle manager — `start(seed?)`, `tryInput(string)`, `tryAnswerPrompt(def, ...args)`, `dispose()`. Reactive signals: `state`, `status`, `activePromptSchema`, `activePromptPlayer`, `activePromptHint`.
|
||||||
|
- **GameModule**: `{ registry?, createInitialState, start }`. `registry` defaults to `createGameCommandRegistry()` if omitted.
|
||||||
|
- **Command system**: CLI-style parsing with schema validation via `inline-schema`. `CommandRunner.run()` uses `this` bound to a `CommandRunnerContext`.
|
||||||
|
- **Prompt system**: `game.prompt(def, validator, currentPlayer?)` — validator **throws a string** to reject, **returns a value** to accept. `PromptEvent.tryCommit(Command | string)` returns `null` on success, error string on failure. `promptEnd` event fires after resolve or cancel.
|
||||||
|
- **Part collections**: `Record<string, Part<TMeta>>` keyed by ID. `Object.values()` for iteration, `Object.keys()` for count/IDs.
|
||||||
|
- **Region system**: `createRegion()` factory; `partMap` maps position keys to part IDs.
|
||||||
|
- **Barrel exports**: `src/index.ts` is the single public API surface.
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
core/ # Core game primitives (game, part, region)
|
index.ts # Barrel export (single public API)
|
||||||
samples/ # Example games (tic-tac-toe, boop)
|
core/
|
||||||
utils/ # Shared utilities (mutable-signal, command, rng, async-queue)
|
game.ts # Game core logic
|
||||||
index.ts # Single public API barrel export
|
game-host.ts # GameHost lifecycle manager
|
||||||
tests/ # Mirrors src/ structure with *.test.ts files
|
part.ts # Part entity
|
||||||
|
part-factory.ts # Part factory
|
||||||
|
region.ts # Region/space management
|
||||||
|
utils/
|
||||||
|
mutable-signal.ts # MutableSignal<T> (extends Preact Signal)
|
||||||
|
rng.ts # Mulberry32 seeded PRNG
|
||||||
|
async-queue.ts # Async queue
|
||||||
|
middleware.ts # Middleware chain
|
||||||
|
command/ # CLI-style command system (7 files)
|
||||||
|
types.ts, command-parse.ts, command-registry.ts
|
||||||
|
command-runner.ts, command-validate.ts, command-apply.ts, schema-parse.ts, index.ts
|
||||||
|
samples/
|
||||||
|
tic-tac-toe.ts # Simple sample game
|
||||||
|
boop/ # Boop board game
|
||||||
|
onitama/ # Onitama card game
|
||||||
|
regicide/ # Regicide card game
|
||||||
|
slay-the-spire-like/ # Roguelike deckbuilder (see samples/slay-the-spire-like/AGENTS.md)
|
||||||
|
tests/ # Mirrors src/ with *.test.ts (20 files)
|
||||||
|
core/ # game-host, game, part, part-factory, region
|
||||||
|
samples/ # Each sample + slay-the-spire-like submodules
|
||||||
|
utils/ # mutable-signal, rng, async-queue, middleware, command
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`src/global.d.ts` — `*.yarnproject` module declaration.
|
||||||
|
|
||||||
|
`testsgames/` and `testsrules/` — empty directories (reserved).
|
||||||
|
|
||||||
|
## Samples Build
|
||||||
|
|
||||||
|
`tsup.samples.config.ts` auto-discovers entries from `src/samples/` (directories → `index.ts`, files → direct `.ts`). It rewrites `@/core/*`, `@/utils/*`, and `@/index` imports to external `boardgame-core`.
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
### Imports
|
- Double quotes for local imports, single quotes for npm packages
|
||||||
- Use **double quotes** for local imports, **single quotes** for npm packages
|
- `@/*` alias maps to `src/*` (configured in tsconfig, vitest, and tsup)
|
||||||
- Use `@/**/*` for `./src/**/*` import alias (configured in tsconfig.json)
|
|
||||||
- Use `@/index` when importing from the public API surface in samples
|
|
||||||
- Group imports: npm packages first, then local `@/` imports, separated by blank line
|
- Group imports: npm packages first, then local `@/` imports, separated by blank line
|
||||||
|
- 4-space indentation, semicolons, trailing commas in multi-line
|
||||||
### Formatting
|
- Arrow functions for callbacks; `function` keyword for methods needing `this` (e.g. command handlers)
|
||||||
- **4-space indentation** (no tabs)
|
- Strict TypeScript — no `any`; type aliases for object shapes (not interfaces)
|
||||||
- **Semicolons** at statement ends
|
- Discriminated unions for results: `{ success: true; result: T } | { success: false; error: string }`
|
||||||
- **Arrow functions** for callbacks; `function` keyword for methods needing `this` (e.g. command handlers)
|
|
||||||
- No trailing whitespace
|
|
||||||
- Trailing commas in multi-line objects/arrays
|
|
||||||
|
|
||||||
### Naming Conventions
|
|
||||||
- **Types/Interfaces**: `PascalCase` — `Part`, `Region`, `Command`, `IGameContext`, `GameHost`
|
|
||||||
- **Classes**: `PascalCase` — `MutableSignal`, `AsyncQueue`, `Mulberry32RNG`, `GameHost`
|
|
||||||
- **Functions**: `camelCase`, verb-first — `createGameContext`, `createGameHost`, `parseCommand`, `isValidMove`
|
|
||||||
- **Variables**: `camelCase`
|
|
||||||
- **Constants**: `UPPER_SNAKE_CASE` — `BOARD_SIZE`, `WINNING_LINES`
|
|
||||||
- **Test files**: `*.test.ts` mirroring `src/` structure under `tests/`
|
|
||||||
- **Factory functions**: prefix with `create` or `mutable` — `createGameContext`, `createGameHost`, `mutableSignal`
|
|
||||||
|
|
||||||
### Types
|
|
||||||
- **Strict TypeScript** is enabled — no `any`
|
|
||||||
- Use **generics** heavily: `MutableSignal<T>`, `CommandRunner<TContext, TResult>`
|
|
||||||
- Use **type aliases** for object shapes (not interfaces)
|
|
||||||
- Use **discriminated unions** for results: `{ success: true; result: T } | { success: false; error: string }`
|
|
||||||
- Use `unknown` for untyped values, narrow with type guards or `as`
|
|
||||||
- Derive state types via `ReturnType<typeof createInitialState>`
|
- Derive state types via `ReturnType<typeof createInitialState>`
|
||||||
|
- Factory function prefix: `create` or `mutable` — `createGameHost`, `mutableSignal`
|
||||||
|
- Validation error messages in Chinese (e.g. `"参数不足"`)
|
||||||
|
|
||||||
### Error Handling
|
## Testing
|
||||||
- Prefer **result objects** over throwing: return `{ success, result/error }`
|
|
||||||
- When catching: `const error = e as Error;` then use `error.message`
|
|
||||||
- Use `throw new Error(...)` only for truly exceptional cases
|
|
||||||
- Validation error messages are in Chinese (e.g. `"参数不足"`)
|
|
||||||
- Prompt validators return `null` for valid input, `string` error message for invalid
|
|
||||||
|
|
||||||
### Testing
|
- Vitest with globals enabled, but **test files import explicitly**: `import { describe, it, expect } from 'vitest'`
|
||||||
- **Vitest** with globals enabled (`describe`, `it`, `expect` available without import)
|
- Use `@/` imports in test files (vitest resolve alias)
|
||||||
- Use `async/await` for async tests
|
- Command/prompt test pattern: `waitForPrompt(ctx)` → `promptEvent.tryCommit(Command)` → returns `null` | error string
|
||||||
- Narrow result types with `if (result.success)` before accessing `result.result`
|
- No mocking — real implementations. Define inline helpers (`createTestContext()`, `waitForPrompt()`) per file.
|
||||||
- Define inline helper functions in test files when needed (e.g. `createTestContext()`)
|
- Narrow result types: `if (result.success)` before accessing `result.result`
|
||||||
- No mocking — use real implementations
|
|
||||||
- For command tests: use `waitForPrompt()` + `promptEvent.tryCommit()` pattern
|
|
||||||
- Test helpers: `createTestContext()`, `createTestRegion()`
|
|
||||||
|
|
||||||
## Architecture Notes
|
|
||||||
|
|
||||||
- **Reactivity**: `MutableSignal<T>` extends Preact Signal — access state via `.value`, mutate via `.produce(draft => ...)`
|
|
||||||
- **Command system**: CLI-style parsing with schema validation via `inline-schema`
|
|
||||||
- **Prompt system**: Commands prompt for input via `PromptEvent` with `resolve`/`reject`; `tryCommit` accepts `Command | string` and validates against schema before custom validator; `promptEnd` event fires when prompt completes (success or cancel)
|
|
||||||
- **GameHost**: Lifecycle manager for game sessions — provides `setup()`, `onInput()`, `dispose()`, reactive `state`/`status`/`activePromptSchema` signals
|
|
||||||
- **Barrel exports**: `src/index.ts` is the single public API surface
|
|
||||||
- **Game modules**: export `registry` and `createInitialState` for use with `createGameContextFromModule` or `createGameHost`
|
|
||||||
- **Region system**: plain `Region` type with `createRegion()` factory; `parts` are stored as `Record<string, Part>` keyed by ID, with `partMap` in regions mapping position keys to part IDs
|
|
||||||
- **Part collections**: Game state uses `Record<string, Part<TMeta>>` (not arrays) for O(1) lookup by ID. Use `Object.values(parts)` when iteration is needed, `Object.keys(parts)` for count/IDs
|
|
||||||
- **Mutative**: used for immutable state updates inside `MutableSignal.produce()`
|
|
||||||
Generated
+364
-3
@@ -10,11 +10,13 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@preact/signals-core": "^1.5.1",
|
"@preact/signals-core": "^1.5.1",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
"inline-schema": "file:../inline-schema",
|
"inline-schema": "file:../inline-schema",
|
||||||
"mutative": "^1.3.0",
|
"mutative": "^1.3.0",
|
||||||
"tsup": "^8.0.2",
|
"tsup": "^8.0.2",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vitest": "^1.3.1"
|
"vitest": "^1.3.1",
|
||||||
|
"yarn-spinner-loader": "^0.2.0"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@preact/signals-core": "^1.5.1",
|
"@preact/signals-core": "^1.5.1",
|
||||||
@@ -34,10 +36,20 @@
|
|||||||
"@types/node": "^25.5.0",
|
"@types/node": "^25.5.0",
|
||||||
"tsup": "^8.5.1",
|
"tsup": "^8.5.1",
|
||||||
"tsx": "^4.21.0",
|
"tsx": "^4.21.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@rspack/core": "^1.x"
|
"@rspack/core": "^1.x",
|
||||||
|
"esbuild": "*"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@rspack/core": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"esbuild": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
@@ -534,6 +546,44 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
|
"version": "2.1.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
|
"integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodelib/fs.stat": "2.0.5",
|
||||||
|
"run-parallel": "^1.1.9"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nodelib/fs.stat": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@nodelib/fs.walk": {
|
||||||
|
"version": "1.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz",
|
||||||
|
"integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodelib/fs.scandir": "2.1.5",
|
||||||
|
"fastq": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@preact/signals-core": {
|
"node_modules/@preact/signals-core": {
|
||||||
"version": "1.14.0",
|
"version": "1.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.0.tgz",
|
||||||
@@ -909,6 +959,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
|
||||||
|
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vitest/expect": {
|
"node_modules/@vitest/expect": {
|
||||||
"version": "1.6.1",
|
"version": "1.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz",
|
||||||
@@ -1023,6 +1083,23 @@
|
|||||||
"node": ">=0.4.0"
|
"node": ">=0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ajv": {
|
||||||
|
"version": "8.18.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
|
||||||
|
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"fast-uri": "^3.0.1",
|
||||||
|
"json-schema-traverse": "^1.0.0",
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ansi-styles": {
|
"node_modules/ansi-styles": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
|
||||||
@@ -1053,6 +1130,19 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/braces": {
|
||||||
|
"version": "3.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||||
|
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fill-range": "^7.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/bundle-require": {
|
"node_modules/bundle-require": {
|
||||||
"version": "5.1.0",
|
"version": "5.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/bundle-require/-/bundle-require-5.1.0.tgz",
|
||||||
@@ -1286,6 +1376,57 @@
|
|||||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-deep-equal": {
|
||||||
|
"version": "3.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/fast-glob": {
|
||||||
|
"version": "3.3.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
|
"integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@nodelib/fs.stat": "^2.0.2",
|
||||||
|
"@nodelib/fs.walk": "^1.2.3",
|
||||||
|
"glob-parent": "^5.1.2",
|
||||||
|
"merge2": "^1.3.0",
|
||||||
|
"micromatch": "^4.0.8"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/fast-uri": {
|
||||||
|
"version": "3.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz",
|
||||||
|
"integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/fastify"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/fastify"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/fastq": {
|
||||||
|
"version": "1.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
|
||||||
|
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"reusify": "^1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fdir": {
|
"node_modules/fdir": {
|
||||||
"version": "6.5.0",
|
"version": "6.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||||
@@ -1304,6 +1445,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/fill-range": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"to-regex-range": "^5.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fix-dts-default-cjs-exports": {
|
"node_modules/fix-dts-default-cjs-exports": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/fix-dts-default-cjs-exports/-/fix-dts-default-cjs-exports-1.0.1.tgz",
|
||||||
@@ -1354,6 +1508,19 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/glob-parent": {
|
||||||
|
"version": "5.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
|
||||||
|
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"is-glob": "^4.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/human-signals": {
|
"node_modules/human-signals": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/human-signals/-/human-signals-5.0.0.tgz",
|
||||||
@@ -1368,6 +1535,39 @@
|
|||||||
"resolved": "../inline-schema",
|
"resolved": "../inline-schema",
|
||||||
"link": true
|
"link": true
|
||||||
},
|
},
|
||||||
|
"node_modules/is-extglob": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-glob": {
|
||||||
|
"version": "4.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
|
||||||
|
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-extglob": "^2.1.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/is-number": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-stream": {
|
"node_modules/is-stream": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-stream/-/is-stream-3.0.0.tgz",
|
||||||
@@ -1405,6 +1605,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/json-schema-traverse": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lilconfig": {
|
"node_modules/lilconfig": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||||
@@ -1479,6 +1686,43 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/merge2": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"braces": "^3.0.3",
|
||||||
|
"picomatch": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/micromatch/node_modules/picomatch": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mimic-fn": {
|
"node_modules/mimic-fn": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-4.0.0.tgz",
|
||||||
@@ -1780,6 +2024,27 @@
|
|||||||
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
"node": "^14.15.0 || ^16.10.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/queue-microtask": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/react-is": {
|
"node_modules/react-is": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||||
@@ -1801,6 +2066,16 @@
|
|||||||
"url": "https://paulmillr.com/funding/"
|
"url": "https://paulmillr.com/funding/"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve-from": {
|
"node_modules/resolve-from": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
|
||||||
@@ -1811,6 +2086,17 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/reusify": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"iojs": ">=1.0.0",
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "4.60.1",
|
"version": "4.60.1",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
|
||||||
@@ -1856,6 +2142,30 @@
|
|||||||
"fsevents": "~2.3.2"
|
"fsevents": "~2.3.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/run-parallel": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "patreon",
|
||||||
|
"url": "https://www.patreon.com/feross"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "consulting",
|
||||||
|
"url": "https://feross.org/support"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"queue-microtask": "^1.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/shebang-command": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -2056,6 +2366,19 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/to-regex-range": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-number": "^7.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tree-kill": {
|
"node_modules/tree-kill": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tree-kill/-/tree-kill-1.2.2.tgz",
|
||||||
@@ -2157,6 +2480,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.19.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
|
||||||
|
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "5.4.21",
|
"version": "5.4.21",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
|
||||||
@@ -2783,6 +3113,37 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/yarn-spinner-loader": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/yarn-spinner-loader/-/yarn-spinner-loader-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-tWq+n7qziugn7pj+0Zf/XXWXqVxXZez1JkqQgk7JJD4yikHbY1OihYwqSvsUiNeav52j5VCrpU8vWlerwCawPw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.17.1",
|
||||||
|
"fast-glob": "^3.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"esbuild": ">=0.20.0",
|
||||||
|
"rollup": ">=4.0.0",
|
||||||
|
"vite": ">=5.0.0",
|
||||||
|
"webpack": ">=5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"esbuild": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"rollup": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"vite": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"webpack": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.2.2.tgz",
|
||||||
|
|||||||
+4
-2
@@ -18,7 +18,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"build:samples": "tsup --config tsup.samples.config.ts",
|
"build:samples": "tsup --config tsup.samples.config.ts",
|
||||||
"prepare": "npm run build",
|
"prepare": "npm run build && npm run build:samples",
|
||||||
"test": "vitest",
|
"test": "vitest",
|
||||||
"test:run": "vitest run",
|
"test:run": "vitest run",
|
||||||
"typecheck": "tsc --noEmit"
|
"typecheck": "tsc --noEmit"
|
||||||
@@ -30,11 +30,13 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@preact/signals-core": "^1.5.1",
|
"@preact/signals-core": "^1.5.1",
|
||||||
|
"@types/node": "^25.6.0",
|
||||||
"inline-schema": "file:../inline-schema",
|
"inline-schema": "file:../inline-schema",
|
||||||
"mutative": "^1.3.0",
|
"mutative": "^1.3.0",
|
||||||
"tsup": "^8.0.2",
|
"tsup": "^8.0.2",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "^5.3.3",
|
||||||
"vitest": "^1.3.1"
|
"vitest": "^1.3.1",
|
||||||
|
"yarn-spinner-loader": "^0.2.0"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"boardgame",
|
"boardgame",
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
import { createPromptContext, PromptDef } from "@/utils/command";
|
||||||
|
import { computed, effect, signal } from "@preact/signals-core";
|
||||||
|
import { createGameContext, IGameContext } from "./game";
|
||||||
|
|
||||||
|
export enum ClientStatus {
|
||||||
|
Idle = "idle",
|
||||||
|
Running = "running",
|
||||||
|
Disposed = "disposed",
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptInfo =
|
||||||
|
| {
|
||||||
|
hasPrompt: false;
|
||||||
|
player: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
hasPrompt: true;
|
||||||
|
player: string;
|
||||||
|
def: PromptDef<unknown[]>;
|
||||||
|
tryAnswer(...args: unknown[]): string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 1. create a client instance
|
||||||
|
* 2. setStatus("running")
|
||||||
|
* 3. select("selector", t => {}) for c# to capture game state
|
||||||
|
* 4. useClient("status" | "prompt") for c# to handle prompt / game status
|
||||||
|
*/
|
||||||
|
export abstract class BaseGameClient<
|
||||||
|
T extends Record<string, unknown> = Record<string, unknown>,
|
||||||
|
R = unknown,
|
||||||
|
> {
|
||||||
|
protected _initialState: T;
|
||||||
|
protected _context: IGameContext<T>;
|
||||||
|
protected _prompts = createPromptContext();
|
||||||
|
protected _status = signal(ClientStatus.Idle);
|
||||||
|
protected _result = signal(null as null | R);
|
||||||
|
public get state() {
|
||||||
|
return this._context.value;
|
||||||
|
}
|
||||||
|
constructor(is: T) {
|
||||||
|
this._initialState = is;
|
||||||
|
this._context = createGameContext(this._prompts, is);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStatus(status: ClientStatus) {
|
||||||
|
this._status.value = status;
|
||||||
|
if (status === ClientStatus.Disposed) {
|
||||||
|
this._prompts.reset();
|
||||||
|
} else if (status === ClientStatus.Running) {
|
||||||
|
this._prompts.reset();
|
||||||
|
this._context._state.value = this._initialState;
|
||||||
|
this._result.value = null;
|
||||||
|
this._result.value = await this.start(this._context);
|
||||||
|
this.setStatus(ClientStatus.Idle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract start(ctx: IGameContext<T>): Promise<R>;
|
||||||
|
|
||||||
|
assignSelector<S>(selector: (t: T) => S, callback: (s: S) => void) {
|
||||||
|
const selected = computed(() => selector(this._context.value));
|
||||||
|
return effect(() => {
|
||||||
|
callback(selected.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addInterruption() {
|
||||||
|
let resolve!: () => void;
|
||||||
|
const promise = new Promise<void>((resolver) => {
|
||||||
|
resolve = resolver;
|
||||||
|
});
|
||||||
|
this._context._state.addInterruption(promise);
|
||||||
|
return resolve;
|
||||||
|
}
|
||||||
|
|
||||||
|
selectPrompt(callback: (prompt: PromptInfo) => void) {
|
||||||
|
return this._prompts.handleCall.use(async (ctx, next) => {
|
||||||
|
function tryAnswer(...args: any[]) {
|
||||||
|
try {
|
||||||
|
const res = ctx.validator(...args);
|
||||||
|
ctx.resolve(res);
|
||||||
|
return null;
|
||||||
|
} catch (reason) {
|
||||||
|
if (typeof reason === "string") {
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
throw reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
callback({
|
||||||
|
hasPrompt: true,
|
||||||
|
player: ctx.player || "global",
|
||||||
|
def: ctx.def,
|
||||||
|
tryAnswer,
|
||||||
|
});
|
||||||
|
return await next();
|
||||||
|
} finally {
|
||||||
|
callback({
|
||||||
|
hasPrompt: false,
|
||||||
|
player: ctx.player || "global",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectStatus(callback: (status: ClientStatus) => void) {
|
||||||
|
return effect(() => {
|
||||||
|
callback(this._status.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
selectResult(callback: (status: R | null) => void) {
|
||||||
|
return effect(() => {
|
||||||
|
callback(this._result.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
+101
-157
@@ -1,180 +1,124 @@
|
|||||||
import { ReadonlySignal, Signal } from '@preact/signals-core';
|
import { createPromptContext } from "@/utils/command";
|
||||||
import {
|
import { PromptContext } from "@/utils/command/command-prompt";
|
||||||
CommandSchema,
|
import { ReadonlySignal, Signal } from "@preact/signals-core";
|
||||||
CommandRegistry,
|
import { createGameContext, IGameContext } from "./game";
|
||||||
PromptEvent,
|
|
||||||
} from '@/utils/command';
|
|
||||||
import {createGameCommandRegistry, createGameContext, IGameContext, PromptDef} from './game';
|
|
||||||
|
|
||||||
export type GameHostStatus = 'created' | 'running' | 'disposed';
|
export type GameHostStatus = "created" | "running" | "disposed";
|
||||||
|
|
||||||
export class GameHost<TState extends Record<string, unknown>, TResult=unknown> {
|
export class GameHost<
|
||||||
readonly state: ReadonlySignal<TState>;
|
TState extends Record<string, unknown>,
|
||||||
readonly status: ReadonlySignal<GameHostStatus>;
|
TResult = unknown,
|
||||||
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
|
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
|
||||||
readonly activePromptPlayer: ReadonlySignal<string | null>;
|
> {
|
||||||
readonly activePromptHint: ReadonlySignal<string | null>;
|
readonly state: ReadonlySignal<TState>;
|
||||||
|
readonly status: ReadonlySignal<GameHostStatus>;
|
||||||
|
readonly prompts: PromptContext;
|
||||||
|
|
||||||
private _context: IGameContext<TState>;
|
private _context: IGameContext<TState>;
|
||||||
private _start: (ctx: IGameContext<TState>) => Promise<TResult>;
|
private _start: (ctx: IGameContext<TState>) => Promise<TResult>;
|
||||||
private _status: Signal<GameHostStatus>;
|
private _status: Signal<GameHostStatus>;
|
||||||
private _activePromptSchema: Signal<CommandSchema | null>;
|
private _createInitialState: () => TState;
|
||||||
private _activePromptPlayer: Signal<string | null>;
|
private _eventListeners: Map<"start" | "dispose", Set<() => void>>;
|
||||||
private _activePromptHint: Signal<string | null>;
|
private _isDisposed = false;
|
||||||
private _createInitialState: () => TState;
|
|
||||||
private _eventListeners: Map<'start' | 'dispose', Set<() => void>>;
|
|
||||||
private _isDisposed = false;
|
|
||||||
|
|
||||||
constructor(
|
constructor(public readonly gameModule: TModule) {
|
||||||
registry: CommandRegistry<IGameContext<TState>>,
|
const { createInitialState, start } = gameModule;
|
||||||
createInitialState: () => TState,
|
this._createInitialState = createInitialState;
|
||||||
start: (ctx: IGameContext<TState>) => Promise<TResult>
|
this._eventListeners = new Map();
|
||||||
) {
|
|
||||||
this._createInitialState = createInitialState;
|
|
||||||
this._eventListeners = new Map();
|
|
||||||
|
|
||||||
const initialState = createInitialState();
|
this.prompts = createPromptContext();
|
||||||
this._context = createGameContext(registry, initialState);
|
const initialState = createInitialState();
|
||||||
this._start = start;
|
this._context = createGameContext(this.prompts, initialState);
|
||||||
this.state = this._context._state;
|
this._start = start;
|
||||||
|
this.state = this._context._state;
|
||||||
|
|
||||||
this._status = new Signal<GameHostStatus>('created');
|
this._status = new Signal<GameHostStatus>("created");
|
||||||
this.status = this._status;
|
this.status = this._status;
|
||||||
|
}
|
||||||
|
|
||||||
this._activePromptSchema = new Signal<CommandSchema | null>(null);
|
/**
|
||||||
this.activePromptSchema = this._activePromptSchema;
|
* 为下一个 produceAsync 注册中断 Promise(通常用于 UI 动画)。
|
||||||
|
* @see MutableSignal.addInterruption
|
||||||
|
*/
|
||||||
|
addInterruption(promise: Promise<void>): void {
|
||||||
|
this._context._state.addInterruption(promise);
|
||||||
|
}
|
||||||
|
|
||||||
this._activePromptPlayer = new Signal<string | null>(null);
|
/**
|
||||||
this.activePromptPlayer = this._activePromptPlayer;
|
* 清除所有未完成的中断。
|
||||||
|
* @see MutableSignal.clearInterruptions
|
||||||
|
*/
|
||||||
|
clearInterruptions(): void {
|
||||||
|
this._context._state.clearInterruptions();
|
||||||
|
}
|
||||||
|
|
||||||
this._activePromptHint = new Signal<string | null>(null);
|
start(seed?: number): Promise<TResult> {
|
||||||
this.activePromptHint = this._activePromptHint;
|
if (this._isDisposed) {
|
||||||
|
throw new Error("GameHost is disposed");
|
||||||
this._setupPromptTracking();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private _setupPromptTracking() {
|
this.prompts.reset();
|
||||||
let currentPromptEvent: PromptEvent | null = null;
|
|
||||||
|
|
||||||
this._context._commands.on('prompt', (e) => {
|
const initialState = this._createInitialState();
|
||||||
currentPromptEvent = e as PromptEvent;
|
this._context._state.value = initialState as any;
|
||||||
this._activePromptSchema.value = currentPromptEvent.schema;
|
|
||||||
this._activePromptPlayer.value = currentPromptEvent.currentPlayer;
|
|
||||||
this._activePromptHint.value = currentPromptEvent.hintText || null;
|
|
||||||
});
|
|
||||||
|
|
||||||
this._context._commands.on('promptEnd', () => {
|
seed = seed || Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
||||||
currentPromptEvent = null;
|
this._context._rng.setSeed(seed);
|
||||||
this._activePromptSchema.value = null;
|
const promise = this._start(this._context);
|
||||||
this._activePromptPlayer.value = null;
|
|
||||||
this._activePromptHint.value = null;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initial state
|
this._status.value = "running";
|
||||||
this._activePromptSchema.value = null;
|
this._emitEvent("start");
|
||||||
this._activePromptPlayer.value = null;
|
|
||||||
this._activePromptHint.value = null;
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
dispose(): void {
|
||||||
|
if (this._isDisposed) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tryInput(input: string): string | null {
|
this._isDisposed = true;
|
||||||
if (this._isDisposed) {
|
this.prompts.reset();
|
||||||
return 'GameHost is disposed';
|
this._status.value = "disposed";
|
||||||
}
|
|
||||||
return this._context._commands._tryCommit(input);
|
// Emit dispose event BEFORE clearing listeners
|
||||||
|
this._emitEvent("dispose");
|
||||||
|
this._eventListeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
on(event: "start" | "dispose", listener: () => void): () => void {
|
||||||
|
if (!this._eventListeners.has(event)) {
|
||||||
|
this._eventListeners.set(event, new Set());
|
||||||
}
|
}
|
||||||
|
this._eventListeners.get(event)!.add(listener);
|
||||||
|
|
||||||
tryAnswerPrompt<TArgs extends any[]>(def: PromptDef<TArgs>, ...args: TArgs){
|
return () => {
|
||||||
return this._context._commands._tryCommit({
|
this._eventListeners.get(event)?.delete(listener);
|
||||||
name: def.schema.name,
|
};
|
||||||
params: args,
|
}
|
||||||
options: {},
|
|
||||||
flags: {}
|
private _emitEvent(event: "start" | "dispose") {
|
||||||
});
|
const listeners = this._eventListeners.get(event);
|
||||||
}
|
if (listeners) {
|
||||||
|
for (const listener of listeners) {
|
||||||
/**
|
listener();
|
||||||
* 为下一个 produceAsync 注册中断 Promise(通常用于 UI 动画)。
|
}
|
||||||
* @see MutableSignal.addInterruption
|
|
||||||
*/
|
|
||||||
addInterruption(promise: Promise<void>): void {
|
|
||||||
this._context._state.addInterruption(promise);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 清除所有未完成的中断。
|
|
||||||
* @see MutableSignal.clearInterruptions
|
|
||||||
*/
|
|
||||||
clearInterruptions(): void {
|
|
||||||
this._context._state.clearInterruptions();
|
|
||||||
}
|
|
||||||
|
|
||||||
start(seed?: number): Promise<TResult> {
|
|
||||||
if (this._isDisposed) {
|
|
||||||
throw new Error('GameHost is disposed');
|
|
||||||
}
|
|
||||||
|
|
||||||
this._context._commands._cancel();
|
|
||||||
|
|
||||||
const initialState = this._createInitialState();
|
|
||||||
this._context._state.value = initialState as any;
|
|
||||||
|
|
||||||
seed = seed || Math.floor(Math.random() * Number.MAX_SAFE_INTEGER);
|
|
||||||
this._context._rng.setSeed(seed);
|
|
||||||
const promise = this._start(this._context);
|
|
||||||
|
|
||||||
this._status.value = 'running';
|
|
||||||
this._emitEvent('start');
|
|
||||||
|
|
||||||
return promise;
|
|
||||||
}
|
|
||||||
|
|
||||||
dispose(): void {
|
|
||||||
if (this._isDisposed) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._isDisposed = true;
|
|
||||||
this._context._commands._cancel();
|
|
||||||
this._status.value = 'disposed';
|
|
||||||
|
|
||||||
// Emit dispose event BEFORE clearing listeners
|
|
||||||
this._emitEvent('dispose');
|
|
||||||
this._eventListeners.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
on(event: 'start' | 'dispose', listener: () => void): () => void {
|
|
||||||
if (!this._eventListeners.has(event)) {
|
|
||||||
this._eventListeners.set(event, new Set());
|
|
||||||
}
|
|
||||||
this._eventListeners.get(event)!.add(listener);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
this._eventListeners.get(event)?.delete(listener);
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private _emitEvent(event: 'start' | 'dispose') {
|
|
||||||
const listeners = this._eventListeners.get(event);
|
|
||||||
if (listeners) {
|
|
||||||
for (const listener of listeners) {
|
|
||||||
listener();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GameModule<TState extends Record<string, unknown>, TResult=unknown> = {
|
export type GameModule<
|
||||||
registry?: CommandRegistry<IGameContext<TState>>;
|
TState extends Record<string, unknown> = Record<string, unknown>,
|
||||||
createInitialState: () => TState;
|
TResult = unknown,
|
||||||
start: (ctx: IGameContext<TState>) => Promise<TResult>;
|
> = {
|
||||||
}
|
createInitialState: () => TState;
|
||||||
|
start: (ctx: IGameContext<TState>) => Promise<TResult>;
|
||||||
|
};
|
||||||
|
|
||||||
export function createGameHost<TState extends Record<string, unknown>>(
|
export function createGameHost<
|
||||||
gameModule: GameModule<TState>
|
TState extends Record<string, unknown> = Record<string, unknown>,
|
||||||
): GameHost<TState> {
|
TResult = unknown,
|
||||||
return new GameHost(
|
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
|
||||||
gameModule.registry || createGameCommandRegistry(),
|
>(gameModule: TModule): GameHost<TState, TResult, TModule> {
|
||||||
gameModule.createInitialState,
|
return new GameHost(gameModule);
|
||||||
gameModule.start
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+53
-69
@@ -1,80 +1,64 @@
|
|||||||
import {MutableSignal, mutableSignal} from "@/utils/mutable-signal";
|
import { MutableSignal, mutableSignal } from "@/utils/mutable-signal";
|
||||||
import {
|
import { CommandSchema, parseCommandSchema, PromptDef } from "@/utils/command";
|
||||||
Command,
|
import { PromptValidator } from "@/utils/command/command-runner";
|
||||||
CommandRegistry, CommandResult,
|
import { Mulberry32RNG, ReadonlyRNG, RNG } from "@/utils/rng";
|
||||||
CommandRunnerContextExport,
|
import { PromptContext } from "@/utils/command/command-prompt";
|
||||||
CommandSchema,
|
|
||||||
createCommandRegistry,
|
|
||||||
createCommandRunnerContext, parseCommandSchema,
|
|
||||||
} from "@/utils/command";
|
|
||||||
import {PromptValidator} from "@/utils/command/command-runner";
|
|
||||||
import {Mulberry32RNG, ReadonlyRNG, RNG} from "@/utils/rng";
|
|
||||||
|
|
||||||
export interface IGameContext<TState extends Record<string, unknown> = {} > {
|
export interface IGameContext<TState extends Record<string, unknown> = {}> {
|
||||||
get value(): TState;
|
get value(): TState;
|
||||||
get rng(): ReadonlyRNG;
|
get rng(): ReadonlyRNG;
|
||||||
produce(fn: (draft: TState) => void): void;
|
produce(fn: (draft: TState) => undefined): void;
|
||||||
produceAsync(fn: (draft: TState) => void): Promise<void>;
|
produceAsync(fn: (draft: TState) => undefined): Promise<void>;
|
||||||
run<T>(input: string): Promise<CommandResult<T>>;
|
prompt: <TResult, TArgs extends any[] = any[]>(
|
||||||
runParsed<T>(command: Command): Promise<CommandResult<T>>;
|
def: PromptDef<TArgs>,
|
||||||
prompt: <TResult,TArgs extends any[]=any[]>(def: PromptDef<TArgs>, validator: PromptValidator<TResult,TArgs>, currentPlayer?: string | null) => Promise<TResult>;
|
validator: PromptValidator<TResult, TArgs>,
|
||||||
|
player?: string,
|
||||||
|
) => Promise<TResult>;
|
||||||
|
|
||||||
// test only
|
// test only
|
||||||
_state: MutableSignal<TState>;
|
_state: MutableSignal<TState>;
|
||||||
_commands: CommandRunnerContextExport<IGameContext<TState>>;
|
_rng: RNG;
|
||||||
_rng: RNG;
|
|
||||||
}
|
}
|
||||||
|
export type IGameContextExport<TState extends Record<string, unknown> = {}> =
|
||||||
|
Omit<IGameContext<TState>, "_state" | "_commands" | "_rng">;
|
||||||
|
|
||||||
export function createGameContext<TState extends Record<string, unknown> = {} >(
|
export function createGameContext<TState extends Record<string, unknown> = {}>(
|
||||||
commandRegistry: CommandRegistry<IGameContext<TState>>,
|
promptContext: PromptContext,
|
||||||
initialState?: TState | (() => TState)
|
initialState?: TState | (() => TState),
|
||||||
): IGameContext<TState> {
|
): IGameContext<TState> {
|
||||||
const stateValue = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
|
const stateValue =
|
||||||
const state = mutableSignal(stateValue);
|
typeof initialState === "function"
|
||||||
let commands: CommandRunnerContextExport<IGameContext<TState>> = null as any;
|
? initialState()
|
||||||
|
: (initialState ?? ({} as TState));
|
||||||
|
const state = mutableSignal(stateValue);
|
||||||
|
const { prompt } = promptContext;
|
||||||
|
|
||||||
const context: IGameContext<TState> = {
|
const context: IGameContext<TState> = {
|
||||||
get value(): TState {
|
get value(): TState {
|
||||||
return state.value;
|
return state.value;
|
||||||
},
|
},
|
||||||
get rng() {
|
get rng() {
|
||||||
return this._rng;
|
return this._rng;
|
||||||
},
|
},
|
||||||
produce(fn) {
|
produce(fn: (draft: TState) => undefined) {
|
||||||
return state.produce(fn);
|
return state.produce(fn);
|
||||||
},
|
},
|
||||||
produceAsync(fn) {
|
produceAsync(fn: (draft: TState) => undefined) {
|
||||||
return state.produceAsync(fn);
|
return state.produceAsync(fn);
|
||||||
},
|
},
|
||||||
run<T>(input: string) {
|
prompt,
|
||||||
return commands.run<T>(input);
|
|
||||||
},
|
|
||||||
runParsed<T>(command: Command) {
|
|
||||||
return commands.runParsed<T>(command);
|
|
||||||
},
|
|
||||||
prompt(def, validator, currentPlayer) {
|
|
||||||
return commands.prompt(def.schema, validator, def.hintText, currentPlayer);
|
|
||||||
},
|
|
||||||
|
|
||||||
_state: state,
|
_state: state,
|
||||||
_commands: commands,
|
_rng: new Mulberry32RNG(),
|
||||||
_rng: new Mulberry32RNG(),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
context._commands = commands = createCommandRunnerContext(commandRegistry, context);
|
return context;
|
||||||
|
|
||||||
return context;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PromptDef<TArgs extends any[]=any[]> = {
|
export function createPromptDef<TArgs extends any[] = any[]>(
|
||||||
schema: CommandSchema,
|
schema: CommandSchema | string,
|
||||||
hintText?: string,
|
hintText?: string,
|
||||||
}
|
): PromptDef<TArgs> {
|
||||||
export function createPromptDef<TArgs extends any[]=any[]>(schema: CommandSchema | string, hintText?: string): PromptDef<TArgs> {
|
schema = typeof schema === "string" ? parseCommandSchema(schema) : schema;
|
||||||
schema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
|
return { schema, hintText };
|
||||||
return { schema, hintText };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
|
|
||||||
return createCommandRegistry<IGameContext<TState>>();
|
|
||||||
}
|
}
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
import {Part} from "./part";
|
import {Part} from "./part";
|
||||||
import {RNG} from "@/utils/rng";
|
import {ReadonlyRNG} from "@/utils/rng";
|
||||||
|
|
||||||
export type Region = {
|
export type Region = {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -107,7 +107,7 @@ export function applyAlign<TMeta>(region: Region, parts: Record<string, Part<TMe
|
|||||||
region.partMap = buildPartMap(region, parts);
|
region.partMap = buildPartMap(region, parts);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>>, rng: RNG){
|
export function shuffle<TMeta>(region: Region, parts: Record<string, Part<TMeta>>, rng: ReadonlyRNG){
|
||||||
if (region.childIds.length <= 1) return;
|
if (region.childIds.length <= 1) return;
|
||||||
|
|
||||||
const childIds = [...region.childIds];
|
const childIds = [...region.childIds];
|
||||||
|
|||||||
+62
-18
@@ -4,31 +4,75 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Core types
|
// Core types
|
||||||
export type { IGameContext } from './core/game';
|
export type { IGameContext } from "./core/game";
|
||||||
export { createGameCommandRegistry, createPromptDef } from './core/game';
|
export { createPromptDef } from "./core/game";
|
||||||
|
|
||||||
export type { GameHost, GameHostStatus, GameModule } from './core/game-host';
|
export type { GameHost, GameHostStatus, GameModule } from "./core/game-host";
|
||||||
export { createGameHost } from './core/game-host';
|
export { createGameHost } from "./core/game-host";
|
||||||
|
|
||||||
export type { Part } from './core/part';
|
export type { Part } from "./core/part";
|
||||||
export { flip, flipTo, roll } from './core/part';
|
export { flip, flipTo, roll } from "./core/part";
|
||||||
|
|
||||||
export { createParts, createPartsFromTable } from './core/part-factory';
|
export { createParts, createPartsFromTable } from "./core/part-factory";
|
||||||
|
|
||||||
export type { Region, RegionAxis } from './core/region';
|
export type { Region, RegionAxis } from "./core/region";
|
||||||
export { createRegion, createRegionAxis, applyAlign, shuffle, moveToRegion } from './core/region';
|
export {
|
||||||
|
createRegion,
|
||||||
|
createRegionAxis,
|
||||||
|
applyAlign,
|
||||||
|
shuffle,
|
||||||
|
moveToRegion,
|
||||||
|
} from "./core/region";
|
||||||
|
|
||||||
|
export * from "./core/game-client";
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
export type { Command, CommandDef, CommandResult, CommandSchema, CommandParamSchema, CommandOptionSchema, CommandFlagSchema } from './utils/command';
|
export type {
|
||||||
export { parseCommand, parseCommandSchema, validateCommand, parseCommandWithSchema, applyCommandSchema } from './utils/command';
|
Command,
|
||||||
|
CommandDef,
|
||||||
|
CommandResult,
|
||||||
|
CommandSchema,
|
||||||
|
CommandParamSchema,
|
||||||
|
CommandOptionSchema,
|
||||||
|
CommandFlagSchema,
|
||||||
|
} from "./utils/command";
|
||||||
|
export {
|
||||||
|
parseCommand,
|
||||||
|
parseCommandSchema,
|
||||||
|
validateCommand,
|
||||||
|
parseCommandWithSchema,
|
||||||
|
applyCommandSchema,
|
||||||
|
} from "./utils/command";
|
||||||
|
|
||||||
export type { CommandRunner, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './utils/command';
|
export type {
|
||||||
export { createCommandRegistry, registerCommand, unregisterCommand, hasCommand, getCommand, runCommand, runCommandParsed, createCommandRunnerContext, type CommandRegistry, type CommandRunnerContextExport } from './utils/command';
|
CommandRunner,
|
||||||
|
CommandRunnerHandler,
|
||||||
|
CommandRunnerContext,
|
||||||
|
PromptEvent,
|
||||||
|
CommandRunnerEvents,
|
||||||
|
} from "./utils/command";
|
||||||
|
export {
|
||||||
|
createCommandRegistry,
|
||||||
|
registerCommand,
|
||||||
|
unregisterCommand,
|
||||||
|
hasCommand,
|
||||||
|
getCommand,
|
||||||
|
runCommand,
|
||||||
|
runCommandParsed,
|
||||||
|
createCommandRunnerContext,
|
||||||
|
type CommandRegistry,
|
||||||
|
type CommandRunnerContextExport,
|
||||||
|
} from "./utils/command";
|
||||||
|
|
||||||
export type { MutableSignal } from './utils/mutable-signal';
|
export type { MutableSignal } from "./utils/mutable-signal";
|
||||||
export { mutableSignal } from './utils/mutable-signal';
|
export { mutableSignal } from "./utils/mutable-signal";
|
||||||
|
|
||||||
export type { RNG } from './utils/rng';
|
export type { RNG, ReadonlyRNG } from "./utils/rng";
|
||||||
export { createRNG, Mulberry32RNG } from './utils/rng';
|
export { createRNG, Mulberry32RNG } from "./utils/rng";
|
||||||
|
|
||||||
export * from '@preact/signals-core';
|
export type { MiddlewareChain } from "./utils/middleware";
|
||||||
|
export { createMiddlewareChain } from "./utils/middleware";
|
||||||
|
|
||||||
|
export * from "./utils/spawner";
|
||||||
|
|
||||||
|
export * from "@preact/signals-core";
|
||||||
|
|||||||
Vendored
+2
-2
@@ -6,5 +6,5 @@ type BoopPartTable = readonly {
|
|||||||
|
|
||||||
export type BoopPart = BoopPartTable[number];
|
export type BoopPart = BoopPartTable[number];
|
||||||
|
|
||||||
declare const data: BoopPartTable;
|
declare function getData(): BoopPartTable;
|
||||||
export default data;
|
export default getData;
|
||||||
|
|||||||
@@ -1,101 +1,31 @@
|
|||||||
import {BoopGame} from "@/samples/boop/types-extensions";
|
import { BoopGame } from "@/samples/boop/types-extensions";
|
||||||
import {PlayerType} from "@/samples/boop/types";
|
import { PlayerType } from "@/samples/boop/types";
|
||||||
import {turn} from "@/samples/boop/commands/turn";
|
import { turn } from "@/samples/boop/commands/turn";
|
||||||
import {createGameCommandRegistry} from "@/core/game";
|
import { boop } from "./boop";
|
||||||
|
import { checkFullBoard } from "./full-board";
|
||||||
export const registry = createGameCommandRegistry<BoopGame['value']>();
|
import { checkGraduates } from "./graduate";
|
||||||
|
import { place } from "./place";
|
||||||
/**
|
import { checkWin } from "./win";
|
||||||
* 放置棋子到棋盘
|
|
||||||
*/
|
|
||||||
const placeCmd = registry.register({
|
|
||||||
schema: 'place <row:number> <col:number> <player> <type>',
|
|
||||||
run: async (game, row, col, player, type) => {
|
|
||||||
const {place} = await import('./place');
|
|
||||||
return place(game, row, col, player, type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行boop - 推动周围棋子
|
|
||||||
*/
|
|
||||||
const boopCmd = registry.register({
|
|
||||||
schema: 'boop <row:number> <col:number> <type>',
|
|
||||||
run: async (game, row, col, type) => {
|
|
||||||
const {boop} = await import('./boop');
|
|
||||||
return boop(game, row, col, type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查是否有玩家获胜(三个猫连线)
|
|
||||||
*/
|
|
||||||
const checkWinCmd = registry.register({
|
|
||||||
schema: 'check-win',
|
|
||||||
run: async (game) => {
|
|
||||||
const {checkWin} = await import('./win');
|
|
||||||
return checkWin(game);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查并执行小猫升级(三个小猫连线变成猫)
|
|
||||||
*/
|
|
||||||
const checkGraduatesCmd = registry.register({
|
|
||||||
schema: 'check-graduates',
|
|
||||||
run: async (game) => {
|
|
||||||
const {checkGraduates} = await import('./graduate');
|
|
||||||
return checkGraduates(game);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查并执行 8-piece 规则
|
|
||||||
*/
|
|
||||||
const checkFullBoardCmd = registry.register({
|
|
||||||
schema: 'check-full-board <player:string>',
|
|
||||||
run: async (game, player) => {
|
|
||||||
const {checkFullBoard} = await import('./full-board');
|
|
||||||
return checkFullBoard(game, player);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 处理一个回合
|
|
||||||
*/
|
|
||||||
const turnCmd = registry.register({
|
|
||||||
schema: 'turn <player:string>',
|
|
||||||
run: async (game, player) => {
|
|
||||||
const {turn} = await import('./turn');
|
|
||||||
return turn(game, player);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动游戏主循环
|
* 启动游戏主循环
|
||||||
*/
|
*/
|
||||||
export async function start(game: BoopGame) {
|
export async function start(game: BoopGame) {
|
||||||
while (true) {
|
while (true) {
|
||||||
const currentPlayer = game.value.currentPlayer;
|
const currentPlayer = game.value.currentPlayer;
|
||||||
const turnOutput = await turn(game, currentPlayer);
|
const turnOutput = await turn(game, currentPlayer);
|
||||||
|
|
||||||
await game.produceAsync(state => {
|
await game.produceAsync((state) => {
|
||||||
state.winner = turnOutput.winner;
|
state.winner = turnOutput.winner;
|
||||||
if (!state.winner) {
|
if (!state.winner) {
|
||||||
state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white';
|
state.currentPlayer =
|
||||||
}
|
state.currentPlayer === "white" ? "black" : "white";
|
||||||
});
|
}
|
||||||
if (game.value.winner) break;
|
});
|
||||||
}
|
if (game.value.winner) break;
|
||||||
|
}
|
||||||
|
|
||||||
return game.value;
|
return game.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export { place, boop, checkWin, checkGraduates, checkFullBoard, turn };
|
||||||
placeCmd as place,
|
|
||||||
boopCmd as boop,
|
|
||||||
checkWinCmd as checkWin,
|
|
||||||
checkGraduatesCmd as checkGraduates,
|
|
||||||
checkFullBoardCmd as checkFullBoard,
|
|
||||||
turnCmd as turn
|
|
||||||
};
|
|
||||||
|
|||||||
+26
-34
@@ -1,49 +1,41 @@
|
|||||||
// Types
|
// Types
|
||||||
export type {
|
export type {
|
||||||
PlayerType,
|
PlayerType,
|
||||||
PieceType,
|
PieceType,
|
||||||
WinnerType,
|
WinnerType,
|
||||||
RegionType,
|
RegionType,
|
||||||
BoopPartMeta,
|
BoopPartMeta,
|
||||||
BoopPart
|
BoopPart,
|
||||||
} from './types';
|
} from "./types";
|
||||||
|
|
||||||
export type {BoopGame} from './types-extensions';
|
export type { BoopGame } from "./types-extensions";
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
export {
|
export { BOARD_SIZE, MAX_PIECES_PER_PLAYER, WIN_LENGTH } from "./constants";
|
||||||
BOARD_SIZE,
|
|
||||||
MAX_PIECES_PER_PLAYER,
|
|
||||||
WIN_LENGTH
|
|
||||||
} from './constants';
|
|
||||||
|
|
||||||
// State
|
// State
|
||||||
export {
|
export { createInitialState, type BoopState } from "./state";
|
||||||
createInitialState,
|
|
||||||
type BoopState
|
|
||||||
} from './state';
|
|
||||||
|
|
||||||
// Prompts
|
// Prompts
|
||||||
export {prompts} from './prompts';
|
export { prompts } from "./prompts";
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
export {
|
export {
|
||||||
registry,
|
start,
|
||||||
start,
|
place as placeCmd,
|
||||||
place as placeCmd,
|
boop as boopCmd,
|
||||||
boop as boopCmd,
|
checkWin as checkWinCmd,
|
||||||
checkWin as checkWinCmd,
|
checkGraduates as checkGraduatesCmd,
|
||||||
checkGraduates as checkGraduatesCmd,
|
checkFullBoard as checkFullBoardCmd,
|
||||||
checkFullBoard as checkFullBoardCmd,
|
turn as turnCmd,
|
||||||
turn as turnCmd
|
} from "./commands";
|
||||||
} from './commands';
|
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
export {
|
export {
|
||||||
getLineCandidates,
|
getLineCandidates,
|
||||||
isInBounds,
|
isInBounds,
|
||||||
isCellOccupied,
|
isCellOccupied,
|
||||||
getNeighborPositions,
|
getNeighborPositions,
|
||||||
findPartInRegion,
|
findPartInRegion,
|
||||||
findPartAtPosition
|
findPartAtPosition,
|
||||||
} from './utils';
|
} from "./utils";
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import parts from './boopPart.csv';
|
import getParts from './boopPart.csv';
|
||||||
import {createRegion, moveToRegion, Region} from "@/core/region";
|
import {createRegion, moveToRegion, Region} from "@/core/region";
|
||||||
import {createPartsFromTable} from "@/core/part-factory";
|
import {createPartsFromTable} from "@/core/part-factory";
|
||||||
import {BoopPart} from "@/samples/boop/types";
|
import {BoopPart} from "@/samples/boop/types";
|
||||||
@@ -6,6 +6,7 @@ import {BOARD_SIZE} from "@/samples/boop/constants";
|
|||||||
import {PlayerType, PieceType, RegionType} from "@/samples/boop/types";
|
import {PlayerType, PieceType, RegionType} from "@/samples/boop/types";
|
||||||
|
|
||||||
export function createInitialState() {
|
export function createInitialState() {
|
||||||
|
const parts = getParts();
|
||||||
const pieces = createPartsFromTable(
|
const pieces = createPartsFromTable(
|
||||||
parts,
|
parts,
|
||||||
(item, index) => `${item.player}-${item.type}-${index + 1}`,
|
(item, index) => `${item.player}-${item.type}-${index + 1}`,
|
||||||
|
|||||||
Vendored
+2
-2
@@ -7,5 +7,5 @@ type CardsTable = readonly {
|
|||||||
|
|
||||||
export type Cards = CardsTable[number];
|
export type Cards = CardsTable[number];
|
||||||
|
|
||||||
declare const data: CardsTable;
|
declare function getData(): CardsTable;
|
||||||
export default data;
|
export default getData;
|
||||||
|
|||||||
+294
-264
@@ -1,379 +1,409 @@
|
|||||||
import {
|
import {
|
||||||
OnitamaGame,
|
OnitamaGame,
|
||||||
OnitamaState,
|
OnitamaState,
|
||||||
PlayerType,
|
PlayerType,
|
||||||
prompts,
|
prompts,
|
||||||
initializeCards
|
initializeCards,
|
||||||
} from "./types";
|
} from "./types";
|
||||||
import {createGameCommandRegistry} from "@/core/game";
|
import { moveToRegion } from "@/core/region";
|
||||||
import {moveToRegion} from "@/core/region";
|
|
||||||
|
|
||||||
export const registry = createGameCommandRegistry<OnitamaState>();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查位置是否在棋盘范围内
|
* 检查位置是否在棋盘范围内
|
||||||
*/
|
*/
|
||||||
function isInBounds(x: number, y: number): boolean {
|
function isInBounds(x: number, y: number): boolean {
|
||||||
return x >= 0 && x < 5 && y >= 0 && y < 5;
|
return x >= 0 && x < 5 && y >= 0 && y < 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查位置是否有棋子
|
* 检查位置是否有棋子
|
||||||
*/
|
*/
|
||||||
function getPawnAtPosition(state: OnitamaState, x: number, y: number) {
|
function getPawnAtPosition(state: OnitamaState, x: number, y: number) {
|
||||||
const key = `${x},${y}`;
|
const key = `${x},${y}`;
|
||||||
const pawnId = state.regions.board.partMap[key];
|
const pawnId = state.regions.board.partMap[key];
|
||||||
return pawnId ? state.pawns[pawnId] : null;
|
return pawnId ? state.pawns[pawnId] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查玩家是否拥有某张卡牌
|
* 检查玩家是否拥有某张卡牌
|
||||||
*/
|
*/
|
||||||
function playerHasCard(state: OnitamaState, player: PlayerType, cardName: string): boolean {
|
function playerHasCard(
|
||||||
const cardList = player === 'red' ? state.redCards : state.blackCards;
|
state: OnitamaState,
|
||||||
return cardList.includes(cardName);
|
player: PlayerType,
|
||||||
|
cardName: string,
|
||||||
|
): boolean {
|
||||||
|
const cardList = player === "red" ? state.redCards : state.blackCards;
|
||||||
|
return cardList.includes(cardName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取卡牌的移动候选项,根据玩家视角进行旋转
|
* 获取卡牌的移动候选项,根据玩家视角进行旋转
|
||||||
* 黑方需要将卡牌旋转180度
|
* 黑方需要将卡牌旋转180度
|
||||||
*/
|
*/
|
||||||
export function getCardMoveCandidates(state: OnitamaState, cardName: string, player: PlayerType) {
|
export function getCardMoveCandidates(
|
||||||
const card = state.cards[cardName];
|
state: OnitamaState,
|
||||||
const candidates = card.moveCandidates;
|
cardName: string,
|
||||||
|
player: PlayerType,
|
||||||
|
) {
|
||||||
|
const card = state.cards[cardName];
|
||||||
|
const candidates = card.moveCandidates;
|
||||||
|
|
||||||
// 黑方需要将卡牌旋转180度
|
// 黑方需要将卡牌旋转180度
|
||||||
if (player === 'black') {
|
if (player === "black") {
|
||||||
return candidates.map(m => ({ dx: -m.dx, dy: -m.dy }));
|
return candidates.map((m) => ({ dx: -m.dx, dy: -m.dy }));
|
||||||
}
|
}
|
||||||
|
|
||||||
return candidates;
|
return candidates;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查移动是否合法
|
* 检查移动是否合法
|
||||||
*/
|
*/
|
||||||
export function isValidMove(state: OnitamaState, cardName: string, fromX: number, fromY: number, toX: number, toY: number, player: PlayerType): string | null {
|
export function isValidMove(
|
||||||
// 检查玩家是否拥有该卡牌
|
state: OnitamaState,
|
||||||
if (!playerHasCard(state, player, cardName)) {
|
cardName: string,
|
||||||
return `玩家 ${player} 不拥有卡牌 ${cardName}`;
|
fromX: number,
|
||||||
}
|
fromY: number,
|
||||||
|
toX: number,
|
||||||
|
toY: number,
|
||||||
|
player: PlayerType,
|
||||||
|
): string | null {
|
||||||
|
// 检查玩家是否拥有该卡牌
|
||||||
|
if (!playerHasCard(state, player, cardName)) {
|
||||||
|
return `玩家 ${player} 不拥有卡牌 ${cardName}`;
|
||||||
|
}
|
||||||
|
|
||||||
// 检查起始位置是否有玩家的棋子
|
// 检查起始位置是否有玩家的棋子
|
||||||
const fromPawn = getPawnAtPosition(state, fromX, fromY);
|
const fromPawn = getPawnAtPosition(state, fromX, fromY);
|
||||||
if (!fromPawn) {
|
if (!fromPawn) {
|
||||||
return `位置 (${fromX}, ${fromY}) 没有棋子`;
|
return `位置 (${fromX}, ${fromY}) 没有棋子`;
|
||||||
}
|
}
|
||||||
if (fromPawn.owner !== player) {
|
if (fromPawn.owner !== player) {
|
||||||
return `位置 (${fromX}, ${fromY}) 的棋子不属于玩家 ${player}`;
|
return `位置 (${fromX}, ${fromY}) 的棋子不属于玩家 ${player}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查卡牌是否存在
|
// 检查卡牌是否存在
|
||||||
const card = state.cards[cardName];
|
const card = state.cards[cardName];
|
||||||
if (!card) {
|
if (!card) {
|
||||||
return `卡牌 ${cardName} 不存在`;
|
return `卡牌 ${cardName} 不存在`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算移动偏移量
|
// 计算移动偏移量
|
||||||
const dx = toX - fromX;
|
const dx = toX - fromX;
|
||||||
const dy = toY - fromY;
|
const dy = toY - fromY;
|
||||||
|
|
||||||
// 检查移动是否在卡牌的移动候选项中(黑方需要旋转180度)
|
// 检查移动是否在卡牌的移动候选项中(黑方需要旋转180度)
|
||||||
const candidates = getCardMoveCandidates(state, cardName, player);
|
const candidates = getCardMoveCandidates(state, cardName, player);
|
||||||
const isValid = candidates.some(m => m.dx === dx && m.dy === dy);
|
const isValid = candidates.some((m) => m.dx === dx && m.dy === dy);
|
||||||
if (!isValid) {
|
if (!isValid) {
|
||||||
return `卡牌 ${cardName} 不支持移动 (${dx}, ${dy})`;
|
return `卡牌 ${cardName} 不支持移动 (${dx}, ${dy})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查目标位置是否在棋盘内
|
// 检查目标位置是否在棋盘内
|
||||||
if (!isInBounds(toX, toY)) {
|
if (!isInBounds(toX, toY)) {
|
||||||
return `目标位置 (${toX}, ${toY}) 超出棋盘范围`;
|
return `目标位置 (${toX}, ${toY}) 超出棋盘范围`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查目标位置是否有己方棋子
|
// 检查目标位置是否有己方棋子
|
||||||
const toPawn = getPawnAtPosition(state, toX, toY);
|
const toPawn = getPawnAtPosition(state, toX, toY);
|
||||||
if (toPawn && toPawn.owner === player) {
|
if (toPawn && toPawn.owner === player) {
|
||||||
return `目标位置 (${toX}, ${toY}) 已有己方棋子`;
|
return `目标位置 (${toX}, ${toY}) 已有己方棋子`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 执行移动
|
* 执行移动
|
||||||
*/
|
*/
|
||||||
async function handleMove(game: OnitamaGame, player: PlayerType, cardName: string, fromX: number, fromY: number, toX: number, toY: number) {
|
async function move(
|
||||||
const state = game.value;
|
game: OnitamaGame,
|
||||||
|
player: PlayerType,
|
||||||
|
cardName: string,
|
||||||
|
fromX: number,
|
||||||
|
fromY: number,
|
||||||
|
toX: number,
|
||||||
|
toY: number,
|
||||||
|
) {
|
||||||
|
const state = game.value;
|
||||||
|
|
||||||
// 验证移动
|
// 验证移动
|
||||||
const error = isValidMove(state, cardName, fromX, fromY, toX, toY, player);
|
const error = isValidMove(state, cardName, fromX, fromY, toX, toY, player);
|
||||||
if (error) {
|
if (error) {
|
||||||
throw new Error(error);
|
throw new Error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const capturedPawnId = getPawnAtPosition(state, toX, toY)?.id || null;
|
||||||
|
|
||||||
|
await game.produceAsync((state) => {
|
||||||
|
const pawn = state.pawns[getPawnAtPosition(state, fromX, fromY)!.id];
|
||||||
|
|
||||||
|
// 如果目标位置有敌方棋子,将其移除(吃掉)
|
||||||
|
if (capturedPawnId) {
|
||||||
|
const capturedPawn = state.pawns[capturedPawnId];
|
||||||
|
moveToRegion(capturedPawn, state.regions.board, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const capturedPawnId = getPawnAtPosition(state, toX, toY)?.id || null;
|
// 移动棋子到目标位置
|
||||||
|
moveToRegion(pawn, state.regions.board, state.regions.board, [toX, toY]);
|
||||||
|
});
|
||||||
|
|
||||||
await game.produceAsync(state => {
|
// 交换卡牌
|
||||||
const pawn = state.pawns[getPawnAtPosition(state, fromX, fromY)!.id];
|
await swapCard(game, player, cardName);
|
||||||
|
|
||||||
// 如果目标位置有敌方棋子,将其移除(吃掉)
|
return {
|
||||||
if (capturedPawnId) {
|
from: { x: fromX, y: fromY },
|
||||||
const capturedPawn = state.pawns[capturedPawnId];
|
to: { x: toX, y: toY },
|
||||||
moveToRegion(capturedPawn, state.regions.board, null);
|
card: cardName,
|
||||||
}
|
captured: capturedPawnId,
|
||||||
|
};
|
||||||
// 移动棋子到目标位置
|
|
||||||
moveToRegion(pawn, state.regions.board, state.regions.board, [toX, toY]);
|
|
||||||
});
|
|
||||||
|
|
||||||
// 交换卡牌
|
|
||||||
await handleSwapCard(game, player, cardName);
|
|
||||||
|
|
||||||
return {
|
|
||||||
from: { x: fromX, y: fromY },
|
|
||||||
to: { x: toX, y: toY },
|
|
||||||
card: cardName,
|
|
||||||
captured: capturedPawnId
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const move = registry.register({
|
|
||||||
schema: 'move <player> <card:string> <fromX:number> <fromY:number> <toX:number> <toY:number>',
|
|
||||||
run: handleMove
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 交换卡牌:将使用的卡牌与备用卡牌交换
|
* 交换卡牌:将使用的卡牌与备用卡牌交换
|
||||||
*/
|
*/
|
||||||
async function handleSwapCard(game: OnitamaGame, player: PlayerType, usedCard: string) {
|
async function swapCard(
|
||||||
await game.produceAsync(state => {
|
game: OnitamaGame,
|
||||||
const spareCard = state.spareCard;
|
player: PlayerType,
|
||||||
const usedCardData = state.cards[usedCard];
|
usedCard: string,
|
||||||
const spareCardData = state.cards[spareCard];
|
) {
|
||||||
|
await game.produceAsync((state) => {
|
||||||
|
const spareCard = state.spareCard;
|
||||||
|
const usedCardData = state.cards[usedCard];
|
||||||
|
const spareCardData = state.cards[spareCard];
|
||||||
|
|
||||||
// 从玩家手牌中移除使用的卡牌
|
// 从玩家手牌中移除使用的卡牌
|
||||||
if (player === 'red') {
|
if (player === "red") {
|
||||||
state.redCards = state.redCards.filter(c => c !== usedCard);
|
state.redCards = state.redCards.filter((c) => c !== usedCard);
|
||||||
state.redCards.push(spareCard);
|
state.redCards.push(spareCard);
|
||||||
} else {
|
} else {
|
||||||
state.blackCards = state.blackCards.filter(c => c !== usedCard);
|
state.blackCards = state.blackCards.filter((c) => c !== usedCard);
|
||||||
state.blackCards.push(spareCard);
|
state.blackCards.push(spareCard);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新卡牌区域
|
// 更新卡牌区域
|
||||||
usedCardData.regionId = 'spare';
|
usedCardData.regionId = "spare";
|
||||||
spareCardData.regionId = player;
|
spareCardData.regionId = player;
|
||||||
|
|
||||||
// 更新备用卡牌
|
// 更新备用卡牌
|
||||||
state.spareCard = usedCard;
|
state.spareCard = usedCard;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const swapCard = registry.register({
|
|
||||||
schema: 'swap-card <player> <card:string>',
|
|
||||||
run: handleSwapCard
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查占领胜利条件:玩家的师父棋子到达对手的初始位置
|
* 检查占领胜利条件:玩家的师父棋子到达对手的初始位置
|
||||||
* 红色师父需要到达 (2, 4) - 黑色师父的初始位置
|
* 红色师父需要到达 (2, 4) - 黑色师父的初始位置
|
||||||
* 黑色师父需要到达 (2, 0) - 红色师父的初始位置
|
* 黑色师父需要到达 (2, 0) - 红色师父的初始位置
|
||||||
*/
|
*/
|
||||||
async function handleCheckConquestWin(game: OnitamaGame): Promise<PlayerType | null> {
|
async function checkConquestWin(game: OnitamaGame): Promise<PlayerType | null> {
|
||||||
const state = game.value;
|
const state = game.value;
|
||||||
|
|
||||||
// 红色师父到达 (2, 4)(黑色师父的初始位置)
|
// 红色师父到达 (2, 4)(黑色师父的初始位置)
|
||||||
const redMaster = state.pawns['red-master'];
|
const redMaster = state.pawns["red-master"];
|
||||||
if (redMaster && redMaster.regionId === 'board' && redMaster.position[0] === 2 && redMaster.position[1] === 4) {
|
if (
|
||||||
return 'red';
|
redMaster &&
|
||||||
}
|
redMaster.regionId === "board" &&
|
||||||
|
redMaster.position[0] === 2 &&
|
||||||
|
redMaster.position[1] === 4
|
||||||
|
) {
|
||||||
|
return "red";
|
||||||
|
}
|
||||||
|
|
||||||
// 黑色师父到达 (2, 0)(红色师父的初始位置)
|
// 黑色师父到达 (2, 0)(红色师父的初始位置)
|
||||||
const blackMaster = state.pawns['black-master'];
|
const blackMaster = state.pawns["black-master"];
|
||||||
if (blackMaster && blackMaster.regionId === 'board' && blackMaster.position[0] === 2 && blackMaster.position[1] === 0) {
|
if (
|
||||||
return 'black';
|
blackMaster &&
|
||||||
}
|
blackMaster.regionId === "board" &&
|
||||||
|
blackMaster.position[0] === 2 &&
|
||||||
|
blackMaster.position[1] === 0
|
||||||
|
) {
|
||||||
|
return "black";
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkConquestWin = registry.register({
|
|
||||||
schema: 'check-conquest-win',
|
|
||||||
run: handleCheckConquestWin
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查吃掉胜利条件:对手的师父棋子被吃掉(不在棋盘上)
|
* 检查吃掉胜利条件:对手的师父棋子被吃掉(不在棋盘上)
|
||||||
*/
|
*/
|
||||||
async function handleCheckCaptureWin(game: OnitamaGame): Promise<PlayerType | null> {
|
async function checkCaptureWin(game: OnitamaGame): Promise<PlayerType | null> {
|
||||||
const state = game.value;
|
const state = game.value;
|
||||||
|
|
||||||
// 红色师父不在棋盘上,黑色获胜
|
// 红色师父不在棋盘上,黑色获胜
|
||||||
const redMaster = state.pawns['red-master'];
|
const redMaster = state.pawns["red-master"];
|
||||||
if (!redMaster || redMaster.regionId !== 'board') {
|
if (!redMaster || redMaster.regionId !== "board") {
|
||||||
return 'black';
|
return "black";
|
||||||
}
|
}
|
||||||
|
|
||||||
// 黑色师父不在棋盘上,红色获胜
|
// 黑色师父不在棋盘上,红色获胜
|
||||||
const blackMaster = state.pawns['black-master'];
|
const blackMaster = state.pawns["black-master"];
|
||||||
if (!blackMaster || blackMaster.regionId !== 'board') {
|
if (!blackMaster || blackMaster.regionId !== "board") {
|
||||||
return 'red';
|
return "red";
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkCaptureWin = registry.register({
|
|
||||||
schema: 'check-capture-win',
|
|
||||||
run: handleCheckCaptureWin
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 综合胜利检测
|
* 综合胜利检测
|
||||||
*/
|
*/
|
||||||
async function handleCheckWin(game: OnitamaGame): Promise<PlayerType | null> {
|
async function checkWin(game: OnitamaGame): Promise<PlayerType | null> {
|
||||||
const conquestWinner = await handleCheckConquestWin(game);
|
const conquestWinner = await checkConquestWin(game);
|
||||||
if (conquestWinner) {
|
if (conquestWinner) {
|
||||||
return conquestWinner;
|
return conquestWinner;
|
||||||
}
|
}
|
||||||
|
|
||||||
const captureWinner = await handleCheckCaptureWin(game);
|
const captureWinner = await checkCaptureWin(game);
|
||||||
if (captureWinner) {
|
if (captureWinner) {
|
||||||
return captureWinner;
|
return captureWinner;
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const checkWin = registry.register({
|
|
||||||
schema: 'check-win',
|
|
||||||
run: handleCheckWin
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取玩家可用的移动
|
* 获取玩家可用的移动
|
||||||
*/
|
*/
|
||||||
export function getAvailableMoves(state: OnitamaState, player: PlayerType): Array<{card: string, fromX: number, fromY: number, toX: number, toY: number}> {
|
export function getAvailableMoves(
|
||||||
const moves: Array<{card: string, fromX: number, fromY: number, toX: number, toY: number}> = [];
|
state: OnitamaState,
|
||||||
|
player: PlayerType,
|
||||||
|
): Array<{
|
||||||
|
card: string;
|
||||||
|
fromX: number;
|
||||||
|
fromY: number;
|
||||||
|
toX: number;
|
||||||
|
toY: number;
|
||||||
|
}> {
|
||||||
|
const moves: Array<{
|
||||||
|
card: string;
|
||||||
|
fromX: number;
|
||||||
|
fromY: number;
|
||||||
|
toX: number;
|
||||||
|
toY: number;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
// 获取玩家的所有卡牌
|
// 获取玩家的所有卡牌
|
||||||
const cardNames = player === 'red' ? state.redCards : state.blackCards;
|
const cardNames = player === "red" ? state.redCards : state.blackCards;
|
||||||
|
|
||||||
// 获取玩家的所有棋子
|
// 获取玩家的所有棋子
|
||||||
const playerPawns = Object.values(state.pawns).filter(p => p.owner === player && p.regionId === 'board');
|
const playerPawns = Object.values(state.pawns).filter(
|
||||||
|
(p) => p.owner === player && p.regionId === "board",
|
||||||
|
);
|
||||||
|
|
||||||
// 对于每张卡牌
|
// 对于每张卡牌
|
||||||
for (const cardName of cardNames) {
|
for (const cardName of cardNames) {
|
||||||
// 获取旋转后的移动候选项(黑方需要旋转180度)
|
// 获取旋转后的移动候选项(黑方需要旋转180度)
|
||||||
const candidates = getCardMoveCandidates(state, cardName, player);
|
const candidates = getCardMoveCandidates(state, cardName, player);
|
||||||
|
|
||||||
// 对于每个棋子
|
// 对于每个棋子
|
||||||
for (const pawn of playerPawns) {
|
for (const pawn of playerPawns) {
|
||||||
const [fromX, fromY] = pawn.position;
|
const [fromX, fromY] = pawn.position;
|
||||||
|
|
||||||
// 对于卡牌的每个移动
|
// 对于卡牌的每个移动
|
||||||
for (const move of candidates) {
|
for (const move of candidates) {
|
||||||
const toX = fromX + move.dx;
|
const toX = fromX + move.dx;
|
||||||
const toY = fromY + move.dy;
|
const toY = fromY + move.dy;
|
||||||
|
|
||||||
// 检查移动是否合法
|
// 检查移动是否合法
|
||||||
if (isInBounds(toX, toY)) {
|
if (isInBounds(toX, toY)) {
|
||||||
const targetPawn = getPawnAtPosition(state, toX, toY);
|
const targetPawn = getPawnAtPosition(state, toX, toY);
|
||||||
// 目标位置为空或有敌方棋子
|
// 目标位置为空或有敌方棋子
|
||||||
if (!targetPawn || targetPawn.owner !== player) {
|
if (!targetPawn || targetPawn.owner !== player) {
|
||||||
moves.push({ card: cardName, fromX, fromY, toX, toY });
|
moves.push({ card: cardName, fromX, fromY, toX, toY });
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return moves;
|
return moves;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理回合
|
* 处理回合
|
||||||
*/
|
*/
|
||||||
async function handleTurn(game: OnitamaGame, turnPlayer: PlayerType) {
|
async function turn(game: OnitamaGame, turnPlayer: PlayerType) {
|
||||||
const state = game.value;
|
const state = game.value;
|
||||||
const availableMoves = getAvailableMoves(state, turnPlayer);
|
const availableMoves = getAvailableMoves(state, turnPlayer);
|
||||||
|
|
||||||
let moveOutput;
|
let moveOutput;
|
||||||
|
|
||||||
if (availableMoves.length === 0) {
|
if (availableMoves.length === 0) {
|
||||||
// 没有可用移动,玩家必须交换一张卡牌
|
// 没有可用移动,玩家必须交换一张卡牌
|
||||||
const cardToSwap = await game.prompt(
|
const cardToSwap = await game.prompt(
|
||||||
prompts.move,
|
prompts.move,
|
||||||
(player, card, _fromX, _fromY, _toX, _toY) => {
|
(player, card, _fromX, _fromY, _toX, _toY) => {
|
||||||
if (player !== turnPlayer) {
|
if (player !== turnPlayer) {
|
||||||
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
||||||
}
|
|
||||||
if (!playerHasCard(state, player, card)) {
|
|
||||||
throw `Player ${player} does not have card ${card}.`;
|
|
||||||
}
|
|
||||||
return card;
|
|
||||||
},
|
|
||||||
turnPlayer
|
|
||||||
);
|
|
||||||
|
|
||||||
await swapCard(game, turnPlayer, cardToSwap);
|
|
||||||
moveOutput = { swappedCard: cardToSwap, noMoves: true };
|
|
||||||
} else {
|
|
||||||
// 有可用移动,提示玩家选择
|
|
||||||
moveOutput = await game.prompt(
|
|
||||||
prompts.move,
|
|
||||||
(player, card, fromX, fromY, toX, toY) => {
|
|
||||||
if (player !== turnPlayer) {
|
|
||||||
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = isValidMove(state, card, fromX, fromY, toX, toY, player);
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return { player, card, fromX, fromY, toX, toY };
|
|
||||||
},
|
|
||||||
turnPlayer
|
|
||||||
);
|
|
||||||
|
|
||||||
await move(game, moveOutput.player, moveOutput.card, moveOutput.fromX, moveOutput.fromY, moveOutput.toX, moveOutput.toY);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查胜利
|
|
||||||
const winner = await checkWin(game);
|
|
||||||
|
|
||||||
await game.produceAsync(state => {
|
|
||||||
state.winner = winner;
|
|
||||||
if (!winner) {
|
|
||||||
state.currentPlayer = state.currentPlayer === 'red' ? 'black' : 'red';
|
|
||||||
state.turn++;
|
|
||||||
}
|
}
|
||||||
});
|
if (!playerHasCard(state, player, card)) {
|
||||||
|
throw `Player ${player} does not have card ${card}.`;
|
||||||
|
}
|
||||||
|
return card;
|
||||||
|
},
|
||||||
|
turnPlayer,
|
||||||
|
);
|
||||||
|
|
||||||
return { winner, move: moveOutput };
|
await swapCard(game, turnPlayer, cardToSwap);
|
||||||
|
moveOutput = { swappedCard: cardToSwap, noMoves: true };
|
||||||
|
} else {
|
||||||
|
// 有可用移动,提示玩家选择
|
||||||
|
moveOutput = await game.prompt(
|
||||||
|
prompts.move,
|
||||||
|
(player, card, fromX, fromY, toX, toY) => {
|
||||||
|
if (player !== turnPlayer) {
|
||||||
|
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = isValidMove(state, card, fromX, fromY, toX, toY, player);
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { player, card, fromX, fromY, toX, toY };
|
||||||
|
},
|
||||||
|
turnPlayer,
|
||||||
|
);
|
||||||
|
|
||||||
|
await move(
|
||||||
|
game,
|
||||||
|
moveOutput.player,
|
||||||
|
moveOutput.card,
|
||||||
|
moveOutput.fromX,
|
||||||
|
moveOutput.fromY,
|
||||||
|
moveOutput.toX,
|
||||||
|
moveOutput.toY,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查胜利
|
||||||
|
const winner = await checkWin(game);
|
||||||
|
|
||||||
|
await game.produceAsync((state) => {
|
||||||
|
state.winner = winner;
|
||||||
|
if (!winner) {
|
||||||
|
state.currentPlayer = state.currentPlayer === "red" ? "black" : "red";
|
||||||
|
state.turn++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return { winner, move: moveOutput };
|
||||||
}
|
}
|
||||||
|
|
||||||
const turn = registry.register({
|
|
||||||
schema: 'turn <player>',
|
|
||||||
run: handleTurn
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开始游戏主循环
|
* 开始游戏主循环
|
||||||
*/
|
*/
|
||||||
export async function start(game: OnitamaGame) {
|
export async function start(game: OnitamaGame) {
|
||||||
// Initialize cards with RNG at game start
|
// Initialize cards with RNG at game start
|
||||||
initializeCards(game);
|
initializeCards(game);
|
||||||
|
|
||||||
while (true) {
|
while (true) {
|
||||||
const currentPlayer = game.value.currentPlayer;
|
const currentPlayer = game.value.currentPlayer;
|
||||||
const turnOutput = await turn(game, currentPlayer);
|
const turnOutput = await turn(game, currentPlayer);
|
||||||
|
|
||||||
if (turnOutput.winner) {
|
if (turnOutput.winner) {
|
||||||
break;
|
break;
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return game.value;
|
return game.value;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import cards from './cards.csv';
|
import getCards from './cards.csv';
|
||||||
import {Part} from "@/core/part";
|
import {Part} from "@/core/part";
|
||||||
import {createRegion, createRegionAxis, Region} from "@/core/region";
|
import {createRegion, createRegionAxis, Region} from "@/core/region";
|
||||||
import {createPromptDef, IGameContext} from "@/core/game";
|
import {createPromptDef, IGameContext} from "@/core/game";
|
||||||
@@ -38,7 +38,8 @@ export function createGameInfo(){
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createCards(){
|
export function createCards(){
|
||||||
const dataMap = {} as Record<string, CardData>;
|
const cards = getCards();
|
||||||
|
const dataMap = {} as Record<string, CardData>;
|
||||||
|
|
||||||
for(const entry of cards){
|
for(const entry of cards){
|
||||||
const card = dataMap[entry.name] = dataMap[entry.name] || {
|
const card = dataMap[entry.name] = dataMap[entry.name] || {
|
||||||
|
|||||||
@@ -1,421 +1,417 @@
|
|||||||
import {IGameContext} from "@/core/game";
|
import { IGameContext } from "@/core/game";
|
||||||
import {RegicideState} from "@/samples/regicide/state";
|
import { RegicideState } from "@/samples/regicide/state";
|
||||||
import {createGameCommandRegistry} from "@/core/game";
|
import { PlayerType, RegicideCard } from "@/samples/regicide/types";
|
||||||
import {PlayerType, RegicideCard} from "@/samples/regicide/types";
|
import { CARD_VALUES, FACE_CARDS } from "@/samples/regicide/constants";
|
||||||
import {CARD_VALUES, FACE_CARDS} from "@/samples/regicide/constants";
|
import { isEnemyDefeated } from "@/samples/regicide/utils";
|
||||||
import {isEnemyDefeated} from "@/samples/regicide/utils";
|
|
||||||
|
|
||||||
export type RegicideGame = IGameContext<RegicideState>;
|
export type RegicideGame = IGameContext<RegicideState>;
|
||||||
|
|
||||||
export const registry = createGameCommandRegistry<RegicideState>();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打出一张牌(对当前敌人造成伤害)
|
* 打出一张牌(对当前敌人造成伤害)
|
||||||
*/
|
*/
|
||||||
const playCmd = registry.register({
|
async function playCmd(game: RegicideGame, player: string, cardId: string) {
|
||||||
schema: 'play <player:string> <cardId:string>',
|
const state = game.value;
|
||||||
run: async (game: RegicideGame, player: string, cardId: string) => {
|
const card = state.cards[cardId];
|
||||||
const state = game.value;
|
|
||||||
const card = state.cards[cardId];
|
|
||||||
|
|
||||||
if (!card) {
|
if (!card) {
|
||||||
return {success: false, error: `卡牌 ${cardId} 不存在`};
|
return { success: false, error: `卡牌 ${cardId} 不存在` };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查卡牌是否在玩家手牌中
|
// 检查卡牌是否在玩家手牌中
|
||||||
const playerKey = player as PlayerType;
|
const playerKey = player as PlayerType;
|
||||||
const playerHand = state.playerHands[playerKey];
|
const playerHand = state.playerHands[playerKey];
|
||||||
if (!playerHand || !playerHand.includes(cardId)) {
|
if (!playerHand || !playerHand.includes(cardId)) {
|
||||||
return {success: false, error: `卡牌 ${cardId} 不在玩家 ${player} 的手牌中`};
|
return {
|
||||||
}
|
success: false,
|
||||||
|
error: `卡牌 ${cardId} 不在玩家 ${player} 的手牌中`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// 检查是否有当前敌人
|
// 检查是否有当前敌人
|
||||||
if (!state.currentEnemy) {
|
if (!state.currentEnemy) {
|
||||||
return {success: false, error: '没有活跃的敌人'};
|
return { success: false, error: "没有活跃的敌人" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算伤害(基础伤害为卡牌面值)
|
// 计算伤害(基础伤害为卡牌面值)
|
||||||
let damage = card.value;
|
let damage = card.value;
|
||||||
let attackReduction = 0;
|
let attackReduction = 0;
|
||||||
|
|
||||||
// 梅花双倍伤害
|
// 梅花双倍伤害
|
||||||
if (card.suit === 'clubs') {
|
if (card.suit === "clubs") {
|
||||||
damage *= 2;
|
damage *= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 黑桃降低敌人攻击力
|
// 黑桃降低敌人攻击力
|
||||||
if (card.suit === 'spades') {
|
if (card.suit === "spades") {
|
||||||
attackReduction = card.value;
|
attackReduction = card.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const enemyHpBefore = state.currentEnemy.hp;
|
const enemyHpBefore = state.currentEnemy.hp;
|
||||||
|
|
||||||
await game.produce(state => {
|
await game.produceAsync((state) => {
|
||||||
// 对敌人造成伤害
|
// 对敌人造成伤害
|
||||||
state.currentEnemy!.hp -= damage;
|
state.currentEnemy!.hp -= damage;
|
||||||
|
|
||||||
// 记录黑桃的攻击力降低
|
// 记录黑桃的攻击力降低
|
||||||
if (attackReduction > 0) {
|
if (attackReduction > 0) {
|
||||||
state.currentEnemy!.value = Math.max(0, state.currentEnemy!.value - attackReduction);
|
state.currentEnemy!.value = Math.max(
|
||||||
}
|
0,
|
||||||
|
state.currentEnemy!.value - attackReduction,
|
||||||
// 从手牌移除卡牌
|
);
|
||||||
const hand = state.playerHands[playerKey];
|
|
||||||
const cardIndex = hand.indexOf(cardId);
|
|
||||||
if (cardIndex !== -1) {
|
|
||||||
hand.splice(cardIndex, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 将卡牌移到弃牌堆
|
|
||||||
state.cards[cardId].regionId = 'discardPile';
|
|
||||||
|
|
||||||
// 红心能力:将弃牌堆洗回酒馆牌堆
|
|
||||||
if (card.suit === 'hearts') {
|
|
||||||
const discardIds = state.regions.discardPile.childIds.filter(id => id !== state.currentEnemy!.id);
|
|
||||||
if (discardIds.length > 0) {
|
|
||||||
// 将弃牌堆(除当前敌人外)移回酒馆牌堆
|
|
||||||
for (const discardId of discardIds) {
|
|
||||||
state.cards[discardId].regionId = 'tavernDeck';
|
|
||||||
}
|
|
||||||
state.regions.tavernDeck.childIds.push(...discardIds);
|
|
||||||
state.regions.discardPile.childIds = [state.currentEnemy!.id];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 方块能力:从酒馆牌堆抓牌
|
|
||||||
if (card.suit === 'diamonds') {
|
|
||||||
const tavernDeckCount = state.regions.tavernDeck.childIds.length;
|
|
||||||
if (tavernDeckCount > 0) {
|
|
||||||
const drawCardId = state.regions.tavernDeck.childIds.shift()!;
|
|
||||||
state.cards[drawCardId].regionId = `hand_${player}`;
|
|
||||||
hand.push(drawCardId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 检查敌人是否被击败
|
|
||||||
const enemyDefeated = isEnemyDefeated(game.value.currentEnemy);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
damage,
|
|
||||||
attackReduction,
|
|
||||||
enemyHpBefore,
|
|
||||||
enemyHpAfter: game.value.currentEnemy!.hp,
|
|
||||||
enemyDefeated,
|
|
||||||
suitAbility: card.suit
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// 从手牌移除卡牌
|
||||||
|
const hand = state.playerHands[playerKey];
|
||||||
|
const cardIndex = hand.indexOf(cardId);
|
||||||
|
if (cardIndex !== -1) {
|
||||||
|
hand.splice(cardIndex, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将卡牌移到弃牌堆
|
||||||
|
state.cards[cardId].regionId = "discardPile";
|
||||||
|
|
||||||
|
// 红心能力:将弃牌堆洗回酒馆牌堆
|
||||||
|
if (card.suit === "hearts") {
|
||||||
|
const discardIds = state.regions.discardPile.childIds.filter(
|
||||||
|
(id) => id !== state.currentEnemy!.id,
|
||||||
|
);
|
||||||
|
if (discardIds.length > 0) {
|
||||||
|
// 将弃牌堆(除当前敌人外)移回酒馆牌堆
|
||||||
|
for (const discardId of discardIds) {
|
||||||
|
state.cards[discardId].regionId = "tavernDeck";
|
||||||
|
}
|
||||||
|
state.regions.tavernDeck.childIds.push(...discardIds);
|
||||||
|
state.regions.discardPile.childIds = [state.currentEnemy!.id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 方块能力:从酒馆牌堆抓牌
|
||||||
|
if (card.suit === "diamonds") {
|
||||||
|
const tavernDeckCount = state.regions.tavernDeck.childIds.length;
|
||||||
|
if (tavernDeckCount > 0) {
|
||||||
|
const drawCardId = state.regions.tavernDeck.childIds.shift()!;
|
||||||
|
state.cards[drawCardId].regionId = `hand_${player}`;
|
||||||
|
hand.push(drawCardId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 检查敌人是否被击败
|
||||||
|
const enemyDefeated = isEnemyDefeated(game.value.currentEnemy);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
damage,
|
||||||
|
attackReduction,
|
||||||
|
enemyHpBefore,
|
||||||
|
enemyHpAfter: game.value.currentEnemy!.hp,
|
||||||
|
enemyDefeated,
|
||||||
|
suitAbility: card.suit,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 打出A配合另一张牌
|
* 打出A配合另一张牌
|
||||||
*/
|
*/
|
||||||
const playWithACmd = registry.register({
|
async function playWithACmd(
|
||||||
schema: 'play-with-a <player:string> <aceCardId:string> <otherCardId:string>',
|
game: RegicideGame,
|
||||||
run: async (game: RegicideGame, player: string, aceCardId: string, otherCardId: string) => {
|
player: string,
|
||||||
const state = game.value;
|
aceCardId: string,
|
||||||
const aceCard = state.cards[aceCardId];
|
otherCardId: string,
|
||||||
const otherCard = state.cards[otherCardId];
|
) {
|
||||||
|
const state = game.value;
|
||||||
|
const aceCard = state.cards[aceCardId];
|
||||||
|
const otherCard = state.cards[otherCardId];
|
||||||
|
|
||||||
if (!aceCard || !otherCard) {
|
if (!aceCard || !otherCard) {
|
||||||
return {success: false, error: '卡牌不存在'};
|
return { success: false, error: "卡牌不存在" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否是A牌
|
// 检查是否是A牌
|
||||||
if (aceCard.rank !== 'A') {
|
if (aceCard.rank !== "A") {
|
||||||
return {success: false, error: `第一张牌必须是A`};
|
return { success: false, error: `第一张牌必须是A` };
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerKey = player as PlayerType;
|
const playerKey = player as PlayerType;
|
||||||
const playerHand = state.playerHands[playerKey];
|
const playerHand = state.playerHands[playerKey];
|
||||||
|
|
||||||
// 检查两张牌都在手牌中
|
// 检查两张牌都在手牌中
|
||||||
if (!playerHand.includes(aceCardId) || !playerHand.includes(otherCardId)) {
|
if (!playerHand.includes(aceCardId) || !playerHand.includes(otherCardId)) {
|
||||||
return {success: false, error: '卡牌不在手牌中'};
|
return { success: false, error: "卡牌不在手牌中" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.currentEnemy) {
|
if (!state.currentEnemy) {
|
||||||
return {success: false, error: '没有活跃的敌人'};
|
return { success: false, error: "没有活跃的敌人" };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算两张牌的总伤害
|
// 计算两张牌的总伤害
|
||||||
let totalDamage = aceCard.value + otherCard.value;
|
let totalDamage = aceCard.value + otherCard.value;
|
||||||
|
|
||||||
// 如果另一张牌是梅花,双倍伤害
|
// 如果另一张牌是梅花,双倍伤害
|
||||||
if (otherCard.suit === 'clubs') {
|
if (otherCard.suit === "clubs") {
|
||||||
totalDamage *= 2;
|
totalDamage *= 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
let attackReduction = 0;
|
let attackReduction = 0;
|
||||||
if (aceCard.suit === 'spades') {
|
if (aceCard.suit === "spades") {
|
||||||
attackReduction += aceCard.value;
|
attackReduction += aceCard.value;
|
||||||
}
|
}
|
||||||
if (otherCard.suit === 'spades') {
|
if (otherCard.suit === "spades") {
|
||||||
attackReduction += otherCard.value;
|
attackReduction += otherCard.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
await game.produce(state => {
|
await game.produceAsync((state) => {
|
||||||
// 对敌人造成伤害
|
// 对敌人造成伤害
|
||||||
state.currentEnemy!.hp -= totalDamage;
|
state.currentEnemy!.hp -= totalDamage;
|
||||||
|
|
||||||
// 记录黑桃的攻击力降低
|
// 记录黑桃的攻击力降低
|
||||||
if (attackReduction > 0) {
|
if (attackReduction > 0) {
|
||||||
state.currentEnemy!.value = Math.max(0, state.currentEnemy!.value - attackReduction);
|
state.currentEnemy!.value = Math.max(
|
||||||
}
|
0,
|
||||||
|
state.currentEnemy!.value - attackReduction,
|
||||||
// 从手牌移除两张牌
|
);
|
||||||
const hand = state.playerHands[playerKey];
|
|
||||||
const aceIndex = hand.indexOf(aceCardId);
|
|
||||||
const otherIndex = hand.indexOf(otherCardId);
|
|
||||||
if (aceIndex !== -1) hand.splice(aceIndex, 1);
|
|
||||||
if (otherIndex !== -1) hand.splice(otherIndex, 1);
|
|
||||||
|
|
||||||
// 将卡牌移到弃牌堆
|
|
||||||
state.cards[aceCardId].regionId = 'discardPile';
|
|
||||||
state.cards[otherCardId].regionId = 'discardPile';
|
|
||||||
});
|
|
||||||
|
|
||||||
const enemyDefeated = isEnemyDefeated(state.currentEnemy);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
damage: totalDamage,
|
|
||||||
attackReduction,
|
|
||||||
enemyHp: state.currentEnemy!.hp,
|
|
||||||
enemyDefeated
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// 从手牌移除两张牌
|
||||||
|
const hand = state.playerHands[playerKey];
|
||||||
|
const aceIndex = hand.indexOf(aceCardId);
|
||||||
|
const otherIndex = hand.indexOf(otherCardId);
|
||||||
|
if (aceIndex !== -1) hand.splice(aceIndex, 1);
|
||||||
|
if (otherIndex !== -1) hand.splice(otherIndex, 1);
|
||||||
|
|
||||||
|
// 将卡牌移到弃牌堆
|
||||||
|
state.cards[aceCardId].regionId = "discardPile";
|
||||||
|
state.cards[otherCardId].regionId = "discardPile";
|
||||||
|
});
|
||||||
|
|
||||||
|
const enemyDefeated = isEnemyDefeated(state.currentEnemy);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
damage: totalDamage,
|
||||||
|
attackReduction,
|
||||||
|
enemyHp: state.currentEnemy!.hp,
|
||||||
|
enemyDefeated,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 让过(不出牌)
|
* 让过(不出牌)
|
||||||
*/
|
*/
|
||||||
const passCmd = registry.register({
|
async function passCmd(game: RegicideGame, player: string) {
|
||||||
schema: 'pass <player:string>',
|
// 即使让过,也会受到敌人反击(在回合结束时处理)
|
||||||
run: async (game: RegicideGame, player: string) => {
|
return { success: true, result: { message: `${player} 让过` } };
|
||||||
// 即使让过,也会受到敌人反击(在回合结束时处理)
|
}
|
||||||
return {success: true, result: {message: `${player} 让过`}};
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 敌人反击 - 玩家必须弃掉点数和 >= 敌人攻击力的牌
|
* 敌人反击 - 玩家必须弃掉点数和 >= 敌人攻击力的牌
|
||||||
*/
|
*/
|
||||||
const enemyCounterattackCmd = registry.register({
|
async function enemyCounterattackCmd(
|
||||||
schema: 'counterattack <player:string> <discardCards:string[]>',
|
game: RegicideGame,
|
||||||
run: async (game: RegicideGame, player: string, discardCards: string[]) => {
|
player: string,
|
||||||
const state = game.value;
|
discardCards: string[],
|
||||||
|
) {
|
||||||
|
const state = game.value;
|
||||||
|
|
||||||
if (!state.currentEnemy) {
|
if (!state.currentEnemy) {
|
||||||
return {success: false, error: '没有活跃的敌人'};
|
return { success: false, error: "没有活跃的敌人" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const playerKey = player as PlayerType;
|
const playerKey = player as PlayerType;
|
||||||
const playerHand = state.playerHands[playerKey];
|
const playerHand = state.playerHands[playerKey];
|
||||||
|
|
||||||
// 检查要弃的牌都在手牌中
|
// 检查要弃的牌都在手牌中
|
||||||
for (const cardId of discardCards) {
|
for (const cardId of discardCards) {
|
||||||
if (!playerHand.includes(cardId)) {
|
if (!playerHand.includes(cardId)) {
|
||||||
return {success: false, error: `卡牌 ${cardId} 不在手牌中`};
|
return { success: false, error: `卡牌 ${cardId} 不在手牌中` };
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算弃牌的点数和
|
|
||||||
let totalValue = 0;
|
|
||||||
for (const cardId of discardCards) {
|
|
||||||
const card = state.cards[cardId];
|
|
||||||
if (card) {
|
|
||||||
totalValue += card.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const enemyAttack = state.currentEnemy.value;
|
|
||||||
|
|
||||||
// 检查点数和是否 >= 敌人攻击力
|
|
||||||
if (totalValue < enemyAttack) {
|
|
||||||
return {
|
|
||||||
success: false,
|
|
||||||
error: `弃牌点数和 (${totalValue}) 小于敌人攻击力 (${enemyAttack}),游戏失败`
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 执行弃牌
|
|
||||||
await game.produce(state => {
|
|
||||||
const hand = state.playerHands[playerKey];
|
|
||||||
for (const cardId of discardCards) {
|
|
||||||
const index = hand.indexOf(cardId);
|
|
||||||
if (index !== -1) {
|
|
||||||
hand.splice(index, 1);
|
|
||||||
}
|
|
||||||
state.cards[cardId].regionId = 'discardPile';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
discardedCards: discardCards,
|
|
||||||
totalValue,
|
|
||||||
enemyAttack
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
// 计算弃牌的点数和
|
||||||
|
let totalValue = 0;
|
||||||
|
for (const cardId of discardCards) {
|
||||||
|
const card = state.cards[cardId];
|
||||||
|
if (card) {
|
||||||
|
totalValue += card.value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const enemyAttack = state.currentEnemy.value;
|
||||||
|
|
||||||
|
// 检查点数和是否 >= 敌人攻击力
|
||||||
|
if (totalValue < enemyAttack) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: `弃牌点数和 (${totalValue}) 小于敌人攻击力 (${enemyAttack}),游戏失败`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行弃牌
|
||||||
|
await game.produceAsync((state) => {
|
||||||
|
const hand = state.playerHands[playerKey];
|
||||||
|
for (const cardId of discardCards) {
|
||||||
|
const index = hand.indexOf(cardId);
|
||||||
|
if (index !== -1) {
|
||||||
|
hand.splice(index, 1);
|
||||||
|
}
|
||||||
|
state.cards[cardId].regionId = "discardPile";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
discardedCards: discardCards,
|
||||||
|
totalValue,
|
||||||
|
enemyAttack,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查敌人是否被击败,如果击败则翻开下一个敌人
|
* 检查敌人是否被击败,如果击败则翻开下一个敌人
|
||||||
*/
|
*/
|
||||||
const checkEnemyDefeatedCmd = registry.register({
|
async function checkEnemyDefeatedCmd(game: RegicideGame) {
|
||||||
schema: 'check-enemy',
|
const state = game.value;
|
||||||
run: async (game: RegicideGame) => {
|
|
||||||
const state = game.value;
|
|
||||||
|
|
||||||
if (!state.currentEnemy) {
|
if (!state.currentEnemy) {
|
||||||
return {success: false, error: '没有活跃的敌人'};
|
return { success: false as const, error: "没有活跃的敌人" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const defeated = state.currentEnemy.hp <= 0;
|
const defeated = state.currentEnemy.hp <= 0;
|
||||||
|
|
||||||
if (defeated) {
|
if (defeated) {
|
||||||
const defeatedEnemy = {...state.currentEnemy};
|
const defeatedEnemy = { ...state.currentEnemy };
|
||||||
|
|
||||||
await game.produce(state => {
|
await game.produceAsync((state) => {
|
||||||
// 将当前敌人移到弃牌堆
|
// 将当前敌人移到弃牌堆
|
||||||
state.regions.discardPile.childIds.push(state.currentEnemy!.id);
|
state.regions.discardPile.childIds.push(state.currentEnemy!.id);
|
||||||
|
|
||||||
// 翻开下一个敌人
|
// 翻开下一个敌人
|
||||||
if (state.enemyDeck.length > 0) {
|
if (state.enemyDeck.length > 0) {
|
||||||
const nextEnemy = state.enemyDeck.shift()!;
|
const nextEnemy = state.enemyDeck.shift()!;
|
||||||
state.currentEnemy = nextEnemy;
|
state.currentEnemy = nextEnemy;
|
||||||
} else {
|
} else {
|
||||||
// 没有更多敌人了
|
// 没有更多敌人了
|
||||||
state.currentEnemy = null;
|
state.currentEnemy = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 检查是否胜利(没有更多敌人)
|
// 检查是否胜利(没有更多敌人)
|
||||||
if (!game.value.currentEnemy) {
|
if (!game.value.currentEnemy) {
|
||||||
await game.produce(state => {
|
await game.produceAsync((state) => {
|
||||||
state.phase = 'victory';
|
state.phase = "victory";
|
||||||
state.winner = true;
|
state.winner = true;
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
defeated: true,
|
|
||||||
defeatedEnemy,
|
|
||||||
nextEnemy: game.value.currentEnemy,
|
|
||||||
enemiesRemaining: game.value.enemyDeck.length
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
defeated: false,
|
|
||||||
currentEnemy: {...state.currentEnemy},
|
|
||||||
enemiesRemaining: state.enemyDeck.length
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
return {
|
||||||
|
success: true as const,
|
||||||
|
result: {
|
||||||
|
defeated: true,
|
||||||
|
defeatedEnemy,
|
||||||
|
nextEnemy: game.value.currentEnemy,
|
||||||
|
enemiesRemaining: game.value.enemyDeck.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
defeated: false,
|
||||||
|
currentEnemy: { ...state.currentEnemy },
|
||||||
|
enemiesRemaining: state.enemyDeck.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查玩家是否有可出的牌
|
* 检查玩家是否有可出的牌
|
||||||
*/
|
*/
|
||||||
const checkCanPlayCmd = registry.register({
|
async function checkCanPlayCmd(game: RegicideGame, player: string) {
|
||||||
schema: 'check-can-play <player:string>',
|
const state = game.value;
|
||||||
run: async (game: RegicideGame, player: string) => {
|
const playerKey = player as PlayerType;
|
||||||
const state = game.value;
|
const playerHand = state.playerHands[playerKey];
|
||||||
const playerKey = player as PlayerType;
|
|
||||||
const playerHand = state.playerHands[playerKey];
|
|
||||||
|
|
||||||
const canPlay = playerHand.length > 0;
|
const canPlay = playerHand.length > 0;
|
||||||
const canPlayWithA = playerHand.some(cardId => {
|
const canPlayWithA = playerHand.some((cardId) => {
|
||||||
const card = state.cards[cardId];
|
const card = state.cards[cardId];
|
||||||
return card && card.rank === 'A' && playerHand.length > 1;
|
return card && card.rank === "A" && playerHand.length > 1;
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
canPlay,
|
canPlay,
|
||||||
canPlayWithA,
|
canPlayWithA,
|
||||||
handSize: playerHand.length
|
handSize: playerHand.length,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查酒馆牌堆是否为空
|
* 检查酒馆牌堆是否为空
|
||||||
*/
|
*/
|
||||||
const checkTavernDeckCmd = registry.register({
|
async function checkTavernDeckCmd(game: RegicideGame) {
|
||||||
schema: 'check-tavern-deck',
|
const state = game.value;
|
||||||
run: async (game: RegicideGame) => {
|
const isEmpty = state.regions.tavernDeck.childIds.length === 0;
|
||||||
const state = game.value;
|
|
||||||
const isEmpty = state.regions.tavernDeck.childIds.length === 0;
|
|
||||||
|
|
||||||
// 如果酒馆牌堆为空且所有玩家手牌也为空,则游戏失败
|
// 如果酒馆牌堆为空且所有玩家手牌也为空,则游戏失败
|
||||||
if (isEmpty) {
|
if (isEmpty) {
|
||||||
const allHandsEmpty = Object.values(state.playerHands).every(hand => hand.length === 0);
|
const allHandsEmpty = Object.values(state.playerHands).every(
|
||||||
if (allHandsEmpty) {
|
(hand) => hand.length === 0,
|
||||||
await game.produce(state => {
|
);
|
||||||
state.phase = 'defeat';
|
if (allHandsEmpty) {
|
||||||
state.winner = false;
|
await game.produceAsync((state) => {
|
||||||
});
|
state.phase = "defeat";
|
||||||
}
|
state.winner = false;
|
||||||
}
|
});
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
isEmpty,
|
|
||||||
cardsRemaining: state.regions.tavernDeck.childIds.length
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
isEmpty,
|
||||||
|
cardsRemaining: state.regions.tavernDeck.childIds.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 下一个玩家回合
|
* 下一个玩家回合
|
||||||
*/
|
*/
|
||||||
const nextTurnCmd = registry.register({
|
async function nextTurnCmd(game: RegicideGame) {
|
||||||
schema: 'next-turn',
|
const state = game.value;
|
||||||
run: async (game: RegicideGame) => {
|
await game.produce((state) => {
|
||||||
const state = game.value;
|
state.currentPlayerIndex =
|
||||||
await game.produce(state => {
|
(state.currentPlayerIndex + 1) % state.playerCount;
|
||||||
state.currentPlayerIndex = (state.currentPlayerIndex + 1) % state.playerCount;
|
});
|
||||||
});
|
|
||||||
|
|
||||||
const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4'];
|
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
|
||||||
const currentPlayer = players[game.value.currentPlayerIndex];
|
const currentPlayer = players[game.value.currentPlayerIndex];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
currentPlayer,
|
currentPlayer,
|
||||||
currentPlayerIndex: game.value.currentPlayerIndex
|
currentPlayerIndex: game.value.currentPlayerIndex,
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
playCmd as play,
|
playCmd as play,
|
||||||
playWithACmd as playWithA,
|
playWithACmd as playWithA,
|
||||||
passCmd as pass,
|
passCmd as pass,
|
||||||
enemyCounterattackCmd as enemyCounterattack,
|
enemyCounterattackCmd as enemyCounterattack,
|
||||||
checkEnemyDefeatedCmd as checkEnemy,
|
checkEnemyDefeatedCmd as checkEnemy,
|
||||||
checkCanPlayCmd as checkCanPlay,
|
checkCanPlayCmd as checkCanPlay,
|
||||||
checkTavernDeckCmd as checkTavernDeck,
|
checkTavernDeckCmd as checkTavernDeck,
|
||||||
nextTurnCmd as nextTurn,
|
nextTurnCmd as nextTurn,
|
||||||
};
|
};
|
||||||
|
|||||||
+219
-185
@@ -1,8 +1,15 @@
|
|||||||
import {IGameContext} from "@/core/game";
|
import { IGameContext } from "@/core/game";
|
||||||
import {RegicideState} from "@/samples/regicide/state";
|
import { RegicideState } from "@/samples/regicide/state";
|
||||||
import {buildEnemyDeck, buildTavernDeck, createAllCards, getPlayerHandRegionId} from "@/samples/regicide/utils";
|
import {
|
||||||
import {INITIAL_HAND_SIZE} from "@/samples/regicide/constants";
|
buildEnemyDeck,
|
||||||
import {Enemy, PlayerType, RegicideCard} from "@/samples/regicide/types";
|
buildTavernDeck,
|
||||||
|
createAllCards,
|
||||||
|
getPlayerHandRegionId,
|
||||||
|
} from "@/samples/regicide/utils";
|
||||||
|
import { INITIAL_HAND_SIZE } from "@/samples/regicide/constants";
|
||||||
|
import { Enemy, PlayerType, RegicideCard } from "@/samples/regicide/types";
|
||||||
|
import { checkEnemy, nextTurn, pass, playWithA } from "./commands";
|
||||||
|
import { enemyCounterattackCmd, playCmd } from ".";
|
||||||
|
|
||||||
export type RegicideGame = IGameContext<RegicideState>;
|
export type RegicideGame = IGameContext<RegicideState>;
|
||||||
|
|
||||||
@@ -12,228 +19,255 @@ export type RegicideGame = IGameContext<RegicideState>;
|
|||||||
* @param playerCount 玩家数量(1-4)
|
* @param playerCount 玩家数量(1-4)
|
||||||
* @param seed 随机种子(可选)
|
* @param seed 随机种子(可选)
|
||||||
*/
|
*/
|
||||||
export async function setupGame(game: RegicideGame, playerCount: number, seed?: number) {
|
export async function setupGame(
|
||||||
if (playerCount < 1 || playerCount > 4) {
|
game: RegicideGame,
|
||||||
throw new Error('玩家数量必须为 1-4 人');
|
playerCount: number,
|
||||||
|
seed?: number,
|
||||||
|
) {
|
||||||
|
if (playerCount < 1 || playerCount > 4) {
|
||||||
|
throw new Error("玩家数量必须为 1-4 人");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (seed) {
|
||||||
|
// RNG seeding handled by game context
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建所有卡牌
|
||||||
|
const allCards = createAllCards();
|
||||||
|
|
||||||
|
// 构建敌人牌堆(J/Q/K)
|
||||||
|
const enemyDeck = buildEnemyDeck(game._rng);
|
||||||
|
|
||||||
|
// 构建酒馆牌堆(A-10)
|
||||||
|
const tavernDeck = buildTavernDeck(game._rng);
|
||||||
|
|
||||||
|
// 初始化游戏状态
|
||||||
|
await game.produceAsync((state) => {
|
||||||
|
state.cards = allCards;
|
||||||
|
state.playerCount = playerCount;
|
||||||
|
state.currentPlayerIndex = 0;
|
||||||
|
state.enemyDeck = enemyDeck;
|
||||||
|
|
||||||
|
// 设置酒馆牌堆区域
|
||||||
|
for (const card of tavernDeck) {
|
||||||
|
card.regionId = "tavernDeck";
|
||||||
|
state.regions.tavernDeck.childIds.push(card.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (seed) {
|
// 设置敌人牌堆区域(只存储ID,敌人是独立对象)
|
||||||
// RNG seeding handled by game context
|
state.regions.enemyDeck.childIds = enemyDeck.map((e) => e.id);
|
||||||
|
|
||||||
|
// 给每个玩家发牌
|
||||||
|
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
|
||||||
|
for (let i = 0; i < playerCount; i++) {
|
||||||
|
const player = players[i];
|
||||||
|
const regionId = getPlayerHandRegionId(player);
|
||||||
|
|
||||||
|
for (let j = 0; j < INITIAL_HAND_SIZE; j++) {
|
||||||
|
if (tavernDeck.length === 0) break;
|
||||||
|
const card = tavernDeck.shift()!;
|
||||||
|
card.regionId = regionId;
|
||||||
|
state.playerHands[player].push(card.id);
|
||||||
|
const region = state.regions[regionId as keyof typeof state.regions];
|
||||||
|
region.childIds.push(card.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建所有卡牌
|
// 翻开第一个敌人
|
||||||
const allCards = createAllCards();
|
if (enemyDeck.length > 0) {
|
||||||
|
const firstEnemy = enemyDeck.shift()!;
|
||||||
// 构建敌人牌堆(J/Q/K)
|
state.currentEnemy = firstEnemy;
|
||||||
const enemyDeck = buildEnemyDeck(game._rng);
|
}
|
||||||
|
});
|
||||||
// 构建酒馆牌堆(A-10)
|
|
||||||
const tavernDeck = buildTavernDeck(game._rng);
|
|
||||||
|
|
||||||
// 初始化游戏状态
|
|
||||||
await game.produceAsync(state => {
|
|
||||||
state.cards = allCards;
|
|
||||||
state.playerCount = playerCount;
|
|
||||||
state.currentPlayerIndex = 0;
|
|
||||||
state.enemyDeck = enemyDeck;
|
|
||||||
|
|
||||||
// 设置酒馆牌堆区域
|
|
||||||
for (const card of tavernDeck) {
|
|
||||||
card.regionId = 'tavernDeck';
|
|
||||||
state.regions.tavernDeck.childIds.push(card.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置敌人牌堆区域(只存储ID,敌人是独立对象)
|
|
||||||
state.regions.enemyDeck.childIds = enemyDeck.map(e => e.id);
|
|
||||||
|
|
||||||
// 给每个玩家发牌
|
|
||||||
const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4'];
|
|
||||||
for (let i = 0; i < playerCount; i++) {
|
|
||||||
const player = players[i];
|
|
||||||
const regionId = getPlayerHandRegionId(player);
|
|
||||||
|
|
||||||
for (let j = 0; j < INITIAL_HAND_SIZE; j++) {
|
|
||||||
if (tavernDeck.length === 0) break;
|
|
||||||
const card = tavernDeck.shift()!;
|
|
||||||
card.regionId = regionId;
|
|
||||||
state.playerHands[player].push(card.id);
|
|
||||||
const region = state.regions[regionId as keyof typeof state.regions];
|
|
||||||
region.childIds.push(card.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 翻开第一个敌人
|
|
||||||
if (enemyDeck.length > 0) {
|
|
||||||
const firstEnemy = enemyDeck.shift()!;
|
|
||||||
state.currentEnemy = firstEnemy;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 启动游戏主循环
|
* 启动游戏主循环
|
||||||
*/
|
*/
|
||||||
export async function start(game: RegicideGame) {
|
export async function start(game: RegicideGame) {
|
||||||
const state = game.value;
|
const state = game.value;
|
||||||
|
|
||||||
// 检查游戏是否已设置
|
// 检查游戏是否已设置
|
||||||
if (!state.currentEnemy) {
|
if (!state.currentEnemy) {
|
||||||
throw new Error('请先调用 setupGame 初始化游戏');
|
throw new Error("请先调用 setupGame 初始化游戏");
|
||||||
|
}
|
||||||
|
|
||||||
|
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
|
||||||
|
|
||||||
|
// 主游戏循环
|
||||||
|
while (state.phase === "playing") {
|
||||||
|
const currentPlayerIndex = state.currentPlayerIndex;
|
||||||
|
const currentPlayer = players[currentPlayerIndex];
|
||||||
|
|
||||||
|
// 检查当前玩家是否有手牌
|
||||||
|
const currentHand = state.playerHands[currentPlayer];
|
||||||
|
if (currentHand.length === 0) {
|
||||||
|
// 玩家没有手牌,跳过回合
|
||||||
|
await game.produceAsync((state) => {
|
||||||
|
state.currentPlayerIndex =
|
||||||
|
(state.currentPlayerIndex + 1) % state.playerCount;
|
||||||
|
});
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4'];
|
// 等待玩家输入(出牌或让过)
|
||||||
|
// 这里需要外部通过 prompt 系统获取输入
|
||||||
|
// 实际使用时由 UI 或测试代码提供输入
|
||||||
|
|
||||||
// 主游戏循环
|
// 循环会在外部调用 play/pass 命令后继续
|
||||||
while (state.phase === 'playing') {
|
// 当 phase 变为 'victory' 或 'defeat' 时退出
|
||||||
const currentPlayerIndex = state.currentPlayerIndex;
|
break;
|
||||||
const currentPlayer = players[currentPlayerIndex];
|
}
|
||||||
|
|
||||||
// 检查当前玩家是否有手牌
|
return game.value;
|
||||||
const currentHand = state.playerHands[currentPlayer];
|
|
||||||
if (currentHand.length === 0) {
|
|
||||||
// 玩家没有手牌,跳过回合
|
|
||||||
await game.produceAsync(state => {
|
|
||||||
state.currentPlayerIndex = (state.currentPlayerIndex + 1) % state.playerCount;
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 等待玩家输入(出牌或让过)
|
|
||||||
// 这里需要外部通过 prompt 系统获取输入
|
|
||||||
// 实际使用时由 UI 或测试代码提供输入
|
|
||||||
|
|
||||||
// 循环会在外部调用 play/pass 命令后继续
|
|
||||||
// 当 phase 变为 'victory' 或 'defeat' 时退出
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return game.value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理完整的玩家回合
|
* 处理完整的玩家回合
|
||||||
*/
|
*/
|
||||||
export async function playTurn(game: RegicideGame, player: PlayerType, action: 'play' | 'pass', cardId?: string, secondCardId?: string) {
|
export async function playTurn(
|
||||||
const state = game.value;
|
game: RegicideGame,
|
||||||
|
player: PlayerType,
|
||||||
|
action: "play" | "pass",
|
||||||
|
cardId?: string,
|
||||||
|
secondCardId?: string,
|
||||||
|
) {
|
||||||
|
const state = game.value;
|
||||||
|
|
||||||
if (state.phase !== 'playing') {
|
if (state.phase !== "playing") {
|
||||||
return {success: false, error: '游戏已结束'};
|
return { success: false, error: "游戏已结束" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.currentEnemy) {
|
if (!state.currentEnemy) {
|
||||||
return {success: false, error: '没有活跃的敌人'};
|
return { success: false, error: "没有活跃的敌人" };
|
||||||
}
|
}
|
||||||
|
|
||||||
let playResult: any;
|
let playResult: any;
|
||||||
|
|
||||||
// 执行玩家动作
|
// 执行玩家动作
|
||||||
if (action === 'play' && cardId) {
|
if (action === "play" && cardId) {
|
||||||
// 检查是否是A配合另一张牌
|
// 检查是否是A配合另一张牌
|
||||||
const card = state.cards[cardId];
|
const card = state.cards[cardId];
|
||||||
if (card.rank === 'A' && secondCardId) {
|
if (card.rank === "A" && secondCardId) {
|
||||||
playResult = await game.run(`play-with-a ${player} ${cardId} ${secondCardId}`);
|
playResult = await playWithA(game, player, cardId, secondCardId);
|
||||||
} else {
|
|
||||||
playResult = await game.run(`play ${player} ${cardId}`);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 让过
|
playResult = await playCmd(game, player, cardId);
|
||||||
playResult = await game.run(`pass ${player}`);
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// 让过
|
||||||
|
playResult = await pass(game, player);
|
||||||
|
}
|
||||||
|
|
||||||
if (!playResult.success) {
|
if (!playResult.success) {
|
||||||
return playResult;
|
return playResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查敌人是否被击败
|
// 检查敌人是否被击败
|
||||||
const checkResult = await game.run<{defeated: boolean; currentEnemy?: any; nextEnemy?: any; defeatedEnemy?: any; enemiesRemaining?: number}>('check-enemy');
|
const checkResult = await checkEnemy(game);
|
||||||
if (!checkResult.success) {
|
if (!checkResult.success) {
|
||||||
return checkResult;
|
return checkResult;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果敌人未被击败,处理反击
|
|
||||||
if (!checkResult.result.defeated) {
|
|
||||||
// 反击逻辑需要玩家选择弃牌,这里返回状态让外部处理
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
playResult: playResult.result,
|
|
||||||
enemyDefeated: false,
|
|
||||||
needsDiscard: true,
|
|
||||||
enemyAttack: state.currentEnemy.value,
|
|
||||||
playerHand: state.playerHands[player]
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 敌人被击败,检查是否还有更多敌人
|
|
||||||
if (state.enemyDeck.length === 0 && state.currentEnemy && state.currentEnemy.hp <= 0) {
|
|
||||||
await game.produceAsync(state => {
|
|
||||||
state.phase = 'victory';
|
|
||||||
state.winner = true;
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
result: {
|
|
||||||
playResult: playResult.result,
|
|
||||||
enemyDefeated: true,
|
|
||||||
gameWon: true
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 切换到下一个玩家
|
|
||||||
await game.run('next-turn');
|
|
||||||
|
|
||||||
|
// 如果敌人未被击败,处理反击
|
||||||
|
if (!checkResult.result.defeated) {
|
||||||
|
// 反击逻辑需要玩家选择弃牌,这里返回状态让外部处理
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
result: {
|
result: {
|
||||||
playResult: playResult.result,
|
playResult: playResult.result,
|
||||||
enemyDefeated: true,
|
enemyDefeated: false,
|
||||||
nextEnemy: state.currentEnemy
|
needsDiscard: true,
|
||||||
}
|
enemyAttack: state.currentEnemy.value,
|
||||||
|
playerHand: state.playerHands[player],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 敌人被击败,检查是否还有更多敌人
|
||||||
|
if (
|
||||||
|
state.enemyDeck.length === 0 &&
|
||||||
|
state.currentEnemy &&
|
||||||
|
state.currentEnemy.hp <= 0
|
||||||
|
) {
|
||||||
|
await game.produceAsync((state) => {
|
||||||
|
state.phase = "victory";
|
||||||
|
state.winner = true;
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
playResult: playResult.result,
|
||||||
|
enemyDefeated: true,
|
||||||
|
gameWon: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换到下一个玩家
|
||||||
|
await nextTurn(game);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: {
|
||||||
|
playResult: playResult.result,
|
||||||
|
enemyDefeated: true,
|
||||||
|
nextEnemy: state.currentEnemy,
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理反击阶段的弃牌
|
* 处理反击阶段的弃牌
|
||||||
*/
|
*/
|
||||||
export async function handleCounterattack(game: RegicideGame, player: PlayerType, discardCardIds: string[]) {
|
export async function handleCounterattack(
|
||||||
const result = await game.run(`counterattack ${player} ${JSON.stringify(discardCardIds)}`);
|
game: RegicideGame,
|
||||||
|
player: PlayerType,
|
||||||
if (!result.success) {
|
discardCardIds: string[],
|
||||||
// 弃牌失败(点数和不足),游戏失败
|
) {
|
||||||
await game.produceAsync(state => {
|
const result = await enemyCounterattackCmd(game, player, discardCardIds);
|
||||||
state.phase = 'defeat';
|
if (!result.success) {
|
||||||
state.winner = false;
|
// 弃牌失败(点数和不足),游戏失败
|
||||||
});
|
await game.produceAsync((state) => {
|
||||||
return result;
|
state.phase = "defeat";
|
||||||
}
|
state.winner = false;
|
||||||
|
});
|
||||||
// 弃牌成功,切换到下一个玩家
|
|
||||||
await game.run('next-turn');
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弃牌成功,切换到下一个玩家
|
||||||
|
await nextTurn(game);
|
||||||
|
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取当前游戏状态摘要
|
* 获取当前游戏状态摘要
|
||||||
*/
|
*/
|
||||||
export function getGameStatus(game: RegicideGame) {
|
export function getGameStatus(game: RegicideGame) {
|
||||||
const state = game.value;
|
const state = game.value;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
phase: state.phase,
|
phase: state.phase,
|
||||||
currentPlayer: ['player1', 'player2', 'player3', 'player4'][state.currentPlayerIndex],
|
currentPlayer: ["player1", "player2", "player3", "player4"][
|
||||||
currentEnemy: state.currentEnemy ? {
|
state.currentPlayerIndex
|
||||||
...state.currentEnemy,
|
],
|
||||||
hpPercent: Math.round((state.currentEnemy.hp / state.currentEnemy.maxHp) * 100)
|
currentEnemy: state.currentEnemy
|
||||||
} : null,
|
? {
|
||||||
enemiesRemaining: state.enemyDeck.length,
|
...state.currentEnemy,
|
||||||
tavernDeckCount: state.regions.tavernDeck.childIds.length,
|
hpPercent: Math.round(
|
||||||
discardPileCount: state.regions.discardPile.childIds.length,
|
(state.currentEnemy.hp / state.currentEnemy.maxHp) * 100,
|
||||||
playerHands: Object.fromEntries(
|
),
|
||||||
Object.entries(state.playerHands).map(([player, hand]) => [player, hand.length])
|
}
|
||||||
),
|
: null,
|
||||||
winner: state.winner
|
enemiesRemaining: state.enemyDeck.length,
|
||||||
};
|
tavernDeckCount: state.regions.tavernDeck.childIds.length,
|
||||||
|
discardPileCount: state.regions.discardPile.childIds.length,
|
||||||
|
playerHands: Object.fromEntries(
|
||||||
|
Object.entries(state.playerHands).map(([player, hand]) => [
|
||||||
|
player,
|
||||||
|
hand.length,
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
winner: state.winner,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +1,63 @@
|
|||||||
// Types
|
// Types
|
||||||
export type {
|
export type {
|
||||||
SuitType,
|
SuitType,
|
||||||
CardRank,
|
CardRank,
|
||||||
PlayerType,
|
PlayerType,
|
||||||
RegionType,
|
RegionType,
|
||||||
RegicideCardMeta,
|
RegicideCardMeta,
|
||||||
RegicideCard,
|
RegicideCard,
|
||||||
Enemy,
|
Enemy,
|
||||||
GamePhase
|
GamePhase,
|
||||||
} from './types';
|
} from "./types";
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
export {
|
export {
|
||||||
ENEMY_COUNT,
|
ENEMY_COUNT,
|
||||||
INITIAL_HAND_SIZE,
|
INITIAL_HAND_SIZE,
|
||||||
CARD_VALUES,
|
CARD_VALUES,
|
||||||
ALL_SUITS,
|
ALL_SUITS,
|
||||||
ALL_RANKS,
|
ALL_RANKS,
|
||||||
FACE_CARDS,
|
FACE_CARDS,
|
||||||
NUMBER_CARDS
|
NUMBER_CARDS,
|
||||||
} from './constants';
|
} from "./constants";
|
||||||
|
|
||||||
// State
|
// State
|
||||||
export {
|
export { createInitialState, type RegicideState } from "./state";
|
||||||
createInitialState,
|
|
||||||
type RegicideState
|
|
||||||
} from './state';
|
|
||||||
|
|
||||||
// Prompts
|
// Prompts
|
||||||
export {prompts} from './prompts';
|
export { prompts } from "./prompts";
|
||||||
|
|
||||||
// Commands
|
// Commands
|
||||||
export {
|
export {
|
||||||
registry,
|
play as playCmd,
|
||||||
play as playCmd,
|
playWithA as playWithACmd,
|
||||||
playWithA as playWithACmd,
|
pass as passCmd,
|
||||||
pass as passCmd,
|
enemyCounterattack as enemyCounterattackCmd,
|
||||||
enemyCounterattack as enemyCounterattackCmd,
|
checkEnemy as checkEnemyCmd,
|
||||||
checkEnemy as checkEnemyCmd,
|
checkCanPlay as checkCanPlayCmd,
|
||||||
checkCanPlay as checkCanPlayCmd,
|
checkTavernDeck as checkTavernDeckCmd,
|
||||||
checkTavernDeck as checkTavernDeckCmd,
|
nextTurn as nextTurnCmd,
|
||||||
nextTurn as nextTurnCmd,
|
} from "./commands";
|
||||||
} from './commands';
|
|
||||||
|
|
||||||
// Game
|
// Game
|
||||||
export {
|
export {
|
||||||
setupGame,
|
setupGame,
|
||||||
start,
|
start,
|
||||||
playTurn,
|
playTurn,
|
||||||
handleCounterattack,
|
handleCounterattack,
|
||||||
getGameStatus,
|
getGameStatus,
|
||||||
type RegicideGame
|
type RegicideGame,
|
||||||
} from './game';
|
} from "./game";
|
||||||
|
|
||||||
// Utils
|
// Utils
|
||||||
export {
|
export {
|
||||||
getCardValue,
|
getCardValue,
|
||||||
createCard,
|
createCard,
|
||||||
createEnemy,
|
createEnemy,
|
||||||
createAllCards,
|
createAllCards,
|
||||||
buildEnemyDeck,
|
buildEnemyDeck,
|
||||||
buildTavernDeck,
|
buildTavernDeck,
|
||||||
drawFromDeck,
|
drawFromDeck,
|
||||||
isEnemyDefeated,
|
isEnemyDefeated,
|
||||||
getPlayerHandRegionId
|
getPlayerHandRegionId,
|
||||||
} from './utils';
|
} from "./utils";
|
||||||
|
|||||||
@@ -1,72 +0,0 @@
|
|||||||
# slay the spire + backpack heroes
|
|
||||||
|
|
||||||
玩家有4x6的背包
|
|
||||||
|
|
||||||
装备占格子,提供同等数量的牌,并且给周围加buff,背包英雄style
|
|
||||||
|
|
||||||
战斗时玩杀戮尖塔
|
|
||||||
|
|
||||||
## Roguelike structure
|
|
||||||
|
|
||||||
Zone based Point Crawl:
|
|
||||||
- 分4个区域,每个区域有独立的随机遭遇池
|
|
||||||
- 遭遇分为随机遭遇、战斗、服务
|
|
||||||
- 随机遭遇使用d6双阶段结构,共12遭遇
|
|
||||||
- 战斗遭遇分简单和困难,困难可以绕
|
|
||||||
- 服务遭遇分商店(恢复/卖装备/附魔)篝火(恢复/补充药水/下次战斗buff)npc(恢复/任务(任务=完成特定地点遭遇获得奖励))
|
|
||||||
- 每区域12遭遇
|
|
||||||
- 战斗:非战斗=8:4
|
|
||||||
- 5小怪,2精英,1boss,1店1篝1npc1随机
|
|
||||||
- 总遭遇分支1-2-3-4-5-6-6-5-4-3-2-1
|
|
||||||
|
|
||||||
## 战斗
|
|
||||||
|
|
||||||
典中典5手牌3费,每个格子1张牌,同一装备牌逻辑相同,但根据邻接吃到的牌buff不同
|
|
||||||
|
|
||||||
3种牌(攻击/防御/强化)攻击克强化克防御克攻击,强化比sts强化窄但效果更大
|
|
||||||
|
|
||||||
洗牌堆时洗入两张《疲劳》(1费/消耗)
|
|
||||||
|
|
||||||
背包物品可能会引用相邻的物品、同一行的物品、同一列的物品等
|
|
||||||
|
|
||||||
部分物品带使用次数限制,药瓶喝完可以在篝火补
|
|
||||||
|
|
||||||
## 费用
|
|
||||||
|
|
||||||
60%牌1费,20%牌2费,20%牌0费
|
|
||||||
|
|
||||||
怪物强化平均节奏较sts更快
|
|
||||||
|
|
||||||
## 关键词
|
|
||||||
|
|
||||||
行动关键词:以【攻击4】表示。分为指向行动或非指向行动。随机打出指向行动时,指向随机目标。
|
|
||||||
|
|
||||||
buff/debuff:以【防御3】表示。分为不衰减、回合衰减和临时三种。
|
|
||||||
|
|
||||||
物品的构筑配合按邻接性设计,临场动态策略配合按buff考虑
|
|
||||||
|
|
||||||
## Buff / Debuff
|
|
||||||
|
|
||||||
玩家buff主要按照“有默认功能的货币”考虑
|
|
||||||
|
|
||||||
debuff暂时只考虑“条件回合覆盖”类debuff,有时可以当货币用
|
|
||||||
|
|
||||||
敌人buff和debuff与玩家对称设计
|
|
||||||
|
|
||||||
## 遗物与药水
|
|
||||||
|
|
||||||
药水在sts里属于不稳定容错,比较鸡肋
|
|
||||||
|
|
||||||
这里药水作为物品携带,0费消耗次数使用,随篝火补充,功能较难溢出且稳定性更强
|
|
||||||
|
|
||||||
纯触发奖励性遗物用以调节节奏,还是要有的,但是分池发
|
|
||||||
|
|
||||||
## 区域设计
|
|
||||||
|
|
||||||
每区域10小怪,5精英,3boss,12随机遭遇
|
|
||||||
|
|
||||||
## 设计量
|
|
||||||
|
|
||||||
~20 物品 每角色每池
|
|
||||||
-10 小怪 4 精英 1 boss 每区域
|
|
||||||
-8 遗物 每角色
|
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
# Desert
|
|
||||||
|
|
||||||
## 小怪 - 10种
|
|
||||||
|
|
||||||
### 仙人掌怪
|
|
||||||
|
|
||||||
概念:防+强化
|
|
||||||
|
|
||||||
【尖刺X】:对攻击者造成X点伤害
|
|
||||||
|
|
||||||
### 蛇
|
|
||||||
|
|
||||||
概念:攻+强化
|
|
||||||
|
|
||||||
蛇毒/1费:消耗。一回合弃掉超过1张蛇毒时,受到6伤害。
|
|
||||||
|
|
||||||
### 木乃伊
|
|
||||||
|
|
||||||
概念:攻+防
|
|
||||||
|
|
||||||
【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌
|
|
||||||
|
|
||||||
### 枪手
|
|
||||||
|
|
||||||
概念:单回高攻
|
|
||||||
|
|
||||||
【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】。
|
|
||||||
|
|
||||||
### 风卷草
|
|
||||||
|
|
||||||
概念:防+强化
|
|
||||||
|
|
||||||
【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。
|
|
||||||
|
|
||||||
### 秃鹫
|
|
||||||
|
|
||||||
概念:攻+防
|
|
||||||
|
|
||||||
若造成伤害,玩家获得秃鹫之眼。
|
|
||||||
|
|
||||||
秃鹫之眼/0费:受到秃鹫的攻击。当你受到伤害时,自动从手牌打出。
|
|
||||||
|
|
||||||
### 沙蝎
|
|
||||||
|
|
||||||
概念:攻+强化
|
|
||||||
|
|
||||||
【尾刺X】:玩家回合结束时受到沙蝎的X点攻击。受伤时失去等量【尾刺】。
|
|
||||||
|
|
||||||
### 幼沙虫
|
|
||||||
|
|
||||||
防+强化
|
|
||||||
|
|
||||||
每回合第一次受伤时,玩家失去1点能量。
|
|
||||||
|
|
||||||
### 蜥蜴
|
|
||||||
|
|
||||||
概念:攻+防+逃跑
|
|
||||||
|
|
||||||
【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
|
|
||||||
|
|
||||||
### 沙匪
|
|
||||||
|
|
||||||
概念:攻特化
|
|
||||||
|
|
||||||
洗牌时,将一个随机物品的牌全部弃掉。
|
|
||||||
|
|
||||||
## 精英 - 4种
|
|
||||||
|
|
||||||
### 风暴之灵
|
|
||||||
|
|
||||||
静电/0费:抓一张牌。在手里时,受到【电击】伤害+1。
|
|
||||||
|
|
||||||
【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。
|
|
||||||
|
|
||||||
### 骑马枪手
|
|
||||||
|
|
||||||
【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。
|
|
||||||
|
|
||||||
### 沙虫王
|
|
||||||
|
|
||||||
召唤幼体沙虫
|
|
||||||
|
|
||||||
每当玩家弃掉一张牌,恢复1生命
|
|
||||||
|
|
||||||
### 沙漠守卫
|
|
||||||
|
|
||||||
召唤木乃伊
|
|
||||||
|
|
||||||
会复活木乃伊2次
|
|
||||||
|
|
||||||
## boss - 法老之灵
|
|
||||||
|
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
# AGENTS.md - slay-the-spire-like
|
||||||
|
|
||||||
|
Roguelike deckbuilder combining Slay the Spire combat with Backpack Hero grid inventory.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Three-layer design: **system** (content-agnostic) → **data** (CSV content) → **dialogues** (Yarn Spinner).
|
||||||
|
|
||||||
|
### System Layer (`system/`)
|
||||||
|
|
||||||
|
| Module | Files | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `combat/` | types.ts, effects.ts, triggers.ts, prompts.ts | Combat state, effects pipeline, middleware triggers |
|
||||||
|
| `deck/` | types.ts, factory.ts, index.ts | Deck state management |
|
||||||
|
| `grid-inventory/` | types.ts, transform.ts, index.ts | Grid backpack, shape collision, adjacency |
|
||||||
|
| `map/` | types.ts, generator.ts, index.ts | Procedural node generation (point crawl) |
|
||||||
|
| `progress/` | types.ts, encounter.ts, index.ts | Adventure progress, node traversal |
|
||||||
|
| `utils/` | parse-shape.ts, shape-collision.ts | Shape parsing and collision detection |
|
||||||
|
| `types.ts` | — | `EffectData`, `EffectLifecycle`, `EnemyData`, `CardData`, `IntentData`, `EncounterData`, `ItemData` |
|
||||||
|
|
||||||
|
### Data Layer (`data/desert/`)
|
||||||
|
|
||||||
|
All content via CSV (loaded by `inline-schema`):
|
||||||
|
|
||||||
|
| File | Content |
|
||||||
|
|---|---|
|
||||||
|
| `card.csv` | Card definitions |
|
||||||
|
| `effect.csv` | Effect definitions |
|
||||||
|
| `enemy.csv` | Enemies + intents |
|
||||||
|
| `intent.csv` | Intent definitions |
|
||||||
|
| `item.csv` | Equipment items |
|
||||||
|
| `encounter.csv` | Encounter nodes |
|
||||||
|
|
||||||
|
### Triggers (`data/desert/triggers/`)
|
||||||
|
|
||||||
|
Middleware hooks extending the base trigger system:
|
||||||
|
|
||||||
|
| File | Hooks |
|
||||||
|
|---|---|
|
||||||
|
| `effect.ts` | Orchestrator — calls all sub-modules |
|
||||||
|
| `instant.ts` | `onEffectApplied` — attack, draw, gainEnergy, removeWound, summon, etc. |
|
||||||
|
| `damage.ts` | `onDamage` — block, spike, expose, aim, roll, charge, molt, tailSting, energyDrain |
|
||||||
|
| `turn-start.ts` | `onTurnStart` — discard, defendNext, energyNext, drawNext |
|
||||||
|
| `card-events.ts` | `onEnemyIntent`, `onCardPlayed`, `onCardDiscarded`, `onDamage` — storm, crossbow, burnForEnergy, sandwormKing, vulture |
|
||||||
|
|
||||||
|
### Dialogue Layer (`data/desert/dialogues/`)
|
||||||
|
|
||||||
|
`story.yarn` — branching encounter dialogues (inside `dialogues.yarnproject`). Commands resolve via `tryAnswerPrompt`.
|
||||||
|
|
||||||
|
## Key Mechanics
|
||||||
|
|
||||||
|
- **Energy**: 3/turn, spent on weapon/armor/tool cards
|
||||||
|
- **Uses**: Consumable cards use `uses` cost type instead of energy; tracked as `depletion` on `GameItemMeta`
|
||||||
|
- **Grid adjacency**: Items buff adjacent items (e.g. burnForEnergy consumes adjacent item on card play)
|
||||||
|
- **Effect lifecycles**: `instant`, `temporary`, `lingering`, `permanent`, `posture`, `item`, `itemTemporary`, `itemUntilPlay`, `itemUntilDiscard`, `itemPermanent`
|
||||||
|
- **Trigger pipeline**: `createMiddlewareChain` with `use(async (ctx, next) => ...)` pattern
|
||||||
|
- **Cards from items**: `GameCard` instances are generated from `GameItem` inventory items via deck factory
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Tests live in `tests/samples/slay-the-spire-like/`:
|
||||||
|
- `combat/effects.test.ts` — effect application, upkeep, posture damage
|
||||||
|
- `combat/triggers.test.ts` — full desert trigger integration tests
|
||||||
|
- `deck/factory.test.ts` — deck creation
|
||||||
|
- `grid-inventory.test.ts` — placement, adjacency, collision
|
||||||
|
- `map/generator.test.ts` — procedural generation
|
||||||
|
- `utils/shape-utils.test.ts` — shape parsing
|
||||||
|
- `data/index.test.ts` — data loading validation
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# cardDesert: unified card definitions for item cards and status cards
|
||||||
|
# type: 'item' = inventory item card, 'status' = status effect card
|
||||||
|
# costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
|
||||||
|
# targetType: 'single' = target one enemy, 'none' = no target
|
||||||
|
|
||||||
|
# type CardType = 'item' | 'status'
|
||||||
|
# type CardCostType = 'energy' | 'uses' | 'none'
|
||||||
|
# type CardTargetType = 'player' | 'enemy' | 'enemies'
|
||||||
|
|
||||||
|
# inject effects = ~cardEffect(card)
|
||||||
|
|
||||||
|
id,name,desc,type,costType,costCount,targetType
|
||||||
|
string,string,string,CardType,CardCostType,int,CardTargetType
|
||||||
|
sword,剑,【攻击2】【攻击2】,item,energy,1,enemy
|
||||||
|
greataxe,长斧,对全体【攻击5】,item,energy,2,enemies
|
||||||
|
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,enemy
|
||||||
|
dagger,短刀,【攻击3】【攻击3】,item,energy,1,enemy
|
||||||
|
dart,飞镖,【攻击1】抓一张牌,item,energy,0,enemy
|
||||||
|
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,enemy
|
||||||
|
shield,盾,【防御3】,item,energy,1,player
|
||||||
|
hat,斗笠,【防御8】,item,energy,2,player
|
||||||
|
cape,披风,【防御2】下回合【防御2】,item,energy,1,player
|
||||||
|
bracer,护腕,【防御1】抓1张牌,item,energy,0,player
|
||||||
|
greatshield,大盾,【防御5】,item,energy,1,player
|
||||||
|
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,player
|
||||||
|
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,player
|
||||||
|
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,player
|
||||||
|
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,player
|
||||||
|
vitalityPotion,活力药剂,获得1点能量,item,uses,3,player
|
||||||
|
focusPotion,集中药剂,抓2张牌,item,uses,3,player
|
||||||
|
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,player
|
||||||
|
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,player
|
||||||
|
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,player
|
||||||
|
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,player
|
||||||
|
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,player
|
||||||
|
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,player
|
||||||
|
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,player
|
||||||
|
wound,伤口,无效果占用手牌和牌堆,status,none,0,player
|
||||||
|
venom,蛇毒,弃掉时受到3点伤害,status,none,0,player
|
||||||
|
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,player
|
||||||
|
static,静电,在手里时受电击伤害+1,status,none,0,player
|
||||||
|
fatigue,疲劳,占用手牌,status,none,0,player
|
||||||
|
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,player
|
||||||
|
@@ -0,0 +1,21 @@
|
|||||||
|
import type { CardEffect } from './cardEffect.csv';
|
||||||
|
|
||||||
|
export type CardType = 'item' | 'status';
|
||||||
|
export type CardCostType = 'energy' | 'uses' | 'none';
|
||||||
|
export type CardTargetType = 'player' | 'enemy' | 'enemies';
|
||||||
|
|
||||||
|
type CardTable = readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly desc: string;
|
||||||
|
readonly type: CardType;
|
||||||
|
readonly costType: CardCostType;
|
||||||
|
readonly costCount: number;
|
||||||
|
readonly targetType: CardTargetType;
|
||||||
|
readonly effects: CardEffect[];
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type Card = CardTable[number];
|
||||||
|
|
||||||
|
declare function getData(): CardTable;
|
||||||
|
export default getData;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# type CardEffectTrigger = 'onPlay' | 'onDraw' | 'onDiscard'
|
||||||
|
# type CardEffectTarget = 'source' | 'eachTarget' | 'eachEnemy' | 'randomEnemy' | 'player'
|
||||||
|
# type CardEffectList = [effect: @effect; stacks: number][]
|
||||||
|
|
||||||
|
id,card,trigger,target,effects
|
||||||
|
string,@card,CardEffectTrigger,CardEffectTarget,CardEffectList
|
||||||
|
sword,sword,onPlay,eachTarget,[attack;2];[attack;2]
|
||||||
|
greataxe,greataxe,onPlay,eachTarget,[attack;5]
|
||||||
|
spear,spear,onPlay,eachTarget,[attack;2];[attack;2];[attack;2]
|
||||||
|
dagger,dagger,onPlay,eachTarget,[attack;3];[attack;3]
|
||||||
|
dart,dart,onPlay,eachTarget,[attack;1]
|
||||||
|
dart-draw,dart,onPlay,source,[draw;1]
|
||||||
|
crossbow,crossbow,onPlay,eachTarget,[attack;6]
|
||||||
|
crossbow-combo,crossbow,onPlay,source,[crossbow;0]
|
||||||
|
shield,shield,onPlay,source,[defend;3]
|
||||||
|
hat,hat,onPlay,source,[defend;8]
|
||||||
|
cape,cape,onPlay,source,[defend;2];[defendNext;2]
|
||||||
|
bracer,bracer,onPlay,source,[defend;1];[draw;1]
|
||||||
|
greatshield,greatshield,onPlay,source,[defend;5]
|
||||||
|
chainmail,chainmail,onPlay,source,[damageReduce;3]
|
||||||
|
bandage,bandage,onPlay,source,[removeWound;1]
|
||||||
|
poisonPotion,poisonPotion,onPlay,source,[attackBuff;2]
|
||||||
|
fortifyPotion,fortifyPotion,onPlay,source,[defendBuff;2]
|
||||||
|
vitalityPotion,vitalityPotion,onPlay,source,[gainEnergy;1]
|
||||||
|
focusPotion,focusPotion,onPlay,source,[draw;2]
|
||||||
|
healingPotion,healingPotion,onPlay,source,[removeWound;3]
|
||||||
|
waterBag,waterBag,onPlay,source,[energyNext;1];[drawNext;2]
|
||||||
|
rope,rope,onPlay,source,[defendBuffUntilPlay;2]
|
||||||
|
belt,belt,onPlay,source,[drawChoice;1]
|
||||||
|
torch,torch,onPlay,source,[burnForEnergy;1]
|
||||||
|
whetstone,whetstone,onPlay,source,[attackBuffUntilPlay;3]
|
||||||
|
blacksmithHammer,blacksmithHammer,onPlay,source,[transformRandom;1]
|
||||||
|
venom,venom,onDiscard,source,[attack;3]
|
||||||
|
curse,curse,onDraw,source,[curse;1]
|
||||||
|
static,static,onDraw,source,[static;1]
|
||||||
|
vultureEye,vultureEye,onDraw,source,[expose;3]
|
||||||
|
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Card } from './card.csv';
|
||||||
|
import type { Effect } from './effect.csv';
|
||||||
|
|
||||||
|
export type CardEffectTrigger = 'onPlay' | 'onDraw' | 'onDiscard';
|
||||||
|
export type CardEffectTarget = 'source' | 'eachTarget' | 'eachEnemy' | 'randomEnemy' | 'player';
|
||||||
|
export type CardEffectList = [effect: Effect, stacks: number][];
|
||||||
|
|
||||||
|
type CardEffectTable = readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly card: Card;
|
||||||
|
readonly trigger: CardEffectTrigger;
|
||||||
|
readonly target: CardEffectTarget;
|
||||||
|
readonly effects: CardEffectList;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type CardEffect = CardEffectTable[number];
|
||||||
|
|
||||||
|
declare function getData(): CardEffectTable;
|
||||||
|
export default getData;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"projectFileVersion": 4,
|
||||||
|
"sourceFiles": [
|
||||||
|
"**/*.yarn"
|
||||||
|
],
|
||||||
|
"excludeFiles": [],
|
||||||
|
"baseLanguage": "en",
|
||||||
|
"projectName": "encounters",
|
||||||
|
"authorName": [
|
||||||
|
"hyper"
|
||||||
|
],
|
||||||
|
"editorOptions": {
|
||||||
|
"yarnScriptEditor": {
|
||||||
|
"presenter": "try"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
import type { LoadResult } from 'yarn-spinner-loader';
|
||||||
|
const result: LoadResult;
|
||||||
|
export default result;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
title: Start
|
||||||
|
---
|
||||||
|
// A linear story progresses from one scene to the next.
|
||||||
|
// Great for cutscenes, tutorials, or scripted sequences.
|
||||||
|
|
||||||
|
<<jump Scene1>>
|
||||||
|
===
|
||||||
|
title: Scene1
|
||||||
|
---
|
||||||
|
The sun rose over the quiet village.
|
||||||
|
|
||||||
|
Birds sang their morning chorus as the first rays of light crept through the window.
|
||||||
|
|
||||||
|
-> Continue
|
||||||
|
<<jump Scene2>>
|
||||||
|
===
|
||||||
|
title: Scene2
|
||||||
|
---
|
||||||
|
In the kitchen, a kettle began to whistle.
|
||||||
|
|
||||||
|
The smell of fresh bread filled the air.
|
||||||
|
|
||||||
|
-> Continue
|
||||||
|
<<jump Scene3>>
|
||||||
|
===
|
||||||
|
title: Scene3
|
||||||
|
---
|
||||||
|
A knock at the door broke the peaceful morning.
|
||||||
|
|
||||||
|
Who could be visiting so early?
|
||||||
|
|
||||||
|
-> Open the door
|
||||||
|
<<jump Scene4>>
|
||||||
|
===
|
||||||
|
title: Scene4
|
||||||
|
---
|
||||||
|
A messenger stood at the threshold, parchment in hand.
|
||||||
|
|
||||||
|
"News from the capital," they said breathlessly. "You must read this at once."
|
||||||
|
|
||||||
|
// Your story continues from here!
|
||||||
|
===
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
# instant: 不施加buff,瞬间生效
|
||||||
|
|
||||||
|
# temporary: 施加buff,下回合开始时失效
|
||||||
|
# lingering: 施加buff,下回合开始时失去1层
|
||||||
|
# permanent: 施加buff
|
||||||
|
# posture: 施加buff,每受到1点伤害移除1层
|
||||||
|
|
||||||
|
# item: 施加buff到周围物品,永久生效
|
||||||
|
# itemTemporary: 施加buff到周围物品,持续到下次buff更新
|
||||||
|
# itemUntilPlay: 施加buff到周围物品,物品被打出后失效
|
||||||
|
# itemUntilDiscard: 施加buff到周围物品,物品被弃掉后失效
|
||||||
|
# itemPermanent: 施加buff到周围物品,持续整场冒险
|
||||||
|
|
||||||
|
# type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent'
|
||||||
|
|
||||||
|
id, name, description, lifecycle, emoji
|
||||||
|
string, string, string, EffectLifecycle, string
|
||||||
|
attack, 攻击, 对对手造成伤害, instant, ⚔️
|
||||||
|
defend, 防御, 抵消下次行动前受到的伤害, temporary, 🛡️
|
||||||
|
spike, 尖刺, 对攻击者造成X点伤害, permanent, 🌵
|
||||||
|
venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, instant, 🧪
|
||||||
|
curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, lingering, 💀
|
||||||
|
aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture, 🎯
|
||||||
|
roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture, 🎲
|
||||||
|
rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant, 💥
|
||||||
|
vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), instant, 👁️
|
||||||
|
tailSting, 尾刺, 攻击时,伤害提升X, posture, 🦂
|
||||||
|
energyDrain, 能量吸取, 受伤时,玩家失去X点能量, lingering, 🔋
|
||||||
|
molt, 脱皮, 若脱皮达到生命上限则怪物逃跑, posture, 🐚
|
||||||
|
discard, 劫掠, 回合开始时随机弃掉一张手牌, lingering, 🗑️
|
||||||
|
storm, 风暴, 攻击时给玩家塞入1张静电, permanent, ⚡
|
||||||
|
static, 静电, 在手里时受电击伤害+1, permanent, ⚡
|
||||||
|
charge, 冲锋, 受到或造成的伤害翻倍并消耗等量冲锋, lingering, 🐎
|
||||||
|
summonMummy, 召唤木乃伊, 召唤1个木乃伊, instant, 🧟
|
||||||
|
summonSandwormLarva, 召唤幼沙虫, 召唤1个幼沙虫, instant, 🐛
|
||||||
|
reviveMummy, 复活木乃伊, 复活1个已死亡的木乃伊, instant, 🌅
|
||||||
|
draw, 抓牌, 抓X张牌, instant, 🃏
|
||||||
|
crossbow, 十字弩连击, 对同一目标打出其他十字弩, instant, 🏹
|
||||||
|
defendNext, 下回合防御, 下回合开始时获得防御, temporary, 🛡️
|
||||||
|
damageReduce, 减伤, 本回合受到的伤害减少X, temporary, 📉
|
||||||
|
removeWound, 移除伤口, 从牌堆或弃牌堆移除X张伤口, instant, 🩹
|
||||||
|
attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlay, ⬆️
|
||||||
|
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlay, ⬆️
|
||||||
|
gainEnergy, 获得能量, 获得X点能量, instant, ⚡
|
||||||
|
energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary, ⚡
|
||||||
|
drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary, 🃏
|
||||||
|
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlay, 🛡️
|
||||||
|
drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant, 🔍
|
||||||
|
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlay, 🔥
|
||||||
|
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlay, ⚔️
|
||||||
|
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant, 🌀
|
||||||
|
expose, 暴露, 受到的伤害+1/每层, temporary, 👁️
|
||||||
|
@@ -0,0 +1,14 @@
|
|||||||
|
export type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent';
|
||||||
|
|
||||||
|
type EffectTable = readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly lifecycle: EffectLifecycle;
|
||||||
|
readonly emoji: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type Effect = EffectTable[number];
|
||||||
|
|
||||||
|
declare function getData(): EffectTable;
|
||||||
|
export default getData;
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
# minion (10): minor enemies
|
||||||
|
# elite (4): dangerous enemies
|
||||||
|
# event (1): random dangerous event that requires reaction
|
||||||
|
# shop (2): merchant who sells different stuff
|
||||||
|
# camp (2): consumable restock and heal
|
||||||
|
# curio (8): random pickup of treasure or resources
|
||||||
|
# enemies: array of [enemyId; initialHp; buffs[]]
|
||||||
|
|
||||||
|
# type EncounterType = 'minion' | 'elite' | 'event' | 'shop' | 'camp' | 'curio'
|
||||||
|
# type EnemyList = [data: @enemy; hp: int; effects: [effect: @effect; stacks: int][]][]
|
||||||
|
|
||||||
|
id,type,name,description,enemies,dialogue
|
||||||
|
string,EncounterType,string,string,EnemyList,string
|
||||||
|
cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;12;[]];[仙人掌怪;12;[]],
|
||||||
|
snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[]],
|
||||||
|
mummy_cactus,minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;14;[]];[仙人掌怪;12;[]],
|
||||||
|
gunslinger,minion,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】,[枪手;12;[]],
|
||||||
|
tumbleweed_pair,minion,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。,[风卷草;16;[]],
|
||||||
|
vulture_cactus,minion,秃鹫,概念:攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。,[秃鹫;12;[]];[仙人掌怪;12;[]],
|
||||||
|
scorpion_snake,minion,沙蝎,概念:攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。,[沙蝎;10;[]];[蛇;10;[]],
|
||||||
|
sandworm_larva,minion,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。,[幼沙虫;18;[]],
|
||||||
|
lizard_pair,minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。,[蜥蜴;14;[]],
|
||||||
|
bandit_gunslinger,minion,沙匪,概念:弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。,[沙匪;12;[]];[枪手;12;[]],
|
||||||
|
storm_spirit,elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1),[风暴之灵;30;[]],
|
||||||
|
mounted_gunslinger,elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。,[骑马枪手;25;[]];[枪手;12;[]],
|
||||||
|
sandworm_king,elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。,[沙虫王;40;[]],
|
||||||
|
desert_guard,elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。,[沙漠守卫;35;[]];[木乃伊;14;[]],
|
||||||
|
desert_merchant,shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。,,
|
||||||
|
nomad_caravan,shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。,,
|
||||||
|
oasis_campfire,camp,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。,,
|
||||||
|
cave_shelter,camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。,,
|
||||||
|
desert_relic_in_sand,curio,沙中遗物,随机获得一件遗物或受到3点伤害。,,desert_relic_in_sand
|
||||||
|
desert_dry_well,curio,枯井,投入1能量:可能获得药水或什么也没有。,,desert_dry_well
|
||||||
|
desert_ancient_stele,curio,古代石碑,阅读碑文:获得随机Buff直到下次战斗结束。,,desert_ancient_stele
|
||||||
|
desert_storm_wreckage,curio,沙暴残骸,搜索残骸:随机获得一张物品牌或受到2点伤害。,,desert_storm_wreckage
|
||||||
|
desert_mirage_chest,curio,蜃景宝箱,打开宝箱:50%获得宝藏,50%为蜃景什么也没有。,,desert_mirage_chest
|
||||||
|
desert_buried_pot,curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。,,desert_buried_pot
|
||||||
|
desert_weathered_statue,curio,风化雕像,献祭1生命:获得一件随机遗物。,,desert_weathered_statue
|
||||||
|
desert_oasis_fragment,curio,绿洲碎片,小型绿洲:恢复3生命并获得1张随机消耗品。,,desert_oasis_fragment
|
||||||
|
desert_mirage_event,event,海市蜃楼,随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。,,desert_mirage_event
|
||||||
|
@@ -0,0 +1,19 @@
|
|||||||
|
import type { Enemy } from './enemy.csv';
|
||||||
|
import type { Effect } from './effect.csv';
|
||||||
|
|
||||||
|
export type EncounterType = 'minion' | 'elite' | 'event' | 'shop' | 'camp' | 'curio';
|
||||||
|
export type EnemyList = [data: Enemy, hp: int, effects: [effect: Effect, stacks: int][]][];
|
||||||
|
|
||||||
|
type EncounterTable = readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: EncounterType;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string;
|
||||||
|
readonly enemies: EnemyList;
|
||||||
|
readonly dialogue: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type Encounter = EncounterTable[number];
|
||||||
|
|
||||||
|
declare function getData(): EncounterTable;
|
||||||
|
export default getData;
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
id,name,intents,description
|
||||||
|
string,string,@intent[],string
|
||||||
|
仙人掌怪,仙人掌怪,[仙人掌怪-boost;仙人掌怪-defend;仙人掌怪-attack],防+强化。【尖刺X】:对攻击者造成X点伤害。
|
||||||
|
蛇,蛇,[蛇-poison;蛇-attack;蛇-boost],攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。
|
||||||
|
木乃伊,木乃伊,[木乃伊-attack;木乃伊-defend;木乃伊-curse],攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。
|
||||||
|
枪手,枪手,[枪手-aim;枪手-attack;枪手-defend],单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】。
|
||||||
|
风卷草,风卷草,[风卷草-boost;风卷草-defend;风卷草-attack],防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。
|
||||||
|
秃鹫,秃鹫,[秃鹫-attack;秃鹫-defend],攻+防。若造成伤害,玩家获得秃鹫之眼(0费状态牌:打出时移除。抓到时获得3层暴露)。
|
||||||
|
沙蝎,沙蝎,[沙蝎-boost;沙蝎-attack],攻+强化。【尾刺X】:姿态buff,攻击时,伤害提升X。
|
||||||
|
幼沙虫,幼沙虫,[幼沙虫-defend;幼沙虫-boost;幼沙虫-attack],防+强化。每回合第一次受伤时,玩家失去1点能量。
|
||||||
|
蜥蜴,蜥蜴,[蜥蜴-attack;蜥蜴-defend;蜥蜴-molt],攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
|
||||||
|
沙匪,沙匪,[沙匪-attack;沙匪-heavyAttack;沙匪-debuff],弱化玩家。【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。
|
||||||
|
风暴之灵,风暴之灵,[风暴之灵-storm;风暴之灵-attack;风暴之灵-defend],【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1)
|
||||||
|
骑马枪手,骑马枪手,[骑马枪手-charge;骑马枪手-attack;骑马枪手-defend],【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。
|
||||||
|
沙虫王,沙虫王,[沙虫王-summon;沙虫王-attack;沙虫王-defend],召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。
|
||||||
|
沙漠守卫,沙漠守卫,[沙漠守卫-summon;沙漠守卫-attack;沙漠守卫-defend;沙漠守卫-revive],召唤木乃伊;会复活木乃伊2次。
|
||||||
|
@@ -0,0 +1,13 @@
|
|||||||
|
import type { Intent } from './intent.csv';
|
||||||
|
|
||||||
|
type EnemyTable = readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly intents: Intent[];
|
||||||
|
readonly description: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type Enemy = EnemyTable[number];
|
||||||
|
|
||||||
|
declare function getData(): EnemyTable;
|
||||||
|
export default getData;
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import getItems, { Item } from "./item.csv";
|
||||||
|
|
||||||
|
export type * from "./card.csv";
|
||||||
|
export type * from "./cardEffect.csv";
|
||||||
|
export type * from "./effect.csv";
|
||||||
|
export type * from "./encounter.csv";
|
||||||
|
export type * from "./enemy.csv";
|
||||||
|
export type * from "./intent.csv";
|
||||||
|
export type * from "./item.csv";
|
||||||
|
|
||||||
|
export { default as getCards } from "./card.csv";
|
||||||
|
export { default as getEffects } from "./effect.csv";
|
||||||
|
export { default as getEncounters } from "./encounter.csv";
|
||||||
|
export { default as getEnemies } from "./enemy.csv";
|
||||||
|
export { default as getIntents } from "./intent.csv";
|
||||||
|
export { default as getItems } from "./item.csv";
|
||||||
|
export { default as dialogues } from "./dialogues/dialogues.yarnproject";
|
||||||
|
export { addTriggers } from "./triggers";
|
||||||
|
|
||||||
|
const itemSet = ["剑", "盾", "水袋", "绷带"] as string[];
|
||||||
|
export function getStartingItems(): Item[] {
|
||||||
|
return itemSet.map(function (name): Item {
|
||||||
|
return getItems().find((item: Item) => item.name === name) as Item;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
# intentId: unique intent state ID (e.g. '仙人掌怪-boost')
|
||||||
|
# enemy: enemy template this intent belongs to
|
||||||
|
# initialIntent: true if this is the enemy's starting intent
|
||||||
|
# nextIntents: possible next intent IDs after this intent resolves
|
||||||
|
# brokenIntent: possible intent IDs when defend is broken
|
||||||
|
# initBuffs: initial buffs for this intent (applied when intent becomes active)
|
||||||
|
# effects: effects executed when this intent is active
|
||||||
|
|
||||||
|
# type IntentEffectTarget = 'source' | 'eachEnemy' | 'randomEnemy' | 'player'
|
||||||
|
# type IntentEffect = [IntentEffectTarget;@effect;number]
|
||||||
|
# type IntentEffectList = IntentEffect[]
|
||||||
|
|
||||||
|
id,enemy,initialIntent,nextIntents,brokenIntent,effects
|
||||||
|
string,@enemy,boolean,@intent[],@intent[],IntentEffectList
|
||||||
|
仙人掌怪-boost,仙人掌怪,true,仙人掌怪-boost;仙人掌怪-defend,,[source;spike;1];[source;defend;4]
|
||||||
|
仙人掌怪-defend,仙人掌怪,false,仙人掌怪-attack,,[source;defend;8]
|
||||||
|
仙人掌怪-attack,仙人掌怪,false,仙人掌怪-boost,,[player;attack;5]
|
||||||
|
蛇-poison,蛇,true,蛇-attack;蛇-attack,,[player;venom;1];[player;attack;4]
|
||||||
|
蛇-attack,蛇,false,蛇-poison;蛇-boost,,[player;attack;6]
|
||||||
|
蛇-boost,蛇,false,蛇-poison;蛇-attack,,[source;defend;3];[player;venom;1]
|
||||||
|
木乃伊-attack,木乃伊,true,木乃伊-defend;木乃伊-curse,,[player;attack;6]
|
||||||
|
木乃伊-defend,木乃伊,false,木乃伊-attack,,[source;defend;6]
|
||||||
|
木乃伊-curse,木乃伊,false,木乃伊-defend;木乃伊-attack,木乃伊-attack,[player;curse;1]
|
||||||
|
枪手-aim,枪手,true,枪手-attack,,[source;aim;2]
|
||||||
|
枪手-attack,枪手,false,枪手-aim;枪手-defend,枪手-aim,[player;attack;8]
|
||||||
|
枪手-defend,枪手,false,枪手-aim,枪手-aim,[source;defend;5]
|
||||||
|
风卷草-boost,风卷草,true,风卷草-defend;风卷草-defend;风卷草-boost,,[source;roll;5];[source;defend;4]
|
||||||
|
风卷草-defend,风卷草,false,风卷草-boost;风卷草-attack,,[source;defend;8]
|
||||||
|
风卷草-attack,风卷草,false,风卷草-boost,,[player;rollDamage;0]
|
||||||
|
秃鹫-attack,秃鹫,true,秃鹫-defend;秃鹫-defend,,[player;attack;6];[player;vultureEye;1]
|
||||||
|
秃鹫-defend,秃鹫,false,秃鹫-attack;秃鹫-attack,,[source;defend;5]
|
||||||
|
沙蝎-boost,沙蝎,true,沙蝎-attack;沙蝎-attack,,[source;tailSting;2]
|
||||||
|
沙蝎-attack,沙蝎,false,沙蝎-boost;沙蝎-attack,,[player;attack;6]
|
||||||
|
幼沙虫-defend,幼沙虫,true,幼沙虫-defend;幼沙虫-boost,,[source;defend;6]
|
||||||
|
幼沙虫-boost,幼沙虫,false,幼沙虫-attack;幼沙虫-defend,,[source;energyDrain;1];[source;defend;4]
|
||||||
|
幼沙虫-attack,幼沙虫,false,幼沙虫-defend;幼沙虫-defend,,[player;attack;5]
|
||||||
|
蜥蜴-attack,蜥蜴,true,蜥蜴-defend;蜥蜴-molt,,[player;attack;5]
|
||||||
|
蜥蜴-defend,蜥蜴,false,蜥蜴-attack;蜥蜴-attack,,[source;defend;6]
|
||||||
|
蜥蜴-molt,蜥蜴,false,蜥蜴-defend;蜥蜴-attack,,[source;molt;3]
|
||||||
|
沙匪-attack,沙匪,true,沙匪-attack;沙匪-heavyAttack,,[player;attack;6]
|
||||||
|
沙匪-heavyAttack,沙匪,false,沙匪-attack;沙匪-attack;沙匪-debuff,,[player;attack;10]
|
||||||
|
沙匪-debuff,沙匪,false,沙匪-attack;沙匪-attack,,[player;discard;1]
|
||||||
|
风暴之灵-storm,风暴之灵,true,风暴之灵-attack;风暴之灵-storm,,[source;storm;2];[source;defend;3]
|
||||||
|
风暴之灵-attack,风暴之灵,false,风暴之灵-storm;风暴之灵-defend,,[player;attack;8];[player;static;1]
|
||||||
|
风暴之灵-defend,风暴之灵,false,风暴之灵-storm;风暴之灵-attack,,[source;defend;8]
|
||||||
|
骑马枪手-charge,骑马枪手,true,骑马枪手-attack,,[source;charge;2]
|
||||||
|
骑马枪手-attack,骑马枪手,false,骑马枪手-charge;骑马枪手-defend,骑马枪手-charge,[player;attack;6]
|
||||||
|
骑马枪手-defend,骑马枪手,false,骑马枪手-charge;骑马枪手-attack,骑马枪手-charge,[source;defend;5]
|
||||||
|
沙虫王-summon,沙虫王,true,沙虫王-attack;沙虫王-defend,,[source;summonSandwormLarva;18]
|
||||||
|
沙虫王-attack,沙虫王,false,沙虫王-summon;沙虫王-defend,,[player;attack;9]
|
||||||
|
沙虫王-defend,沙虫王,false,沙虫王-attack;沙虫王-summon,,[source;defend;6]
|
||||||
|
沙漠守卫-summon,沙漠守卫,true,沙漠守卫-attack;沙漠守卫-defend,,[source;summonMummy;14]
|
||||||
|
沙漠守卫-attack,沙漠守卫,false,沙漠守卫-defend;沙漠守卫-summon,,[player;attack;8]
|
||||||
|
沙漠守卫-defend,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-revive,,[source;defend;8]
|
||||||
|
沙漠守卫-revive,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-summon,,[source;reviveMummy;1]
|
||||||
|
@@ -0,0 +1,20 @@
|
|||||||
|
import type { Enemy } from './enemy.csv';
|
||||||
|
import type { Effect } from './effect.csv';
|
||||||
|
|
||||||
|
export type IntentEffectTarget = 'source' | 'eachEnemy' | 'randomEnemy' | 'player';
|
||||||
|
export type IntentEffect = [IntentEffectTarget, Effect, number];
|
||||||
|
export type IntentEffectList = IntentEffect[];
|
||||||
|
|
||||||
|
type IntentTable = readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly enemy: Enemy;
|
||||||
|
readonly initialIntent: boolean;
|
||||||
|
readonly nextIntents: Intent[];
|
||||||
|
readonly brokenIntent: Intent[];
|
||||||
|
readonly effects: IntentEffectList;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type Intent = IntentTable[number];
|
||||||
|
|
||||||
|
declare function getData(): IntentTable;
|
||||||
|
export default getData;
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
id,type,name,shape,card,price,description
|
||||||
|
string,string,string,string,@card,int,string
|
||||||
|
sword,weapon,剑,oee,sword,50,【攻击2】【攻击2】
|
||||||
|
greataxe,weapon,长斧,oees,greataxe,80,对全体【攻击5】
|
||||||
|
spear,weapon,长枪,oeee,spear,75,【攻击2】【攻击2】【攻击2】
|
||||||
|
dagger,weapon,短刀,oe,dagger,40,【攻击3】【攻击3】
|
||||||
|
dart,weapon,飞镖,o,dart,30,【攻击1】抓一张牌
|
||||||
|
crossbow,weapon,十字弩,onrersrw,crossbow,120,【攻击6】对同一目标打出其他十字弩
|
||||||
|
shield,armor,盾,oesw,shield,50,【防御3】
|
||||||
|
hat,armor,斗笠,oerwrn,hat,90,【防御8】
|
||||||
|
cape,armor,披风,oers,cape,45,【防御2】下回合【防御2】
|
||||||
|
bracer,armor,护腕,o,bracer,25,【防御1】抓1张牌
|
||||||
|
greatshield,armor,大盾,oesswn,greatshield,70,【防御5】
|
||||||
|
chainmail,armor,锁子甲,oesw,chainmail,60,本回合受到伤害-3
|
||||||
|
bandage,consumable,绷带,o,bandage,20,从牌堆或弃牌堆随机移除1张伤口
|
||||||
|
poisonPotion,consumable,淬毒药剂,o,poisonPotion,30,周围物品的【攻击】+2
|
||||||
|
fortifyPotion,consumable,强固药剂,o,fortifyPotion,30,周围物品的【防御】+2
|
||||||
|
vitalityPotion,consumable,活力药剂,o,vitalityPotion,25,获得1点能量
|
||||||
|
focusPotion,consumable,集中药剂,o,focusPotion,25,抓2张牌
|
||||||
|
healingPotion,consumable,治疗药剂,o,healingPotion,35,从牌堆或弃牌堆移除3张伤口
|
||||||
|
waterBag,tool,水袋,os,waterBag,35,下回合开始时获得1能量抓2张牌
|
||||||
|
rope,tool,绳索,ose,rope,30,周围物品的牌【防御】+2直到打出
|
||||||
|
belt,tool,腰带,owre,belt,40,从牌堆周围物品的牌当中选择一张加入手牌
|
||||||
|
torch,tool,火把,on,torch,25,下次打出周围物品的牌时将其消耗并获得1能量
|
||||||
|
whetstone,tool,磨刀石,o,whetstone,30,周围物品的牌【攻击】+3直到打出
|
||||||
|
blacksmithHammer,tool,铁匠锤,oerwrs,blacksmithHammer,45,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌
|
||||||
|
@@ -0,0 +1,16 @@
|
|||||||
|
import type { Card } from './card.csv';
|
||||||
|
|
||||||
|
type ItemTable = readonly {
|
||||||
|
readonly id: string;
|
||||||
|
readonly type: string;
|
||||||
|
readonly name: string;
|
||||||
|
readonly shape: string;
|
||||||
|
readonly card: Card;
|
||||||
|
readonly price: number;
|
||||||
|
readonly description: string;
|
||||||
|
}[];
|
||||||
|
|
||||||
|
export type Item = ItemTable[number];
|
||||||
|
|
||||||
|
declare function getData(): ItemTable;
|
||||||
|
export default getData;
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
|
||||||
|
import { getCombatEntity } from "@/samples/slay-the-spire-like/system/combat/effects";
|
||||||
|
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
import getEffects from "../effect.csv";
|
||||||
|
import { IRunContext } from "@/samples/slay-the-spire-like/system/combat/types";
|
||||||
|
|
||||||
|
export function addCardEventTriggers(triggers: Triggers, run: IRunContext) {
|
||||||
|
const effects = getEffects();
|
||||||
|
|
||||||
|
function findEffect(id: string): EffectData {
|
||||||
|
const found = effects.find((e) => e.id === id);
|
||||||
|
if (found) return found;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
description: "",
|
||||||
|
lifecycle: "instant",
|
||||||
|
} as EffectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// storm: give static card to player when storm enemy attacks
|
||||||
|
triggers.onEnemyIntent.use(async (ctx, next) => {
|
||||||
|
await next();
|
||||||
|
|
||||||
|
const enemy = getCombatEntity(ctx.game.value, ctx.enemyId);
|
||||||
|
if (!enemy || !enemy.isAlive) return;
|
||||||
|
|
||||||
|
const storm = enemy.effects.storm?.stacks ?? 0;
|
||||||
|
if (storm > 0) {
|
||||||
|
for (let i = 0; i < storm; i++) {
|
||||||
|
await triggers.onEffectApplied.execute(ctx.game, {
|
||||||
|
effect: findEffect("static"),
|
||||||
|
entityId: "player",
|
||||||
|
stacks: 1,
|
||||||
|
sourceEntityId: ctx.enemyId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// crossbow: replay other crossbows on same target
|
||||||
|
triggers.onEffectApplied.use(async (ctx, next) => {
|
||||||
|
await next();
|
||||||
|
|
||||||
|
if (ctx.effect.id !== "crossbow" || !ctx.cardId || !ctx.targetId) return;
|
||||||
|
|
||||||
|
const { cards, regions } = ctx.game.value.player.deck;
|
||||||
|
const handIds = [...regions.hand.childIds];
|
||||||
|
for (const id of handIds) {
|
||||||
|
const card = cards[id];
|
||||||
|
if (card && card.itemId === "crossbow" && id !== ctx.cardId) {
|
||||||
|
await triggers.onCardPlayed.execute(ctx.game, {
|
||||||
|
cardId: id,
|
||||||
|
targetId: ctx.targetId,
|
||||||
|
sourceEntityId: "player",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// burnForEnergy: consume adjacent item, gain energy when its card is played
|
||||||
|
triggers.onCardPlayed.use(async (ctx, next) => {
|
||||||
|
await next();
|
||||||
|
|
||||||
|
const card = ctx.game.value.player.deck.cards[ctx.cardId];
|
||||||
|
if (!card) return;
|
||||||
|
const playedItemId = card.itemId;
|
||||||
|
|
||||||
|
const adjacent = run.getAdjacentItems(playedItemId);
|
||||||
|
for (const adjItemId of adjacent) {
|
||||||
|
const adjEffects = ctx.game.value.player.itemEffects[adjItemId];
|
||||||
|
if (!adjEffects) continue;
|
||||||
|
const burn = adjEffects.burnForEnergy;
|
||||||
|
if (!burn || burn.stacks <= 0) continue;
|
||||||
|
|
||||||
|
const item = run.getItemData(adjItemId);
|
||||||
|
const maxUses =
|
||||||
|
item?.card.costType === "energy" ? item.card.costCount : 0;
|
||||||
|
const consumed = run.getConsumedUses(adjItemId);
|
||||||
|
const toConsume = Math.min(maxUses - consumed, burn.stacks);
|
||||||
|
|
||||||
|
await run.setConsumedUsesAsync(adjItemId, consumed + toConsume);
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
draft.player.energy += burn.stacks;
|
||||||
|
delete draft.player.itemEffects[adjItemId];
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// sandwormKing: heal 10 hp when player discards fatigue
|
||||||
|
triggers.onCardDiscarded.use(async (ctx, next) => {
|
||||||
|
await next();
|
||||||
|
|
||||||
|
const card = ctx.game.value.player.deck.cards[ctx.cardId];
|
||||||
|
if (!card || card.cardData.id !== "fatigue") return;
|
||||||
|
|
||||||
|
const sandwormKing = ctx.game.value.enemies.find(
|
||||||
|
(e) => e.enemy.id === "沙虫王" && e.isAlive,
|
||||||
|
);
|
||||||
|
if (!sandwormKing) return;
|
||||||
|
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const king = draft.enemies.find((e) => e.id === sandwormKing.id);
|
||||||
|
if (king) {
|
||||||
|
king.hp = Math.min(king.hp + 10, king.maxHp);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// vulture: give vultureEye when vulture deals damage
|
||||||
|
triggers.onDamage.use(async (ctx, next) => {
|
||||||
|
await next();
|
||||||
|
|
||||||
|
const dealt = ctx.amount - (ctx.prevented ?? 0);
|
||||||
|
if (dealt <= 0 || !ctx.sourceEntityId) return;
|
||||||
|
|
||||||
|
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityId);
|
||||||
|
if (!attacker || !("enemy" in attacker) || attacker.enemy.id !== "秃鹫")
|
||||||
|
return;
|
||||||
|
|
||||||
|
await triggers.onEffectApplied.execute(ctx.game, {
|
||||||
|
effect: findEffect("vultureEye"),
|
||||||
|
entityId: "player",
|
||||||
|
stacks: 1,
|
||||||
|
sourceEntityId: ctx.sourceEntityId,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
|
||||||
|
import {
|
||||||
|
addEntityEffect,
|
||||||
|
getCombatEntity,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/combat/effects";
|
||||||
|
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
import getEffects from "../effect.csv";
|
||||||
|
|
||||||
|
export function addDamageTriggers(triggers: Triggers) {
|
||||||
|
const effects = getEffects();
|
||||||
|
|
||||||
|
function findEffect(id: string): EffectData {
|
||||||
|
const found = effects.find((e: EffectData) => e.id === id);
|
||||||
|
if (found) return found;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
description: "",
|
||||||
|
lifecycle: "instant",
|
||||||
|
} as EffectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// block / damage prevention
|
||||||
|
triggers.onDamage.use(async (ctx, next) => {
|
||||||
|
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
|
||||||
|
if (!entity) return;
|
||||||
|
|
||||||
|
let preventable = ctx.amount - (ctx.prevented ?? 0);
|
||||||
|
|
||||||
|
// 1. apply damageReduce before block
|
||||||
|
const damageReduce = entity.effects.damageReduce?.stacks ?? 0;
|
||||||
|
if (damageReduce > 0) {
|
||||||
|
const reduced = Math.min(damageReduce, preventable);
|
||||||
|
ctx.prevented = (ctx.prevented ?? 0) + reduced;
|
||||||
|
preventable -= reduced;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. consume defend for damage prevented
|
||||||
|
const blocks = entity.effects.defend?.stacks ?? 0;
|
||||||
|
const blocked = Math.min(blocks, preventable);
|
||||||
|
if (blocked > 0) {
|
||||||
|
ctx.prevented = (ctx.prevented ?? 0) + blocked;
|
||||||
|
preventable -= blocked;
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const e = getCombatEntity(draft, ctx.entityId);
|
||||||
|
if (e) addEntityEffect(e, findEffect("defend"), -blocked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const expose = entity.effects.expose?.stacks ?? 0;
|
||||||
|
if (expose > 0) {
|
||||||
|
ctx.amount += expose;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// energyDrain: player loses energy when enemy takes damage
|
||||||
|
triggers.onDamage.use(async (ctx, next) => {
|
||||||
|
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
|
||||||
|
if (!entity) return;
|
||||||
|
|
||||||
|
const energyDrain = entity.effects.energyDrain?.stacks ?? 0;
|
||||||
|
if (energyDrain > 0 && ctx.entityId !== "player") {
|
||||||
|
const dealt = Math.min(
|
||||||
|
Math.max(0, entity.hp),
|
||||||
|
ctx.amount - (ctx.prevented ?? 0),
|
||||||
|
);
|
||||||
|
if (dealt > 0) {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
draft.player.energy = Math.max(0, draft.player.energy - energyDrain);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// molt: enemy flees if molt >= maxHp
|
||||||
|
triggers.onDamage.use(async (ctx, next) => {
|
||||||
|
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
|
||||||
|
if (!entity || !entity.isAlive) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const molt = entity.effects.molt?.stacks ?? 0;
|
||||||
|
if (molt >= entity.maxHp) {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const e = draft.enemies.find((en) => en.id === ctx.entityId);
|
||||||
|
if (e) {
|
||||||
|
e.isAlive = false;
|
||||||
|
e.hp = 0;
|
||||||
|
}
|
||||||
|
draft.result = draft.enemies.every((en) => !en.isAlive)
|
||||||
|
? "victory"
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
if (ctx.game.value.result) throw ctx.game.value;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// aim: double damage, lose aim on damage
|
||||||
|
triggers.onDamage.use(async (ctx, next) => {
|
||||||
|
if (ctx.sourceEntityId === "player") {
|
||||||
|
const player = ctx.game.value.player;
|
||||||
|
const aim = player.effects.aim?.stacks ?? 0;
|
||||||
|
if (aim > 0) {
|
||||||
|
ctx.amount *= 2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// roll: consume 10 roll per 10 damage
|
||||||
|
triggers.onDamage.use(async (ctx, next) => {
|
||||||
|
if (ctx.sourceEntityId === "player") {
|
||||||
|
const player = ctx.game.value.player;
|
||||||
|
const roll = player.effects.roll?.stacks ?? 0;
|
||||||
|
if (roll >= 10) {
|
||||||
|
const rollDamage = Math.floor(roll / 10) * 10;
|
||||||
|
ctx.amount += rollDamage;
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
addEntityEffect(draft.player, findEffect("roll"), -rollDamage);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// tailSting: bonus damage on attack
|
||||||
|
triggers.onDamage.use(async (ctx, next) => {
|
||||||
|
if (ctx.sourceEntityId && ctx.sourceEntityId !== "player") {
|
||||||
|
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityId);
|
||||||
|
if (attacker) {
|
||||||
|
const tailSting = attacker.effects.tailSting?.stacks ?? 0;
|
||||||
|
if (tailSting > 0) {
|
||||||
|
ctx.amount += tailSting;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// charge: double damage dealt/received, consume equal charge
|
||||||
|
triggers.onDamage.use(async (ctx, next) => {
|
||||||
|
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
|
||||||
|
if (entity) {
|
||||||
|
const charge = entity.effects.charge?.stacks ?? 0;
|
||||||
|
if (charge > 0) {
|
||||||
|
const dealt = Math.min(
|
||||||
|
Math.max(0, entity.hp),
|
||||||
|
ctx.amount - (ctx.prevented ?? 0),
|
||||||
|
);
|
||||||
|
const consumed = Math.min(charge, dealt);
|
||||||
|
ctx.amount += dealt;
|
||||||
|
if (consumed > 0) {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const e = getCombatEntity(draft, ctx.entityId);
|
||||||
|
if (e) addEntityEffect(e, findEffect("charge"), -consumed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ctx.sourceEntityId) {
|
||||||
|
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityId);
|
||||||
|
if (attacker) {
|
||||||
|
const charge = attacker.effects.charge?.stacks ?? 0;
|
||||||
|
if (charge > 0) {
|
||||||
|
const baseAmount = ctx.amount;
|
||||||
|
const targetEntity = getCombatEntity(ctx.game.value, ctx.entityId);
|
||||||
|
const dealt = Math.min(
|
||||||
|
Math.max(0, targetEntity?.hp ?? 0),
|
||||||
|
baseAmount - (ctx.prevented ?? 0),
|
||||||
|
);
|
||||||
|
const consumed = Math.min(charge, dealt);
|
||||||
|
ctx.amount += dealt;
|
||||||
|
if (consumed > 0) {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const a = getCombatEntity(draft, ctx.sourceEntityId!);
|
||||||
|
if (a) addEntityEffect(a, findEffect("charge"), -consumed);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { addInstantEffectTriggers } from "./instant";
|
||||||
|
import { addDamageTriggers } from "./damage";
|
||||||
|
import { addTurnStartTriggers } from "./turn-start";
|
||||||
|
import { addCardEventTriggers } from "./card-events";
|
||||||
|
import {
|
||||||
|
IRunContext,
|
||||||
|
Triggers,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/combat";
|
||||||
|
|
||||||
|
export function addTriggers(triggers: Triggers, run: IRunContext) {
|
||||||
|
addInstantEffectTriggers(triggers);
|
||||||
|
addDamageTriggers(triggers);
|
||||||
|
addTurnStartTriggers(triggers);
|
||||||
|
addCardEventTriggers(triggers, run);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
|
||||||
|
import {
|
||||||
|
addEntityEffect,
|
||||||
|
getCombatEntity,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/combat/effects";
|
||||||
|
import { moveToRegion } from "@/core/region";
|
||||||
|
import { CombatGameContext } from "@/samples/slay-the-spire-like/system/combat/types";
|
||||||
|
import { GameCard } from "@/samples/slay-the-spire-like/system/deck";
|
||||||
|
import getCards from "../card.csv";
|
||||||
|
|
||||||
|
export function addInstantEffectTriggers(triggers: Triggers) {
|
||||||
|
const cards = getCards();
|
||||||
|
|
||||||
|
function findCard(id: string) {
|
||||||
|
return cards.find((c) => c.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createStatusCard(
|
||||||
|
draft: CombatGameContext["value"],
|
||||||
|
cardId: string,
|
||||||
|
): void {
|
||||||
|
const cardData = findCard(cardId);
|
||||||
|
if (!cardData) return;
|
||||||
|
|
||||||
|
const instanceId = `${cardId}-${draft.player.deck.regions.drawPile.childIds.length}-${draft.player.deck.regions.discardPile.childIds.length}`;
|
||||||
|
const card: GameCard = {
|
||||||
|
id: instanceId,
|
||||||
|
regionId: "",
|
||||||
|
position: [],
|
||||||
|
itemId: cardId,
|
||||||
|
cardData: {
|
||||||
|
id: cardData.id,
|
||||||
|
name: cardData.name,
|
||||||
|
desc: cardData.desc,
|
||||||
|
type: cardData.type,
|
||||||
|
costType: cardData.costType,
|
||||||
|
costCount: cardData.costCount,
|
||||||
|
targetType: cardData.targetType,
|
||||||
|
effects: cardData.effects,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
draft.player.deck.cards[instanceId] = card;
|
||||||
|
moveToRegion(card, null, draft.player.deck.regions.drawPile);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAllEnemyDataFromState(state: CombatGameContext["value"]) {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const result: (typeof state.enemies)[number]["enemy"][] = [];
|
||||||
|
for (const enemy of state.enemies) {
|
||||||
|
if (!seen.has(enemy.enemy.id)) {
|
||||||
|
seen.add(enemy.enemy.id);
|
||||||
|
result.push(enemy.enemy);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summonEnemy(
|
||||||
|
draft: CombatGameContext["value"],
|
||||||
|
enemyId: string,
|
||||||
|
hp: number,
|
||||||
|
) {
|
||||||
|
for (const enemyData of getAllEnemyDataFromState(draft)) {
|
||||||
|
if (enemyData.id === enemyId) {
|
||||||
|
const existing = draft.enemies.find(
|
||||||
|
(e) => e.enemy.id === enemyId && !e.isAlive,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
existing.isAlive = true;
|
||||||
|
existing.hp = existing.maxHp;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const intent =
|
||||||
|
enemyData.intents.find((i) => i.initialIntent) ??
|
||||||
|
enemyData.intents[0];
|
||||||
|
const instanceId = `${enemyData.id}-${draft.enemies.length}`;
|
||||||
|
const intents: Record<string, typeof intent> = {};
|
||||||
|
for (const i of enemyData.intents) {
|
||||||
|
intents[i.id] = i;
|
||||||
|
}
|
||||||
|
draft.enemies.push({
|
||||||
|
id: instanceId,
|
||||||
|
enemy: enemyData,
|
||||||
|
hp,
|
||||||
|
maxHp: hp,
|
||||||
|
isAlive: true,
|
||||||
|
effects: {},
|
||||||
|
intents,
|
||||||
|
currentIntent: intent,
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
triggers.onEffectApplied.use(async (ctx, next) => {
|
||||||
|
if (ctx.effect.id === "attack") {
|
||||||
|
await triggers.onDamage.execute(ctx.game, {
|
||||||
|
entityId: ctx.entityId,
|
||||||
|
amount: ctx.stacks,
|
||||||
|
sourceEntityId: ctx.sourceEntityId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
|
||||||
|
const spike = entity?.effects.spike?.stacks ?? 0;
|
||||||
|
if (spike > 0 && ctx.sourceEntityId) {
|
||||||
|
await triggers.onDamage.execute(ctx.game, {
|
||||||
|
entityId: ctx.sourceEntityId,
|
||||||
|
amount: spike,
|
||||||
|
sourceEntityId: ctx.entityId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (ctx.effect.id === "draw") {
|
||||||
|
await triggers.onDraw.execute(ctx.game, { count: ctx.stacks });
|
||||||
|
} else if (ctx.effect.id === "gainEnergy") {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
draft.player.energy += ctx.stacks;
|
||||||
|
});
|
||||||
|
} else if (ctx.effect.id === "removeWound") {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const { cards, regions } = draft.player.deck;
|
||||||
|
let removed = 0;
|
||||||
|
const allPileIds = [
|
||||||
|
...regions.drawPile.childIds,
|
||||||
|
...regions.discardPile.childIds,
|
||||||
|
];
|
||||||
|
for (const cardId of allPileIds) {
|
||||||
|
if (removed >= ctx.stacks) break;
|
||||||
|
const card = cards[cardId];
|
||||||
|
if (card && card.cardData.id === "wound") {
|
||||||
|
const sourceRegion =
|
||||||
|
card.regionId === "drawPile"
|
||||||
|
? regions.drawPile
|
||||||
|
: regions.discardPile;
|
||||||
|
moveToRegion(card, sourceRegion, null);
|
||||||
|
delete cards[cardId];
|
||||||
|
removed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (ctx.effect.id === "venom") {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
createStatusCard(draft, "venom");
|
||||||
|
});
|
||||||
|
} else if (ctx.effect.id === "vultureEye") {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
createStatusCard(draft, "vultureEye");
|
||||||
|
});
|
||||||
|
} else if (ctx.effect.id === "static") {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
createStatusCard(draft, "static");
|
||||||
|
});
|
||||||
|
} else if (ctx.effect.id === "summonMummy") {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
summonEnemy(draft, "木乃伊", ctx.stacks);
|
||||||
|
});
|
||||||
|
} else if (ctx.effect.id === "summonSandwormLarva") {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
summonEnemy(draft, "幼沙虫", ctx.stacks);
|
||||||
|
});
|
||||||
|
} else if (ctx.effect.id === "reviveMummy") {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const deadMummy = draft.enemies.find(
|
||||||
|
(e) => e.enemy.id === "木乃伊" && !e.isAlive,
|
||||||
|
);
|
||||||
|
if (deadMummy) {
|
||||||
|
deadMummy.isAlive = true;
|
||||||
|
deadMummy.hp = deadMummy.maxHp;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (ctx.effect.id === "curse") {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
addEntityEffect(draft.player, ctx.effect, ctx.stacks);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
|
||||||
|
import { addEntityEffect } from "@/samples/slay-the-spire-like/system/combat/effects";
|
||||||
|
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
import getEffects from "../effect.csv";
|
||||||
|
|
||||||
|
export function addTurnStartTriggers(triggers: Triggers) {
|
||||||
|
const effects = getEffects();
|
||||||
|
|
||||||
|
function findEffect(id: string): EffectData {
|
||||||
|
const found = effects.find((e) => e.id === id);
|
||||||
|
if (found) return found;
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
description: "",
|
||||||
|
lifecycle: "instant",
|
||||||
|
} as EffectData;
|
||||||
|
}
|
||||||
|
|
||||||
|
// discard: random discard at turn start
|
||||||
|
triggers.onTurnStart.use(async (ctx, next) => {
|
||||||
|
if (ctx.entityId !== "player") {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const discard = ctx.game.value.player.effects.discard;
|
||||||
|
if (discard && discard.stacks > 0) {
|
||||||
|
const handIds = [...ctx.game.value.player.deck.regions.hand.childIds];
|
||||||
|
if (handIds.length > 0) {
|
||||||
|
const randomIndex = ctx.game.rng.nextInt(handIds.length);
|
||||||
|
const randomCardId = handIds[randomIndex];
|
||||||
|
await triggers.onCardDiscarded.execute(ctx.game, {
|
||||||
|
cardId: randomCardId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// defendNext: gain block next turn
|
||||||
|
// record how many defendNext there is, before buff update
|
||||||
|
// then apply after buff update
|
||||||
|
triggers.onTurnStart.use(async (ctx, next) => {
|
||||||
|
const defendNext = ctx.game.value.player.effects.defendNext;
|
||||||
|
if (!defendNext || defendNext.stacks <= 0) {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await next();
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
addEntityEffect(draft.player, findEffect("defend"), defendNext.stacks);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// energyNext: gain energy next turn
|
||||||
|
triggers.onTurnStart.use(async (ctx, next) => {
|
||||||
|
if (ctx.entityId !== "player") {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const energyNext = ctx.game.value.player.effects.energyNext;
|
||||||
|
if (energyNext && energyNext.stacks > 0) {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
draft.player.energy += energyNext.stacks;
|
||||||
|
addEntityEffect(draft.player, energyNext.data, -energyNext.stacks);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
|
||||||
|
// drawNext: draw extra cards next turn
|
||||||
|
triggers.onTurnStart.use(async (ctx, next) => {
|
||||||
|
if (ctx.entityId !== "player") {
|
||||||
|
await next();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drawNext = ctx.game.value.player.effects.drawNext;
|
||||||
|
if (drawNext && drawNext.stacks > 0) {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
addEntityEffect(draft.player, drawNext.data, -drawNext.stacks);
|
||||||
|
});
|
||||||
|
await triggers.onDraw.execute(ctx.game, { count: drawNext.stacks });
|
||||||
|
}
|
||||||
|
|
||||||
|
await next();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
# minion (10): minor enemies
|
|
||||||
# elite (4): dangerous enemies
|
|
||||||
# event (1): random dangerous event that requires reaction
|
|
||||||
# shop (2): merchant who sells different stuff
|
|
||||||
# camp (2): consumable restock and heal
|
|
||||||
# curio (8): random pickup of treasure or resources
|
|
||||||
type,name,description
|
|
||||||
'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string
|
|
||||||
minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。
|
|
||||||
minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(消耗。一回合弃掉超过1张蛇毒时,受到6伤害)。
|
|
||||||
minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。
|
|
||||||
minion,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】。
|
|
||||||
minion,风卷草,概念:防+强化。【滚动X】:攻击时,每消耗10点【滚动】,造成等量伤害。
|
|
||||||
minion,秃鹫,概念:攻+防。造成伤害后玩家获得秃鹫之眼(当你受到伤害时自动从手牌打出受到秃鹫的攻击)。
|
|
||||||
minion,沙蝎,概念:攻+强化。【尾刺X】:玩家回合结束时受到沙蝎的X点攻击。受伤时失去等量【尾刺】。
|
|
||||||
minion,幼沙虫,概念:防+强化。每回合第一次受伤时,玩家失去1点能量。
|
|
||||||
minion,蜥蜴,概念:攻+防+逃跑。【脱皮】:若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
|
|
||||||
minion,沙匪,概念:攻特化。洗牌时,将一个随机物品的牌全部弃掉。
|
|
||||||
elite,风暴之灵,【风暴X】:攻击时,玩家获得1张静电。受伤时失去等量【风暴】。(静电:在手里时受【电击】伤害+1)
|
|
||||||
elite,骑马枪手,【冲锋X】:受到或造成的伤害翻倍并消耗等量的冲锋。
|
|
||||||
elite,沙虫王,召唤幼体沙虫;每当玩家弃掉一张牌,恢复1生命。
|
|
||||||
elite,沙漠守卫,召唤木乃伊;会复活木乃伊2次。
|
|
||||||
shop,沙漠商人,商店:可以恢复生命、出售装备、附魔物品。
|
|
||||||
shop,游牧商队,商队:出售稀有物品、移除牌组中一张牌。
|
|
||||||
camp,绿洲篝火,篝火:可以恢复生命、补充药水使用次数、获得下次战斗Buff。
|
|
||||||
camp,岩洞庇护所,篝火:可以恢复生命、升级一张牌。
|
|
||||||
curio,沙中遗物,随机获得一件遗物或受到3点伤害。
|
|
||||||
curio,枯井,投入1能量:可能获得药水或什么也没有。
|
|
||||||
curio,古代石碑,阅读碑文:获得随机Buff直到下次战斗结束。
|
|
||||||
curio,沙暴残骸,搜索残骸:随机获得一张物品牌或受到2点伤害。
|
|
||||||
curio,蜃景宝箱,打开宝箱:50%获得宝藏,50%为蜃景什么也没有。
|
|
||||||
curio,埋藏陶罐,挖掘:获得随机资源(金币、药水或遗物碎片)。
|
|
||||||
curio,风化雕像,献祭1生命:获得一件随机遗物。
|
|
||||||
curio,绿洲碎片,小型绿洲:恢复3生命并获得1张随机消耗品。
|
|
||||||
event,海市蜃楼,随机遭遇:可能获得宝藏或遭遇陷阱,使用d6双阶段结构结算。
|
|
||||||
|
@@ -1,10 +0,0 @@
|
|||||||
type EncounterDesertTable = readonly {
|
|
||||||
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio";
|
|
||||||
readonly name: string;
|
|
||||||
readonly description: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
export type EncounterDesert = EncounterDesertTable[number];
|
|
||||||
|
|
||||||
declare const data: EncounterDesertTable;
|
|
||||||
export default data;
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
# type can be one of: weapon, armor, consumable, tool
|
|
||||||
#
|
|
||||||
# shape represents a tetris-like shape, consult ../utils/parse-shape.ts
|
|
||||||
# n
|
|
||||||
# w o e
|
|
||||||
# s
|
|
||||||
# costType can be one of: energy, uses
|
|
||||||
#
|
|
||||||
# targetType can be one of: single, none
|
|
||||||
|
|
||||||
type,name,shape,costType,costCount,targetType,desc
|
|
||||||
string,string,string,'energy'|'uses',int,'single'|'none',string
|
|
||||||
weapon,剑,oee,energy,1,single,【攻击2】【攻击2】
|
|
||||||
weapon,长斧,oees,energy,2,none,对全体【攻击5】
|
|
||||||
weapon,长枪,oeee,energy,1,single,【攻击2】【攻击2】【攻击2】
|
|
||||||
weapon,短刀,oe,energy,1,single,【攻击3】【攻击3】
|
|
||||||
weapon,飞镖,o,energy,0,single,【攻击1】,抓一张牌
|
|
||||||
weapon,十字弩,onrersrw,energy,2,single,【攻击6】。对同一目标打出其他十字弩
|
|
||||||
armor,盾,oesw,energy,1,none,【防御3】
|
|
||||||
armor,斗笠,oerwrn,energy,2,none,【防御8】
|
|
||||||
armor,披风,oers,energy,1,none,【防御2】,下回合【防御2】
|
|
||||||
armor,护腕,o,energy,0,none,【防御1】,抓1张牌
|
|
||||||
armor,大盾,oesswn,energy,1,none,【防御5】
|
|
||||||
armor,锁子甲,oers,energy,1,none,本回合受到伤害-3
|
|
||||||
consumable,绷带,o,uses,3,none,从牌堆或弃牌堆随机移除1张伤口
|
|
||||||
consumable,淬毒药剂,o,uses,3,none,周围物品的【攻击】+2
|
|
||||||
consumable,强固药剂,o,uses,3,none,周围物品的【防御】+2
|
|
||||||
consumable,活力药剂,o,uses,3,none,获得1点能量
|
|
||||||
consumable,集中药剂,o,uses,3,none,抓2张牌
|
|
||||||
consumable,治疗药剂,o,uses,3,none,从牌堆或弃牌堆移除3张伤口
|
|
||||||
tool,水袋,os,energy,1,none,下回合开始时,获得1能量,抓2张牌
|
|
||||||
tool,绳索,ose,energy,1,none,周围物品的牌【防御】+2直到打出
|
|
||||||
tool,腰带,owre,energy,0,none,从牌堆周围物品的牌当中选择一张加入手牌
|
|
||||||
tool,火把,on,energy,1,none,下次打出周围物品的牌时,将其消耗并获得1能量
|
|
||||||
tool,磨刀石,o,energy,1,none,周围物品的牌【攻击】+3直到打出
|
|
||||||
tool,铁匠锤,oers,energy,1,none,从牌堆/弃牌堆选择一张牌,随机变为一张周围物品的牌
|
|
||||||
|
@@ -1,14 +0,0 @@
|
|||||||
type HeroItemFighter1Table = readonly {
|
|
||||||
readonly type: string;
|
|
||||||
readonly name: string;
|
|
||||||
readonly shape: string;
|
|
||||||
readonly costType: "energy" | "uses";
|
|
||||||
readonly costCount: number;
|
|
||||||
readonly targetType: "single" | "none";
|
|
||||||
readonly desc: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
export type HeroItemFighter1 = HeroItemFighter1Table[number];
|
|
||||||
|
|
||||||
declare const data: HeroItemFighter1Table;
|
|
||||||
export default data;
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import heroItemFighter1Csv from './heroItemFighter1.csv';
|
import * as desert from "./desert";
|
||||||
import encounterDesertCsv from './encounterDesert.csv';
|
export type { ContentModule } from "./types";
|
||||||
|
|
||||||
export const heroItemFighter1Data = heroItemFighter1Csv;
|
export default {
|
||||||
export const encounterDesertData = encounterDesertCsv;
|
desert,
|
||||||
export { default as encounterDesertCsv, type EncounterDesert } from './encounterDesert.csv';
|
};
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { LoadResult as YarnDialogues } from "yarn-spinner-loader";
|
||||||
|
import { Triggers } from "../system/combat/triggers";
|
||||||
|
|
||||||
|
import type * as desert from "./desert";
|
||||||
|
import { IRunContext } from "../system/combat";
|
||||||
|
|
||||||
|
export type ContentModule = {
|
||||||
|
readonly getCards: () => readonly desert.Card[];
|
||||||
|
readonly getEffects: () => readonly desert.Effect[];
|
||||||
|
readonly getEncounters: () => readonly desert.Encounter[];
|
||||||
|
readonly getEnemies: () => readonly desert.Enemy[];
|
||||||
|
readonly getIntents: () => readonly desert.Intent[];
|
||||||
|
readonly getItems: () => readonly desert.Item[];
|
||||||
|
readonly getStartingItems: () => readonly desert.Item[];
|
||||||
|
|
||||||
|
readonly dialogues: YarnDialogues;
|
||||||
|
readonly addTriggers: (triggers: Triggers, run: IRunContext) => void;
|
||||||
|
};
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
# 杀戮尖塔 + 背包英雄
|
||||||
|
|
||||||
|
玩家有4x6的背包
|
||||||
|
|
||||||
|
装备占格子,提供同等数量的牌,并且给周围加buff,背包英雄style
|
||||||
|
|
||||||
|
战斗时玩杀戮尖塔
|
||||||
|
|
||||||
|
## 肉鸽结构
|
||||||
|
|
||||||
|
区域点到点地图
|
||||||
|
|
||||||
|
- 分4个区域,每个区域有独立的随机遭遇池,目前先考虑第一个区域
|
||||||
|
- 每区域10层,26个遭遇节点
|
||||||
|
- 荒野层6层×3节点(小怪50%/精英25%/事件25%)
|
||||||
|
- 聚落层2层×4节点(≥1篝火/≥1商店/≥1奇遇,第4随机)
|
||||||
|
- 起点→终点,终点为boss战
|
||||||
|
|
||||||
|
## 战斗(小怪、精英、boss)
|
||||||
|
|
||||||
|
基本流程
|
||||||
|
- 怪物战斗开始效应
|
||||||
|
- 玩家战斗开始效应
|
||||||
|
- 重复直至一方不剩下存活角色
|
||||||
|
- 玩家回合
|
||||||
|
- 怪物回合
|
||||||
|
|
||||||
|
怪物战斗开始
|
||||||
|
- 获得初始buff、初始血量、初始意图
|
||||||
|
|
||||||
|
玩家战斗开始
|
||||||
|
- 获得3点能量,抓5张手牌
|
||||||
|
|
||||||
|
玩家回合
|
||||||
|
- buff更新阶段:临时buff消失、延时buff-1层
|
||||||
|
- 回合开始:触发回合开始效果
|
||||||
|
- 行动:玩家可以自由出牌直至结束回合
|
||||||
|
- 回合结束:触发回合结束效果
|
||||||
|
- 重置:恢复到3点能量,弃掉剩余手牌,抓5张手牌
|
||||||
|
- 洗回牌堆:每当牌堆为空需要抓牌或与牌堆顶交互,则将弃牌堆与2张《疲劳》(没有效果,不能打出)洗回牌堆,然后再继续。
|
||||||
|
|
||||||
|
怪物回合
|
||||||
|
- buff更新阶段:每个怪物临时buff消失、延时buff-1层
|
||||||
|
- 回合开始:触发每个怪物的回合开始效果
|
||||||
|
- 行动:怪物的意图生效,然后更新意图
|
||||||
|
- 回合结束:触发每个怪物的回合结束效果
|
||||||
|
|
||||||
|
## 篝火
|
||||||
|
|
||||||
|
在篝火处可进行3次行动,每次行动可以:
|
||||||
|
- 恢复20%最大生命
|
||||||
|
- 恢复1次所有药品的使用次数
|
||||||
|
- 合成一个物品(暂未设计)
|
||||||
|
|
||||||
|
## 商店
|
||||||
|
|
||||||
|
在商店可以买/卖物品
|
||||||
|
- 有5个随机的不同物品可以买,价格为原价50%-150%随机
|
||||||
|
- 购买之后不会补充
|
||||||
|
- 可以以原价25%的价格卖出背包内的任意物品
|
||||||
|
|
||||||
|
## 奇遇
|
||||||
|
|
||||||
|
奇遇为Yarn Spinner驱动的分支对话遭遇
|
||||||
|
|
||||||
|
对玩家产生的影响根据Yarn Spinner的命令结算
|
||||||
|
|
||||||
|
在对话结束后奇遇结算结束
|
||||||
|
|
||||||
|
## 战斗内Effect
|
||||||
|
|
||||||
|
Effect分以下类型:
|
||||||
|
- 瞬间:立即生效,直接改变状态
|
||||||
|
- Buff:作为状态叠加到角色身上
|
||||||
|
- 持久Buff:持续整场战斗
|
||||||
|
- 临时Buff:持续到下次Buff更新
|
||||||
|
- 延时Buff:每次Buff更新-1层,持续到归零
|
||||||
|
- 姿态Buff:受到伤害时,移除同等数量的层数
|
||||||
|
- 卡牌:作为状态卡牌加入
|
||||||
|
- 弃牌:加入弃牌堆
|
||||||
|
- 手牌:加入手牌
|
||||||
|
- 牌堆:加入抓牌堆
|
||||||
|
- 物品:作为物品状态叠加到物品上,通常是效应来源卡牌对应的物品周围的物品,若没有上下文物品则可能无效或对玩家的随机物品生效
|
||||||
|
- 临时:持续到下次Buff更新
|
||||||
|
- 持久:持续整场战斗
|
||||||
|
- 永久:持续整个冒险
|
||||||
|
- 直到打出:持续到下次打出该物品的卡牌
|
||||||
|
- 直到弃掉:持续到下次弃掉该物品的卡牌
|
||||||
|
|
||||||
|
## 时机
|
||||||
|
|
||||||
|
产生Effect的时机包括:
|
||||||
|
- 打出卡牌/抓到卡牌/弃掉卡牌
|
||||||
|
- 敌人意图生效
|
||||||
|
- Effect侦听器需要的特定侦听事件
|
||||||
|
|
||||||
|
## 卡牌属性
|
||||||
|
|
||||||
|
- 费用:能量费用或物品使用次数费用,如果不能支付则无法打出
|
||||||
|
- 抓牌效果/打出效果/弃掉效果:包括目标(自身、选择目标、全体),效果类型,和层数,可能有复数条
|
||||||
|
|
||||||
|
能量费用以3费为准,希望大约60%牌1费,20%牌2费,20%牌0费
|
||||||
|
|
||||||
|
## 遗物
|
||||||
|
|
||||||
|
遗物不需要放在背包格里,且仅提供正面触发效果,暂未设计
|
||||||
|
|
||||||
|
## 区域设计量
|
||||||
|
|
||||||
|
每区域10小怪,4精英,8奇遇,1 boss(暂未设计)
|
||||||
|
目前仅考虑沙漠一个区域
|
||||||
|
|
||||||
|
## 角色设计量
|
||||||
|
|
||||||
|
~24 物品 每角色(4种各6个)
|
||||||
|
-8 遗物 每角色
|
||||||
|
|
||||||
|
在扩展更多区域后需添加更多内容随区域/奇遇解锁
|
||||||
+15
-14
@@ -1,33 +1,29 @@
|
|||||||
# Fighter
|
# Fighter
|
||||||
|
|
||||||
|
战士角色:默认的角色。
|
||||||
|
|
||||||
## 物品
|
## 物品
|
||||||
|
|
||||||
每池4种物品各5个:武器、防具、耗材、工具。
|
4种物品各6个:武器、防具、耗材、工具。
|
||||||
|
|
||||||
玩家共24格,每种物品预算6格,每个物品尺寸1-6格。武器防具大部分在2-4格,少部分有5-6格,耗材、工具大部分在1-2格。
|
玩家共24格,每种物品预算6格,每个物品尺寸1-6格。武器防具大部分在2-4格,少部分有5-6格,耗材、工具大部分在1-2格。
|
||||||
|
|
||||||
每种物品每格提供一张牌加入玩家牌堆。
|
每种物品每格提供一张牌加入玩家牌堆。
|
||||||
|
|
||||||
使用武器、防具、工具的卡牌花费能量,每回合3点。耗材使用不计入费用,消耗本身的使用次数。使用次数在篝火处会完全恢复。
|
使用武器、防具、工具的卡牌花费能量。每回合有3点能量。耗材使用不计入费用,但消耗本身的使用次数。使用次数在篝火处可以恢复。
|
||||||
|
|
||||||
### 基础
|
### 初始装备
|
||||||
|
|
||||||
攻击:对目标造成伤害
|
剑、短刀、绷带、水袋、治疗药剂
|
||||||
|
|
||||||
防御:抵消本回合受到的最多3点伤害
|
|
||||||
|
|
||||||
剑 1x3/1费:【攻击2】【攻击2】
|
|
||||||
盾 2x2/1费:【防御3】
|
|
||||||
绷带 1x1/3次:随机移除1格伤口。
|
|
||||||
水袋 2格/1费:下回合开始时,获得1费,抓2张牌。
|
|
||||||
|
|
||||||
### 武器
|
### 武器
|
||||||
|
|
||||||
武器是配合对象,不需要有邻接性检查。
|
武器是配合对象,不需要有邻接性检查。
|
||||||
|
|
||||||
|
剑 1x3/1费:【攻击2】【攻击2】
|
||||||
长斧 4格L型/2费:对全体【攻击5】
|
长斧 4格L型/2费:对全体【攻击5】
|
||||||
长枪 4格I型/1费:【攻击2】【攻击2】【攻击2】
|
长枪 4格I型/1费:【攻击2】【攻击2】【攻击2】
|
||||||
短刀 2格/1费:【攻击3】【攻击3】
|
短刀 1x2/1费:【攻击3】【攻击3】
|
||||||
飞镖 1格/0费:【攻击1】,抓一张牌
|
飞镖 1格/0费:【攻击1】,抓一张牌
|
||||||
十字弩 4格X型/2费:【攻击6】。对同一目标打出其他十字弩。
|
十字弩 4格X型/2费:【攻击6】。对同一目标打出其他十字弩。
|
||||||
|
|
||||||
@@ -35,6 +31,9 @@
|
|||||||
|
|
||||||
防具可以检查周围物品,每个匹配的给奖励。
|
防具可以检查周围物品,每个匹配的给奖励。
|
||||||
|
|
||||||
|
物品被动:每个防具的周围每种不同防具+1最大生命。
|
||||||
|
|
||||||
|
盾 2x2/1费:【防御3】。
|
||||||
斗笠 4格T型/2费:【防御8】
|
斗笠 4格T型/2费:【防御8】
|
||||||
披风 3格L型/1费:【防御2】,下回合【防御2】
|
披风 3格L型/1费:【防御2】,下回合【防御2】
|
||||||
护腕 1格/0费:【防御1】,抓1张牌
|
护腕 1格/0费:【防御1】,抓1张牌
|
||||||
@@ -45,16 +44,18 @@
|
|||||||
|
|
||||||
工具可以检查周围物品,给触发或者触发给强化。
|
工具可以检查周围物品,给触发或者触发给强化。
|
||||||
|
|
||||||
|
水袋 1x2/1费:下回合开始时,获得1能量,抓2张牌。
|
||||||
绳索 3格L型/1费:生效物品的牌【防御】+2直到打出。
|
绳索 3格L型/1费:生效物品的牌【防御】+2直到打出。
|
||||||
腰带 3格I型/0费:抓一张牌,然后从生效物品的牌当中选择一张放到牌堆顶。
|
腰带 3格I型/0费:抓一张牌,然后从生效物品的牌当中选择一张加入手牌。
|
||||||
火把 2格/1费:下次打出生效物品的牌时,将其消并获得1能量。
|
火把 2格/1费:下次打出生效物品的牌时,将其消并获得1能量。
|
||||||
磨刀石 1x1/1费:生效物品的牌【攻击】+3直到打出。
|
磨刀石 1x1/1费:生效物品的牌【攻击】+3直到打出。
|
||||||
铁匠锤 3格T型:从牌堆/弃牌堆选择一张牌,随机变为一张生效物品的牌。
|
铁匠锤 3格T型/1费:从牌堆/弃牌堆选择一张牌,随机变为一张生效物品的牌。
|
||||||
|
|
||||||
### 消耗品
|
### 消耗品
|
||||||
|
|
||||||
消耗品本身不应该给强化,但可以被防具/工具给强化。
|
消耗品本身不应该给强化,但可以被防具/工具给强化。
|
||||||
|
|
||||||
|
绷带 1x1/3次:从牌堆或弃牌堆随机移除1张伤口。
|
||||||
淬毒药剂 1格/3次:生效物品的攻击+2。
|
淬毒药剂 1格/3次:生效物品的攻击+2。
|
||||||
强固药剂 1格/3次:生效物品获得的防御+2。
|
强固药剂 1格/3次:生效物品获得的防御+2。
|
||||||
活力药剂 1格/3次:获得1点能量。
|
活力药剂 1格/3次:获得1点能量。
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
# Desert
|
||||||
|
|
||||||
|
## 小怪 - 10种
|
||||||
|
|
||||||
|
### 仙人掌怪
|
||||||
|
|
||||||
|
概念:防+强化
|
||||||
|
|
||||||
|
【尖刺X】:对自己施加的持续buff,对攻击者造成X点伤害
|
||||||
|
|
||||||
|
### 蛇
|
||||||
|
|
||||||
|
概念:攻+强化
|
||||||
|
|
||||||
|
【蛇毒X】:对玩家施加的手牌状态(同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害。)
|
||||||
|
|
||||||
|
### 木乃伊
|
||||||
|
|
||||||
|
概念:攻+防
|
||||||
|
|
||||||
|
【诅咒】:对自己施加的延时buff。受玩家卡牌攻击时,卡牌对应物品【攻击】-1,直到玩家弃掉一张该物品的牌
|
||||||
|
|
||||||
|
### 枪手
|
||||||
|
|
||||||
|
概念:单回高攻
|
||||||
|
|
||||||
|
【瞄准X】:对自己施加的姿态buff。造成双倍伤害。
|
||||||
|
|
||||||
|
### 风卷草
|
||||||
|
|
||||||
|
概念:防+强化
|
||||||
|
|
||||||
|
【滚动X】:对自己施加的姿态buff。攻击时,若有至少10层滚动,则消耗10层滚动让攻击+10。
|
||||||
|
|
||||||
|
### 秃鹫
|
||||||
|
|
||||||
|
概念:攻+防
|
||||||
|
|
||||||
|
若造成伤害,玩家获得秃鹫之眼。
|
||||||
|
|
||||||
|
秃鹫之眼:对玩家施加的抓牌堆状态卡牌(同名状态牌/0费:打出时移除。抓到时获得3层【暴露】(临时debuff,受到的伤害+1/每层))
|
||||||
|
|
||||||
|
### 沙蝎
|
||||||
|
|
||||||
|
概念:攻+强化
|
||||||
|
|
||||||
|
【尾刺X】:对自己施加的姿态buff。攻击时,伤害提升X。
|
||||||
|
|
||||||
|
### 幼沙虫
|
||||||
|
|
||||||
|
防+强化
|
||||||
|
|
||||||
|
【能量吸取】:对自己施加的延时buff。受伤时,玩家失去X点能量。
|
||||||
|
|
||||||
|
### 蜥蜴
|
||||||
|
|
||||||
|
概念:攻+防+逃跑
|
||||||
|
|
||||||
|
【脱皮】:对自己施加的姿态buff。若脱皮达到生命上限,则怪物逃跑,玩家不能获得战斗奖励。
|
||||||
|
|
||||||
|
### 沙匪
|
||||||
|
|
||||||
|
概念:弱化玩家
|
||||||
|
|
||||||
|
【劫掠】:对玩家施加的延时debuff。回合开始时,随机弃掉一张手牌。
|
||||||
|
|
||||||
|
## 精英 - 4种
|
||||||
|
|
||||||
|
### 风暴之灵
|
||||||
|
|
||||||
|
静电:对玩家施加的弃牌堆状态卡牌/0费:打出时自己获得1层【暴露】并抓一张牌。(临时debuff,受到的伤害+1/每层)
|
||||||
|
|
||||||
|
【风暴X】:对自己施加的姿态buff。攻击时,玩家获得1张静电。
|
||||||
|
|
||||||
|
### 骑马枪手
|
||||||
|
|
||||||
|
【冲锋X】:对自己施加的姿态buff,受到或造成的伤害翻倍并消耗等量的冲锋。(额外伤害不会消耗姿态)
|
||||||
|
|
||||||
|
### 沙虫王
|
||||||
|
|
||||||
|
召唤幼体沙虫
|
||||||
|
|
||||||
|
玩家弃掉疲劳时,恢复10生命
|
||||||
|
|
||||||
|
### 沙漠守卫
|
||||||
|
|
||||||
|
召唤木乃伊
|
||||||
|
|
||||||
|
会复活木乃伊2次
|
||||||
|
|
||||||
|
## boss - 法老之灵
|
||||||
|
|
||||||
|
Boss在区域终点节点触发,不属于遭遇类型池。
|
||||||
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
# 文件结构
|
||||||
|
|
||||||
|
## 系统实现
|
||||||
|
|
||||||
|
- `system/`:系统实现。
|
||||||
|
- `combat/`:管理一次战斗内的战斗状态、流程、触发。
|
||||||
|
- `progress/`:管理一次冒险的进度。
|
||||||
|
- `map/`:管理地图生成。
|
||||||
|
- `grid-inventory/`:管理背包状态。
|
||||||
|
- `deck/`:管理卡组状态。
|
||||||
|
|
||||||
|
## 数据
|
||||||
|
|
||||||
|
- `data/`:所有的数据文件
|
||||||
|
- `desert/`:游戏的测试模组,沙漠区域的内容。
|
||||||
|
- `card.csv`:所有的卡牌定义。
|
||||||
|
- `effect.csv`:所有的效应定义。
|
||||||
|
- `encounter.csv`:所有的遭遇定义。
|
||||||
|
- `enemy.csv`:所有的敌人和意图定义。
|
||||||
|
- `item.csv`:所有的玩家物品定义。
|
||||||
|
- `dialogues/`:yarn对话项目目录
|
||||||
|
- `dialogues.yarnproject`:yarn对话项目入口
|
||||||
|
- `**.yarn`:具体的yarn对话文件,代码不关心
|
||||||
|
- `index.ts`:统一导出所有的数据。
|
||||||
|
- `trigger.ts`:额外的触发器脚本,用于实现测试模组中涉及的效果。
|
||||||
|
|
||||||
|
## 开发流程
|
||||||
|
|
||||||
|
### SRD / 系统设计
|
||||||
|
|
||||||
|
首先编写srd文档。srd文档描述游戏系统和规则,并提供少量内联的基础游戏数据。
|
||||||
|
|
||||||
|
SRD是系统实现的基础,系统实现只能基于SRD,不能依赖具体的游戏内容。
|
||||||
|
|
||||||
|
### Setting / 环境设定
|
||||||
|
|
||||||
|
使用SRD提供的特性来设计环境内容:
|
||||||
|
- 效应:效应在技术上不依赖其他类型内容,可以首先确定。但在创意上可能不会直接设计效应,而是从若干创意中提取共性,并创建一个效应来作为主题。
|
||||||
|
- 卡牌:部分效应会创建卡牌或修改卡牌。在设计时需要注意。
|
||||||
|
- 敌人和意图:结合效应与主题表达设计敌人。通常需要为每个敌人设计独特的效应。
|
||||||
|
- 玩家物品:结合效应与主题表达设计物品。常见物品的效应容易找到替代,不常见物品则有特殊的效应。
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
export type { CellCoordinate, GridInventory, InventoryItem, PlacementResult } from './types';
|
|
||||||
export {
|
|
||||||
createGridInventory,
|
|
||||||
flipItem,
|
|
||||||
getAdjacentItems,
|
|
||||||
getItemAtCell,
|
|
||||||
getOccupiedCellSet,
|
|
||||||
moveItem,
|
|
||||||
placeItem,
|
|
||||||
removeItem,
|
|
||||||
rotateItem,
|
|
||||||
validatePlacement,
|
|
||||||
} from './transform';
|
|
||||||
@@ -1,232 +0,0 @@
|
|||||||
import type { ParsedShape } from '../utils/parse-shape';
|
|
||||||
import type { Transform2D } from '../utils/shape-collision';
|
|
||||||
import {
|
|
||||||
checkBoardCollision,
|
|
||||||
checkBounds,
|
|
||||||
flipXTransform,
|
|
||||||
flipYTransform,
|
|
||||||
rotateTransform,
|
|
||||||
transformShape,
|
|
||||||
} from '../utils/shape-collision';
|
|
||||||
import type { GridInventory, InventoryItem, PlacementResult } from './types';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new empty grid inventory.
|
|
||||||
* Note: When used inside `.produce()`, call this before returning the draft.
|
|
||||||
*/
|
|
||||||
export function createGridInventory(width: number, height: number): GridInventory {
|
|
||||||
return {
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
items: new Map<string, InventoryItem>(),
|
|
||||||
occupiedCells: new Set<string>(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a Set of occupied cell keys from a shape and transform.
|
|
||||||
*/
|
|
||||||
function getShapeCellKeys(shape: ParsedShape, transform: Transform2D): Set<string> {
|
|
||||||
const cells = transformShape(shape, transform);
|
|
||||||
return new Set(cells.map(c => `${c.x},${c.y}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates whether an item can be placed at the given transform.
|
|
||||||
* Checks bounds and collision with all other items.
|
|
||||||
*/
|
|
||||||
export function validatePlacement(
|
|
||||||
inventory: GridInventory,
|
|
||||||
shape: InventoryItem['shape'],
|
|
||||||
transform: Transform2D
|
|
||||||
): PlacementResult {
|
|
||||||
if (!checkBounds(shape, transform, inventory.width, inventory.height)) {
|
|
||||||
return { valid: false, reason: '超出边界' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkBoardCollision(shape, transform, inventory.occupiedCells)) {
|
|
||||||
return { valid: false, reason: '与已有物品重叠' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Places an item onto the grid.
|
|
||||||
* **Mutates directly** — call inside a `.produce()` callback.
|
|
||||||
* Does not validate; call `validatePlacement` first.
|
|
||||||
*/
|
|
||||||
export function placeItem(inventory: GridInventory, item: InventoryItem): void {
|
|
||||||
const cells = getShapeCellKeys(item.shape, item.transform);
|
|
||||||
for (const cellKey of cells) {
|
|
||||||
inventory.occupiedCells.add(cellKey);
|
|
||||||
}
|
|
||||||
inventory.items.set(item.id, item);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes an item from the grid by its ID.
|
|
||||||
* **Mutates directly** — call inside a `.produce()` callback.
|
|
||||||
*/
|
|
||||||
export function removeItem(inventory: GridInventory, itemId: string): void {
|
|
||||||
const item = inventory.items.get(itemId);
|
|
||||||
if (!item) return;
|
|
||||||
|
|
||||||
const cells = getShapeCellKeys(item.shape, item.transform);
|
|
||||||
for (const cellKey of cells) {
|
|
||||||
inventory.occupiedCells.delete(cellKey);
|
|
||||||
}
|
|
||||||
inventory.items.delete(itemId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Moves an item to a new position with a new transform.
|
|
||||||
* **Mutates directly** — call inside a `.produce()` callback.
|
|
||||||
* Validates before applying; returns result indicating success.
|
|
||||||
*/
|
|
||||||
export function moveItem(
|
|
||||||
inventory: GridInventory,
|
|
||||||
itemId: string,
|
|
||||||
newTransform: Transform2D
|
|
||||||
): { success: true } | { success: false; reason: string } {
|
|
||||||
const item = inventory.items.get(itemId);
|
|
||||||
if (!item) {
|
|
||||||
return { success: false, reason: '物品不存在' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Temporarily remove item's cells for validation
|
|
||||||
const oldCells = getShapeCellKeys(item.shape, item.transform);
|
|
||||||
for (const cellKey of oldCells) {
|
|
||||||
inventory.occupiedCells.delete(cellKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate new position
|
|
||||||
const validation = validatePlacement(inventory, item.shape, newTransform);
|
|
||||||
if (!validation.valid) {
|
|
||||||
// Restore old cells
|
|
||||||
for (const cellKey of oldCells) {
|
|
||||||
inventory.occupiedCells.add(cellKey);
|
|
||||||
}
|
|
||||||
return { success: false, reason: validation.reason };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply new transform
|
|
||||||
item.transform = newTransform;
|
|
||||||
|
|
||||||
// Add new cells
|
|
||||||
const newCells = getShapeCellKeys(item.shape, item.transform);
|
|
||||||
for (const cellKey of newCells) {
|
|
||||||
inventory.occupiedCells.add(cellKey);
|
|
||||||
}
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotates an item by the given degrees (typically 90, 180, or 270).
|
|
||||||
* **Mutates directly** — call inside a `.produce()` callback.
|
|
||||||
* Validates before applying; returns result indicating success.
|
|
||||||
*/
|
|
||||||
export function rotateItem(
|
|
||||||
inventory: GridInventory,
|
|
||||||
itemId: string,
|
|
||||||
degrees: number
|
|
||||||
): { success: true } | { success: false; reason: string } {
|
|
||||||
const item = inventory.items.get(itemId);
|
|
||||||
if (!item) {
|
|
||||||
return { success: false, reason: '物品不存在' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const rotatedTransform = rotateTransform(item.transform, degrees);
|
|
||||||
return moveItem(inventory, itemId, rotatedTransform);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flips an item horizontally or vertically.
|
|
||||||
* **Mutates directly** — call inside a `.produce()` callback.
|
|
||||||
* Validates before applying; returns result indicating success.
|
|
||||||
*/
|
|
||||||
export function flipItem(
|
|
||||||
inventory: GridInventory,
|
|
||||||
itemId: string,
|
|
||||||
axis: 'x' | 'y'
|
|
||||||
): { success: true } | { success: false; reason: string } {
|
|
||||||
const item = inventory.items.get(itemId);
|
|
||||||
if (!item) {
|
|
||||||
return { success: false, reason: '物品不存在' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const flippedTransform = axis === 'x'
|
|
||||||
? flipXTransform(item.transform)
|
|
||||||
: flipYTransform(item.transform);
|
|
||||||
|
|
||||||
return moveItem(inventory, itemId, flippedTransform);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns a copy of the occupied cells set.
|
|
||||||
*/
|
|
||||||
export function getOccupiedCellSet(inventory: GridInventory): Set<string> {
|
|
||||||
return new Set(inventory.occupiedCells);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds the item occupying the given cell, if any.
|
|
||||||
*/
|
|
||||||
export function getItemAtCell(
|
|
||||||
inventory: GridInventory,
|
|
||||||
x: number,
|
|
||||||
y: number
|
|
||||||
): InventoryItem | undefined {
|
|
||||||
const cellKey = `${x},${y}`;
|
|
||||||
if (!inventory.occupiedCells.has(cellKey)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const item of inventory.items.values()) {
|
|
||||||
const cells = getShapeCellKeys(item.shape, item.transform);
|
|
||||||
if (cells.has(cellKey)) {
|
|
||||||
return item;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all items adjacent to the given item (orthogonally, not diagonally).
|
|
||||||
* Returns a Map of itemId -> item for deduplication.
|
|
||||||
*/
|
|
||||||
export function getAdjacentItems(
|
|
||||||
inventory: GridInventory,
|
|
||||||
itemId: string
|
|
||||||
): Map<string, InventoryItem> {
|
|
||||||
const item = inventory.items.get(itemId);
|
|
||||||
if (!item) {
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
const ownCells = getShapeCellKeys(item.shape, item.transform);
|
|
||||||
const adjacent = new Map<string, InventoryItem>();
|
|
||||||
|
|
||||||
for (const cellKey of ownCells) {
|
|
||||||
const [cx, cy] = cellKey.split(',').map(Number);
|
|
||||||
const neighbors = [
|
|
||||||
`${cx + 1},${cy}`,
|
|
||||||
`${cx - 1},${cy}`,
|
|
||||||
`${cx},${cy + 1}`,
|
|
||||||
`${cx},${cy - 1}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const neighborKey of neighbors) {
|
|
||||||
if (inventory.occupiedCells.has(neighborKey) && !ownCells.has(neighborKey)) {
|
|
||||||
const neighborItem = getItemAtCell(inventory, ...neighborKey.split(',').map(Number) as [number, number]);
|
|
||||||
if (neighborItem) {
|
|
||||||
adjacent.set(neighborItem.id, neighborItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return adjacent;
|
|
||||||
}
|
|
||||||
@@ -1,44 +0,0 @@
|
|||||||
import type { ParsedShape } from '../utils/parse-shape';
|
|
||||||
import type { Transform2D } from '../utils/shape-collision';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple 2D coordinate for grid cells.
|
|
||||||
*/
|
|
||||||
export interface CellCoordinate {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An item placed on the grid inventory.
|
|
||||||
*/
|
|
||||||
export interface InventoryItem {
|
|
||||||
/** Unique item identifier */
|
|
||||||
id: string;
|
|
||||||
/** Reference to the item's shape definition */
|
|
||||||
shape: ParsedShape;
|
|
||||||
/** Current transformation (position, rotation, flips) */
|
|
||||||
transform: Transform2D;
|
|
||||||
/** Optional metadata for game-specific data */
|
|
||||||
meta?: Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Result of a placement validation check.
|
|
||||||
*/
|
|
||||||
export type PlacementResult = { valid: true } | { valid: false; reason: string };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Grid inventory state.
|
|
||||||
* Designed to be mutated directly inside a `mutative .produce()` callback.
|
|
||||||
*/
|
|
||||||
export interface GridInventory {
|
|
||||||
/** Board width in cells */
|
|
||||||
width: number;
|
|
||||||
/** Board height in cells */
|
|
||||||
height: number;
|
|
||||||
/** Map of itemId -> InventoryItem for all placed items */
|
|
||||||
items: Map<string, InventoryItem>;
|
|
||||||
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
|
|
||||||
occupiedCells: Set<string>;
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,8 @@
|
|||||||
// Data
|
export * from "./system/combat";
|
||||||
export { heroItemFighter1Data, encounterDesertData } from './data';
|
export * from "./system/deck";
|
||||||
export { default as encounterDesertCsv } from './data/encounterDesert.csv';
|
export * from "./system/encounter";
|
||||||
export type { EncounterDesert } from './data/encounterDesert.csv';
|
export * from "./system/grid-inventory";
|
||||||
|
export * from "./system/map";
|
||||||
// Grid Inventory
|
export * from "./system/utils";
|
||||||
export type { CellCoordinate, GridInventory, InventoryItem, PlacementResult } from './grid-inventory';
|
export * from "./system/types";
|
||||||
export {
|
export { default as data } from "./data";
|
||||||
createGridInventory,
|
|
||||||
flipItem,
|
|
||||||
getAdjacentItems,
|
|
||||||
getItemAtCell,
|
|
||||||
getOccupiedCellSet,
|
|
||||||
moveItem,
|
|
||||||
placeItem,
|
|
||||||
removeItem,
|
|
||||||
rotateItem,
|
|
||||||
validatePlacement,
|
|
||||||
} from './grid-inventory';
|
|
||||||
|
|
||||||
// Map
|
|
||||||
export { MapNodeType, MapLayerType } from './map';
|
|
||||||
export type { MapNode, MapLayer, PointCrawlMap } from './map';
|
|
||||||
export { generatePointCrawlMap, getNode, getChildren, getParents, hasPath, findAllPaths } from './map';
|
|
||||||
|
|
||||||
// Utils - Parse Shape
|
|
||||||
export type { ParsedShape } from './utils/parse-shape';
|
|
||||||
export { parseShapeString } from './utils/parse-shape';
|
|
||||||
|
|
||||||
// Utils - Shape Collision
|
|
||||||
export type { Point2D, Transform2D } from './utils/shape-collision';
|
|
||||||
export {
|
|
||||||
IDENTITY_TRANSFORM,
|
|
||||||
getOccupiedCells,
|
|
||||||
transformPoint,
|
|
||||||
transformShape,
|
|
||||||
checkCollision,
|
|
||||||
checkBoardCollision,
|
|
||||||
checkBounds,
|
|
||||||
validatePlacement as validateShapePlacement,
|
|
||||||
rotateTransform,
|
|
||||||
flipXTransform,
|
|
||||||
flipYTransform,
|
|
||||||
} from './utils/shape-collision';
|
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
export { MapNodeType, MapLayerType } from './types';
|
|
||||||
export type { MapNode, MapLayer, PointCrawlMap, MapGenerationConfig } from './types';
|
|
||||||
|
|
||||||
export { generatePointCrawlMap } from './generator';
|
|
||||||
export { getNode, getChildren, getParents, hasPath, findAllPaths } from './generator';
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
/**
|
|
||||||
* Types of nodes that can appear on the point crawl map.
|
|
||||||
*/
|
|
||||||
export enum MapNodeType {
|
|
||||||
Start = 'start',
|
|
||||||
End = 'end',
|
|
||||||
Minion = 'minion',
|
|
||||||
Elite = 'elite',
|
|
||||||
Event = 'event',
|
|
||||||
Camp = 'camp',
|
|
||||||
Shop = 'shop',
|
|
||||||
Curio = 'curio',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Semantic type of a layer.
|
|
||||||
*/
|
|
||||||
export enum MapLayerType {
|
|
||||||
Wild = 'wild',
|
|
||||||
Settlement = 'settlement',
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A single node on the map.
|
|
||||||
*/
|
|
||||||
export interface MapNode {
|
|
||||||
/** Unique identifier */
|
|
||||||
id: string;
|
|
||||||
/** Which layer this node belongs to */
|
|
||||||
layerIndex: number;
|
|
||||||
/** Semantic type of the node */
|
|
||||||
type: MapNodeType;
|
|
||||||
/** IDs of nodes in the next layer this node connects to */
|
|
||||||
childIds: string[];
|
|
||||||
/** Encounter data assigned to this node (from encounter CSV) */
|
|
||||||
encounter?: {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A horizontal layer of nodes at the same progression stage.
|
|
||||||
*/
|
|
||||||
export interface MapLayer {
|
|
||||||
/** Layer index (0 = start, last = end) */
|
|
||||||
index: number;
|
|
||||||
/** Ordered IDs of nodes in this layer */
|
|
||||||
nodeIds: string[];
|
|
||||||
/** Semantic type of the layer */
|
|
||||||
layerType: MapLayerType | 'start' | 'end';
|
|
||||||
/** Direct references to nodes in this layer (for performance) */
|
|
||||||
nodes: MapNode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A fully generated point crawl map.
|
|
||||||
*/
|
|
||||||
export interface PointCrawlMap {
|
|
||||||
/** Layers from start to end */
|
|
||||||
layers: MapLayer[];
|
|
||||||
/** All nodes keyed by ID */
|
|
||||||
nodes: Map<string, MapNode>;
|
|
||||||
/** RNG seed used for generation (for reproducibility) */
|
|
||||||
seed: number;
|
|
||||||
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
|
|
||||||
parentIndex?: Map<string, string[]>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Configuration for map generation.
|
|
||||||
*/
|
|
||||||
export interface MapGenerationConfig {
|
|
||||||
/** Total number of layers (including start and end) */
|
|
||||||
totalLayers: number;
|
|
||||||
/** Number of nodes in each wild layer */
|
|
||||||
wildLayerNodeCount: number;
|
|
||||||
/** Number of nodes in each settlement layer */
|
|
||||||
settlementLayerNodeCount: number;
|
|
||||||
/** Probability weights for wild node types (should sum to 100) */
|
|
||||||
wildNodeTypeWeights: {
|
|
||||||
[MapNodeType.Minion]: number;
|
|
||||||
[MapNodeType.Elite]: number;
|
|
||||||
[MapNodeType.Event]: number;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import {
|
||||||
|
CombatEntity,
|
||||||
|
CombatGameContext,
|
||||||
|
CombatState,
|
||||||
|
EffectTable,
|
||||||
|
IRunContext,
|
||||||
|
PlayerEntity,
|
||||||
|
} from "./types";
|
||||||
|
import {
|
||||||
|
CardData,
|
||||||
|
CardEffectTarget,
|
||||||
|
EffectData,
|
||||||
|
IntentEffectTarget,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
|
||||||
|
export function addEffect(
|
||||||
|
effects: EffectTable,
|
||||||
|
effect: EffectData,
|
||||||
|
stacks: number,
|
||||||
|
) {
|
||||||
|
let current = effects[effect.id];
|
||||||
|
|
||||||
|
if (!current) current = { data: effect, stacks };
|
||||||
|
else current.stacks += stacks;
|
||||||
|
|
||||||
|
if (current.stacks === 0 && effects[effect.id]) delete effects[effect.id];
|
||||||
|
else if (current.stacks !== 0 && !effects[effect.id])
|
||||||
|
effects[effect.id] = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addEntityEffect(
|
||||||
|
entity: CombatEntity,
|
||||||
|
effect: EffectData,
|
||||||
|
stacks: number,
|
||||||
|
) {
|
||||||
|
addEffect(entity.effects, effect, stacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addItemEffect(
|
||||||
|
entity: PlayerEntity,
|
||||||
|
itemKey: string,
|
||||||
|
effect: EffectData,
|
||||||
|
stacks: number,
|
||||||
|
) {
|
||||||
|
entity.itemEffects[itemKey] = entity.itemEffects[itemKey] || {};
|
||||||
|
addEffect(entity.itemEffects[itemKey], effect, stacks);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onEntityEffectUpkeep(entity: CombatEntity) {
|
||||||
|
for (const effect of Object.values(entity.effects)) {
|
||||||
|
const lifecycle = effect.data.lifecycle;
|
||||||
|
if (lifecycle === "temporary")
|
||||||
|
addEntityEffect(entity, effect.data, -effect.stacks);
|
||||||
|
else if (lifecycle === "lingering")
|
||||||
|
addEntityEffect(entity, effect.data, effect.stacks >= 0 ? -1 : 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onEntityPostureDamage(entity: CombatEntity, damage: number) {
|
||||||
|
for (const effect of Object.values(entity.effects)) {
|
||||||
|
const lifecycle = effect.data.lifecycle;
|
||||||
|
if (lifecycle === "posture")
|
||||||
|
addEntityEffect(entity, effect.data, -Math.min(damage, effect.stacks));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onPlayerItemEffectUpkeep(entity: PlayerEntity) {
|
||||||
|
for (const [itemKey, itemEffects] of Object.entries(entity.itemEffects)) {
|
||||||
|
for (const effect of Object.values(itemEffects)) {
|
||||||
|
const lifecycle = effect.data.lifecycle;
|
||||||
|
if (lifecycle === "itemTemporary")
|
||||||
|
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onItemPlay(entity: PlayerEntity, itemKey: string) {
|
||||||
|
const effects = entity.itemEffects[itemKey];
|
||||||
|
if (!effects) return;
|
||||||
|
for (const effect of Object.values(effects)) {
|
||||||
|
if (effect.data.lifecycle === "itemUntilPlay") {
|
||||||
|
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function onItemDiscard(entity: PlayerEntity, itemKey: string) {
|
||||||
|
const effects = entity.itemEffects[itemKey];
|
||||||
|
if (!effects) return;
|
||||||
|
for (const effect of Object.values(effects)) {
|
||||||
|
if (effect.data.lifecycle === "itemUntilDiscard") {
|
||||||
|
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* getAliveEnemies(state: CombatState) {
|
||||||
|
for (let enemy of state.enemies) {
|
||||||
|
if (enemy.isAlive) {
|
||||||
|
yield enemy;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* getEffectTargets(
|
||||||
|
target: CardEffectTarget | IntentEffectTarget,
|
||||||
|
game: CombatGameContext,
|
||||||
|
targetId?: string,
|
||||||
|
sourceEntityKey: "player" | string = "player",
|
||||||
|
) {
|
||||||
|
if (target === "eachEnemy") {
|
||||||
|
for (const enemy of getAliveEnemies(game.value)) {
|
||||||
|
yield enemy;
|
||||||
|
}
|
||||||
|
} else if (target === "source") {
|
||||||
|
const entity = getCombatEntity(game.value, sourceEntityKey);
|
||||||
|
if (entity) yield entity;
|
||||||
|
} else if (target === "player") {
|
||||||
|
yield game.value.player;
|
||||||
|
} else if (target === "eachTarget") {
|
||||||
|
if (!targetId) return;
|
||||||
|
const entity = getCombatEntity(game.value, targetId);
|
||||||
|
if (entity) yield entity;
|
||||||
|
} else if (target === "randomEnemy") {
|
||||||
|
const aliveEnemies = [...getAliveEnemies(game.value)];
|
||||||
|
if (aliveEnemies.length === 0) return;
|
||||||
|
const index = game.rng.nextInt(aliveEnemies.length);
|
||||||
|
yield aliveEnemies[index];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCombatEntity(state: CombatState, entityKey: string) {
|
||||||
|
return entityKey === "player"
|
||||||
|
? state.player
|
||||||
|
: state.enemies.find((e) => e.id === entityKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canPlayCard(
|
||||||
|
player: PlayerEntity,
|
||||||
|
costType: CardData["costType"],
|
||||||
|
costCount: number,
|
||||||
|
itemId: string,
|
||||||
|
run: IRunContext,
|
||||||
|
): boolean {
|
||||||
|
if (costType === "energy") {
|
||||||
|
return player.energy >= costCount;
|
||||||
|
}
|
||||||
|
if (costType === "uses") {
|
||||||
|
const item = run.getItemData(itemId);
|
||||||
|
if (!item) return false;
|
||||||
|
const maxUses = item?.card.costType === "uses" ? item.card.costCount : 0;
|
||||||
|
const consumed = run.getConsumedUses(itemId);
|
||||||
|
return consumed < maxUses;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function payCardCost(
|
||||||
|
player: PlayerEntity,
|
||||||
|
costType: CardData["costType"],
|
||||||
|
costCount: number,
|
||||||
|
itemId: string,
|
||||||
|
run: IRunContext,
|
||||||
|
): Promise<void> {
|
||||||
|
if (costType === "energy") {
|
||||||
|
player.energy -= costCount;
|
||||||
|
} else if (costType === "uses") {
|
||||||
|
const consumed = run.getConsumedUses(itemId);
|
||||||
|
await run.setConsumedUsesAsync(itemId, consumed + costCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
CombatState,
|
||||||
|
EffectTable,
|
||||||
|
EnemyEntity,
|
||||||
|
IPlayerDeck,
|
||||||
|
IPlayerState,
|
||||||
|
PlayerEntity,
|
||||||
|
} from "./types";
|
||||||
|
import { EffectData, EncounterData, EnemyData, IntentData } from "../types";
|
||||||
|
|
||||||
|
export function buildCombatState(
|
||||||
|
playerState: IPlayerState,
|
||||||
|
deck: IPlayerDeck,
|
||||||
|
encounter: EncounterData,
|
||||||
|
): CombatState {
|
||||||
|
const player = createPlayerEntity(playerState, deck);
|
||||||
|
const enemies = createEnemyEntities(encounter);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enemies,
|
||||||
|
player,
|
||||||
|
phase: "playerTurn",
|
||||||
|
turnNumber: 1,
|
||||||
|
result: null,
|
||||||
|
loot: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEnemyEntities(encounter: EncounterData): EnemyEntity[] {
|
||||||
|
const enemies: EnemyEntity[] = [];
|
||||||
|
let instanceCounter = 0;
|
||||||
|
|
||||||
|
for (const [enemyData, hp, encounterBuffs] of encounter.enemies) {
|
||||||
|
const instanceId = `${enemyData.id}-${instanceCounter++}`;
|
||||||
|
const intents = buildIntentMap(enemyData);
|
||||||
|
const initialIntent = findInitialIntent(enemyData);
|
||||||
|
const effects = buildEffectTable(encounterBuffs);
|
||||||
|
|
||||||
|
const entity: EnemyEntity = {
|
||||||
|
id: instanceId,
|
||||||
|
enemy: enemyData,
|
||||||
|
hp,
|
||||||
|
maxHp: hp,
|
||||||
|
isAlive: true,
|
||||||
|
effects,
|
||||||
|
intents,
|
||||||
|
currentIntent: initialIntent,
|
||||||
|
};
|
||||||
|
enemies.push(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
return enemies;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a map of intent ID -> IntentData for an enemy.
|
||||||
|
*/
|
||||||
|
function buildIntentMap(enemy: EnemyData): Record<string, IntentData> {
|
||||||
|
const intents: Record<string, IntentData> = {};
|
||||||
|
for (const intent of enemy.intents) {
|
||||||
|
intents[intent.id] = intent;
|
||||||
|
}
|
||||||
|
return intents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the initial intent ID for an enemy.
|
||||||
|
*/
|
||||||
|
function findInitialIntent(enemy: EnemyData): IntentData {
|
||||||
|
for (const intent of enemy.intents) {
|
||||||
|
if (intent.initialIntent) {
|
||||||
|
return intent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (enemy.intents.length === 0) {
|
||||||
|
throw new Error(`Enemy "${enemy.id}" has no intents`);
|
||||||
|
}
|
||||||
|
return enemy.intents[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable {
|
||||||
|
const table: EffectTable = {};
|
||||||
|
for (const [effect, stacks] of buffs) {
|
||||||
|
table[effect.id] = { data: effect, stacks };
|
||||||
|
}
|
||||||
|
return table;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayerEntity(
|
||||||
|
playerState: IPlayerState,
|
||||||
|
deck: IPlayerDeck,
|
||||||
|
): PlayerEntity {
|
||||||
|
return {
|
||||||
|
id: "player",
|
||||||
|
hp: playerState.hp,
|
||||||
|
maxHp: playerState.maxHp,
|
||||||
|
isAlive: playerState.hp > 0,
|
||||||
|
energy: 3,
|
||||||
|
maxEnergy: 3,
|
||||||
|
deck,
|
||||||
|
itemEffects: playerState.itemEffects,
|
||||||
|
effects: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export * from "./effects";
|
||||||
|
export * from "./factory";
|
||||||
|
export * from "./prompts";
|
||||||
|
export * from "./triggers";
|
||||||
|
export * from "./types";
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { createPromptDef } from "@/core/game";
|
||||||
|
import { CombatGameContext, IRunContext } from "./types";
|
||||||
|
import { canPlayCard } from "@/samples/slay-the-spire-like/system/combat/effects";
|
||||||
|
|
||||||
|
export const prompts = {
|
||||||
|
mainAction: createPromptDef<[string, string?]>(
|
||||||
|
"main-action <cardId:string> [targetId:string]",
|
||||||
|
"选择卡牌并指定目标",
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function promptMainAction(
|
||||||
|
game: CombatGameContext,
|
||||||
|
run: IRunContext,
|
||||||
|
) {
|
||||||
|
return await game.prompt(prompts.mainAction, (cardId, targetId) => {
|
||||||
|
if (cardId === "end-turn")
|
||||||
|
return {
|
||||||
|
action: "end-turn" as "end-turn",
|
||||||
|
};
|
||||||
|
|
||||||
|
const exists =
|
||||||
|
game.value.player.deck.regions.hand.childIds.includes(cardId);
|
||||||
|
if (!exists) throw `卡牌"${cardId}"不在手牌中`;
|
||||||
|
|
||||||
|
const card = game.value.player.deck.cards[cardId];
|
||||||
|
const { cardData, itemId } = card;
|
||||||
|
if (
|
||||||
|
!canPlayCard(
|
||||||
|
game.value.player,
|
||||||
|
cardData.costType,
|
||||||
|
cardData.costCount,
|
||||||
|
itemId,
|
||||||
|
run,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
throw `无法支付卡牌"${cardId}"的费用`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { targetType } = cardData;
|
||||||
|
if (targetType === "enemy") {
|
||||||
|
if (!targetId) throw `请指定目标`;
|
||||||
|
const target = game.value.enemies.find((e) => e.id === targetId);
|
||||||
|
if (!target) throw `目标"${targetId}"不存在`;
|
||||||
|
if (!target.isAlive) throw `目标"${targetId}"已死亡`;
|
||||||
|
} else if (targetType === "enemies" || targetType === "player") {
|
||||||
|
if (targetId) throw `目标"${targetId}"无效`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
action: "play" as "play",
|
||||||
|
cardId,
|
||||||
|
targetId,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import { CombatGameContext, IRunContext } from "./types";
|
||||||
|
import {
|
||||||
|
addEntityEffect,
|
||||||
|
addItemEffect,
|
||||||
|
getAliveEnemies,
|
||||||
|
onEntityPostureDamage,
|
||||||
|
onEntityEffectUpkeep,
|
||||||
|
onPlayerItemEffectUpkeep,
|
||||||
|
onItemDiscard,
|
||||||
|
onItemPlay,
|
||||||
|
payCardCost,
|
||||||
|
getCombatEntity,
|
||||||
|
getEffectTargets,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/combat/effects";
|
||||||
|
import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/prompts";
|
||||||
|
import { moveToRegion, shuffle } from "@/core/region";
|
||||||
|
import { createMiddlewareChain } from "@/utils/middleware";
|
||||||
|
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
|
||||||
|
type TriggerTypes = {
|
||||||
|
onCombatStart: {};
|
||||||
|
onTurnStart: { entityId: "player" | string };
|
||||||
|
onTurnEnd: { entityId: "player" | string };
|
||||||
|
onShuffle: {};
|
||||||
|
onCardPlayed: {
|
||||||
|
cardId: string;
|
||||||
|
targetId?: string;
|
||||||
|
sourceEntityId?: "player" | string;
|
||||||
|
};
|
||||||
|
onCardDiscarded: { cardId: string; sourceEntityId?: "player" | string };
|
||||||
|
onCardDrawn: { cardId: string; sourceEntityId?: "player" | string };
|
||||||
|
onDraw: { count: number };
|
||||||
|
onEffectApplied: {
|
||||||
|
effect: EffectData;
|
||||||
|
entityId: "player" | string;
|
||||||
|
stacks: number;
|
||||||
|
cardId?: string;
|
||||||
|
sourceEntityId?: "player" | string;
|
||||||
|
targetId?: string;
|
||||||
|
};
|
||||||
|
onHpChange: { entityId: "player" | string; amount: number };
|
||||||
|
onDamage: {
|
||||||
|
entityId: "player" | string;
|
||||||
|
amount: number;
|
||||||
|
prevented?: number;
|
||||||
|
sourceEntityId?: "player" | string;
|
||||||
|
};
|
||||||
|
onEnemyIntent: { enemyId: string; sourceEntityId?: "player" | string };
|
||||||
|
onIntentUpdate: { enemyId: string };
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createTriggers(run: IRunContext) {
|
||||||
|
const triggers = {
|
||||||
|
onCombatStart: createTrigger("onCombatStart", async (ctx) => {
|
||||||
|
await triggers.onShuffle.execute(ctx.game, {});
|
||||||
|
await triggers.onDraw.execute(ctx.game, { count: 5 });
|
||||||
|
}),
|
||||||
|
onTurnStart: createTrigger("onTurnStart", async (ctx) => {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const entity = getCombatEntity(draft, ctx.entityId);
|
||||||
|
if (entity) onEntityEffectUpkeep(entity);
|
||||||
|
if (entity === draft.player) onPlayerItemEffectUpkeep(draft.player);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
onTurnEnd: createTrigger("onTurnEnd", async (ctx) => {
|
||||||
|
if (ctx.entityId !== "player") return;
|
||||||
|
const { regions } = ctx.game.value.player.deck;
|
||||||
|
for (const cardId of Object.values(regions.hand.childIds)) {
|
||||||
|
await triggers.onCardDiscarded.execute(ctx.game, { cardId });
|
||||||
|
}
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
draft.player.energy = draft.player.maxEnergy;
|
||||||
|
});
|
||||||
|
await triggers.onDraw.execute(ctx.game, { count: 5 });
|
||||||
|
}),
|
||||||
|
onShuffle: createTrigger("onShuffle", async (ctx) => {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const { cards, regions } = draft.player.deck;
|
||||||
|
for (const cardId of Object.values(regions.discardPile.childIds))
|
||||||
|
moveToRegion(cards[cardId], regions.discardPile, regions.drawPile);
|
||||||
|
shuffle(regions.drawPile, cards, ctx.game.rng);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
onCardPlayed: createTrigger("onCardPlayed", async (ctx) => {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const { cards, regions } = draft.player.deck;
|
||||||
|
const card = cards[ctx.cardId];
|
||||||
|
payCardCost(
|
||||||
|
draft.player,
|
||||||
|
card.cardData.costType,
|
||||||
|
card.cardData.costCount,
|
||||||
|
card.itemId,
|
||||||
|
run,
|
||||||
|
);
|
||||||
|
moveToRegion(card, regions.hand, regions.discardPile);
|
||||||
|
onItemPlay(draft.player, card.itemId);
|
||||||
|
});
|
||||||
|
const { cards, regions } = ctx.game.value.player.deck;
|
||||||
|
const card = cards[ctx.cardId];
|
||||||
|
const source = ctx.sourceEntityId ?? "player";
|
||||||
|
for (const { trigger, target, effects } of card.cardData.effects) {
|
||||||
|
if (trigger !== "onPlay") continue;
|
||||||
|
for (const [effect, stacks] of effects)
|
||||||
|
for (const entity of getEffectTargets(
|
||||||
|
target,
|
||||||
|
ctx.game,
|
||||||
|
ctx.targetId,
|
||||||
|
source,
|
||||||
|
))
|
||||||
|
await triggers.onEffectApplied.execute(ctx.game, {
|
||||||
|
effect,
|
||||||
|
entityId: entity.id,
|
||||||
|
stacks,
|
||||||
|
cardId: ctx.cardId,
|
||||||
|
sourceEntityId: source,
|
||||||
|
targetId: ctx.targetId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
onCardDiscarded: createTrigger("onCardDiscarded", async (ctx) => {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const { cards, regions } = draft.player.deck;
|
||||||
|
moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile);
|
||||||
|
onItemDiscard(draft.player, cards[ctx.cardId].itemId);
|
||||||
|
});
|
||||||
|
const { cards, regions } = ctx.game.value.player.deck;
|
||||||
|
const card = cards[ctx.cardId];
|
||||||
|
const source = ctx.sourceEntityId ?? "player";
|
||||||
|
for (const { trigger, target, effects } of card.cardData.effects) {
|
||||||
|
if (trigger !== "onDiscard") continue;
|
||||||
|
for (const [effect, stacks] of effects)
|
||||||
|
for (const entity of getEffectTargets(
|
||||||
|
target,
|
||||||
|
ctx.game,
|
||||||
|
undefined,
|
||||||
|
source,
|
||||||
|
))
|
||||||
|
await triggers.onEffectApplied.execute(ctx.game, {
|
||||||
|
effect,
|
||||||
|
entityId: entity.id,
|
||||||
|
stacks,
|
||||||
|
cardId: ctx.cardId,
|
||||||
|
sourceEntityId: source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
onCardDrawn: createTrigger("onCardDrawn", async (ctx) => {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const { cards, regions } = draft.player.deck;
|
||||||
|
moveToRegion(cards[ctx.cardId], regions.drawPile, regions.hand);
|
||||||
|
});
|
||||||
|
const { cards, regions } = ctx.game.value.player.deck;
|
||||||
|
const card = cards[ctx.cardId];
|
||||||
|
const source = ctx.sourceEntityId ?? "player";
|
||||||
|
for (const { trigger, target, effects } of card.cardData.effects) {
|
||||||
|
if (trigger !== "onDraw") continue;
|
||||||
|
for (const [effect, stacks] of effects)
|
||||||
|
for (const entity of getEffectTargets(
|
||||||
|
target,
|
||||||
|
ctx.game,
|
||||||
|
undefined,
|
||||||
|
source,
|
||||||
|
))
|
||||||
|
await triggers.onEffectApplied.execute(ctx.game, {
|
||||||
|
effect,
|
||||||
|
entityId: entity.id,
|
||||||
|
stacks,
|
||||||
|
cardId: ctx.cardId,
|
||||||
|
sourceEntityId: source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
onDraw: createTrigger("onDraw", async (ctx) => {
|
||||||
|
let toDraw = ctx.count;
|
||||||
|
while (toDraw > 0) {
|
||||||
|
let inDraw =
|
||||||
|
ctx.game.value.player.deck.regions.drawPile.childIds.length;
|
||||||
|
if (inDraw <= 0) await triggers.onShuffle.execute(ctx.game, {});
|
||||||
|
|
||||||
|
inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length;
|
||||||
|
if (inDraw <= 0) break;
|
||||||
|
|
||||||
|
const children = ctx.game.value.player.deck.regions.drawPile.childIds;
|
||||||
|
const cardId = children[children.length - 1];
|
||||||
|
await triggers.onCardDrawn.execute(ctx.game, { cardId });
|
||||||
|
toDraw--;
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
onEffectApplied: createTrigger("onEffectApplied", async (ctx) => {
|
||||||
|
if (ctx.effect.lifecycle === "instant") return;
|
||||||
|
|
||||||
|
if (ctx.effect.lifecycle.startsWith("item")) {
|
||||||
|
if (ctx.cardId) {
|
||||||
|
const card = ctx.game.value.player.deck.cards[ctx.cardId];
|
||||||
|
const nearby = run.getAdjacentItems(card.itemId);
|
||||||
|
for (const itemId of nearby) {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const entity =
|
||||||
|
ctx.entityId === "player"
|
||||||
|
? draft.player
|
||||||
|
: draft.enemies.find((e) => e.id === ctx.entityId);
|
||||||
|
if (entity) addEntityEffect(entity, ctx.effect, ctx.stacks);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
onHpChange: createTrigger("onHpChange", async (ctx) => {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const entity =
|
||||||
|
ctx.entityId === "player"
|
||||||
|
? draft.player
|
||||||
|
: draft.enemies.find((e) => e.id === ctx.entityId);
|
||||||
|
if (!entity) return;
|
||||||
|
entity.hp += ctx.amount;
|
||||||
|
entity.isAlive = entity.hp > 0;
|
||||||
|
draft.result = !draft.player.isAlive
|
||||||
|
? "defeat"
|
||||||
|
: draft.enemies.every((e) => !e.isAlive)
|
||||||
|
? "victory"
|
||||||
|
: null;
|
||||||
|
});
|
||||||
|
if (ctx.game.value.result) throw ctx.game.value;
|
||||||
|
}),
|
||||||
|
onDamage: createTrigger("onDamage", async (ctx) => {
|
||||||
|
const entity =
|
||||||
|
ctx.entityId === "player"
|
||||||
|
? ctx.game.value.player
|
||||||
|
: ctx.game.value.enemies.find((e) => e.id === ctx.entityId);
|
||||||
|
if (!entity || !entity.isAlive) return;
|
||||||
|
const dealt = Math.min(
|
||||||
|
Math.max(0, entity.hp),
|
||||||
|
ctx.amount - (ctx.prevented || 0),
|
||||||
|
);
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
onEntityPostureDamage(entity, dealt);
|
||||||
|
});
|
||||||
|
await triggers.onHpChange.execute(ctx.game, {
|
||||||
|
entityId: ctx.entityId,
|
||||||
|
amount: -dealt,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
onEnemyIntent: createTrigger("onEnemyIntent", async (ctx) => {
|
||||||
|
const enemy = ctx.game.value.enemies.find((e) => e.id === ctx.enemyId);
|
||||||
|
if (!enemy || !enemy.isAlive) return;
|
||||||
|
|
||||||
|
const intent = enemy.currentIntent;
|
||||||
|
if (!intent) return;
|
||||||
|
|
||||||
|
const source = ctx.sourceEntityId ?? enemy.id;
|
||||||
|
for (const [target, effect, stacks] of intent.effects) {
|
||||||
|
for (const entity of getEffectTargets(
|
||||||
|
target,
|
||||||
|
ctx.game,
|
||||||
|
undefined,
|
||||||
|
source,
|
||||||
|
))
|
||||||
|
await triggers.onEffectApplied.execute(ctx.game, {
|
||||||
|
effect,
|
||||||
|
entityId: entity.id,
|
||||||
|
stacks,
|
||||||
|
sourceEntityId: source,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
onIntentUpdate: createTrigger("onIntentUpdate", async (ctx) => {
|
||||||
|
await ctx.game.produceAsync((draft) => {
|
||||||
|
const enemy = draft.enemies.find((e) => e.id === ctx.enemyId);
|
||||||
|
if (!enemy) return;
|
||||||
|
|
||||||
|
const intent = enemy.currentIntent;
|
||||||
|
if (!intent) return;
|
||||||
|
|
||||||
|
const nextIntents = intent.nextIntents;
|
||||||
|
if (nextIntents.length > 0) {
|
||||||
|
const nextIndex = ctx.game.rng.nextInt(nextIntents.length);
|
||||||
|
enemy.currentIntent = nextIntents[nextIndex];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
return triggers;
|
||||||
|
}
|
||||||
|
export type Triggers = ReturnType<typeof createTriggers>;
|
||||||
|
export function createStart(triggers: Triggers, run: IRunContext) {
|
||||||
|
return async function (game: CombatGameContext) {
|
||||||
|
await triggers.onCombatStart.execute(game, {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
await triggers.onTurnStart.execute(game, { entityId: "player" });
|
||||||
|
while (true) {
|
||||||
|
const action = await promptMainAction(game, run);
|
||||||
|
if (action.action === "end-turn") break;
|
||||||
|
if (action.action === "play") {
|
||||||
|
await triggers.onCardPlayed.execute(game, action);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await triggers.onTurnEnd.execute(game, { entityId: "player" });
|
||||||
|
|
||||||
|
for (const enemy of getAliveEnemies(game.value)) {
|
||||||
|
await triggers.onTurnStart.execute(game, { entityId: enemy.id });
|
||||||
|
}
|
||||||
|
for (const enemy of getAliveEnemies(game.value)) {
|
||||||
|
await triggers.onEnemyIntent.execute(game, { enemyId: enemy.id });
|
||||||
|
await triggers.onIntentUpdate.execute(game, { enemyId: enemy.id });
|
||||||
|
}
|
||||||
|
for (const enemy of getAliveEnemies(game.value)) {
|
||||||
|
await triggers.onTurnEnd.execute(game, { entityId: enemy.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (e === game.value) return game.value.result;
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type TriggerContext<TKey extends keyof TriggerTypes> = TriggerTypes[TKey] & {
|
||||||
|
event: TKey;
|
||||||
|
game: CombatGameContext;
|
||||||
|
};
|
||||||
|
function createTrigger<TKey extends keyof TriggerTypes>(
|
||||||
|
event: TKey,
|
||||||
|
fallback?: (ctx: TriggerContext<TKey>) => Promise<void>,
|
||||||
|
) {
|
||||||
|
const { use, execute } = createMiddlewareChain<TriggerContext<TKey>, void>(
|
||||||
|
fallback,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
use,
|
||||||
|
execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => {
|
||||||
|
const param = { ...ctx, game, event };
|
||||||
|
await execute(param);
|
||||||
|
return param;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { Part } from "@/core/part";
|
||||||
|
import { Region } from "@/core/region";
|
||||||
|
import {
|
||||||
|
CardData,
|
||||||
|
EnemyData,
|
||||||
|
IntentData,
|
||||||
|
ItemData,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
|
||||||
|
export type EffectTable = Record<string, { data: EffectData; stacks: number }>;
|
||||||
|
|
||||||
|
export type CombatEntity = {
|
||||||
|
id: string; // player is just "player"
|
||||||
|
effects: EffectTable;
|
||||||
|
hp: number;
|
||||||
|
maxHp: number;
|
||||||
|
isAlive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IPlayerState {
|
||||||
|
hp: number;
|
||||||
|
maxHp: number;
|
||||||
|
itemEffects: Record<string, EffectTable>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlayerDeck {
|
||||||
|
cards: Record<string, Part<{ cardData: CardData; itemId: string }>>;
|
||||||
|
regions: {
|
||||||
|
hand: Region;
|
||||||
|
discardPile: Region;
|
||||||
|
drawPile: Region;
|
||||||
|
exhaustPile: Region;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlayerEntity = CombatEntity & {
|
||||||
|
energy: number;
|
||||||
|
maxEnergy: number;
|
||||||
|
deck: IPlayerDeck;
|
||||||
|
itemEffects: Record<string, EffectTable>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EnemyEntity = CombatEntity & {
|
||||||
|
enemy: EnemyData;
|
||||||
|
intents: Record<string, IntentData>;
|
||||||
|
currentIntent: IntentData;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
|
||||||
|
export type CombatResult = "victory" | "defeat";
|
||||||
|
|
||||||
|
export type LootEntry =
|
||||||
|
| {
|
||||||
|
type: "gold";
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: "item";
|
||||||
|
itemId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CombatState = {
|
||||||
|
enemies: EnemyEntity[];
|
||||||
|
player: PlayerEntity;
|
||||||
|
|
||||||
|
phase: CombatPhase;
|
||||||
|
turnNumber: number;
|
||||||
|
result: CombatResult | null;
|
||||||
|
|
||||||
|
loot: LootEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IRunContext {
|
||||||
|
getItemData(id: string): ItemData | null;
|
||||||
|
getAdjacentItems(id: string): Iterable<string>;
|
||||||
|
|
||||||
|
getConsumedUses(id: string): number;
|
||||||
|
setConsumedUsesAsync(id: string, uses: number): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CombatGameContext =
|
||||||
|
import("@/core/game").IGameContextExport<CombatState>;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { moveToRegion } from "@/core/region";
|
||||||
|
import { createRegion } from "@/core/region";
|
||||||
|
import type { CardData } from "../types";
|
||||||
|
import type { DeckRegions, GameCard, IInventory, PlayerDeck } from "./types";
|
||||||
|
|
||||||
|
function generateCardId(itemId: string, cellIndex: number): string {
|
||||||
|
return `card-${itemId}-${cellIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCard(
|
||||||
|
itemId: string,
|
||||||
|
cardData: CardData,
|
||||||
|
cellIndex: number,
|
||||||
|
): GameCard {
|
||||||
|
return {
|
||||||
|
id: generateCardId(itemId, cellIndex),
|
||||||
|
regionId: "",
|
||||||
|
position: [],
|
||||||
|
itemId,
|
||||||
|
cardData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeckRegions(): DeckRegions {
|
||||||
|
return {
|
||||||
|
drawPile: createRegion("drawPile", []),
|
||||||
|
hand: createRegion("hand", []),
|
||||||
|
discardPile: createRegion("discardPile", []),
|
||||||
|
exhaustPile: createRegion("exhaustPile", []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateDeckFromInventory(inventory: IInventory): PlayerDeck {
|
||||||
|
const cards: Record<string, GameCard> = {};
|
||||||
|
const regions = createDeckRegions();
|
||||||
|
|
||||||
|
for (const item of inventory.items.values()) {
|
||||||
|
const itemData = item.meta?.itemData;
|
||||||
|
if (!itemData) continue;
|
||||||
|
|
||||||
|
const count = item.shape.count;
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const card = createCard(item.id, itemData.card, i);
|
||||||
|
cards[card.id] = card;
|
||||||
|
moveToRegion(card, null, regions.drawPile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
cards,
|
||||||
|
regions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayerDeck(): PlayerDeck {
|
||||||
|
return {
|
||||||
|
cards: {},
|
||||||
|
regions: createDeckRegions(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export {
|
||||||
|
generateDeckFromInventory,
|
||||||
|
createCard,
|
||||||
|
createPlayerDeck,
|
||||||
|
createDeckRegions,
|
||||||
|
generateCardId,
|
||||||
|
};
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./factory";
|
||||||
|
export * from "./types";
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { Part } from "@/core/part";
|
||||||
|
import { CardData, ItemData } from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
import { Region } from "@/core/region";
|
||||||
|
import { ParsedShape } from "../utils/parse-shape";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Metadata for a game card.
|
||||||
|
* Bridges inventory item data with the card system.
|
||||||
|
*/
|
||||||
|
export interface GameCardMeta {
|
||||||
|
cardData: CardData;
|
||||||
|
itemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IInventory {
|
||||||
|
items: Map<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
id: string;
|
||||||
|
meta?: { itemData: ItemData };
|
||||||
|
shape: ParsedShape;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A card instance in the game.
|
||||||
|
* Cards are generated from inventory items or created as status effects.
|
||||||
|
*/
|
||||||
|
export type GameCard = Part<GameCardMeta>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player deck structure containing card pools.
|
||||||
|
*/
|
||||||
|
export interface PlayerDeck {
|
||||||
|
/** All cards indexed by ID */
|
||||||
|
cards: Record<string, GameCard>;
|
||||||
|
|
||||||
|
regions: DeckRegions;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeckRegions {
|
||||||
|
/** Card IDs in the draw pile */
|
||||||
|
drawPile: Region;
|
||||||
|
/** Card IDs in the player's hand */
|
||||||
|
hand: Region;
|
||||||
|
/** Card IDs in the discard pile */
|
||||||
|
discardPile: Region;
|
||||||
|
/** Card IDs in the exhaust pile (removed from combat) */
|
||||||
|
exhaustPile: Region;
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./run";
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { RunState } from "./types";
|
||||||
|
|
||||||
|
const DEFAULT_MAX_HP = 50;
|
||||||
|
const DEFAULT_GOLD = 50;
|
||||||
|
|
||||||
|
export function createRunState(): RunState {
|
||||||
|
return {
|
||||||
|
maxHp: DEFAULT_MAX_HP,
|
||||||
|
currentHp: DEFAULT_MAX_HP,
|
||||||
|
gold: DEFAULT_GOLD,
|
||||||
|
_idCounter: { value: 0 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function damagePlayer(runState: RunState, amount: number): void {
|
||||||
|
runState.currentHp = Math.max(0, runState.currentHp - amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function healPlayer(runState: RunState, amount: number): void {
|
||||||
|
runState.currentHp = Math.min(runState.maxHp, runState.currentHp + amount);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setMaxHp(runState: RunState, newMax: number): void {
|
||||||
|
const diff = Math.max(0, newMax - runState.maxHp);
|
||||||
|
runState.maxHp = newMax;
|
||||||
|
runState.currentHp = Math.max(0, Math.min(newMax, runState.currentHp + diff));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds gold to the player.
|
||||||
|
*/
|
||||||
|
export function addGold(runState: RunState, amount: number): void {
|
||||||
|
runState.gold += amount;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spends gold. Returns false if the player doesn't have enough gold.
|
||||||
|
*/
|
||||||
|
export function spendGold(
|
||||||
|
runState: RunState,
|
||||||
|
amount: number,
|
||||||
|
): { success: true } | { success: false; reason: string } {
|
||||||
|
if (amount <= 0) {
|
||||||
|
return { success: false, reason: "金额必须大于零" };
|
||||||
|
}
|
||||||
|
if (runState.gold < amount) {
|
||||||
|
return { success: false, reason: "金币不足" };
|
||||||
|
}
|
||||||
|
|
||||||
|
runState.gold -= amount;
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { GridInventory } from "../grid-inventory";
|
||||||
|
import { GameItemMeta } from "../grid-inventory/types";
|
||||||
|
import { EncounterData } from "../types";
|
||||||
|
|
||||||
|
export interface RunState {
|
||||||
|
_idCounter: { value: number };
|
||||||
|
|
||||||
|
currentHp: number;
|
||||||
|
maxHp: number;
|
||||||
|
gold: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EncounterState =
|
||||||
|
| CombatEncounterState
|
||||||
|
| ShopEncounterState
|
||||||
|
| CurioEncounterState
|
||||||
|
| CampEncounterState
|
||||||
|
| DialogueEncounterState;
|
||||||
|
|
||||||
|
export type CombatEncounterState = {
|
||||||
|
data: EncounterData<"minion" | "elite">;
|
||||||
|
blocked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ShopEncounterState = {
|
||||||
|
data: EncounterData<"shop">;
|
||||||
|
items: GridInventory<GameItemMeta & { sellPrice: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CurioEncounterState = {
|
||||||
|
data: EncounterData<"curio">;
|
||||||
|
items: GridInventory<GameItemMeta>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CampEncounterState = {
|
||||||
|
data: EncounterData<"camp">;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DialogueEncounterState = {
|
||||||
|
data: EncounterData<"event">;
|
||||||
|
blocked: boolean;
|
||||||
|
};
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { parseShapeString } from "../utils/parse-shape";
|
||||||
|
import type { ParsedShape } from "../utils/parse-shape";
|
||||||
|
import { IDENTITY_TRANSFORM, type Transform2D } from "../utils/shape-collision";
|
||||||
|
import { createGridInventory, placeItem, validatePlacement } from "./transform";
|
||||||
|
import type { GameItemMeta, GridInventory, MutationResult } from "./types";
|
||||||
|
import type { ItemData } from "../types";
|
||||||
|
import { ReadonlyRNG, RNG } from "@/utils/rng";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates and places a GameItemMeta item into the grid inventory.
|
||||||
|
* Parses the shape from itemData.shape and finds the first valid placement automatically.
|
||||||
|
* **Mutates directly** — call inside a `.produce()` callback.
|
||||||
|
*/
|
||||||
|
export function createItemIn(
|
||||||
|
inventory: GridInventory<GameItemMeta>,
|
||||||
|
id: string,
|
||||||
|
item: ItemData | GameItemMeta,
|
||||||
|
): MutationResult {
|
||||||
|
const itemData = "itemData" in item ? item.itemData : item;
|
||||||
|
const shape = parseShapeString(itemData.shape);
|
||||||
|
const transform = findFirstValidPlacement(inventory, shape);
|
||||||
|
if (!transform) {
|
||||||
|
return { success: false, reason: "无可用位置" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta: GameItemMeta = "itemData" in item ? item : { itemData, shape };
|
||||||
|
placeItem(inventory, { id, shape, transform, meta });
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for the first valid placement in the grid (row by row, left to right).
|
||||||
|
* Returns the transform or null if no valid placement exists.
|
||||||
|
*/
|
||||||
|
function findFirstValidPlacement(
|
||||||
|
inventory: GridInventory<GameItemMeta>,
|
||||||
|
shape: ParsedShape,
|
||||||
|
): Transform2D | null {
|
||||||
|
for (let y = 0; y <= inventory.height - shape.height; y++) {
|
||||||
|
for (let x = 0; x <= inventory.width - shape.width; x++) {
|
||||||
|
const transform: Transform2D = {
|
||||||
|
offset: { x, y },
|
||||||
|
rotation: 0,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
};
|
||||||
|
const result = validatePlacement(inventory, shape, transform);
|
||||||
|
if (result.valid) {
|
||||||
|
return transform;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateInstanceId(counter: { value: number }): string {
|
||||||
|
counter.value++;
|
||||||
|
return `item-${counter.value}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createShopInventory(
|
||||||
|
allItems: ItemData[],
|
||||||
|
rng: ReadonlyRNG,
|
||||||
|
counter: { value: number },
|
||||||
|
) {
|
||||||
|
const shopItems: GameItemMeta[] = [];
|
||||||
|
const rolledIndices = new Set<number>();
|
||||||
|
|
||||||
|
for (let i = 0; i < 5 && rolledIndices.size < allItems.length; i++) {
|
||||||
|
let index: number;
|
||||||
|
do {
|
||||||
|
index = rng.nextInt(allItems.length);
|
||||||
|
} while (rolledIndices.has(index));
|
||||||
|
rolledIndices.add(index);
|
||||||
|
|
||||||
|
const itemData = allItems[index];
|
||||||
|
const shape = parseShapeString(itemData.shape);
|
||||||
|
const tradePrice = Math.floor(
|
||||||
|
(rng.nextInt(5) + rng.nextInt(5) + 1) * 0.2 * itemData.price,
|
||||||
|
);
|
||||||
|
|
||||||
|
shopItems.push({ itemData, shape, tradePrice });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inventory = createGridInventory<GameItemMeta>(6, 4);
|
||||||
|
|
||||||
|
for (let i = 0; i < shopItems.length; i++) {
|
||||||
|
createItemIn(inventory, generateInstanceId(counter), shopItems[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inventory;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createCurioItems(
|
||||||
|
allItems: ItemData[],
|
||||||
|
rng: RNG,
|
||||||
|
counter: { value: number },
|
||||||
|
) {
|
||||||
|
const curioItems: GameItemMeta[] = [];
|
||||||
|
const rolledIndices = new Set<number>();
|
||||||
|
|
||||||
|
for (let i = 0; i < 2 && rolledIndices.size < allItems.length; i++) {
|
||||||
|
let index: number;
|
||||||
|
do {
|
||||||
|
index = rng.nextInt(allItems.length);
|
||||||
|
} while (rolledIndices.has(index));
|
||||||
|
rolledIndices.add(index);
|
||||||
|
|
||||||
|
const itemData = allItems[index];
|
||||||
|
const shape = parseShapeString(itemData.shape);
|
||||||
|
curioItems.push({ itemData, shape });
|
||||||
|
}
|
||||||
|
|
||||||
|
const inventory = createGridInventory<GameItemMeta>(4, 3);
|
||||||
|
|
||||||
|
for (let i = 0; i < curioItems.length; i++) {
|
||||||
|
createItemIn(inventory, generateInstanceId(counter), curioItems[i]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return inventory;
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./types";
|
||||||
|
export * from "./transform";
|
||||||
|
export * from "./factory";
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
import { EffectData } from "../types";
|
||||||
|
import type { ParsedShape } from "../utils/parse-shape";
|
||||||
|
import type { Transform2D } from "../utils/shape-collision";
|
||||||
|
import {
|
||||||
|
checkBoardCollision,
|
||||||
|
checkBounds,
|
||||||
|
flipXTransform,
|
||||||
|
flipYTransform,
|
||||||
|
rotateTransform,
|
||||||
|
transformShape,
|
||||||
|
} from "../utils/shape-collision";
|
||||||
|
import type {
|
||||||
|
CellKey,
|
||||||
|
GameItemMeta,
|
||||||
|
GridInventory,
|
||||||
|
InventoryItem,
|
||||||
|
MutationResult,
|
||||||
|
PlacementResult,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new empty grid inventory.
|
||||||
|
* Note: When used inside `.produce()`, call this before returning the draft.
|
||||||
|
*/
|
||||||
|
export function createGridInventory<TMeta = Record<string, unknown>>(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): GridInventory<TMeta> {
|
||||||
|
return {
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
items: new Map<string, InventoryItem<TMeta>>(),
|
||||||
|
occupiedCells: new Set<CellKey>(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a Set of occupied cell keys from a shape and transform.
|
||||||
|
*/
|
||||||
|
function getShapeCellKeys(
|
||||||
|
shape: ParsedShape,
|
||||||
|
transform: Transform2D,
|
||||||
|
): Set<CellKey> {
|
||||||
|
const cells = transformShape(shape, transform);
|
||||||
|
return new Set(cells.map((c) => `${c.x},${c.y}` as CellKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates whether an item can be placed at the given transform.
|
||||||
|
* Checks bounds and collision with all other items.
|
||||||
|
*/
|
||||||
|
export function validatePlacement<TMeta = Record<string, unknown>>(
|
||||||
|
inventory: GridInventory<TMeta>,
|
||||||
|
shape: InventoryItem<TMeta>["shape"],
|
||||||
|
transform: Transform2D,
|
||||||
|
): PlacementResult {
|
||||||
|
if (!checkBounds(shape, transform, inventory.width, inventory.height)) {
|
||||||
|
return { valid: false, reason: "超出边界" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkBoardCollision(shape, transform, inventory.occupiedCells)) {
|
||||||
|
return { valid: false, reason: "与已有物品重叠" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places an item onto the grid.
|
||||||
|
* **Mutates directly** — call inside a `.produce()` callback.
|
||||||
|
* Does not validate; call `validatePlacement` first.
|
||||||
|
*/
|
||||||
|
export function placeItem<TMeta = Record<string, unknown>>(
|
||||||
|
inventory: GridInventory<TMeta>,
|
||||||
|
item: InventoryItem<TMeta>,
|
||||||
|
): void {
|
||||||
|
const cells = getShapeCellKeys(item.shape, item.transform);
|
||||||
|
for (const cellKey of cells) {
|
||||||
|
inventory.occupiedCells.add(cellKey);
|
||||||
|
}
|
||||||
|
inventory.items.set(item.id, item);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an item from the grid by its ID.
|
||||||
|
* **Mutates directly** — call inside a `.produce()` callback.
|
||||||
|
*/
|
||||||
|
export function removeItem<TMeta = Record<string, unknown>>(
|
||||||
|
inventory: GridInventory<TMeta>,
|
||||||
|
itemId: string,
|
||||||
|
): void {
|
||||||
|
const item = inventory.items.get(itemId);
|
||||||
|
if (!item) return;
|
||||||
|
|
||||||
|
const cells = getShapeCellKeys(item.shape, item.transform);
|
||||||
|
for (const cellKey of cells) {
|
||||||
|
inventory.occupiedCells.delete(cellKey);
|
||||||
|
}
|
||||||
|
inventory.items.delete(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moves an item to a new position with a new transform.
|
||||||
|
* **Mutates directly** — call inside a `.produce()` callback.
|
||||||
|
* Validates before applying; returns result indicating success.
|
||||||
|
*/
|
||||||
|
export function moveItem<TMeta = Record<string, unknown>>(
|
||||||
|
inventory: GridInventory<TMeta>,
|
||||||
|
itemId: string,
|
||||||
|
newTransform: Transform2D,
|
||||||
|
): MutationResult {
|
||||||
|
const item = inventory.items.get(itemId);
|
||||||
|
if (!item) {
|
||||||
|
return { success: false, reason: "物品不存在" };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Temporarily remove item's cells for validation
|
||||||
|
const oldCells = getShapeCellKeys(item.shape, item.transform);
|
||||||
|
for (const cellKey of oldCells) {
|
||||||
|
inventory.occupiedCells.delete(cellKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate new position
|
||||||
|
const validation = validatePlacement(inventory, item.shape, newTransform);
|
||||||
|
if (!validation.valid) {
|
||||||
|
// Restore old cells
|
||||||
|
for (const cellKey of oldCells) {
|
||||||
|
inventory.occupiedCells.add(cellKey);
|
||||||
|
}
|
||||||
|
return { success: false, reason: validation.reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply new transform
|
||||||
|
item.transform = newTransform;
|
||||||
|
|
||||||
|
// Add new cells
|
||||||
|
const newCells = getShapeCellKeys(item.shape, item.transform);
|
||||||
|
for (const cellKey of newCells) {
|
||||||
|
inventory.occupiedCells.add(cellKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotates an item by the given degrees (typically 90, 180, or 270).
|
||||||
|
* **Mutates directly** — call inside a `.produce()` callback.
|
||||||
|
* Validates before applying; returns result indicating success.
|
||||||
|
*/
|
||||||
|
export function rotateItem<TMeta = Record<string, unknown>>(
|
||||||
|
inventory: GridInventory<TMeta>,
|
||||||
|
itemId: string,
|
||||||
|
degrees: number,
|
||||||
|
): MutationResult {
|
||||||
|
const item = inventory.items.get(itemId);
|
||||||
|
if (!item) {
|
||||||
|
return { success: false, reason: "物品不存在" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rotatedTransform = rotateTransform(item.transform, degrees);
|
||||||
|
return moveItem(inventory, itemId, rotatedTransform);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flips an item horizontally or vertically.
|
||||||
|
* **Mutates directly** — call inside a `.produce()` callback.
|
||||||
|
* Validates before applying; returns result indicating success.
|
||||||
|
*/
|
||||||
|
export function flipItem<TMeta = Record<string, unknown>>(
|
||||||
|
inventory: GridInventory<TMeta>,
|
||||||
|
itemId: string,
|
||||||
|
axis: "x" | "y",
|
||||||
|
): MutationResult {
|
||||||
|
const item = inventory.items.get(itemId);
|
||||||
|
if (!item) {
|
||||||
|
return { success: false, reason: "物品不存在" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const flippedTransform =
|
||||||
|
axis === "x"
|
||||||
|
? flipXTransform(item.transform)
|
||||||
|
: flipYTransform(item.transform);
|
||||||
|
|
||||||
|
return moveItem(inventory, itemId, flippedTransform);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a copy of the occupied cells set.
|
||||||
|
*/
|
||||||
|
export function getOccupiedCellSet<TMeta = Record<string, unknown>>(
|
||||||
|
inventory: GridInventory<TMeta>,
|
||||||
|
): Set<CellKey> {
|
||||||
|
return new Set(inventory.occupiedCells);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds the item occupying the given cell, if any.
|
||||||
|
*/
|
||||||
|
export function getItemAtCell<TMeta = Record<string, unknown>>(
|
||||||
|
inventory: GridInventory<TMeta>,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
): InventoryItem<TMeta> | undefined {
|
||||||
|
const cellKey = `${x},${y}` as CellKey;
|
||||||
|
if (!inventory.occupiedCells.has(cellKey)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of inventory.items.values()) {
|
||||||
|
const cells = getShapeCellKeys(item.shape, item.transform);
|
||||||
|
if (cells.has(cellKey)) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all items adjacent to the given item (orthogonally, not diagonally).
|
||||||
|
* Returns a Map of itemId -> item for deduplication.
|
||||||
|
*/
|
||||||
|
export function getAdjacentItems<TMeta>(
|
||||||
|
inventory: GridInventory<TMeta>,
|
||||||
|
itemId: string,
|
||||||
|
): Map<string, InventoryItem<TMeta>> {
|
||||||
|
const item = inventory.items.get(itemId);
|
||||||
|
if (!item) {
|
||||||
|
return new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
const ownCells = getShapeCellKeys(item.shape, item.transform);
|
||||||
|
const adjacent = new Map<string, InventoryItem<TMeta>>();
|
||||||
|
|
||||||
|
for (const cellKey of ownCells) {
|
||||||
|
const [cx, cy] = cellKey.split(",").map(Number);
|
||||||
|
const neighbors: CellKey[] = [
|
||||||
|
`${cx + 1},${cy}` as CellKey,
|
||||||
|
`${cx - 1},${cy}` as CellKey,
|
||||||
|
`${cx},${cy + 1}` as CellKey,
|
||||||
|
`${cx},${cy - 1}` as CellKey,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const neighborKey of neighbors) {
|
||||||
|
if (
|
||||||
|
inventory.occupiedCells.has(neighborKey) &&
|
||||||
|
!ownCells.has(neighborKey)
|
||||||
|
) {
|
||||||
|
const [nx, ny] = neighborKey.split(",").map(Number);
|
||||||
|
const neighborItem = getItemAtCell(inventory, nx, ny);
|
||||||
|
if (neighborItem) {
|
||||||
|
adjacent.set(neighborItem.id, neighborItem);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return adjacent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// export type EffectTable = Record<string, { data: EffectData; stacks: number }>;
|
||||||
|
export function getItemEffects(inv: GridInventory<GameItemMeta>) {
|
||||||
|
const effects = {} as Record<
|
||||||
|
string,
|
||||||
|
Record<string, { data: EffectData; stacks: number }>
|
||||||
|
>;
|
||||||
|
|
||||||
|
for (const item of inv.items.values()) {
|
||||||
|
if (!item.meta) continue;
|
||||||
|
const { startEffects } = item.meta;
|
||||||
|
if (!startEffects) continue;
|
||||||
|
|
||||||
|
effects[item.id] = startEffects;
|
||||||
|
}
|
||||||
|
|
||||||
|
return effects;
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { EffectData, ItemData } from "../types";
|
||||||
|
import type { ParsedShape } from "../utils/parse-shape";
|
||||||
|
import type { Transform2D } from "../utils/shape-collision";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* String key representing a grid cell in "x,y" format.
|
||||||
|
*/
|
||||||
|
export type CellKey = `${number},${number}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simple 2D coordinate for grid cells.
|
||||||
|
*/
|
||||||
|
export interface CellCoordinate {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An item placed on the grid inventory.
|
||||||
|
* @template TMeta - Optional metadata type for game-specific data
|
||||||
|
*/
|
||||||
|
export interface InventoryItem<TMeta> {
|
||||||
|
/** Unique item identifier */
|
||||||
|
id: string;
|
||||||
|
/** Reference to the item's shape definition */
|
||||||
|
shape: ParsedShape;
|
||||||
|
/** Current transformation (position, rotation, flips) */
|
||||||
|
transform: Transform2D;
|
||||||
|
/** Optional metadata for game-specific data */
|
||||||
|
meta?: TMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a placement validation check.
|
||||||
|
*/
|
||||||
|
export type PlacementResult =
|
||||||
|
| { valid: true }
|
||||||
|
| { valid: false; reason: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a mutation operation (move, rotate, flip).
|
||||||
|
*/
|
||||||
|
export type MutationResult =
|
||||||
|
| { success: true }
|
||||||
|
| { success: false; reason: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grid inventory state.
|
||||||
|
* Designed to be mutated directly inside a `mutative .produce()` callback.
|
||||||
|
* @template TMeta - Optional metadata type for items
|
||||||
|
*/
|
||||||
|
export interface GridInventory<TMeta> {
|
||||||
|
/** Board width in cells */
|
||||||
|
width: number;
|
||||||
|
/** Board height in cells */
|
||||||
|
height: number;
|
||||||
|
/** Map of itemId -> InventoryItem for all placed items */
|
||||||
|
items: Map<string, InventoryItem<TMeta>>;
|
||||||
|
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
|
||||||
|
occupiedCells: Set<CellKey>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameItemMeta {
|
||||||
|
itemData: ItemData;
|
||||||
|
shape: ParsedShape;
|
||||||
|
consumedUses?: number;
|
||||||
|
startEffects?: Record<string, { data: EffectData; stacks: number }>;
|
||||||
|
tradePrice?: number;
|
||||||
|
}
|
||||||
|
export type GameItem = InventoryItem<GameItemMeta>;
|
||||||
+54
-42
@@ -1,14 +1,11 @@
|
|||||||
import { Mulberry32RNG, type RNG } from '@/utils/rng';
|
import { ReadonlyRNG } from '@/utils/rng';
|
||||||
import encounterDesertCsv, { type EncounterDesert } from '../data/encounterDesert.csv';
|
|
||||||
import { MapNodeType, MapLayerType } from './types';
|
import { MapNodeType, MapLayerType } from './types';
|
||||||
import type { MapLayer, MapNode, PointCrawlMap, MapGenerationConfig } from './types';
|
import type { MapLayer, MapNode, PointCrawlMap, MapGenerationConfig } from './types';
|
||||||
|
import {EncounterData} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
|
||||||
/** Pre-indexed encounters by type */
|
function buildEncounterIndex(src: Iterable<EncounterData>): Map<string, EncounterData[]> {
|
||||||
const encountersByType = buildEncounterIndex();
|
const index = new Map<string, EncounterData[]>();
|
||||||
|
for (const encounter of src) {
|
||||||
function buildEncounterIndex(): Map<string, EncounterDesert[]> {
|
|
||||||
const index = new Map<string, EncounterDesert[]>();
|
|
||||||
for (const encounter of encounterDesertCsv) {
|
|
||||||
const type = encounter.type;
|
const type = encounter.type;
|
||||||
if (!index.has(type)) {
|
if (!index.has(type)) {
|
||||||
index.set(type, []);
|
index.set(type, []);
|
||||||
@@ -20,12 +17,12 @@ function buildEncounterIndex(): Map<string, EncounterDesert[]> {
|
|||||||
|
|
||||||
/** Map from MapNodeType to encounter type key */
|
/** Map from MapNodeType to encounter type key */
|
||||||
const NODE_TYPE_TO_ENCOUNTER: Partial<Record<MapNodeType, string>> = {
|
const NODE_TYPE_TO_ENCOUNTER: Partial<Record<MapNodeType, string>> = {
|
||||||
[MapNodeType.Minion]: 'enemy',
|
[MapNodeType.Minion]: 'minion',
|
||||||
[MapNodeType.Elite]: 'elite',
|
[MapNodeType.Elite]: 'elite',
|
||||||
[MapNodeType.Event]: 'event',
|
[MapNodeType.Event]: 'event',
|
||||||
[MapNodeType.Camp]: 'shelter',
|
[MapNodeType.Camp]: 'camp',
|
||||||
[MapNodeType.Shop]: 'npc',
|
[MapNodeType.Shop]: 'shop',
|
||||||
[MapNodeType.Curio]: 'shelter',
|
[MapNodeType.Curio]: 'curio',
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Default map generation configuration */
|
/** Default map generation configuration */
|
||||||
@@ -61,7 +58,7 @@ const LAYER_STRUCTURE: Array<{ layerType: MapLayerType | 'start' | 'end'; count:
|
|||||||
* Fisher-Yates shuffle algorithm for unbiased random permutation.
|
* Fisher-Yates shuffle algorithm for unbiased random permutation.
|
||||||
* Mutates the array in place and returns it.
|
* Mutates the array in place and returns it.
|
||||||
*/
|
*/
|
||||||
function fisherYatesShuffle<T>(array: T[], rng: RNG): T[] {
|
function fisherYatesShuffle<T>(array: T[], rng: ReadonlyRNG): T[] {
|
||||||
for (let i = array.length - 1; i > 0; i--) {
|
for (let i = array.length - 1; i > 0; i--) {
|
||||||
const j = rng.nextInt(i + 1);
|
const j = rng.nextInt(i + 1);
|
||||||
[array[i], array[j]] = [array[j], array[i]];
|
[array[i], array[j]] = [array[j], array[i]];
|
||||||
@@ -73,11 +70,7 @@ function fisherYatesShuffle<T>(array: T[], rng: RNG): T[] {
|
|||||||
* Picks a random encounter for the given node type.
|
* Picks a random encounter for the given node type.
|
||||||
* Returns undefined if no matching encounter exists.
|
* Returns undefined if no matching encounter exists.
|
||||||
*/
|
*/
|
||||||
function pickEncounterForNode(type: MapNodeType, rng: RNG): EncounterDesert | undefined {
|
function pickEncounterForNode(pool: EncounterData[] | undefined, rng: ReadonlyRNG): EncounterData | undefined {
|
||||||
const encounterType = NODE_TYPE_TO_ENCOUNTER[type];
|
|
||||||
if (!encounterType) return undefined;
|
|
||||||
|
|
||||||
const pool = encountersByType.get(encounterType);
|
|
||||||
if (!pool || pool.length === 0) return undefined;
|
if (!pool || pool.length === 0) return undefined;
|
||||||
|
|
||||||
return pool[rng.nextInt(pool.length)];
|
return pool[rng.nextInt(pool.length)];
|
||||||
@@ -96,12 +89,9 @@ const TOTAL_LAYERS = 10;
|
|||||||
* - Each settlement layer has at least 1 of each: camp, shop, curio
|
* - Each settlement layer has at least 1 of each: camp, shop, curio
|
||||||
* - Wild nodes connect to 1 wild node or 2 settlement nodes
|
* - Wild nodes connect to 1 wild node or 2 settlement nodes
|
||||||
*
|
*
|
||||||
* @param seed Random seed for reproducibility
|
|
||||||
*/
|
*/
|
||||||
export function generatePointCrawlMap(seed?: number): PointCrawlMap {
|
export function generatePointCrawlMap(rng: ReadonlyRNG, src: Iterable<EncounterData>): PointCrawlMap {
|
||||||
const rng = new Mulberry32RNG(seed ?? Date.now());
|
const encounters = buildEncounterIndex(src);
|
||||||
const actualSeed = rng.getSeed();
|
|
||||||
|
|
||||||
const layers: MapLayer[] = [];
|
const layers: MapLayer[] = [];
|
||||||
const nodes = new Map<string, MapNode>();
|
const nodes = new Map<string, MapNode>();
|
||||||
|
|
||||||
@@ -125,16 +115,22 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
|
|||||||
const nodeIds: string[] = [];
|
const nodeIds: string[] = [];
|
||||||
const layerNodes: MapNode[] = [];
|
const layerNodes: MapNode[] = [];
|
||||||
|
|
||||||
|
// Pre-generate settlement types if this is a settlement layer
|
||||||
|
let settlementTypes: MapNodeType[] | undefined;
|
||||||
|
if (structure.layerType === MapLayerType.Settlement) {
|
||||||
|
settlementTypes = generateSettlementTypes(rng);
|
||||||
|
}
|
||||||
|
|
||||||
for (let j = 0; j < structure.count; j++) {
|
for (let j = 0; j < structure.count; j++) {
|
||||||
const id = `node-${i}-${j}`;
|
const id = `node-${i}-${j}`;
|
||||||
const type = resolveNodeType(structure.layerType, j, structure.count, rng, wildPairTypes.get(i), j);
|
const type = resolveNodeType(structure.layerType, j, structure.count, rng, wildPairTypes.get(i), j, settlementTypes, j);
|
||||||
const encounter = pickEncounterForNode(type, rng);
|
const encounter = pickEncounterForNode(encounters.get(NODE_TYPE_TO_ENCOUNTER[type]!), rng);
|
||||||
const node: MapNode = {
|
const node: MapNode = {
|
||||||
id,
|
id,
|
||||||
layerIndex: i,
|
layerIndex: i,
|
||||||
type,
|
type,
|
||||||
childIds: [],
|
childIds: [],
|
||||||
...(encounter ? { encounter: { name: encounter.name, description: encounter.description } } : {}),
|
encounter
|
||||||
};
|
};
|
||||||
nodes.set(id, node);
|
nodes.set(id, node);
|
||||||
nodeIds.push(id);
|
nodeIds.push(id);
|
||||||
@@ -163,7 +159,7 @@ export function generatePointCrawlMap(seed?: number): PointCrawlMap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { layers, nodes, seed: actualSeed, parentIndex };
|
return { layers, nodes, parentIndex };
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -173,9 +169,11 @@ function resolveNodeType(
|
|||||||
layerType: MapLayerType | 'start' | 'end',
|
layerType: MapLayerType | 'start' | 'end',
|
||||||
_nodeIndex: number,
|
_nodeIndex: number,
|
||||||
_layerCount: number,
|
_layerCount: number,
|
||||||
rng: RNG,
|
rng: ReadonlyRNG,
|
||||||
preGeneratedTypes?: MapNodeType[],
|
preGeneratedTypes?: MapNodeType[],
|
||||||
nodeIndex?: number
|
nodeIndex?: number,
|
||||||
|
settlementTypes?: MapNodeType[],
|
||||||
|
settlementIndex?: number
|
||||||
): MapNodeType {
|
): MapNodeType {
|
||||||
switch (layerType) {
|
switch (layerType) {
|
||||||
case 'start':
|
case 'start':
|
||||||
@@ -189,8 +187,11 @@ function resolveNodeType(
|
|||||||
}
|
}
|
||||||
return pickWildNodeType(rng);
|
return pickWildNodeType(rng);
|
||||||
case MapLayerType.Settlement:
|
case MapLayerType.Settlement:
|
||||||
// This will be overridden by assignSettlementTypes
|
// Use pre-generated settlement types if available
|
||||||
return MapNodeType.Camp; // placeholder
|
if (settlementTypes && settlementIndex !== undefined) {
|
||||||
|
return settlementTypes[settlementIndex];
|
||||||
|
}
|
||||||
|
return MapNodeType.Camp; // fallback
|
||||||
default:
|
default:
|
||||||
return MapNodeType.Minion;
|
return MapNodeType.Minion;
|
||||||
}
|
}
|
||||||
@@ -200,7 +201,7 @@ function resolveNodeType(
|
|||||||
* Picks a random type for a wild node based on configured weights.
|
* Picks a random type for a wild node based on configured weights.
|
||||||
* Default: minion: 50%, elite: 25%, event: 25%
|
* Default: minion: 50%, elite: 25%, event: 25%
|
||||||
*/
|
*/
|
||||||
function pickWildNodeType(rng: RNG): MapNodeType {
|
function pickWildNodeType(rng: ReadonlyRNG): MapNodeType {
|
||||||
const weights = DEFAULT_CONFIG.wildNodeTypeWeights;
|
const weights = DEFAULT_CONFIG.wildNodeTypeWeights;
|
||||||
const roll = rng.nextInt(100);
|
const roll = rng.nextInt(100);
|
||||||
|
|
||||||
@@ -213,7 +214,7 @@ function pickWildNodeType(rng: RNG): MapNodeType {
|
|||||||
* Generates random types for a pair of wild layers (3 nodes each).
|
* Generates random types for a pair of wild layers (3 nodes each).
|
||||||
* Returns two arrays of 3 node types each.
|
* Returns two arrays of 3 node types each.
|
||||||
*/
|
*/
|
||||||
function generateWildPair(rng: RNG): [MapNodeType[], MapNodeType[]] {
|
function generateWildPair(rng: ReadonlyRNG): [MapNodeType[], MapNodeType[]] {
|
||||||
const layer1Types: MapNodeType[] = [];
|
const layer1Types: MapNodeType[] = [];
|
||||||
const layer2Types: MapNodeType[] = [];
|
const layer2Types: MapNodeType[] = [];
|
||||||
|
|
||||||
@@ -271,7 +272,7 @@ function countRepetitions(
|
|||||||
* Generates optimal wild layer pair by trying multiple attempts and selecting the one with fewest repetitions.
|
* Generates optimal wild layer pair by trying multiple attempts and selecting the one with fewest repetitions.
|
||||||
*/
|
*/
|
||||||
function generateOptimalWildPair(
|
function generateOptimalWildPair(
|
||||||
rng: RNG,
|
rng: ReadonlyRNG,
|
||||||
attempts = 3
|
attempts = 3
|
||||||
): [MapNodeType[], MapNodeType[]] {
|
): [MapNodeType[], MapNodeType[]] {
|
||||||
let bestLayer1: MapNodeType[] = [];
|
let bestLayer1: MapNodeType[] = [];
|
||||||
@@ -295,11 +296,24 @@ function generateOptimalWildPair(
|
|||||||
return [bestLayer1, bestLayer2];
|
return [bestLayer1, bestLayer2];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates settlement node types ensuring at least 1 of each: camp, shop, curio.
|
||||||
|
* The 4th node is randomly chosen from the three.
|
||||||
|
* Returns shuffled array of 4 node types.
|
||||||
|
*/
|
||||||
|
function generateSettlementTypes(rng: ReadonlyRNG): MapNodeType[] {
|
||||||
|
const requiredTypes = [MapNodeType.Camp, MapNodeType.Shop, MapNodeType.Curio];
|
||||||
|
const randomType = requiredTypes[rng.nextInt(3)];
|
||||||
|
const types = [...requiredTypes, randomType];
|
||||||
|
return fisherYatesShuffle(types, rng);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Assigns settlement node types ensuring at least 1 of each: camp, shop, curio.
|
* Assigns settlement node types ensuring at least 1 of each: camp, shop, curio.
|
||||||
* The 4th node is randomly chosen from the three.
|
* The 4th node is randomly chosen from the three.
|
||||||
|
* @deprecated Use generateSettlementTypes() during node creation instead.
|
||||||
*/
|
*/
|
||||||
function assignSettlementTypes(nodeIds: string[], nodes: MapNode[], rng: RNG): void {
|
function assignSettlementTypes(nodeIds: string[], nodes: MapNode[], rng: ReadonlyRNG): void {
|
||||||
// Shuffle node order to randomize which position gets which type
|
// Shuffle node order to randomize which position gets which type
|
||||||
const shuffledIndices = fisherYatesShuffle([0, 1, 2, 3], rng);
|
const shuffledIndices = fisherYatesShuffle([0, 1, 2, 3], rng);
|
||||||
|
|
||||||
@@ -321,12 +335,10 @@ function generateLayerEdges(
|
|||||||
sourceLayer: MapLayer,
|
sourceLayer: MapLayer,
|
||||||
targetLayer: MapLayer,
|
targetLayer: MapLayer,
|
||||||
nodes: Map<string, MapNode>,
|
nodes: Map<string, MapNode>,
|
||||||
rng: RNG
|
rng: ReadonlyRNG
|
||||||
): void {
|
): void {
|
||||||
// Assign settlement types when creating settlement layer
|
// Settlement types are now pre-generated during node creation
|
||||||
if (targetLayer.layerType === MapLayerType.Settlement) {
|
// No need to assign them here anymore
|
||||||
assignSettlementTypes(targetLayer.nodeIds, targetLayer.nodes, rng);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceType = sourceLayer.layerType;
|
const sourceType = sourceLayer.layerType;
|
||||||
const targetType = targetLayer.layerType;
|
const targetType = targetLayer.layerType;
|
||||||
@@ -378,7 +390,7 @@ function connectWildToWild(
|
|||||||
function connectWildToSettlement(
|
function connectWildToSettlement(
|
||||||
wildLayer: MapLayer,
|
wildLayer: MapLayer,
|
||||||
settlementLayer: MapLayer,
|
settlementLayer: MapLayer,
|
||||||
_rng: RNG
|
_rng: ReadonlyRNG
|
||||||
): void {
|
): void {
|
||||||
// Non-crossing connection pattern: each wild connects to 2 adjacent settlements.
|
// Non-crossing connection pattern: each wild connects to 2 adjacent settlements.
|
||||||
// Pattern: w[0]→{s[0],s[1]}, w[1]→{s[1],s[2]}, w[2]→{s[2],s[3]}
|
// Pattern: w[0]→{s[0],s[1]}, w[1]→{s[1],s[2]}, w[2]→{s[2],s[3]}
|
||||||
@@ -403,7 +415,7 @@ function connectWildToSettlement(
|
|||||||
function connectSettlementToWild(
|
function connectSettlementToWild(
|
||||||
settlementLayer: MapLayer,
|
settlementLayer: MapLayer,
|
||||||
wildLayer: MapLayer,
|
wildLayer: MapLayer,
|
||||||
_rng: RNG
|
_rng: ReadonlyRNG
|
||||||
): void {
|
): void {
|
||||||
// Non-crossing pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2
|
// Non-crossing pattern: s0→w0, s1→w0,w1, s2→w1,w2, s3→w2
|
||||||
// This pattern guarantees no crossings because when edges are sorted by
|
// This pattern guarantees no crossings because when edges are sorted by
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./generator";
|
||||||
|
export * from "./navigation";
|
||||||
|
export * from "./types";
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { getNode } from "./generator";
|
||||||
|
import { PointCrawlMap, PointCrawlMapNavigator } from "./types";
|
||||||
|
|
||||||
|
export function canMoveTo(
|
||||||
|
navigator: PointCrawlMapNavigator,
|
||||||
|
map: PointCrawlMap,
|
||||||
|
targetNodeId: string,
|
||||||
|
): boolean {
|
||||||
|
const currentNode = getNode(map, navigator.currentNodeId);
|
||||||
|
if (!currentNode) return false;
|
||||||
|
|
||||||
|
return currentNode.childIds.includes(targetNodeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveToNode(
|
||||||
|
navigator: PointCrawlMapNavigator,
|
||||||
|
map: PointCrawlMap,
|
||||||
|
targetNodeId: string,
|
||||||
|
): boolean {
|
||||||
|
if (!canMoveTo(navigator, map, targetNodeId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetNode = getNode(map, targetNodeId);
|
||||||
|
if (!targetNode) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update current position
|
||||||
|
navigator.currentNodeId = targetNodeId;
|
||||||
|
navigator.visitedNodes.add(targetNodeId);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function* getReachableChildren(
|
||||||
|
map: PointCrawlMap,
|
||||||
|
navigator: PointCrawlMapNavigator,
|
||||||
|
) {
|
||||||
|
const currentNode = getNode(map, navigator.currentNodeId);
|
||||||
|
if (!currentNode) return;
|
||||||
|
|
||||||
|
for (const id of currentNode.childIds) {
|
||||||
|
const node = getNode(map, id);
|
||||||
|
if (!node) continue;
|
||||||
|
yield node;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAtStartNode(
|
||||||
|
map: PointCrawlMap,
|
||||||
|
navigator: PointCrawlMapNavigator,
|
||||||
|
): boolean {
|
||||||
|
return navigator.currentNodeId === map.layers[0]?.nodes[0]?.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the current encounter is the end node.
|
||||||
|
*/
|
||||||
|
export function isAtEndNode(
|
||||||
|
map: PointCrawlMap,
|
||||||
|
navigator: PointCrawlMapNavigator,
|
||||||
|
): boolean {
|
||||||
|
const endLayer = map.layers[map.layers.length - 1];
|
||||||
|
return endLayer?.nodes[0]?.id === navigator.currentNodeId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
import { EncounterData } from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Types of nodes that can appear on the point crawl map.
|
||||||
|
*/
|
||||||
|
export enum MapNodeType {
|
||||||
|
Start = "start",
|
||||||
|
End = "end",
|
||||||
|
Minion = "minion",
|
||||||
|
Elite = "elite",
|
||||||
|
Event = "event",
|
||||||
|
Camp = "camp",
|
||||||
|
Shop = "shop",
|
||||||
|
Curio = "curio",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semantic type of a layer.
|
||||||
|
*/
|
||||||
|
export enum MapLayerType {
|
||||||
|
Wild = "wild",
|
||||||
|
Settlement = "settlement",
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single node on the map.
|
||||||
|
*/
|
||||||
|
export interface MapNode {
|
||||||
|
/** Unique identifier */
|
||||||
|
id: string;
|
||||||
|
/** Which layer this node belongs to */
|
||||||
|
layerIndex: number;
|
||||||
|
/** Semantic type of the node */
|
||||||
|
type: MapNodeType;
|
||||||
|
/** IDs of nodes in the next layer this node connects to */
|
||||||
|
childIds: string[];
|
||||||
|
/** Encounter data assigned to this node (from encounter CSV) */
|
||||||
|
encounter?: EncounterData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A horizontal layer of nodes at the same progression stage.
|
||||||
|
*/
|
||||||
|
export interface MapLayer {
|
||||||
|
/** Layer index (0 = start, last = end) */
|
||||||
|
index: number;
|
||||||
|
/** Ordered IDs of nodes in this layer */
|
||||||
|
nodeIds: string[];
|
||||||
|
/** Semantic type of the layer */
|
||||||
|
layerType: MapLayerType | "start" | "end";
|
||||||
|
/** Direct references to nodes in this layer (for performance) */
|
||||||
|
nodes: MapNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A fully generated point crawl map.
|
||||||
|
*/
|
||||||
|
export interface PointCrawlMap {
|
||||||
|
/** Layers from start to end */
|
||||||
|
layers: MapLayer[];
|
||||||
|
/** All nodes keyed by ID */
|
||||||
|
nodes: Map<string, MapNode>;
|
||||||
|
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
|
||||||
|
parentIndex?: Map<string, string[]>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PointCrawlMapNavigator {
|
||||||
|
currentNodeId: string;
|
||||||
|
visitedNodes: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configuration for map generation.
|
||||||
|
*/
|
||||||
|
export interface MapGenerationConfig {
|
||||||
|
/** Total number of layers (including start and end) */
|
||||||
|
totalLayers: number;
|
||||||
|
/** Number of nodes in each wild layer */
|
||||||
|
wildLayerNodeCount: number;
|
||||||
|
/** Number of nodes in each settlement layer */
|
||||||
|
settlementLayerNodeCount: number;
|
||||||
|
/** Probability weights for wild node types (should sum to 100) */
|
||||||
|
wildNodeTypeWeights: {
|
||||||
|
[MapNodeType.Minion]: number;
|
||||||
|
[MapNodeType.Elite]: number;
|
||||||
|
[MapNodeType.Event]: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import {
|
||||||
|
Card,
|
||||||
|
Effect,
|
||||||
|
Encounter,
|
||||||
|
EncounterType,
|
||||||
|
Enemy,
|
||||||
|
Intent,
|
||||||
|
Item,
|
||||||
|
} from "../data/desert";
|
||||||
|
|
||||||
|
export type CardData = Card;
|
||||||
|
export type ItemData = Item;
|
||||||
|
export type EffectData = Effect;
|
||||||
|
export type IntentData = Intent;
|
||||||
|
export type EnemyData = Enemy;
|
||||||
|
export type EncounterData<T extends EncounterType = EncounterType> =
|
||||||
|
Encounter & { type: T };
|
||||||
|
|
||||||
|
export {
|
||||||
|
CardTargetType,
|
||||||
|
CardEffectTarget,
|
||||||
|
IntentEffectTarget,
|
||||||
|
} from "../data/desert";
|
||||||
|
|
||||||
|
export { ContentModule } from "../data";
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export * from "./parse-shape";
|
||||||
|
export * from "./shape-collision";
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
import type { ParsedShape } from "./parse-shape";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a 2D point in grid coordinates.
|
||||||
|
*/
|
||||||
|
export interface Point2D {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 2D transformation to apply to a shape.
|
||||||
|
*/
|
||||||
|
export interface Transform2D {
|
||||||
|
/** Translation offset in grid units */
|
||||||
|
offset: Point2D;
|
||||||
|
/** Rotation in degrees (0, 90, 180, 270) */
|
||||||
|
rotation: number;
|
||||||
|
/** Whether to flip horizontally */
|
||||||
|
flipX: boolean;
|
||||||
|
/** Whether to flip vertically */
|
||||||
|
flipY: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default transform (identity).
|
||||||
|
*/
|
||||||
|
export const IDENTITY_TRANSFORM: Transform2D = {
|
||||||
|
offset: { x: 0, y: 0 },
|
||||||
|
rotation: 0,
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets all occupied cell coordinates from a shape.
|
||||||
|
*/
|
||||||
|
export function getOccupiedCells(shape: ParsedShape): Point2D[] {
|
||||||
|
const cells: Point2D[] = [];
|
||||||
|
for (let y = 0; y < shape.height; y++) {
|
||||||
|
for (let x = 0; x < shape.width; x++) {
|
||||||
|
if (shape.grid[y]?.[x]) {
|
||||||
|
cells.push({ x, y });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Applies a 2D transformation to a point.
|
||||||
|
*/
|
||||||
|
export function transformPoint(
|
||||||
|
point: Point2D,
|
||||||
|
transform: Transform2D,
|
||||||
|
shapeWidth: number,
|
||||||
|
shapeHeight: number,
|
||||||
|
): Point2D {
|
||||||
|
let { x, y } = point;
|
||||||
|
|
||||||
|
// Apply flips
|
||||||
|
if (transform.flipX) {
|
||||||
|
x = shapeWidth - 1 - x;
|
||||||
|
}
|
||||||
|
if (transform.flipY) {
|
||||||
|
y = shapeHeight - 1 - y;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply rotation (around origin 0,0)
|
||||||
|
const rotation = ((transform.rotation % 360) + 360) % 360;
|
||||||
|
let rotatedX = x;
|
||||||
|
let rotatedY = y;
|
||||||
|
|
||||||
|
switch (rotation) {
|
||||||
|
case 90:
|
||||||
|
rotatedX = y;
|
||||||
|
rotatedY = -x;
|
||||||
|
break;
|
||||||
|
case 180:
|
||||||
|
rotatedX = -x;
|
||||||
|
rotatedY = -y;
|
||||||
|
break;
|
||||||
|
case 270:
|
||||||
|
rotatedX = -y;
|
||||||
|
rotatedY = x;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply offset
|
||||||
|
return {
|
||||||
|
x: rotatedX + transform.offset.x,
|
||||||
|
y: rotatedY + transform.offset.y,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transforms a shape and returnss its occupied cells in world coordinates.
|
||||||
|
*/
|
||||||
|
export function transformShape(
|
||||||
|
shape: ParsedShape,
|
||||||
|
transform: Transform2D,
|
||||||
|
): Point2D[] {
|
||||||
|
const cells = getOccupiedCells(shape);
|
||||||
|
return cells.map((cell) =>
|
||||||
|
transformPoint(cell, transform, shape.width, shape.height),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if two transformed shapes collide (share any occupied cell).
|
||||||
|
*/
|
||||||
|
export function checkCollision(
|
||||||
|
shapeA: ParsedShape,
|
||||||
|
transformA: Transform2D,
|
||||||
|
shapeB: ParsedShape,
|
||||||
|
transformB: Transform2D,
|
||||||
|
): boolean {
|
||||||
|
const cellsA = transformShape(shapeA, transformA);
|
||||||
|
const cellsB = transformShape(shapeB, transformB);
|
||||||
|
|
||||||
|
const setA = new Set(cellsA.map((c) => `${c.x},${c.y}`));
|
||||||
|
|
||||||
|
for (const cell of cellsB) {
|
||||||
|
if (setA.has(`${cell.x},${cell.y}`)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a transformed shape collides with any occupied cells on a board.
|
||||||
|
* @param shape The shape to check
|
||||||
|
* @param transform The transform to apply to the shape
|
||||||
|
* @param occupiedCells Set of occupied board cells in "x,y" format
|
||||||
|
*/
|
||||||
|
export function checkBoardCollision(
|
||||||
|
shape: ParsedShape,
|
||||||
|
transform: Transform2D,
|
||||||
|
occupiedCells: Set<string>,
|
||||||
|
): boolean {
|
||||||
|
const cells = transformShape(shape, transform);
|
||||||
|
|
||||||
|
for (const cell of cells) {
|
||||||
|
if (occupiedCells.has(`${cell.x},${cell.y}`)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a transformed shape is within bounds.
|
||||||
|
* @param shape The shape to check
|
||||||
|
* @param transform The transform to apply to the shape
|
||||||
|
* @param boardWidth Board width
|
||||||
|
* @param boardHeight Board height
|
||||||
|
*/
|
||||||
|
export function checkBounds(
|
||||||
|
shape: ParsedShape,
|
||||||
|
transform: Transform2D,
|
||||||
|
boardWidth: number,
|
||||||
|
boardHeight: number,
|
||||||
|
): boolean {
|
||||||
|
const cells = transformShape(shape, transform);
|
||||||
|
|
||||||
|
for (const cell of cells) {
|
||||||
|
if (
|
||||||
|
cell.x < 0 ||
|
||||||
|
cell.x >= boardWidth ||
|
||||||
|
cell.y < 0 ||
|
||||||
|
cell.y >= boardHeight
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validates that a placement is both in bounds and collision-free.
|
||||||
|
* @returns Object with `valid` flag and optional `reason` string
|
||||||
|
*/
|
||||||
|
export function validateShapePlacement(
|
||||||
|
shape: ParsedShape,
|
||||||
|
transform: Transform2D,
|
||||||
|
boardWidth: number,
|
||||||
|
boardHeight: number,
|
||||||
|
occupiedCells: Set<string>,
|
||||||
|
): { valid: true } | { valid: false; reason: string } {
|
||||||
|
if (!checkBounds(shape, transform, boardWidth, boardHeight)) {
|
||||||
|
return { valid: false, reason: "超出边界" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (checkBoardCollision(shape, transform, occupiedCells)) {
|
||||||
|
return { valid: false, reason: "与已有形状重叠" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rotates a transform by the given degrees.
|
||||||
|
* @param current The current transform
|
||||||
|
* @param degrees Degrees to rotate (typically 90, 180, or 270)
|
||||||
|
*/
|
||||||
|
export function rotateTransform(
|
||||||
|
current: Transform2D,
|
||||||
|
degrees: number,
|
||||||
|
): Transform2D {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
rotation: (((current.rotation + degrees) % 360) + 360) % 360,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flips a transform horizontally.
|
||||||
|
*/
|
||||||
|
export function flipXTransform(current: Transform2D): Transform2D {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
flipX: !current.flipX,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Flips a transform vertically.
|
||||||
|
*/
|
||||||
|
export function flipYTransform(current: Transform2D): Transform2D {
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
flipY: !current.flipY,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,227 +0,0 @@
|
|||||||
import type { ParsedShape } from './parse-shape';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Represents a 2D point in grid coordinates.
|
|
||||||
*/
|
|
||||||
export interface Point2D {
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 2D transformation to apply to a shape.
|
|
||||||
*/
|
|
||||||
export interface Transform2D {
|
|
||||||
/** Translation offset in grid units */
|
|
||||||
offset: Point2D;
|
|
||||||
/** Rotation in degrees (0, 90, 180, 270) */
|
|
||||||
rotation: number;
|
|
||||||
/** Whether to flip horizontally */
|
|
||||||
flipX: boolean;
|
|
||||||
/** Whether to flip vertically */
|
|
||||||
flipY: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Default transform (identity).
|
|
||||||
*/
|
|
||||||
export const IDENTITY_TRANSFORM: Transform2D = {
|
|
||||||
offset: { x: 0, y: 0 },
|
|
||||||
rotation: 0,
|
|
||||||
flipX: false,
|
|
||||||
flipY: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Gets all occupied cell coordinates from a shape.
|
|
||||||
*/
|
|
||||||
export function getOccupiedCells(shape: ParsedShape): Point2D[] {
|
|
||||||
const cells: Point2D[] = [];
|
|
||||||
for (let y = 0; y < shape.height; y++) {
|
|
||||||
for (let x = 0; x < shape.width; x++) {
|
|
||||||
if (shape.grid[y]?.[x]) {
|
|
||||||
cells.push({ x, y });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cells;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Applies a 2D transformation to a point.
|
|
||||||
*/
|
|
||||||
export function transformPoint(
|
|
||||||
point: Point2D,
|
|
||||||
transform: Transform2D,
|
|
||||||
shapeWidth: number,
|
|
||||||
shapeHeight: number
|
|
||||||
): Point2D {
|
|
||||||
let { x, y } = point;
|
|
||||||
|
|
||||||
// Apply flips
|
|
||||||
if (transform.flipX) {
|
|
||||||
x = shapeWidth - 1 - x;
|
|
||||||
}
|
|
||||||
if (transform.flipY) {
|
|
||||||
y = shapeHeight - 1 - y;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply rotation (around origin 0,0)
|
|
||||||
const rotation = ((transform.rotation % 360) + 360) % 360;
|
|
||||||
let rotatedX = x;
|
|
||||||
let rotatedY = y;
|
|
||||||
|
|
||||||
switch (rotation) {
|
|
||||||
case 90:
|
|
||||||
rotatedX = y;
|
|
||||||
rotatedY = -x;
|
|
||||||
break;
|
|
||||||
case 180:
|
|
||||||
rotatedX = -x;
|
|
||||||
rotatedY = -y;
|
|
||||||
break;
|
|
||||||
case 270:
|
|
||||||
rotatedX = -y;
|
|
||||||
rotatedY = x;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply offset
|
|
||||||
return {
|
|
||||||
x: rotatedX + transform.offset.x,
|
|
||||||
y: rotatedY + transform.offset.y,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Transforms a shape and returnss its occupied cells in world coordinates.
|
|
||||||
*/
|
|
||||||
export function transformShape(shape: ParsedShape, transform: Transform2D): Point2D[] {
|
|
||||||
const cells = getOccupiedCells(shape);
|
|
||||||
return cells.map(cell =>
|
|
||||||
transformPoint(cell, transform, shape.width, shape.height)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if two transformed shapes collide (share any occupied cell).
|
|
||||||
*/
|
|
||||||
export function checkCollision(
|
|
||||||
shapeA: ParsedShape,
|
|
||||||
transformA: Transform2D,
|
|
||||||
shapeB: ParsedShape,
|
|
||||||
transformB: Transform2D
|
|
||||||
): boolean {
|
|
||||||
const cellsA = transformShape(shapeA, transformA);
|
|
||||||
const cellsB = transformShape(shapeB, transformB);
|
|
||||||
|
|
||||||
const setA = new Set(cellsA.map(c => `${c.x},${c.y}`));
|
|
||||||
|
|
||||||
for (const cell of cellsB) {
|
|
||||||
if (setA.has(`${cell.x},${cell.y}`)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a transformed shape collides with any occupied cells on a board.
|
|
||||||
* @param shape The shape to check
|
|
||||||
* @param transform The transform to apply to the shape
|
|
||||||
* @param occupiedCells Set of occupied board cells in "x,y" format
|
|
||||||
*/
|
|
||||||
export function checkBoardCollision(
|
|
||||||
shape: ParsedShape,
|
|
||||||
transform: Transform2D,
|
|
||||||
occupiedCells: Set<string>
|
|
||||||
): boolean {
|
|
||||||
const cells = transformShape(shape, transform);
|
|
||||||
|
|
||||||
for (const cell of cells) {
|
|
||||||
if (occupiedCells.has(`${cell.x},${cell.y}`)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks if a transformed shape is within bounds.
|
|
||||||
* @param shape The shape to check
|
|
||||||
* @param transform The transform to apply to the shape
|
|
||||||
* @param boardWidth Board width
|
|
||||||
* @param boardHeight Board height
|
|
||||||
*/
|
|
||||||
export function checkBounds(
|
|
||||||
shape: ParsedShape,
|
|
||||||
transform: Transform2D,
|
|
||||||
boardWidth: number,
|
|
||||||
boardHeight: number
|
|
||||||
): boolean {
|
|
||||||
const cells = transformShape(shape, transform);
|
|
||||||
|
|
||||||
for (const cell of cells) {
|
|
||||||
if (cell.x < 0 || cell.x >= boardWidth || cell.y < 0 || cell.y >= boardHeight) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validates that a placement is both in bounds and collision-free.
|
|
||||||
* @returns Object with `valid` flag and optional `reason` string
|
|
||||||
*/
|
|
||||||
export function validatePlacement(
|
|
||||||
shape: ParsedShape,
|
|
||||||
transform: Transform2D,
|
|
||||||
boardWidth: number,
|
|
||||||
boardHeight: number,
|
|
||||||
occupiedCells: Set<string>
|
|
||||||
): { valid: true } | { valid: false; reason: string } {
|
|
||||||
if (!checkBounds(shape, transform, boardWidth, boardHeight)) {
|
|
||||||
return { valid: false, reason: '超出边界' };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (checkBoardCollision(shape, transform, occupiedCells)) {
|
|
||||||
return { valid: false, reason: '与已有形状重叠' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { valid: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Rotates a transform by the given degrees.
|
|
||||||
* @param current The current transform
|
|
||||||
* @param degrees Degrees to rotate (typically 90, 180, or 270)
|
|
||||||
*/
|
|
||||||
export function rotateTransform(current: Transform2D, degrees: number): Transform2D {
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
rotation: ((current.rotation + degrees) % 360 + 360) % 360,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flips a transform horizontally.
|
|
||||||
*/
|
|
||||||
export function flipXTransform(current: Transform2D): Transform2D {
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
flipX: !current.flipX,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flips a transform vertically.
|
|
||||||
*/
|
|
||||||
export function flipYTransform(current: Transform2D): Transform2D {
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
flipY: !current.flipY,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
+191
-93
@@ -1,132 +1,230 @@
|
|||||||
import {Part} from "boardgame-core";
|
import { Part } from "@/core/part";
|
||||||
import {createRegion} from "boardgame-core";
|
import { createRegion, Region } from "@/core/region";
|
||||||
import {createGameCommandRegistry, createPromptDef, IGameContext} from "boardgame-core";
|
import { createPromptDef, IGameContext } from "@/core/game";
|
||||||
|
import { BaseGameClient } from "@/core/game-client";
|
||||||
|
import { createMiddlewareChain } from "@/utils/middleware";
|
||||||
|
|
||||||
const BOARD_SIZE = 3;
|
const BOARD_SIZE = 3;
|
||||||
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
|
||||||
const WINNING_LINES: number[][][] = [
|
const WINNING_LINES: number[][][] = [
|
||||||
[[0, 0], [0, 1], [0, 2]],
|
[
|
||||||
[[1, 0], [1, 1], [1, 2]],
|
[0, 0],
|
||||||
[[2, 0], [2, 1], [2, 2]],
|
[0, 1],
|
||||||
[[0, 0], [1, 0], [2, 0]],
|
[0, 2],
|
||||||
[[0, 1], [1, 1], [2, 1]],
|
],
|
||||||
[[0, 2], [1, 2], [2, 2]],
|
[
|
||||||
[[0, 0], [1, 1], [2, 2]],
|
[1, 0],
|
||||||
[[0, 2], [1, 1], [2, 0]],
|
[1, 1],
|
||||||
|
[1, 2],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[2, 0],
|
||||||
|
[2, 1],
|
||||||
|
[2, 2],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[0, 0],
|
||||||
|
[1, 0],
|
||||||
|
[2, 0],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[0, 1],
|
||||||
|
[1, 1],
|
||||||
|
[2, 1],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[0, 2],
|
||||||
|
[1, 2],
|
||||||
|
[2, 2],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[0, 0],
|
||||||
|
[1, 1],
|
||||||
|
[2, 2],
|
||||||
|
],
|
||||||
|
[
|
||||||
|
[0, 2],
|
||||||
|
[1, 1],
|
||||||
|
[2, 0],
|
||||||
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
export type PlayerType = 'X' | 'O';
|
export type PlayerType = "X" | "O";
|
||||||
export type WinnerType = PlayerType | 'draw' | null;
|
export type WinnerType = PlayerType | "draw" | null;
|
||||||
export type TicTacToePart = Part<{ player: PlayerType }>;
|
export type TicTacToePart = Part<{ player: PlayerType }>;
|
||||||
|
|
||||||
export function createInitialState() {
|
export function createInitialState() {
|
||||||
return {
|
return {
|
||||||
board: createRegion('board', [
|
board: createRegion("board", [
|
||||||
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
|
{ name: "x", min: 0, max: BOARD_SIZE - 1 },
|
||||||
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
|
{ name: "y", min: 0, max: BOARD_SIZE - 1 },
|
||||||
]),
|
]),
|
||||||
parts: {} as Record<string, TicTacToePart>,
|
parts: {} as Record<string, TicTacToePart>,
|
||||||
currentPlayer: 'X' as PlayerType,
|
currentPlayer: "X" as PlayerType,
|
||||||
winner: null as WinnerType,
|
winner: null as WinnerType,
|
||||||
turn: 0,
|
turn: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
export type TicTacToeState = ReturnType<typeof createInitialState>;
|
||||||
export type TicTacToeGame = IGameContext<TicTacToeState>;
|
export type TicTacToeGame = IGameContext<TicTacToeState>;
|
||||||
export const registry = createGameCommandRegistry<TicTacToeState>();
|
|
||||||
export const prompts = {
|
export const prompts = {
|
||||||
play: createPromptDef<[PlayerType, number, number]>(
|
play: createPromptDef<[PlayerType, number, number]>(
|
||||||
'play <player> <row:number> <col:number>')
|
"play <player> <row:number> <col:number>",
|
||||||
}
|
),
|
||||||
|
};
|
||||||
|
|
||||||
export async function start(game: TicTacToeGame) {
|
export async function start(game: TicTacToeGame) {
|
||||||
while (true) {
|
while (true) {
|
||||||
const currentPlayer = game.value.currentPlayer;
|
const currentPlayer = game.value.currentPlayer;
|
||||||
const turnNumber = game.value.turn + 1;
|
const turnNumber = game.value.turn + 1;
|
||||||
const turnOutput = await turn(game, currentPlayer, turnNumber);
|
const turnOutput = await turn(game, currentPlayer, turnNumber);
|
||||||
|
|
||||||
game.produce(state => {
|
await game.produceAsync((state) => {
|
||||||
state.winner = turnOutput.winner;
|
state.winner = turnOutput.winner;
|
||||||
if (!state.winner) {
|
if (!state.winner) {
|
||||||
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
|
state.currentPlayer = state.currentPlayer === "X" ? "O" : "X";
|
||||||
state.turn = turnNumber;
|
state.turn = turnNumber;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (game.value.winner) break;
|
if (game.value.winner) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return game.value;
|
return game.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
const turn = registry.register({
|
async function turn(
|
||||||
schema: 'turn <player> <turnNumber:number>',
|
game: TicTacToeGame,
|
||||||
async run(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) {
|
turnPlayer: PlayerType,
|
||||||
const {player, row, col} = await game.prompt(
|
turnNumber: number,
|
||||||
prompts.play,
|
) {
|
||||||
(player, row, col) => {
|
const { player, row, col } = await game.prompt(
|
||||||
if (player !== turnPlayer) {
|
prompts.play,
|
||||||
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
(player, row, col) => {
|
||||||
} else if (!isValidMove(row, col)) {
|
if (player !== turnPlayer) {
|
||||||
throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
|
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
|
||||||
} else if (isCellOccupied(game, row, col)) {
|
} else if (!isValidMove(row, col)) {
|
||||||
throw `Cell (${row}, ${col}) is already occupied.`;
|
throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
|
||||||
} else {
|
} else if (isCellOccupied(game, row, col)) {
|
||||||
return { player, row, col };
|
throw `Cell (${row}, ${col}) is already occupied.`;
|
||||||
}
|
} else {
|
||||||
},
|
return { player, row, col };
|
||||||
game.value.currentPlayer
|
}
|
||||||
);
|
},
|
||||||
|
game.value.currentPlayer,
|
||||||
|
);
|
||||||
|
|
||||||
placePiece(game, row, col, turnPlayer);
|
await placePiece(game, row, col, turnPlayer);
|
||||||
|
|
||||||
const winner = checkWinner(game);
|
const winner = checkWinner(game);
|
||||||
if (winner) return { winner };
|
if (winner) return { winner };
|
||||||
if (turnNumber >= MAX_TURNS) return { winner: 'draw' as WinnerType };
|
if (turnNumber >= MAX_TURNS) return { winner: "draw" as WinnerType };
|
||||||
|
|
||||||
return { winner: null };
|
return { winner: null };
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
function isValidMove(row: number, col: number): boolean {
|
function isValidMove(row: number, col: number): boolean {
|
||||||
return !isNaN(row) && !isNaN(col) && row >= 0 && row < BOARD_SIZE && col >= 0 && col < BOARD_SIZE;
|
return (
|
||||||
|
!isNaN(row) &&
|
||||||
|
!isNaN(col) &&
|
||||||
|
row >= 0 &&
|
||||||
|
row < BOARD_SIZE &&
|
||||||
|
col >= 0 &&
|
||||||
|
col < BOARD_SIZE
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isCellOccupied(host: TicTacToeGame, row: number, col: number): boolean {
|
export function isCellOccupied(
|
||||||
return !!host.value.board.partMap[`${row},${col}`];
|
host: TicTacToeGame,
|
||||||
|
row: number,
|
||||||
|
col: number,
|
||||||
|
): boolean {
|
||||||
|
return !!host.value.board.partMap[`${row},${col}`];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function hasWinningLine(positions: number[][]): boolean {
|
export function hasWinningLine(positions: number[][]): boolean {
|
||||||
return WINNING_LINES.some(line =>
|
return WINNING_LINES.some((line) =>
|
||||||
line.every(([r, c]) =>
|
line.every(([r, c]) => positions.some(([pr, pc]) => pr === r && pc === c)),
|
||||||
positions.some(([pr, pc]) => pr === r && pc === c)
|
);
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function checkWinner(host: TicTacToeGame): WinnerType {
|
export function checkWinner(host: TicTacToeGame): WinnerType {
|
||||||
const parts = host.value.parts;
|
const parts = host.value.parts;
|
||||||
const partsArray = Object.values(parts);
|
const partsArray = Object.values(parts);
|
||||||
|
|
||||||
const xPositions = partsArray.filter((p: TicTacToePart) => p.player === 'X').map((p: TicTacToePart) => p.position);
|
const xPositions = partsArray
|
||||||
const oPositions = partsArray.filter((p: TicTacToePart) => p.player === 'O').map((p: TicTacToePart) => p.position);
|
.filter((p: TicTacToePart) => p.player === "X")
|
||||||
|
.map((p: TicTacToePart) => p.position);
|
||||||
|
const oPositions = partsArray
|
||||||
|
.filter((p: TicTacToePart) => p.player === "O")
|
||||||
|
.map((p: TicTacToePart) => p.position);
|
||||||
|
|
||||||
if (hasWinningLine(xPositions)) return 'X';
|
if (hasWinningLine(xPositions)) return "X";
|
||||||
if (hasWinningLine(oPositions)) return 'O';
|
if (hasWinningLine(oPositions)) return "O";
|
||||||
if (partsArray.length >= MAX_TURNS) return 'draw';
|
if (partsArray.length >= MAX_TURNS) return "draw";
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function placePiece(host: TicTacToeGame, row: number, col: number, player: PlayerType) {
|
export async function placePiece(
|
||||||
const board = host.value.board;
|
host: TicTacToeGame,
|
||||||
const moveNumber = Object.keys(host.value.parts).length + 1;
|
row: number,
|
||||||
const piece: TicTacToePart = {
|
col: number,
|
||||||
regionId: 'board', position: [row, col], player,
|
player: PlayerType,
|
||||||
id: `piece-${player}-${moveNumber}`
|
) {
|
||||||
|
const board = host.value.board;
|
||||||
|
const moveNumber = Object.keys(host.value.parts).length + 1;
|
||||||
|
const piece: TicTacToePart = {
|
||||||
|
regionId: "board",
|
||||||
|
position: [row, col],
|
||||||
|
player,
|
||||||
|
id: `piece-${player}-${moveNumber}`,
|
||||||
|
};
|
||||||
|
await host.produceAsync((state) => {
|
||||||
|
state.parts[piece.id] = piece;
|
||||||
|
board.childIds.push(piece.id);
|
||||||
|
board.partMap[`${row},${col}`] = piece.id;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Client extends BaseGameClient<TicTacToeState> {
|
||||||
|
private onTurn = createMiddlewareChain(
|
||||||
|
async (ctx: { player: PlayerType; turn: number }) => {
|
||||||
|
return await turn(this._context, ctx.player, ctx.turn);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super(createInitialState());
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(game: IGameContext<TicTacToeState>): Promise<TicTacToeState> {
|
||||||
|
while (true) {
|
||||||
|
const currentPlayer = game.value.currentPlayer;
|
||||||
|
const turnNumber = game.value.turn + 1;
|
||||||
|
const turnOutput = await this.onTurn.execute({
|
||||||
|
player: currentPlayer,
|
||||||
|
turn: turnNumber,
|
||||||
|
});
|
||||||
|
|
||||||
|
await game.produceAsync((state) => {
|
||||||
|
state.winner = turnOutput.winner;
|
||||||
|
if (!state.winner) {
|
||||||
|
state.currentPlayer = state.currentPlayer === "X" ? "O" : "X";
|
||||||
|
state.turn = turnNumber;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (game.value.winner) break;
|
||||||
}
|
}
|
||||||
host.produce(state => {
|
|
||||||
state.parts[piece.id] = piece;
|
return game.value;
|
||||||
board.childIds.push(piece.id);
|
}
|
||||||
board.partMap[`${row},${col}`] = piece.id;
|
selectPart(id: string, callback: (t: TicTacToePart) => void): () => void {
|
||||||
});
|
return this.assignSelector((state) => state.parts[id], callback);
|
||||||
|
}
|
||||||
|
selectTurn(callback: (turns: number) => void): () => void {
|
||||||
|
return this.assignSelector((s) => s.turn, callback);
|
||||||
|
}
|
||||||
|
selectCurrentPlayer(callback: (player: PlayerType) => void) {
|
||||||
|
return this.assignSelector((s) => s.currentPlayer, callback);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { createMiddlewareChain } from "../middleware";
|
||||||
|
import { PromptValidator } from "./command-runner";
|
||||||
|
import { CommandSchema } from "./types";
|
||||||
|
|
||||||
|
export interface PromptDef<TArgs extends any[]> {
|
||||||
|
schema: CommandSchema;
|
||||||
|
hintText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromptCall<TArgs extends any[] = unknown[], TRes = unknown> {
|
||||||
|
def: PromptDef<TArgs>;
|
||||||
|
player?: string;
|
||||||
|
validator: PromptValidator<TRes, TArgs>;
|
||||||
|
|
||||||
|
resolve(res: TRes): void;
|
||||||
|
reject(reason: string): void;
|
||||||
|
promise: Promise<TRes>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PromptTryResult =
|
||||||
|
| {
|
||||||
|
ok: false;
|
||||||
|
reason: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
ok: true;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PromptContext = ReturnType<typeof createPromptContext>;
|
||||||
|
export function createPromptContext() {
|
||||||
|
const map = new Map<string, PromptCall>();
|
||||||
|
const handleCall = createMiddlewareChain(async (call: PromptCall) => {
|
||||||
|
const key = call.player ?? "global";
|
||||||
|
const existing = map.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.reject("Prompt overriden");
|
||||||
|
}
|
||||||
|
|
||||||
|
map.set(key, call);
|
||||||
|
try {
|
||||||
|
return await call.promise;
|
||||||
|
} finally {
|
||||||
|
if (map.get(key) === call) map.delete(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function tryCommit<TArgs extends any[]>(
|
||||||
|
def: PromptDef<TArgs>,
|
||||||
|
player: string,
|
||||||
|
...args: TArgs
|
||||||
|
): PromptTryResult {
|
||||||
|
const call = map.get(player);
|
||||||
|
if (!call) return { ok: false, reason: "No Prompt" };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = call.validator(...args);
|
||||||
|
call.resolve(res);
|
||||||
|
return { ok: true };
|
||||||
|
} catch (reason) {
|
||||||
|
if (typeof reason === "string") return { ok: false, reason };
|
||||||
|
throw reason;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancel(player: string, reason = "Prompt Cancelled") {
|
||||||
|
map.get(player)?.reject(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prompt<TArgs extends any[], TRes>(
|
||||||
|
def: PromptDef<TArgs>,
|
||||||
|
validator: PromptValidator<TRes, TArgs>,
|
||||||
|
player?: string,
|
||||||
|
) {
|
||||||
|
let resolve: (res: TRes) => void;
|
||||||
|
let reject: (reason: string) => void;
|
||||||
|
const promise = new Promise<TRes>((res, rej) => {
|
||||||
|
resolve = res;
|
||||||
|
reject = rej;
|
||||||
|
});
|
||||||
|
const call = {
|
||||||
|
def,
|
||||||
|
player,
|
||||||
|
validator,
|
||||||
|
resolve: resolve!,
|
||||||
|
reject: reject!,
|
||||||
|
promise,
|
||||||
|
} as PromptCall<unknown[], unknown>;
|
||||||
|
return (await handleCall.execute(call)) as TRes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
for (const call of map.values()) {
|
||||||
|
call.reject("Prompt Reset");
|
||||||
|
}
|
||||||
|
map.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
prompt,
|
||||||
|
tryCommit,
|
||||||
|
cancel,
|
||||||
|
handleCall,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
+39
-20
@@ -1,22 +1,41 @@
|
|||||||
export { parseCommand } from './command-parse';
|
export { parseCommand } from "./command-parse";
|
||||||
export { parseCommandSchema } from './schema-parse';
|
export { parseCommandSchema } from "./schema-parse";
|
||||||
export { validateCommand, parseCommandWithSchema, applyCommandSchema } from './command-validate';
|
|
||||||
export {
|
export {
|
||||||
createCommandRegistry,
|
validateCommand,
|
||||||
registerCommand,
|
parseCommandWithSchema,
|
||||||
unregisterCommand,
|
applyCommandSchema,
|
||||||
hasCommand,
|
} from "./command-validate";
|
||||||
getCommand,
|
export {
|
||||||
runCommand,
|
createCommandRegistry,
|
||||||
runCommandParsed,
|
registerCommand,
|
||||||
createCommandRunnerContext,
|
unregisterCommand,
|
||||||
} from './command-registry';
|
hasCommand,
|
||||||
|
getCommand,
|
||||||
|
runCommand,
|
||||||
|
runCommandParsed,
|
||||||
|
createCommandRunnerContext,
|
||||||
|
} from "./command-registry";
|
||||||
export type {
|
export type {
|
||||||
Command,
|
Command,
|
||||||
CommandParamSchema,
|
CommandParamSchema,
|
||||||
CommandOptionSchema,
|
CommandOptionSchema,
|
||||||
CommandFlagSchema,
|
CommandFlagSchema,
|
||||||
CommandSchema,
|
CommandSchema,
|
||||||
} from './types';
|
} from "./types";
|
||||||
export type { CommandRunner, CommandDef, CommandResult, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './command-runner';
|
export type {
|
||||||
export type { CommandRegistry, CommandRunnerContextExport } from './command-registry';
|
CommandRunner,
|
||||||
|
CommandDef,
|
||||||
|
CommandResult,
|
||||||
|
CommandRunnerHandler,
|
||||||
|
CommandRunnerContext,
|
||||||
|
PromptEvent,
|
||||||
|
CommandRunnerEvents,
|
||||||
|
} from "./command-runner";
|
||||||
|
export type {
|
||||||
|
CommandRegistry,
|
||||||
|
CommandRunnerContextExport,
|
||||||
|
} from "./command-registry";
|
||||||
|
|
||||||
|
export type { PromptDef, PromptTryResult, PromptCall } from "./command-prompt";
|
||||||
|
|
||||||
|
export { createPromptContext } from "./command-prompt";
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
type Middleware<TContext, TReturn> = (
|
||||||
|
context: TContext,
|
||||||
|
next: () => Promise<TReturn>,
|
||||||
|
) => Promise<TReturn>;
|
||||||
|
|
||||||
|
export type MiddlewareChain<TContext, TReturn> = {
|
||||||
|
use: (middleware: Middleware<TContext, TReturn>) => () => void;
|
||||||
|
execute: (context: TContext) => Promise<TReturn>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createMiddlewareChain<
|
||||||
|
TContext extends object,
|
||||||
|
TReturn = TContext,
|
||||||
|
>(
|
||||||
|
fallback?: (context: TContext) => Promise<TReturn>,
|
||||||
|
): MiddlewareChain<TContext, TReturn> {
|
||||||
|
const middlewares: Middleware<TContext, TReturn>[] = [];
|
||||||
|
|
||||||
|
return {
|
||||||
|
use(middleware: Middleware<TContext, TReturn>) {
|
||||||
|
middlewares.push(middleware);
|
||||||
|
|
||||||
|
return function () {
|
||||||
|
const index = middlewares.indexOf(middleware);
|
||||||
|
if (index !== -1) {
|
||||||
|
middlewares.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async execute(context: TContext) {
|
||||||
|
let index = 0;
|
||||||
|
|
||||||
|
async function dispatch(ctx: TContext): Promise<TReturn> {
|
||||||
|
if (index >= middlewares.length) {
|
||||||
|
return fallback ? fallback(ctx) : (ctx as unknown as TReturn);
|
||||||
|
}
|
||||||
|
const current = middlewares[index++];
|
||||||
|
return current(ctx, () => dispatch(ctx));
|
||||||
|
}
|
||||||
|
|
||||||
|
return dispatch(context);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,20 @@
|
|||||||
import {Signal, SignalOptions} from '@preact/signals-core';
|
import { Signal, SignalOptions } from "@preact/signals-core";
|
||||||
import {create} from 'mutative';
|
import { create } from "mutative";
|
||||||
|
|
||||||
export class MutableSignal<T> extends Signal<T> {
|
export interface MutableSignal<T> extends Signal<T> {
|
||||||
|
produce(fn: (draft: T) => undefined): void;
|
||||||
|
addInterruption(promise: Promise<void>): void;
|
||||||
|
clearInterruptions(): void;
|
||||||
|
produceAsync(fn: (draft: T) => undefined): Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
class MutableSignalImpl<T> extends Signal<T> {
|
||||||
private _interruptions: Promise<void>[] = [];
|
private _interruptions: Promise<void>[] = [];
|
||||||
|
|
||||||
public constructor(t?: T, options?: SignalOptions<T>) {
|
public constructor(t?: T, options?: SignalOptions<T>) {
|
||||||
super(t, options);
|
super(t, options);
|
||||||
}
|
}
|
||||||
produce(fn: (draft: T) => void) {
|
produce(fn: (draft: T) => undefined) {
|
||||||
this.value = create(this.value, fn);
|
this.value = create(this.value, fn);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,13 +37,16 @@ export class MutableSignal<T> extends Signal<T> {
|
|||||||
* 异步版本的 produce。会先等待所有通过 `addInterruption` 添加的 Promise 完成,
|
* 异步版本的 produce。会先等待所有通过 `addInterruption` 添加的 Promise 完成,
|
||||||
* 然后再更新状态。适用于需要在状态更新前播放动画的场景。
|
* 然后再更新状态。适用于需要在状态更新前播放动画的场景。
|
||||||
*/
|
*/
|
||||||
async produceAsync(fn: (draft: T) => void): Promise<void> {
|
async produceAsync(fn: (draft: T) => undefined): Promise<void> {
|
||||||
await Promise.allSettled(this._interruptions);
|
await Promise.allSettled(this._interruptions);
|
||||||
this._interruptions = [];
|
this._interruptions = [];
|
||||||
this.produce(fn);
|
this.produce(fn);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function mutableSignal<T>(initial?: T, options?: SignalOptions<T>): MutableSignal<T> {
|
export function mutableSignal<T>(
|
||||||
return new MutableSignal<T>(initial, options);
|
initial?: T,
|
||||||
|
options?: SignalOptions<T>,
|
||||||
|
): MutableSignal<T> {
|
||||||
|
return new MutableSignalImpl<T>(initial, options);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { effect } from "@preact/signals-core";
|
||||||
|
|
||||||
|
export type SpawnerFunction = (id: string) => () => void;
|
||||||
|
|
||||||
|
export function createSpawner(spawner: SpawnerFunction) {
|
||||||
|
const entries = new Map<string, () => void>();
|
||||||
|
|
||||||
|
function update(set: Set<string>) {
|
||||||
|
for (const key of set) {
|
||||||
|
if (!entries.has(key)) {
|
||||||
|
entries.set(key, spawner(key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, entry] of entries) {
|
||||||
|
if (!set.has(key)) {
|
||||||
|
entry();
|
||||||
|
entries.delete(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return update;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function spawnerEffect(
|
||||||
|
iterable: () => Iterable<string>,
|
||||||
|
spawner: SpawnerFunction,
|
||||||
|
) {
|
||||||
|
const update = createSpawner(spawner);
|
||||||
|
|
||||||
|
return effect(() => {
|
||||||
|
const set = new Set<string>();
|
||||||
|
for (const each of iterable()) {
|
||||||
|
set.add(each);
|
||||||
|
}
|
||||||
|
update(set);
|
||||||
|
});
|
||||||
|
}
|
||||||
+579
-562
File diff suppressed because it is too large
Load Diff
+181
-159
@@ -1,193 +1,215 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from "vitest";
|
||||||
import { createGameContext, createGameCommandRegistry, createPromptDef, IGameContext, PromptDef } from '@/core/game';
|
import {
|
||||||
import type { PromptEvent, Command } from '@/utils/command';
|
createGameContext,
|
||||||
|
createGameCommandRegistry,
|
||||||
|
createPromptDef,
|
||||||
|
IGameContext,
|
||||||
|
PromptDef,
|
||||||
|
} from "@/core/game";
|
||||||
|
import type {
|
||||||
|
PromptEvent,
|
||||||
|
Command,
|
||||||
|
CommandRunnerContext,
|
||||||
|
} from "@/utils/command";
|
||||||
|
|
||||||
type MyState = {
|
type MyState = {
|
||||||
score: number;
|
score: number;
|
||||||
round: number;
|
round: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('createGameContext', () => {
|
describe("createGameContext", () => {
|
||||||
it('should create a game context with state', () => {
|
it("should create a game context with state", () => {
|
||||||
const registry = createGameCommandRegistry();
|
const registry = createGameCommandRegistry();
|
||||||
const ctx = createGameContext(registry);
|
const ctx = createGameContext(registry);
|
||||||
|
|
||||||
expect(ctx._state).not.toBeNull();
|
expect(ctx._state).not.toBeNull();
|
||||||
expect(ctx._state.value).toBeDefined();
|
expect(ctx._state.value).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should wire commands to the context", () => {
|
||||||
|
const registry = createGameCommandRegistry();
|
||||||
|
const ctx = createGameContext(registry);
|
||||||
|
|
||||||
|
expect(ctx._commands).not.toBeNull();
|
||||||
|
expect(ctx._commands.registry).toBe(registry);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should accept initial state as an object", () => {
|
||||||
|
const registry = createGameCommandRegistry<MyState>();
|
||||||
|
const ctx = createGameContext<MyState>(registry, {
|
||||||
|
score: 0,
|
||||||
|
round: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should wire commands to the context', () => {
|
expect(ctx._state.value.score).toBe(0);
|
||||||
const registry = createGameCommandRegistry();
|
expect(ctx._state.value.round).toBe(1);
|
||||||
const ctx = createGameContext(registry);
|
});
|
||||||
|
|
||||||
expect(ctx._commands).not.toBeNull();
|
it("should accept initial state as a factory function", () => {
|
||||||
expect(ctx._commands.registry).toBe(registry);
|
const registry = createGameCommandRegistry<MyState>();
|
||||||
|
const ctx = createGameContext<MyState>(registry, () => ({
|
||||||
|
score: 10,
|
||||||
|
round: 3,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(ctx._state.value.score).toBe(10);
|
||||||
|
expect(ctx._state.value.round).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should forward prompt events via listener", async () => {
|
||||||
|
const registry = createGameCommandRegistry();
|
||||||
|
const ctx = createGameContext(registry);
|
||||||
|
|
||||||
|
registry.register("test <value>", async function (_ctx, value) {
|
||||||
|
return _ctx.prompt(
|
||||||
|
createPromptDef("prompt <answer>"),
|
||||||
|
(answer) => answer,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should accept initial state as an object', () => {
|
const promptPromise = new Promise<PromptEvent>((resolve) => {
|
||||||
const registry = createGameCommandRegistry<MyState>();
|
ctx._commands.on("prompt", resolve);
|
||||||
const ctx = createGameContext<MyState>(registry, {
|
|
||||||
score: 0,
|
|
||||||
round: 1,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(ctx._state.value.score).toBe(0);
|
|
||||||
expect(ctx._state.value.round).toBe(1);
|
|
||||||
});
|
});
|
||||||
|
const runPromise = ctx.run("test hello");
|
||||||
|
|
||||||
it('should accept initial state as a factory function', () => {
|
const promptEvent = await promptPromise;
|
||||||
const registry = createGameCommandRegistry<MyState>();
|
expect(promptEvent).not.toBeNull();
|
||||||
const ctx = createGameContext<MyState>(registry, () => ({
|
expect(promptEvent.schema.name).toBe("prompt");
|
||||||
score: 10,
|
|
||||||
round: 3,
|
|
||||||
}));
|
|
||||||
|
|
||||||
expect(ctx._state.value.score).toBe(10);
|
const error = promptEvent.tryCommit({
|
||||||
expect(ctx._state.value.round).toBe(3);
|
name: "prompt",
|
||||||
|
params: ["yes"],
|
||||||
|
options: {},
|
||||||
|
flags: {},
|
||||||
});
|
});
|
||||||
|
expect(error).toBeNull();
|
||||||
|
|
||||||
it('should forward prompt events via listener', async () => {
|
const result = await runPromise;
|
||||||
const registry = createGameCommandRegistry();
|
expect(result.success).toBe(true);
|
||||||
const ctx = createGameContext(registry);
|
});
|
||||||
|
|
||||||
registry.register('test <value>', async function (_ctx, value) {
|
|
||||||
return this.prompt({schema: 'prompt <answer>'}, () => 'ok');
|
|
||||||
});
|
|
||||||
|
|
||||||
const promptPromise = new Promise<PromptEvent>(resolve => {
|
|
||||||
ctx._commands.on('prompt', resolve);
|
|
||||||
});
|
|
||||||
const runPromise = ctx.run('test hello');
|
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
|
||||||
expect(promptEvent).not.toBeNull();
|
|
||||||
expect(promptEvent.schema.name).toBe('prompt');
|
|
||||||
|
|
||||||
const error = promptEvent.tryCommit({ name: 'prompt', params: ['yes'], options: {}, flags: {} });
|
|
||||||
expect(error).toBeNull();
|
|
||||||
|
|
||||||
const result = await runPromise;
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createGameCommand', () => {
|
describe("createGameCommand", () => {
|
||||||
it('should run a command with access to game context', async () => {
|
it("should run a command with access to game context", async () => {
|
||||||
const registry = createGameCommandRegistry<{ marker: string }>();
|
const registry = createGameCommandRegistry<{ marker: string }>();
|
||||||
|
|
||||||
registry.register('set-marker <id>', async function (ctx, id) {
|
registry.register("set-marker <id>", async function (ctx, id) {
|
||||||
ctx.produce(state => {
|
ctx.produce((state) => {
|
||||||
state.marker = id;
|
state.marker = id;
|
||||||
});
|
});
|
||||||
return id;
|
return id;
|
||||||
|
});
|
||||||
|
|
||||||
|
const ctx = createGameContext(registry, { marker: "" });
|
||||||
|
|
||||||
|
const result = await ctx.run("set-marker board");
|
||||||
|
if (!result.success) {
|
||||||
|
console.error("Error:", result.error);
|
||||||
|
}
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.result).toBe("board");
|
||||||
|
}
|
||||||
|
expect(ctx._state.value.marker).toBe("board");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should run a typed command with extended context", async () => {
|
||||||
|
const registry = createGameCommandRegistry<MyState>();
|
||||||
|
|
||||||
|
registry.register(
|
||||||
|
"add-score <amount:number>",
|
||||||
|
async function (ctx, amount) {
|
||||||
|
ctx.produce((state) => {
|
||||||
|
state.score += amount;
|
||||||
});
|
});
|
||||||
|
return ctx.value.score;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
const ctx = createGameContext(registry, { marker: '' });
|
const ctx = createGameContext<MyState>(registry, () => ({
|
||||||
|
score: 0,
|
||||||
|
round: 1,
|
||||||
|
}));
|
||||||
|
|
||||||
const result = await ctx.run('set-marker board');
|
const result = await ctx.run("add-score 5");
|
||||||
if (!result.success) {
|
expect(result.success).toBe(true);
|
||||||
console.error('Error:', result.error);
|
if (result.success) {
|
||||||
}
|
expect(result.result).toBe(5);
|
||||||
expect(result.success).toBe(true);
|
}
|
||||||
if (result.success) {
|
expect(ctx._state.value.score).toBe(5);
|
||||||
expect(result.result).toBe('board');
|
});
|
||||||
}
|
|
||||||
expect(ctx._state.value.marker).toBe('board');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should run a typed command with extended context', async () => {
|
it("should return error for unknown command", async () => {
|
||||||
const registry = createGameCommandRegistry<MyState>();
|
const registry = createGameCommandRegistry();
|
||||||
|
const ctx = createGameContext(registry);
|
||||||
|
|
||||||
registry.register(
|
const result = await ctx.run("nonexistent");
|
||||||
'add-score <amount:number>',
|
expect(result.success).toBe(false);
|
||||||
async function (ctx, amount) {
|
if (!result.success) {
|
||||||
ctx.produce(state => {
|
expect(result.error).toContain("nonexistent");
|
||||||
state.score += amount;
|
}
|
||||||
});
|
});
|
||||||
return ctx.value.score;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
const ctx = createGameContext<MyState>(registry, () => ({
|
|
||||||
score: 0,
|
|
||||||
round: 1,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const result = await ctx.run('add-score 5');
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.result).toBe(5);
|
|
||||||
}
|
|
||||||
expect(ctx._state.value.score).toBe(5);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return error for unknown command', async () => {
|
|
||||||
const registry = createGameCommandRegistry();
|
|
||||||
const ctx = createGameContext(registry);
|
|
||||||
|
|
||||||
const result = await ctx.run('nonexistent');
|
|
||||||
expect(result.success).toBe(false);
|
|
||||||
if (!result.success) {
|
|
||||||
expect(result.error).toContain('nonexistent');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createPromptDef', () => {
|
describe("createPromptDef", () => {
|
||||||
it('should create a PromptDef with string schema', () => {
|
it("should create a PromptDef with string schema", () => {
|
||||||
const promptDef = createPromptDef<[string, number]>('play <player> <score:number>');
|
const promptDef = createPromptDef<[string, number]>(
|
||||||
|
"play <player> <score:number>",
|
||||||
|
);
|
||||||
|
|
||||||
expect(promptDef).toBeDefined();
|
expect(promptDef).toBeDefined();
|
||||||
expect(promptDef.schema.name).toBe('play');
|
expect(promptDef.schema.name).toBe("play");
|
||||||
expect(promptDef.schema.params).toHaveLength(2);
|
expect(promptDef.schema.params).toHaveLength(2);
|
||||||
expect(promptDef.schema.params[0].name).toBe('player');
|
expect(promptDef.schema.params[0].name).toBe("player");
|
||||||
expect(promptDef.schema.params[1].name).toBe('score');
|
expect(promptDef.schema.params[1].name).toBe("score");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should create a PromptDef with CommandSchema object", () => {
|
||||||
|
const schemaObj = {
|
||||||
|
name: "test",
|
||||||
|
params: [],
|
||||||
|
options: {},
|
||||||
|
flags: {},
|
||||||
|
};
|
||||||
|
const promptDef = createPromptDef<[]>(schemaObj);
|
||||||
|
|
||||||
|
expect(promptDef.schema).toEqual(schemaObj);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should be usable with game.prompt", async () => {
|
||||||
|
const registry = createGameCommandRegistry<{ score: number }>();
|
||||||
|
|
||||||
|
registry.register("test-prompt", async function (ctx) {
|
||||||
|
const promptDef = createPromptDef<[number]>("input <value:number>");
|
||||||
|
const result = await ctx.prompt(promptDef, (value) => {
|
||||||
|
if (value < 0) throw "Value must be positive";
|
||||||
|
return value;
|
||||||
|
});
|
||||||
|
return result;
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create a PromptDef with CommandSchema object', () => {
|
const ctx = createGameContext(registry, { score: 0 });
|
||||||
const schemaObj = {
|
|
||||||
name: 'test',
|
|
||||||
params: [],
|
|
||||||
options: {},
|
|
||||||
flags: {}
|
|
||||||
};
|
|
||||||
const promptDef = createPromptDef<[]>(schemaObj);
|
|
||||||
|
|
||||||
expect(promptDef.schema).toEqual(schemaObj);
|
const promptPromise = new Promise<PromptEvent>((resolve) => {
|
||||||
|
ctx._commands.on("prompt", resolve);
|
||||||
});
|
});
|
||||||
|
const runPromise = ctx.run("test-prompt");
|
||||||
|
|
||||||
it('should be usable with game.prompt', async () => {
|
const promptEvent = await promptPromise;
|
||||||
const registry = createGameCommandRegistry<{ score: number }>();
|
expect(promptEvent.schema.name).toBe("input");
|
||||||
|
|
||||||
registry.register('test-prompt', async function(ctx) {
|
const error = promptEvent.tryCommit({
|
||||||
const promptDef = createPromptDef<[number]>('input <value:number>');
|
name: "input",
|
||||||
const result = await ctx.prompt(
|
params: [42],
|
||||||
promptDef,
|
options: {},
|
||||||
(value) => {
|
flags: {},
|
||||||
if (value < 0) throw 'Value must be positive';
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
|
|
||||||
const ctx = createGameContext(registry, { score: 0 });
|
|
||||||
|
|
||||||
const promptPromise = new Promise<PromptEvent>(resolve => {
|
|
||||||
ctx._commands.on('prompt', resolve);
|
|
||||||
});
|
|
||||||
const runPromise = ctx.run('test-prompt');
|
|
||||||
|
|
||||||
const promptEvent = await promptPromise;
|
|
||||||
expect(promptEvent.schema.name).toBe('input');
|
|
||||||
|
|
||||||
const error = promptEvent.tryCommit({ name: 'input', params: [42], options: {}, flags: {} });
|
|
||||||
expect(error).toBeNull();
|
|
||||||
|
|
||||||
const result = await runPromise;
|
|
||||||
expect(result.success).toBe(true);
|
|
||||||
if (result.success) {
|
|
||||||
expect(result.result).toBe(42);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
expect(error).toBeNull();
|
||||||
|
|
||||||
|
const result = await runPromise;
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
if (result.success) {
|
||||||
|
expect(result.result).toBe(42);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+728
-604
File diff suppressed because it is too large
Load Diff
+302
-282
@@ -1,353 +1,373 @@
|
|||||||
import {createGameContext} from '@/core/game';
|
import { describe, it, expect } from "vitest";
|
||||||
import {registry} from '@/samples/regicide/commands';
|
import { createGameContext } from "@/core/game";
|
||||||
import {createInitialState} from '@/samples/regicide/state';
|
import { registry } from "@/samples/regicide/commands";
|
||||||
|
import { createInitialState } from "@/samples/regicide/state";
|
||||||
import {
|
import {
|
||||||
buildEnemyDeck,
|
buildEnemyDeck,
|
||||||
buildTavernDeck,
|
buildTavernDeck,
|
||||||
createAllCards,
|
createAllCards,
|
||||||
createCard,
|
createCard,
|
||||||
createEnemy,
|
createEnemy,
|
||||||
getCardValue,
|
getCardValue,
|
||||||
isEnemyDefeated
|
isEnemyDefeated,
|
||||||
} from '@/samples/regicide/utils';
|
} from "@/samples/regicide/utils";
|
||||||
import {Mulberry32RNG} from '@/utils/rng';
|
import { Mulberry32RNG } from "@/utils/rng";
|
||||||
import {CARD_VALUES, ENEMY_COUNT, FACE_CARDS, INITIAL_HAND_SIZE} from '@/samples/regicide/constants';
|
import {
|
||||||
import {PlayerType} from '@/samples/regicide/types';
|
CARD_VALUES,
|
||||||
|
ENEMY_COUNT,
|
||||||
|
FACE_CARDS,
|
||||||
|
INITIAL_HAND_SIZE,
|
||||||
|
} from "@/samples/regicide/constants";
|
||||||
|
import { PlayerType } from "@/samples/regicide/types";
|
||||||
|
|
||||||
describe('Regicide - Utils', () => {
|
describe("Regicide - Utils", () => {
|
||||||
describe('getCardValue', () => {
|
describe("getCardValue", () => {
|
||||||
it('should return correct value for number cards', () => {
|
it("should return correct value for number cards", () => {
|
||||||
expect(getCardValue('A')).toBe(1);
|
expect(getCardValue("A")).toBe(1);
|
||||||
expect(getCardValue('5')).toBe(5);
|
expect(getCardValue("5")).toBe(5);
|
||||||
expect(getCardValue('10')).toBe(10);
|
expect(getCardValue("10")).toBe(10);
|
||||||
});
|
|
||||||
|
|
||||||
it('should return correct value for face cards', () => {
|
|
||||||
expect(getCardValue('J')).toBe(10);
|
|
||||||
expect(getCardValue('Q')).toBe(15);
|
|
||||||
expect(getCardValue('K')).toBe(20);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createCard', () => {
|
it("should return correct value for face cards", () => {
|
||||||
it('should create a card with correct properties', () => {
|
expect(getCardValue("J")).toBe(10);
|
||||||
const card = createCard('spades_A', 'spades', 'A');
|
expect(getCardValue("Q")).toBe(15);
|
||||||
expect(card.id).toBe('spades_A');
|
expect(getCardValue("K")).toBe(20);
|
||||||
expect(card.suit).toBe('spades');
|
});
|
||||||
expect(card.rank).toBe('A');
|
});
|
||||||
expect(card.value).toBe(1);
|
|
||||||
});
|
describe("createCard", () => {
|
||||||
|
it("should create a card with correct properties", () => {
|
||||||
|
const card = createCard("spades_A", "spades", "A");
|
||||||
|
expect(card.id).toBe("spades_A");
|
||||||
|
expect(card.suit).toBe("spades");
|
||||||
|
expect(card.rank).toBe("A");
|
||||||
|
expect(card.value).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createEnemy", () => {
|
||||||
|
it("should create an enemy with correct HP", () => {
|
||||||
|
const enemy = createEnemy("enemy_0", "J", "spades");
|
||||||
|
expect(enemy.rank).toBe("J");
|
||||||
|
expect(enemy.value).toBe(10);
|
||||||
|
expect(enemy.hp).toBe(20);
|
||||||
|
expect(enemy.maxHp).toBe(20);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createEnemy', () => {
|
it("should create enemy with different values for different ranks", () => {
|
||||||
it('should create an enemy with correct HP', () => {
|
const jEnemy = createEnemy("enemy_0", "J", "spades");
|
||||||
const enemy = createEnemy('enemy_0', 'J', 'spades');
|
const qEnemy = createEnemy("enemy_1", "Q", "hearts");
|
||||||
expect(enemy.rank).toBe('J');
|
const kEnemy = createEnemy("enemy_2", "K", "diamonds");
|
||||||
expect(enemy.value).toBe(10);
|
|
||||||
expect(enemy.hp).toBe(20);
|
|
||||||
expect(enemy.maxHp).toBe(20);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should create enemy with different values for different ranks', () => {
|
expect(jEnemy.value).toBe(10);
|
||||||
const jEnemy = createEnemy('enemy_0', 'J', 'spades');
|
expect(qEnemy.value).toBe(15);
|
||||||
const qEnemy = createEnemy('enemy_1', 'Q', 'hearts');
|
expect(kEnemy.value).toBe(20);
|
||||||
const kEnemy = createEnemy('enemy_2', 'K', 'diamonds');
|
|
||||||
|
|
||||||
expect(jEnemy.value).toBe(10);
|
expect(jEnemy.hp).toBe(20);
|
||||||
expect(qEnemy.value).toBe(15);
|
expect(qEnemy.hp).toBe(30);
|
||||||
expect(kEnemy.value).toBe(20);
|
expect(kEnemy.hp).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
expect(jEnemy.hp).toBe(20);
|
describe("createAllCards", () => {
|
||||||
expect(qEnemy.hp).toBe(30);
|
it("should create 52 cards", () => {
|
||||||
expect(kEnemy.hp).toBe(40);
|
const cards = createAllCards();
|
||||||
});
|
expect(Object.keys(cards).length).toBe(52);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('createAllCards', () => {
|
it("should have all suits and ranks", () => {
|
||||||
it('should create 52 cards', () => {
|
const cards = createAllCards();
|
||||||
const cards = createAllCards();
|
const suits = ["spades", "hearts", "diamonds", "clubs"];
|
||||||
expect(Object.keys(cards).length).toBe(52);
|
const ranks = [
|
||||||
});
|
"A",
|
||||||
|
"2",
|
||||||
|
"3",
|
||||||
|
"4",
|
||||||
|
"5",
|
||||||
|
"6",
|
||||||
|
"7",
|
||||||
|
"8",
|
||||||
|
"9",
|
||||||
|
"10",
|
||||||
|
"J",
|
||||||
|
"Q",
|
||||||
|
"K",
|
||||||
|
];
|
||||||
|
|
||||||
it('should have all suits and ranks', () => {
|
for (const suit of suits) {
|
||||||
const cards = createAllCards();
|
for (const rank of ranks) {
|
||||||
const suits = ['spades', 'hearts', 'diamonds', 'clubs'];
|
const id = `${suit}_${rank}`;
|
||||||
const ranks = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
|
expect(cards[id]).toBeDefined();
|
||||||
|
expect(cards[id].suit).toBe(suit);
|
||||||
|
expect(cards[id].rank).toBe(rank);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
for (const suit of suits) {
|
describe("buildEnemyDeck", () => {
|
||||||
for (const rank of ranks) {
|
it("should create 12 enemies (J/Q/K)", () => {
|
||||||
const id = `${suit}_${rank}`;
|
const rng = new Mulberry32RNG(12345);
|
||||||
expect(cards[id]).toBeDefined();
|
const deck = buildEnemyDeck(rng);
|
||||||
expect(cards[id].suit).toBe(suit);
|
expect(deck.length).toBe(12);
|
||||||
expect(cards[id].rank).toBe(rank);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildEnemyDeck', () => {
|
it("should have J at top, Q in middle, K at bottom", () => {
|
||||||
it('should create 12 enemies (J/Q/K)', () => {
|
const rng = new Mulberry32RNG(12345);
|
||||||
const rng = new Mulberry32RNG(12345);
|
const deck = buildEnemyDeck(rng);
|
||||||
const deck = buildEnemyDeck(rng);
|
|
||||||
expect(deck.length).toBe(12);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have J at top, Q in middle, K at bottom', () => {
|
for (let i = 0; i < 4; i++) {
|
||||||
const rng = new Mulberry32RNG(12345);
|
expect(deck[i].rank).toBe("J");
|
||||||
const deck = buildEnemyDeck(rng);
|
}
|
||||||
|
|
||||||
for (let i = 0; i < 4; i++) {
|
for (let i = 4; i < 8; i++) {
|
||||||
expect(deck[i].rank).toBe('J');
|
expect(deck[i].rank).toBe("Q");
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 4; i < 8; i++) {
|
for (let i = 8; i < 12; i++) {
|
||||||
expect(deck[i].rank).toBe('Q');
|
expect(deck[i].rank).toBe("K");
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
for (let i = 8; i < 12; i++) {
|
describe("buildTavernDeck", () => {
|
||||||
expect(deck[i].rank).toBe('K');
|
it("should create 40 cards (A-10)", () => {
|
||||||
}
|
const rng = new Mulberry32RNG(12345);
|
||||||
});
|
const deck = buildTavernDeck(rng);
|
||||||
|
expect(deck.length).toBe(40);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('buildTavernDeck', () => {
|
it("should not contain face cards", () => {
|
||||||
it('should create 40 cards (A-10)', () => {
|
const rng = new Mulberry32RNG(12345);
|
||||||
const rng = new Mulberry32RNG(12345);
|
const deck = buildTavernDeck(rng);
|
||||||
const deck = buildTavernDeck(rng);
|
|
||||||
expect(deck.length).toBe(40);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should not contain face cards', () => {
|
for (const card of deck) {
|
||||||
const rng = new Mulberry32RNG(12345);
|
expect(FACE_CARDS.includes(card.rank)).toBe(false);
|
||||||
const deck = buildTavernDeck(rng);
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
for (const card of deck) {
|
describe("isEnemyDefeated", () => {
|
||||||
expect(FACE_CARDS.includes(card.rank)).toBe(false);
|
it("should return true when enemy HP <= 0", () => {
|
||||||
}
|
const enemy = createEnemy("enemy_0", "J", "spades");
|
||||||
});
|
expect(isEnemyDefeated(enemy)).toBe(false);
|
||||||
|
|
||||||
|
enemy.hp = 0;
|
||||||
|
expect(isEnemyDefeated(enemy)).toBe(true);
|
||||||
|
|
||||||
|
enemy.hp = -5;
|
||||||
|
expect(isEnemyDefeated(enemy)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('isEnemyDefeated', () => {
|
it("should return false for null enemy", () => {
|
||||||
it('should return true when enemy HP <= 0', () => {
|
expect(isEnemyDefeated(null)).toBe(false);
|
||||||
const enemy = createEnemy('enemy_0', 'J', 'spades');
|
|
||||||
expect(isEnemyDefeated(enemy)).toBe(false);
|
|
||||||
|
|
||||||
enemy.hp = 0;
|
|
||||||
expect(isEnemyDefeated(enemy)).toBe(true);
|
|
||||||
|
|
||||||
enemy.hp = -5;
|
|
||||||
expect(isEnemyDefeated(enemy)).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return false for null enemy', () => {
|
|
||||||
expect(isEnemyDefeated(null)).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Regicide - Commands', () => {
|
describe("Regicide - Commands", () => {
|
||||||
function createTestContext() {
|
function createTestContext() {
|
||||||
const initialState = createInitialState();
|
const initialState = createInitialState();
|
||||||
return createGameContext(registry, initialState);
|
return createGameContext(registry, initialState);
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupTestGame(game: ReturnType<typeof createTestContext>) {
|
function setupTestGame(game: ReturnType<typeof createTestContext>) {
|
||||||
const cards = createAllCards();
|
const cards = createAllCards();
|
||||||
const rng = new Mulberry32RNG(12345);
|
const rng = new Mulberry32RNG(12345);
|
||||||
const enemyDeck = buildEnemyDeck(rng);
|
const enemyDeck = buildEnemyDeck(rng);
|
||||||
const tavernDeck = buildTavernDeck(rng);
|
const tavernDeck = buildTavernDeck(rng);
|
||||||
|
|
||||||
game.produce(state => {
|
game.produce((state) => {
|
||||||
state.cards = cards;
|
state.cards = cards;
|
||||||
state.playerCount = 2;
|
state.playerCount = 2;
|
||||||
state.currentPlayerIndex = 0;
|
state.currentPlayerIndex = 0;
|
||||||
state.enemyDeck = [...enemyDeck];
|
state.enemyDeck = [...enemyDeck];
|
||||||
state.currentEnemy = {...enemyDeck[0]};
|
state.currentEnemy = { ...enemyDeck[0] };
|
||||||
|
|
||||||
for (const card of tavernDeck) {
|
for (const card of tavernDeck) {
|
||||||
state.regions.tavernDeck.childIds.push(card.id);
|
state.regions.tavernDeck.childIds.push(card.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
const card1 = tavernDeck[i];
|
const card1 = tavernDeck[i];
|
||||||
const card2 = tavernDeck[i + 6];
|
const card2 = tavernDeck[i + 6];
|
||||||
card1.regionId = 'hand_player1';
|
card1.regionId = "hand_player1";
|
||||||
card2.regionId = 'hand_player2';
|
card2.regionId = "hand_player2";
|
||||||
state.playerHands.player1.push(card1.id);
|
state.playerHands.player1.push(card1.id);
|
||||||
state.playerHands.player2.push(card2.id);
|
state.playerHands.player2.push(card2.id);
|
||||||
state.regions.hand_player1.childIds.push(card1.id);
|
state.regions.hand_player1.childIds.push(card1.id);
|
||||||
state.regions.hand_player2.childIds.push(card2.id);
|
state.regions.hand_player2.childIds.push(card2.id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('play command', () => {
|
describe("play command", () => {
|
||||||
it('should deal damage to current enemy', async () => {
|
it("should deal damage to current enemy", async () => {
|
||||||
const game = createTestContext();
|
const game = createTestContext();
|
||||||
setupTestGame(game);
|
setupTestGame(game);
|
||||||
|
|
||||||
const enemyHpBefore = game.value.currentEnemy!.hp;
|
const enemyHpBefore = game.value.currentEnemy!.hp;
|
||||||
const cardId = game.value.playerHands.player1[0];
|
const cardId = game.value.playerHands.player1[0];
|
||||||
const card = game.value.cards[cardId];
|
const card = game.value.cards[cardId];
|
||||||
|
|
||||||
const result = await game.run(`play player1 ${cardId}`);
|
const result = await game.run(`play player1 ${cardId}`);
|
||||||
|
|
||||||
expect(game.value.currentEnemy!.hp).toBe(enemyHpBefore - card.value);
|
expect(game.value.currentEnemy!.hp).toBe(enemyHpBefore - card.value);
|
||||||
});
|
|
||||||
|
|
||||||
it('should double damage for clubs suit', async () => {
|
|
||||||
const game = createTestContext();
|
|
||||||
setupTestGame(game);
|
|
||||||
|
|
||||||
game.produce(state => {
|
|
||||||
state.cards['clubs_5'] = createCard('clubs_5', 'clubs', '5');
|
|
||||||
state.playerHands.player1.push('clubs_5');
|
|
||||||
state.regions.hand_player1.childIds.push('clubs_5');
|
|
||||||
});
|
|
||||||
|
|
||||||
const clubsCardId = 'clubs_5';
|
|
||||||
const enemyHpBefore = game.value.currentEnemy!.hp;
|
|
||||||
const card = game.value.cards[clubsCardId];
|
|
||||||
|
|
||||||
await game.run(`play player1 ${clubsCardId}`);
|
|
||||||
|
|
||||||
expect(game.value.currentEnemy!.hp).toBe(enemyHpBefore - card.value * 2);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('pass command', () => {
|
it("should double damage for clubs suit", async () => {
|
||||||
it('should allow player to pass', async () => {
|
const game = createTestContext();
|
||||||
const game = createTestContext();
|
setupTestGame(game);
|
||||||
setupTestGame(game);
|
|
||||||
|
|
||||||
const result = await game.run('pass player1');
|
game.produce((state) => {
|
||||||
|
state.cards["clubs_5"] = createCard("clubs_5", "clubs", "5");
|
||||||
|
state.playerHands.player1.push("clubs_5");
|
||||||
|
state.regions.hand_player1.childIds.push("clubs_5");
|
||||||
|
});
|
||||||
|
|
||||||
expect(result.success).toBe(true);
|
const clubsCardId = "clubs_5";
|
||||||
});
|
const enemyHpBefore = game.value.currentEnemy!.hp;
|
||||||
|
const card = game.value.cards[clubsCardId];
|
||||||
|
|
||||||
|
await game.run(`play player1 ${clubsCardId}`);
|
||||||
|
|
||||||
|
expect(game.value.currentEnemy!.hp).toBe(enemyHpBefore - card.value * 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("pass command", () => {
|
||||||
|
it("should allow player to pass", async () => {
|
||||||
|
const game = createTestContext();
|
||||||
|
setupTestGame(game);
|
||||||
|
|
||||||
|
const result = await game.run("pass player1");
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("check-enemy command", () => {
|
||||||
|
it("should detect defeated enemy and reveal next", async () => {
|
||||||
|
const game = createTestContext();
|
||||||
|
setupTestGame(game);
|
||||||
|
|
||||||
|
const firstEnemy = game.value.currentEnemy!;
|
||||||
|
game.produce((state) => {
|
||||||
|
state.currentEnemy!.hp = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
await game.run("check-enemy");
|
||||||
|
|
||||||
|
expect(game.value.regions.discardPile.childIds).toContain(firstEnemy.id);
|
||||||
|
expect(game.value.currentEnemy).not.toBe(firstEnemy);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('check-enemy command', () => {
|
it("should not defeat enemy if HP > 0", async () => {
|
||||||
it('should detect defeated enemy and reveal next', async () => {
|
const game = createTestContext();
|
||||||
const game = createTestContext();
|
setupTestGame(game);
|
||||||
setupTestGame(game);
|
|
||||||
|
|
||||||
const firstEnemy = game.value.currentEnemy!;
|
const currentEnemyId = game.value.currentEnemy!.id;
|
||||||
game.produce(state => {
|
|
||||||
state.currentEnemy!.hp = 0;
|
|
||||||
});
|
|
||||||
|
|
||||||
await game.run('check-enemy');
|
await game.run("check-enemy");
|
||||||
|
|
||||||
expect(game.value.regions.discardPile.childIds).toContain(firstEnemy.id);
|
expect(game.value.currentEnemy!.id).toBe(currentEnemyId);
|
||||||
expect(game.value.currentEnemy).not.toBe(firstEnemy);
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not defeat enemy if HP > 0', async () => {
|
describe("next-turn command", () => {
|
||||||
const game = createTestContext();
|
it("should switch to next player", async () => {
|
||||||
setupTestGame(game);
|
const game = createTestContext();
|
||||||
|
setupTestGame(game);
|
||||||
|
|
||||||
const currentEnemyId = game.value.currentEnemy!.id;
|
expect(game.value.currentPlayerIndex).toBe(0);
|
||||||
|
|
||||||
await game.run('check-enemy');
|
await game.run("next-turn");
|
||||||
|
|
||||||
expect(game.value.currentEnemy!.id).toBe(currentEnemyId);
|
expect(game.value.currentPlayerIndex).toBe(1);
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('next-turn command', () => {
|
it("should wrap around to first player", async () => {
|
||||||
it('should switch to next player', async () => {
|
const game = createTestContext();
|
||||||
const game = createTestContext();
|
setupTestGame(game);
|
||||||
setupTestGame(game);
|
|
||||||
|
|
||||||
expect(game.value.currentPlayerIndex).toBe(0);
|
game.produce((state) => {
|
||||||
|
state.currentPlayerIndex = 1;
|
||||||
|
});
|
||||||
|
|
||||||
await game.run('next-turn');
|
await game.run("next-turn");
|
||||||
|
|
||||||
expect(game.value.currentPlayerIndex).toBe(1);
|
expect(game.value.currentPlayerIndex).toBe(0);
|
||||||
});
|
|
||||||
|
|
||||||
it('should wrap around to first player', async () => {
|
|
||||||
const game = createTestContext();
|
|
||||||
setupTestGame(game);
|
|
||||||
|
|
||||||
game.produce(state => {
|
|
||||||
state.currentPlayerIndex = 1;
|
|
||||||
});
|
|
||||||
|
|
||||||
await game.run('next-turn');
|
|
||||||
|
|
||||||
expect(game.value.currentPlayerIndex).toBe(0);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('Regicide - Game Flow', () => {
|
describe("Regicide - Game Flow", () => {
|
||||||
function createTestContext() {
|
function createTestContext() {
|
||||||
const initialState = createInitialState();
|
const initialState = createInitialState();
|
||||||
return createGameContext(registry, initialState);
|
return createGameContext(registry, initialState);
|
||||||
}
|
}
|
||||||
|
|
||||||
it('should complete a full turn cycle', async () => {
|
it("should complete a full turn cycle", async () => {
|
||||||
const game = createTestContext();
|
const game = createTestContext();
|
||||||
|
|
||||||
const cards = createAllCards();
|
const cards = createAllCards();
|
||||||
const rng = new Mulberry32RNG(12345);
|
const rng = new Mulberry32RNG(12345);
|
||||||
const enemyDeck = buildEnemyDeck(rng);
|
const enemyDeck = buildEnemyDeck(rng);
|
||||||
const tavernDeck = buildTavernDeck(rng);
|
const tavernDeck = buildTavernDeck(rng);
|
||||||
|
|
||||||
game.produce(state => {
|
game.produce((state) => {
|
||||||
state.cards = cards;
|
state.cards = cards;
|
||||||
state.playerCount = 1;
|
state.playerCount = 1;
|
||||||
state.currentPlayerIndex = 0;
|
state.currentPlayerIndex = 0;
|
||||||
state.enemyDeck = [...enemyDeck.slice(1)];
|
state.enemyDeck = [...enemyDeck.slice(1)];
|
||||||
state.currentEnemy = {...enemyDeck[0]};
|
state.currentEnemy = { ...enemyDeck[0] };
|
||||||
|
|
||||||
for (const card of tavernDeck) {
|
for (const card of tavernDeck) {
|
||||||
state.regions.tavernDeck.childIds.push(card.id);
|
state.regions.tavernDeck.childIds.push(card.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (let i = 0; i < 6; i++) {
|
for (let i = 0; i < 6; i++) {
|
||||||
const card = tavernDeck[i];
|
const card = tavernDeck[i];
|
||||||
card.regionId = 'hand_player1';
|
card.regionId = "hand_player1";
|
||||||
state.playerHands.player1.push(card.id);
|
state.playerHands.player1.push(card.id);
|
||||||
state.regions.hand_player1.childIds.push(card.id);
|
state.regions.hand_player1.childIds.push(card.id);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
const cardId = game.value.playerHands.player1[0];
|
|
||||||
const card = game.value.cards[cardId];
|
|
||||||
const enemyHpBefore = game.value.currentEnemy!.hp;
|
|
||||||
|
|
||||||
await game.run(`play player1 ${cardId}`);
|
|
||||||
|
|
||||||
expect(game.value.currentEnemy!.hp).toBeLessThan(enemyHpBefore);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should win game when all enemies defeated', async () => {
|
const cardId = game.value.playerHands.player1[0];
|
||||||
const game = createTestContext();
|
const card = game.value.cards[cardId];
|
||||||
|
const enemyHpBefore = game.value.currentEnemy!.hp;
|
||||||
|
|
||||||
const cards = createAllCards();
|
await game.run(`play player1 ${cardId}`);
|
||||||
const rng = new Mulberry32RNG(12345);
|
|
||||||
const tavernDeck = buildTavernDeck(rng);
|
|
||||||
|
|
||||||
game.produce(state => {
|
expect(game.value.currentEnemy!.hp).toBeLessThan(enemyHpBefore);
|
||||||
state.cards = cards;
|
});
|
||||||
state.playerCount = 1;
|
|
||||||
state.currentPlayerIndex = 0;
|
|
||||||
state.enemyDeck = [];
|
|
||||||
state.currentEnemy = null;
|
|
||||||
|
|
||||||
for (const card of tavernDeck) {
|
it("should win game when all enemies defeated", async () => {
|
||||||
state.regions.tavernDeck.childIds.push(card.id);
|
const game = createTestContext();
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
game.produce(state => {
|
const cards = createAllCards();
|
||||||
state.phase = 'victory';
|
const rng = new Mulberry32RNG(12345);
|
||||||
state.winner = true;
|
const tavernDeck = buildTavernDeck(rng);
|
||||||
});
|
|
||||||
|
|
||||||
expect(game.value.phase).toBe('victory');
|
game.produce((state) => {
|
||||||
expect(game.value.winner).toBe(true);
|
state.cards = cards;
|
||||||
|
state.playerCount = 1;
|
||||||
|
state.currentPlayerIndex = 0;
|
||||||
|
state.enemyDeck = [];
|
||||||
|
state.currentEnemy = null;
|
||||||
|
|
||||||
|
for (const card of tavernDeck) {
|
||||||
|
state.regions.tavernDeck.childIds.push(card.id);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
game.produce((state) => {
|
||||||
|
state.phase = "victory";
|
||||||
|
state.winner = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(game.value.phase).toBe("victory");
|
||||||
|
expect(game.value.winner).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,649 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
addEffect,
|
||||||
|
addEntityEffect,
|
||||||
|
addItemEffect,
|
||||||
|
onEntityEffectUpkeep,
|
||||||
|
onEntityPostureDamage,
|
||||||
|
onPlayerItemEffectUpkeep,
|
||||||
|
onItemPlay,
|
||||||
|
onItemDiscard,
|
||||||
|
getAliveEnemies,
|
||||||
|
getCombatEntity,
|
||||||
|
canPlayCard,
|
||||||
|
payCardCost,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/combat/effects";
|
||||||
|
import type {
|
||||||
|
CombatEntity,
|
||||||
|
CombatState,
|
||||||
|
EffectTable,
|
||||||
|
IRunContext,
|
||||||
|
PlayerEntity,
|
||||||
|
EnemyEntity,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/combat/types";
|
||||||
|
import type {
|
||||||
|
EffectData,
|
||||||
|
ItemData,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
import type {
|
||||||
|
CellKey,
|
||||||
|
GridInventory,
|
||||||
|
InventoryItem,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
|
||||||
|
import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
|
||||||
|
import type { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
|
||||||
|
import type { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
|
||||||
|
import { CardEffect } from "@/samples/slay-the-spire-like/data/desert";
|
||||||
|
|
||||||
|
function createRunContext(
|
||||||
|
items: Map<string, InventoryItem<GameItemMeta>>,
|
||||||
|
): IRunContext {
|
||||||
|
return {
|
||||||
|
getItemData(id: string): ItemData | null {
|
||||||
|
const item = items.get(id);
|
||||||
|
return item?.meta?.itemData ?? null;
|
||||||
|
},
|
||||||
|
getAdjacentItems(_id: string): Iterable<string> {
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
getConsumedUses(id: string): number {
|
||||||
|
const item = items.get(id);
|
||||||
|
return item?.meta?.consumedUses ?? 0;
|
||||||
|
},
|
||||||
|
setConsumedUsesAsync(id: string, uses: number): Promise<void> {
|
||||||
|
const item = items.get(id);
|
||||||
|
if (item?.meta) {
|
||||||
|
item.meta.consumedUses = uses;
|
||||||
|
}
|
||||||
|
return Promise.resolve();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEffect(
|
||||||
|
id: string,
|
||||||
|
lifecycle: EffectData["lifecycle"],
|
||||||
|
): EffectData {
|
||||||
|
return { id, name: id, description: "", lifecycle, emoji: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCard(
|
||||||
|
id: string,
|
||||||
|
costType: "energy" | "uses" | "none",
|
||||||
|
costCount: number,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: id,
|
||||||
|
desc: "",
|
||||||
|
type: "item" as const,
|
||||||
|
costType,
|
||||||
|
costCount,
|
||||||
|
targetType: "player" as const,
|
||||||
|
effects: [] as CardEffect[],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createItem(
|
||||||
|
itemId: string,
|
||||||
|
cardId: string,
|
||||||
|
costType: "energy" | "uses" | "none",
|
||||||
|
costCount: number,
|
||||||
|
depletion = 0,
|
||||||
|
): InventoryItem<GameItemMeta> {
|
||||||
|
return {
|
||||||
|
id: itemId,
|
||||||
|
shape: { id: "1x1", cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
|
||||||
|
transform: {
|
||||||
|
offset: { x: 0, y: 0 },
|
||||||
|
flipX: false,
|
||||||
|
flipY: false,
|
||||||
|
} as unknown as Transform2D,
|
||||||
|
meta: {
|
||||||
|
itemData: {
|
||||||
|
id: itemId,
|
||||||
|
type: "weapon",
|
||||||
|
name: itemId,
|
||||||
|
shape: "1x1",
|
||||||
|
card: createCard(cardId, costType, costCount),
|
||||||
|
price: 0,
|
||||||
|
description: "",
|
||||||
|
},
|
||||||
|
shape: { id: "1x1", cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
|
||||||
|
consumedUses: costType === "uses" ? depletion : undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInventory(
|
||||||
|
items: InventoryItem<GameItemMeta>[],
|
||||||
|
): GridInventory<GameItemMeta> {
|
||||||
|
const map = new Map<string, InventoryItem<GameItemMeta>>();
|
||||||
|
const occupied = new Set<CellKey>();
|
||||||
|
for (const item of items) {
|
||||||
|
map.set(item.id, item);
|
||||||
|
occupied.add(`${item.transform.offset.x},${item.transform.offset.y}`);
|
||||||
|
}
|
||||||
|
return { width: 6, height: 4, items: map, occupiedCells: occupied };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCombatEntity(hp = 10, maxHp = 10): CombatEntity {
|
||||||
|
return {
|
||||||
|
id: "",
|
||||||
|
effects: {},
|
||||||
|
hp,
|
||||||
|
maxHp,
|
||||||
|
isAlive: hp > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPlayerEntity(hp = 30, maxHp = 30): PlayerEntity {
|
||||||
|
return {
|
||||||
|
...createCombatEntity(hp, maxHp),
|
||||||
|
energy: 3,
|
||||||
|
maxEnergy: 3,
|
||||||
|
deck: {
|
||||||
|
cards: {},
|
||||||
|
regions: {
|
||||||
|
drawPile: { id: "drawPile", axes: [], childIds: [], partMap: {} },
|
||||||
|
hand: { id: "hand", axes: [], childIds: [], partMap: {} },
|
||||||
|
discardPile: { id: "discardPile", axes: [], childIds: [], partMap: {} },
|
||||||
|
exhaustPile: { id: "exhaustPile", axes: [], childIds: [], partMap: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
itemEffects: {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEnemyEntity(id: string, hp = 10, maxHp = 10): EnemyEntity {
|
||||||
|
return {
|
||||||
|
...createCombatEntity(hp, maxHp),
|
||||||
|
id,
|
||||||
|
enemy: { id, name: id, description: "", intents: null! },
|
||||||
|
intents: {},
|
||||||
|
currentIntent: null!,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCombatState(
|
||||||
|
playerHp = 30,
|
||||||
|
enemies: EnemyEntity[] = [],
|
||||||
|
): CombatState {
|
||||||
|
return {
|
||||||
|
player: createPlayerEntity(playerHp),
|
||||||
|
enemies,
|
||||||
|
phase: "playerTurn",
|
||||||
|
turnNumber: 1,
|
||||||
|
result: null,
|
||||||
|
loot: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("combat/effects", () => {
|
||||||
|
describe("addEffect", () => {
|
||||||
|
it("should add a new effect to an empty table", () => {
|
||||||
|
const table: EffectTable = {};
|
||||||
|
const effect = createEffect("strength", "temporary");
|
||||||
|
|
||||||
|
addEffect(table, effect, 3);
|
||||||
|
|
||||||
|
expect(table["strength"]).toBeDefined();
|
||||||
|
expect(table["strength"].data).toBe(effect);
|
||||||
|
expect(table["strength"].stacks).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stack with existing effect of same id", () => {
|
||||||
|
const table: EffectTable = {};
|
||||||
|
const effect = createEffect("strength", "lingering");
|
||||||
|
|
||||||
|
addEffect(table, effect, 2);
|
||||||
|
addEffect(table, effect, 3);
|
||||||
|
|
||||||
|
expect(table["strength"].stacks).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove effect when stacks reach 0", () => {
|
||||||
|
const table: EffectTable = {};
|
||||||
|
const effect = createEffect("strength", "temporary");
|
||||||
|
|
||||||
|
addEffect(table, effect, 3);
|
||||||
|
addEffect(table, effect, -3);
|
||||||
|
|
||||||
|
expect(table["strength"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not add effect when stacks is 0", () => {
|
||||||
|
const table: EffectTable = {};
|
||||||
|
const effect = createEffect("strength", "temporary");
|
||||||
|
|
||||||
|
addEffect(table, effect, 0);
|
||||||
|
|
||||||
|
expect(table["strength"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle negative stacks", () => {
|
||||||
|
const table: EffectTable = {};
|
||||||
|
const effect = createEffect("weak", "temporary");
|
||||||
|
|
||||||
|
addEffect(table, effect, -2);
|
||||||
|
|
||||||
|
expect(table["weak"].stacks).toBe(-2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addEntityEffect", () => {
|
||||||
|
it("should add effect to entity.effects", () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const effect = createEffect("vulnerable", "lingering");
|
||||||
|
|
||||||
|
addEntityEffect(entity, effect, 2);
|
||||||
|
|
||||||
|
expect(entity.effects["vulnerable"].stacks).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("addItemEffect", () => {
|
||||||
|
it("should add effect to player.itemEffects[itemKey]", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect("adjacent-buff", "itemTemporary");
|
||||||
|
|
||||||
|
addItemEffect(player, "sword-1", effect, 3);
|
||||||
|
|
||||||
|
expect(player.itemEffects["sword-1"]["adjacent-buff"].stacks).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should initialize itemEffects entry if not present", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect("adjacent-buff", "itemTemporary");
|
||||||
|
|
||||||
|
addItemEffect(player, "new-item", effect, 1);
|
||||||
|
|
||||||
|
expect(player.itemEffects["new-item"]).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should stack with existing item effect", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect("adjacent-buff", "itemTemporary");
|
||||||
|
|
||||||
|
addItemEffect(player, "sword-1", effect, 2);
|
||||||
|
addItemEffect(player, "sword-1", effect, 3);
|
||||||
|
|
||||||
|
expect(player.itemEffects["sword-1"]["adjacent-buff"].stacks).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onEntityEffectUpkeep", () => {
|
||||||
|
it("should remove temporary effects", () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const tempEffect = createEffect("temp-shield", "temporary");
|
||||||
|
|
||||||
|
addEntityEffect(entity, tempEffect, 5);
|
||||||
|
onEntityEffectUpkeep(entity);
|
||||||
|
|
||||||
|
expect(entity.effects["temp-shield"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should decrement lingering effects by 1", () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const lingeringEffect = createEffect("poison", "lingering");
|
||||||
|
|
||||||
|
addEntityEffect(entity, lingeringEffect, 3);
|
||||||
|
onEntityEffectUpkeep(entity);
|
||||||
|
|
||||||
|
expect(entity.effects["poison"].stacks).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove lingering effects when stacks reach 0", () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const lingeringEffect = createEffect("poison", "lingering");
|
||||||
|
|
||||||
|
addEntityEffect(entity, lingeringEffect, 1);
|
||||||
|
onEntityEffectUpkeep(entity);
|
||||||
|
|
||||||
|
expect(entity.effects["poison"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect permanent effects", () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const permEffect = createEffect("max-hp-up", "permanent");
|
||||||
|
|
||||||
|
addEntityEffect(entity, permEffect, 5);
|
||||||
|
onEntityEffectUpkeep(entity);
|
||||||
|
|
||||||
|
expect(entity.effects["max-hp-up"].stacks).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect instant effects", () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const instantEffect = createEffect("instant-damage", "instant");
|
||||||
|
|
||||||
|
addEntityEffect(entity, instantEffect, 10);
|
||||||
|
onEntityEffectUpkeep(entity);
|
||||||
|
|
||||||
|
expect(entity.effects["instant-damage"].stacks).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should increment lingering effects with negative stacks", () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const lingeringEffect = createEffect("regen", "lingering");
|
||||||
|
|
||||||
|
addEntityEffect(entity, lingeringEffect, -3);
|
||||||
|
onEntityEffectUpkeep(entity);
|
||||||
|
|
||||||
|
expect(entity.effects["regen"].stacks).toBe(-2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onEntityPostureDamage", () => {
|
||||||
|
it("should reduce posture effects by damage amount", () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const postureEffect = createEffect("block", "posture");
|
||||||
|
|
||||||
|
addEntityEffect(entity, postureEffect, 10);
|
||||||
|
onEntityPostureDamage(entity, 4);
|
||||||
|
|
||||||
|
expect(entity.effects["block"].stacks).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not reduce posture effects below 0", () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const postureEffect = createEffect("block", "posture");
|
||||||
|
|
||||||
|
addEntityEffect(entity, postureEffect, 3);
|
||||||
|
onEntityPostureDamage(entity, 10);
|
||||||
|
|
||||||
|
expect(entity.effects["block"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect non-posture effects", () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const postureEffect = createEffect("block", "posture");
|
||||||
|
const permEffect = createEffect("strength", "permanent");
|
||||||
|
|
||||||
|
addEntityEffect(entity, postureEffect, 5);
|
||||||
|
addEntityEffect(entity, permEffect, 3);
|
||||||
|
onEntityPostureDamage(entity, 2);
|
||||||
|
|
||||||
|
expect(entity.effects["block"].stacks).toBe(3);
|
||||||
|
expect(entity.effects["strength"].stacks).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle zero damage", () => {
|
||||||
|
const entity = createCombatEntity();
|
||||||
|
const postureEffect = createEffect("block", "posture");
|
||||||
|
|
||||||
|
addEntityEffect(entity, postureEffect, 5);
|
||||||
|
onEntityPostureDamage(entity, 0);
|
||||||
|
|
||||||
|
expect(entity.effects["block"].stacks).toBe(5);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onPlayerItemEffectUpkeep", () => {
|
||||||
|
it("should remove itemTemporary effects", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect("adjacent-buff", "itemTemporary");
|
||||||
|
|
||||||
|
addItemEffect(player, "sword-1", effect, 5);
|
||||||
|
onPlayerItemEffectUpkeep(player);
|
||||||
|
|
||||||
|
expect(player.itemEffects["sword-1"]["adjacent-buff"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect itemPermanent effects", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect("adjacent-buff", "itemPermanent");
|
||||||
|
|
||||||
|
addItemEffect(player, "sword-1", effect, 5);
|
||||||
|
onPlayerItemEffectUpkeep(player);
|
||||||
|
|
||||||
|
expect(player.itemEffects["sword-1"]["adjacent-buff"].stacks).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect itemUntilPlay effects", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect("charged", "itemUntilPlay");
|
||||||
|
|
||||||
|
addItemEffect(player, "sword-1", effect, 3);
|
||||||
|
onPlayerItemEffectUpkeep(player);
|
||||||
|
|
||||||
|
expect(player.itemEffects["sword-1"]["charged"].stacks).toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onItemPlay", () => {
|
||||||
|
it("should remove itemUntilPlay effects", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect("charged", "itemUntilPlay");
|
||||||
|
|
||||||
|
addItemEffect(player, "sword-1", effect, 3);
|
||||||
|
onItemPlay(player, "sword-1");
|
||||||
|
|
||||||
|
expect(player.itemEffects["sword-1"]["charged"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect other lifecycle effects", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const permEffect = createEffect("passive", "itemPermanent");
|
||||||
|
const playEffect = createEffect("charged", "itemUntilPlay");
|
||||||
|
|
||||||
|
addItemEffect(player, "sword-1", permEffect, 5);
|
||||||
|
addItemEffect(player, "sword-1", playEffect, 3);
|
||||||
|
onItemPlay(player, "sword-1");
|
||||||
|
|
||||||
|
expect(player.itemEffects["sword-1"]["passive"].stacks).toBe(5);
|
||||||
|
expect(player.itemEffects["sword-1"]["charged"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should do nothing for item with no effects", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
|
||||||
|
expect(() => onItemPlay(player, "nonexistent")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("onItemDiscard", () => {
|
||||||
|
it("should remove itemUntilDiscard effects", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const effect = createEffect("discard-buff", "itemUntilDiscard");
|
||||||
|
|
||||||
|
addItemEffect(player, "sword-1", effect, 3);
|
||||||
|
onItemDiscard(player, "sword-1");
|
||||||
|
|
||||||
|
expect(player.itemEffects["sword-1"]["discard-buff"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should not affect other lifecycle effects", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const permEffect = createEffect("passive", "itemPermanent");
|
||||||
|
const discardEffect = createEffect("discard-buff", "itemUntilDiscard");
|
||||||
|
|
||||||
|
addItemEffect(player, "sword-1", permEffect, 5);
|
||||||
|
addItemEffect(player, "sword-1", discardEffect, 3);
|
||||||
|
onItemDiscard(player, "sword-1");
|
||||||
|
|
||||||
|
expect(player.itemEffects["sword-1"]["passive"].stacks).toBe(5);
|
||||||
|
expect(player.itemEffects["sword-1"]["discard-buff"]).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should do nothing for item with no effects", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
|
||||||
|
expect(() => onItemDiscard(player, "nonexistent")).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getAliveEnemies", () => {
|
||||||
|
it("should yield only alive enemies", () => {
|
||||||
|
const state = createCombatState(30, [
|
||||||
|
createEnemyEntity("slime-1", 10, 10),
|
||||||
|
createEnemyEntity("slime-2", 0, 10),
|
||||||
|
createEnemyEntity("slime-3", 5, 10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const alive = [...getAliveEnemies(state)];
|
||||||
|
|
||||||
|
expect(alive.length).toBe(2);
|
||||||
|
expect(alive[0].id).toBe("slime-1");
|
||||||
|
expect(alive[1].id).toBe("slime-3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty for no enemies", () => {
|
||||||
|
const state = createCombatState(30, []);
|
||||||
|
|
||||||
|
const alive = [...getAliveEnemies(state)];
|
||||||
|
|
||||||
|
expect(alive.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return empty when all enemies are dead", () => {
|
||||||
|
const state = createCombatState(30, [
|
||||||
|
createEnemyEntity("slime-1", 0, 10),
|
||||||
|
createEnemyEntity("slime-2", 0, 10),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const alive = [...getAliveEnemies(state)];
|
||||||
|
|
||||||
|
expect(alive.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCombatEntity", () => {
|
||||||
|
it('should return player for "player" key', () => {
|
||||||
|
const state = createCombatState(30);
|
||||||
|
|
||||||
|
const entity = getCombatEntity(state, "player");
|
||||||
|
|
||||||
|
expect(entity).toBe(state.player);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return enemy by id", () => {
|
||||||
|
const enemy = createEnemyEntity("boss-1", 50, 50);
|
||||||
|
const state = createCombatState(30, [enemy]);
|
||||||
|
|
||||||
|
const entity = getCombatEntity(state, "boss-1");
|
||||||
|
|
||||||
|
expect(entity).toBe(enemy);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should return undefined for non-existent enemy", () => {
|
||||||
|
const state = createCombatState(30, [createEnemyEntity("slime-1")]);
|
||||||
|
|
||||||
|
const entity = getCombatEntity(state, "nonexistent");
|
||||||
|
|
||||||
|
expect(entity).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("canPlayCard", () => {
|
||||||
|
it("should allow playing energy card when player has enough energy", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
player.energy = 3;
|
||||||
|
const inventory = createInventory([]);
|
||||||
|
const run = createRunContext(inventory.items);
|
||||||
|
|
||||||
|
const result = canPlayCard(player, "energy", 2, "any", run);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject playing energy card when player lacks energy", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
player.energy = 1;
|
||||||
|
const inventory = createInventory([]);
|
||||||
|
const run = createRunContext(inventory.items);
|
||||||
|
|
||||||
|
const result = canPlayCard(player, "energy", 2, "any", run);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should allow playing uses card when item has remaining uses", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const item = createItem("potion-1", "potion-card", "uses", 3, 1);
|
||||||
|
const inventory = createInventory([item]);
|
||||||
|
const run = createRunContext(inventory.items);
|
||||||
|
|
||||||
|
const result = canPlayCard(player, "uses", 3, "potion-1", run);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject playing uses card when item is depleted", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const item = createItem("potion-1", "potion-card", "uses", 3, 3);
|
||||||
|
const inventory = createInventory([item]);
|
||||||
|
const run = createRunContext(inventory.items);
|
||||||
|
|
||||||
|
const result = canPlayCard(player, "uses", 3, "potion-1", run);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reject playing uses card when item not in inventory", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const inventory = createInventory([]);
|
||||||
|
const run = createRunContext(inventory.items);
|
||||||
|
|
||||||
|
const result = canPlayCard(player, "uses", 1, "missing", run);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should always allow playing none cost card", () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
player.energy = 0;
|
||||||
|
const inventory = createInventory([]);
|
||||||
|
const run = createRunContext(inventory.items);
|
||||||
|
|
||||||
|
const result = canPlayCard(player, "none", 0, "any", run);
|
||||||
|
|
||||||
|
expect(result).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("payCardCost", () => {
|
||||||
|
it("should deduct energy for energy cost card", async () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
player.energy = 3;
|
||||||
|
const inventory = createInventory([]);
|
||||||
|
const run = createRunContext(inventory.items);
|
||||||
|
|
||||||
|
await payCardCost(player, "energy", 2, "any", run);
|
||||||
|
|
||||||
|
expect(player.energy).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should increment depletion for uses cost card", async () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const item = createItem("potion-1", "potion-card", "uses", 3, 1);
|
||||||
|
const inventory = createInventory([item]);
|
||||||
|
const run = createRunContext(inventory.items);
|
||||||
|
|
||||||
|
await payCardCost(player, "uses", 3, "potion-1", run);
|
||||||
|
|
||||||
|
expect(item.meta?.consumedUses).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should do nothing for none cost card", async () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
player.energy = 3;
|
||||||
|
const inventory = createInventory([]);
|
||||||
|
const run = createRunContext(inventory.items);
|
||||||
|
|
||||||
|
await payCardCost(player, "none", 0, "any", run);
|
||||||
|
|
||||||
|
expect(player.energy).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle missing item gracefully for uses cost", async () => {
|
||||||
|
const player = createPlayerEntity();
|
||||||
|
const inventory = createInventory([]);
|
||||||
|
const run = createRunContext(inventory.items);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
payCardCost(player, "uses", 1, "missing", run),
|
||||||
|
).resolves.not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
createGameContext,
|
||||||
|
createGameCommandRegistry,
|
||||||
|
IGameContext,
|
||||||
|
} from "@/core/game";
|
||||||
|
import { createRegion } from "@/core/region";
|
||||||
|
import {
|
||||||
|
createTriggers,
|
||||||
|
Triggers,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/combat/triggers";
|
||||||
|
import { addTriggers } from "@/samples/slay-the-spire-like/data/desert/triggers";
|
||||||
|
import {
|
||||||
|
CombatState,
|
||||||
|
EnemyEntity,
|
||||||
|
IRunContext,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/combat/types";
|
||||||
|
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
import {
|
||||||
|
GameCard,
|
||||||
|
DeckRegions,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/deck";
|
||||||
|
import {
|
||||||
|
CellKey,
|
||||||
|
GridInventory,
|
||||||
|
InventoryItem,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
|
||||||
|
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
|
||||||
|
import {
|
||||||
|
CardEffect,
|
||||||
|
getCards,
|
||||||
|
getEffects,
|
||||||
|
getEnemies,
|
||||||
|
} from "@/samples/slay-the-spire-like/data/desert";
|
||||||
|
const cards = getCards();
|
||||||
|
const effects = getEffects();
|
||||||
|
const enemies = getEnemies();
|
||||||
|
|
||||||
|
function createEffect(
|
||||||
|
id: string,
|
||||||
|
lifecycle: EffectData["lifecycle"] = "instant",
|
||||||
|
): EffectData {
|
||||||
|
const found = effects.find((e) => e.id === id);
|
||||||
|
if (found) return found;
|
||||||
|
return { id, name: id, description: "", lifecycle, emoji: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDeckRegions(): DeckRegions {
|
||||||
|
return {
|
||||||
|
drawPile: createRegion("drawPile", []),
|
||||||
|
hand: createRegion("hand", []),
|
||||||
|
discardPile: createRegion("discardPile", []),
|
||||||
|
exhaustPile: createRegion("exhaustPile", []),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCard(
|
||||||
|
id: string,
|
||||||
|
itemId: string,
|
||||||
|
costType: "energy" | "uses" | "none" = "energy",
|
||||||
|
costCount = 0,
|
||||||
|
): GameCard {
|
||||||
|
const cardData = cards.find((c) => c.id === itemId) ?? {
|
||||||
|
id: itemId,
|
||||||
|
name: itemId,
|
||||||
|
desc: "",
|
||||||
|
type: "item" as const,
|
||||||
|
costType,
|
||||||
|
costCount,
|
||||||
|
targetType: "player" as const,
|
||||||
|
effects: [] as CardEffect[],
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
regionId: "",
|
||||||
|
position: [0],
|
||||||
|
itemId,
|
||||||
|
cardData,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEnemyEntity(
|
||||||
|
enemyId: string,
|
||||||
|
hp: number,
|
||||||
|
maxHp: number,
|
||||||
|
instanceIndex = 0,
|
||||||
|
): EnemyEntity {
|
||||||
|
const enemyData = enemies.find((e) => e.id === enemyId);
|
||||||
|
if (!enemyData) throw new Error(`Enemy "${enemyId}" not found`);
|
||||||
|
const intent =
|
||||||
|
enemyData.intents.find((i) => i.initialIntent) ?? enemyData.intents[0];
|
||||||
|
const instanceId = `${enemyId}-${instanceIndex}`;
|
||||||
|
const intentMap: Record<string, typeof intent> = {};
|
||||||
|
for (const i of enemyData.intents) {
|
||||||
|
intentMap[i.id] = i;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: instanceId,
|
||||||
|
enemy: enemyData,
|
||||||
|
hp,
|
||||||
|
maxHp,
|
||||||
|
isAlive: true,
|
||||||
|
effects: {},
|
||||||
|
intents: intentMap,
|
||||||
|
currentIntent: intent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createInventory(
|
||||||
|
itemsList: InventoryItem<GameItemMeta>[],
|
||||||
|
): GridInventory<GameItemMeta> {
|
||||||
|
const map = new Map<string, InventoryItem<GameItemMeta>>();
|
||||||
|
const occupied = new Set<CellKey>();
|
||||||
|
for (const item of itemsList) {
|
||||||
|
map.set(item.id, item);
|
||||||
|
occupied.add(`${item.transform.offset.x},${item.transform.offset.y}`);
|
||||||
|
}
|
||||||
|
return { width: 6, height: 4, items: map, occupiedCells: occupied };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createCombatState(overrides: Partial<CombatState> = {}): CombatState {
|
||||||
|
const regions = createDeckRegions();
|
||||||
|
return {
|
||||||
|
player: {
|
||||||
|
id: "player",
|
||||||
|
effects: {},
|
||||||
|
hp: 30,
|
||||||
|
maxHp: 30,
|
||||||
|
isAlive: true,
|
||||||
|
energy: 3,
|
||||||
|
maxEnergy: 3,
|
||||||
|
deck: { cards: {}, regions },
|
||||||
|
itemEffects: {},
|
||||||
|
},
|
||||||
|
enemies: [],
|
||||||
|
phase: "playerTurn",
|
||||||
|
turnNumber: 1,
|
||||||
|
result: null,
|
||||||
|
loot: [],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestContext(state?: CombatState): IGameContext<CombatState> {
|
||||||
|
const registry = createGameCommandRegistry<CombatState>();
|
||||||
|
const ctx = createGameContext(registry, state ?? createCombatState());
|
||||||
|
ctx._rng.setSeed(42);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTriggers(): Triggers {
|
||||||
|
const run: IRunContext = {
|
||||||
|
getConsumedUses() {
|
||||||
|
return 0;
|
||||||
|
},
|
||||||
|
getItemData() {
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
*getAdjacentItems() {},
|
||||||
|
async setConsumedUsesAsync() {},
|
||||||
|
};
|
||||||
|
const triggers = createTriggers(run);
|
||||||
|
addTriggers(triggers, run);
|
||||||
|
return triggers;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCardToHand(ctx: IGameContext<CombatState>, card: GameCard) {
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
draft.player.deck.cards[card.id] = card;
|
||||||
|
card.regionId = "hand";
|
||||||
|
draft.player.deck.regions.hand.childIds.push(card.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCardToDrawPile(ctx: IGameContext<CombatState>, card: GameCard) {
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
draft.player.deck.cards[card.id] = card;
|
||||||
|
card.regionId = "drawPile";
|
||||||
|
draft.player.deck.regions.drawPile.childIds.push(card.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addCardToDiscardPile(ctx: IGameContext<CombatState>, card: GameCard) {
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
draft.player.deck.cards[card.id] = card;
|
||||||
|
card.regionId = "discardPile";
|
||||||
|
draft.player.deck.regions.discardPile.childIds.push(card.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeDummyEnemy() {
|
||||||
|
return createEnemyEntity("仙人掌怪", 999, 999);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("desert triggers", () => {
|
||||||
|
describe("instant effects", () => {
|
||||||
|
it("should apply attack effect as damage", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [makeDummyEnemy()],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const attackEffect = createEffect("attack");
|
||||||
|
|
||||||
|
await triggers.onEffectApplied.execute(ctx, {
|
||||||
|
effect: attackEffect,
|
||||||
|
entityId: "player",
|
||||||
|
stacks: 5,
|
||||||
|
sourceEntityId: "enemy-0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.player.hp).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply draw effect", async () => {
|
||||||
|
const ctx = createTestContext();
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const drawEffect = createEffect("draw");
|
||||||
|
|
||||||
|
addCardToDrawPile(ctx, createCard("card-1", "sword"));
|
||||||
|
addCardToDrawPile(ctx, createCard("card-2", "sword"));
|
||||||
|
|
||||||
|
await triggers.onEffectApplied.execute(ctx, {
|
||||||
|
effect: drawEffect,
|
||||||
|
entityId: "player",
|
||||||
|
stacks: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2);
|
||||||
|
expect(ctx.value.player.deck.regions.drawPile.childIds.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should apply gainEnergy effect", async () => {
|
||||||
|
const ctx = createTestContext();
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const gainEnergyEffect = createEffect("gainEnergy");
|
||||||
|
|
||||||
|
const initialEnergy = ctx.value.player.energy;
|
||||||
|
await triggers.onEffectApplied.execute(ctx, {
|
||||||
|
effect: gainEnergyEffect,
|
||||||
|
entityId: "player",
|
||||||
|
stacks: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.player.energy).toBe(initialEnergy + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should remove wound cards from draw and discard piles", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [makeDummyEnemy()],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const removeWoundEffect = createEffect("removeWound");
|
||||||
|
|
||||||
|
addCardToDrawPile(ctx, createCard("wound-1", "wound", "none", 0));
|
||||||
|
addCardToDiscardPile(ctx, createCard("wound-2", "wound", "none", 0));
|
||||||
|
addCardToDrawPile(ctx, createCard("sword-1", "sword"));
|
||||||
|
|
||||||
|
await triggers.onEffectApplied.execute(ctx, {
|
||||||
|
effect: removeWoundEffect,
|
||||||
|
entityId: "player",
|
||||||
|
stacks: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.player.deck.cards["wound-1"]).toBeUndefined();
|
||||||
|
expect(ctx.value.player.deck.cards["wound-2"]).toBeUndefined();
|
||||||
|
expect(ctx.value.player.deck.cards["sword-1"]).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("damage pipeline", () => {
|
||||||
|
it("should prevent damage with block", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [makeDummyEnemy()],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const defendEffect = createEffect("defend", "temporary");
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
draft.player.effects.defend = { data: defendEffect, stacks: 5 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onDamage.execute(ctx, {
|
||||||
|
entityId: "player",
|
||||||
|
amount: 8,
|
||||||
|
sourceEntityId: "enemy-0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.player.hp).toBe(27);
|
||||||
|
expect(ctx.value.player.effects.defend?.stacks).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should reduce damage with damageReduce", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [makeDummyEnemy()],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const damageReduceEffect = createEffect("damageReduce", "temporary");
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
draft.player.effects.damageReduce = {
|
||||||
|
data: damageReduceEffect,
|
||||||
|
stacks: 3,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onDamage.execute(ctx, {
|
||||||
|
entityId: "player",
|
||||||
|
amount: 8,
|
||||||
|
sourceEntityId: "enemy-0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.player.hp).toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should increase damage with expose", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [makeDummyEnemy()],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const exposeEffect = createEffect("expose", "temporary");
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
draft.player.effects.expose = { data: exposeEffect, stacks: 2 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onDamage.execute(ctx, {
|
||||||
|
entityId: "player",
|
||||||
|
amount: 5,
|
||||||
|
sourceEntityId: "enemy-0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.player.hp).toBe(23);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("spike reflection", () => {
|
||||||
|
it("should damage attacker when entity has spike", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [createEnemyEntity("仙人掌怪", 12, 12)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const spikeEffect = createEffect("spike", "permanent");
|
||||||
|
const attackEffect = createEffect("attack", "instant");
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
const enemy = draft.enemies[0];
|
||||||
|
enemy.effects.spike = { data: spikeEffect, stacks: 3 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onEffectApplied.execute(ctx, {
|
||||||
|
effect: attackEffect,
|
||||||
|
entityId: "仙人掌怪-0",
|
||||||
|
stacks: 5,
|
||||||
|
sourceEntityId: "player",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.player.hp).toBe(27);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("storm static card generation", () => {
|
||||||
|
it("should give player static cards when storm enemy executes intent", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [createEnemyEntity("风暴之灵", 30, 30)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
|
||||||
|
await triggers.onEnemyIntent.execute(ctx, { enemyId: "风暴之灵-0" });
|
||||||
|
|
||||||
|
const staticCards = Object.values(ctx.value.player.deck.cards).filter(
|
||||||
|
(c: GameCard) => c.itemId === "static",
|
||||||
|
);
|
||||||
|
expect(staticCards.length).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("energyDrain", () => {
|
||||||
|
it("should drain player energy when energyDrain enemy takes damage", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [createEnemyEntity("幼沙虫", 18, 18)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const energyDrainEffect = createEffect("energyDrain", "lingering");
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
const enemy = draft.enemies[0];
|
||||||
|
enemy.effects.energyDrain = { data: energyDrainEffect, stacks: 1 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onDamage.execute(ctx, {
|
||||||
|
entityId: "幼沙虫-0",
|
||||||
|
amount: 5,
|
||||||
|
sourceEntityId: "player",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.player.energy).toBe(2);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("molt flee", () => {
|
||||||
|
it("should make enemy flee when molt >= maxHp after taking damage", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [createEnemyEntity("蜥蜴", 14, 14)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const moltEffect = createEffect("molt", "posture");
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
const enemy = draft.enemies[0];
|
||||||
|
enemy.effects.molt = { data: moltEffect, stacks: 14 };
|
||||||
|
});
|
||||||
|
|
||||||
|
let threw = false;
|
||||||
|
try {
|
||||||
|
await triggers.onDamage.execute(ctx, {
|
||||||
|
entityId: "蜥蜴-0",
|
||||||
|
amount: 1,
|
||||||
|
sourceEntityId: "player",
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
threw = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(threw).toBe(true);
|
||||||
|
expect(ctx.value.result).toBe("victory");
|
||||||
|
expect(ctx.value.enemies[0].isAlive).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("discard at turn start", () => {
|
||||||
|
it("should randomly discard a card when discard effect is active", async () => {
|
||||||
|
const ctx = createTestContext();
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const discardEffect = createEffect("discard", "lingering");
|
||||||
|
|
||||||
|
addCardToHand(ctx, createCard("card-1", "sword"));
|
||||||
|
addCardToHand(ctx, createCard("card-2", "shield"));
|
||||||
|
addCardToHand(ctx, createCard("card-3", "dagger"));
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
draft.player.effects.discard = { data: discardEffect, stacks: 1 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onTurnStart.execute(ctx, { entityId: "player" });
|
||||||
|
|
||||||
|
expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2);
|
||||||
|
expect(ctx.value.player.deck.regions.discardPile.childIds.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("next-turn effects", () => {
|
||||||
|
it("should gain block from defendNext at turn start", async () => {
|
||||||
|
const ctx = createTestContext();
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const defendNextEffect = createEffect("defendNext", "temporary");
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
draft.player.effects.defendNext = { data: defendNextEffect, stacks: 5 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onTurnStart.execute(ctx, { entityId: "player" });
|
||||||
|
|
||||||
|
expect(ctx.value.player.effects.defend?.stacks).toBe(5);
|
||||||
|
expect(ctx.value.player.effects.defendNext).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should gain energy from energyNext at turn start", async () => {
|
||||||
|
const ctx = createTestContext();
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const energyNextEffect = createEffect("energyNext", "temporary");
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
draft.player.effects.energyNext = { data: energyNextEffect, stacks: 2 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onTurnStart.execute(ctx, { entityId: "player" });
|
||||||
|
|
||||||
|
expect(ctx.value.player.energy).toBe(5);
|
||||||
|
expect(ctx.value.player.effects.energyNext).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should draw extra cards from drawNext at turn start", async () => {
|
||||||
|
const ctx = createTestContext();
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const drawNextEffect = createEffect("drawNext", "temporary");
|
||||||
|
|
||||||
|
addCardToDrawPile(ctx, createCard("card-1", "sword"));
|
||||||
|
addCardToDrawPile(ctx, createCard("card-2", "sword"));
|
||||||
|
addCardToDrawPile(ctx, createCard("card-3", "sword"));
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
draft.player.effects.drawNext = { data: drawNextEffect, stacks: 2 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onTurnStart.execute(ctx, { entityId: "player" });
|
||||||
|
|
||||||
|
expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2);
|
||||||
|
expect(ctx.value.player.effects.drawNext).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("posture damage effects", () => {
|
||||||
|
it("should double damage with aim", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [createEnemyEntity("仙人掌怪", 12, 12)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const aimEffect = createEffect("aim", "posture");
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
draft.player.effects.aim = { data: aimEffect, stacks: 2 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onDamage.execute(ctx, {
|
||||||
|
entityId: "仙人掌怪-0",
|
||||||
|
amount: 5,
|
||||||
|
sourceEntityId: "player",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.enemies[0].hp).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add bonus damage with roll", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [createEnemyEntity("仙人掌怪", 99, 99)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const rollEffect = createEffect("roll", "posture");
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
draft.player.effects.roll = { data: rollEffect, stacks: 20 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onDamage.execute(ctx, {
|
||||||
|
entityId: "仙人掌怪-0",
|
||||||
|
amount: 5,
|
||||||
|
sourceEntityId: "player",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.enemies[0].hp).toBe(74);
|
||||||
|
expect(ctx.value.player.effects.roll).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should add bonus damage with tailSting", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [createEnemyEntity("沙蝎", 10, 10)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const tailStingEffect = createEffect("tailSting", "posture");
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
const enemy = draft.enemies[0];
|
||||||
|
enemy.effects.tailSting = { data: tailStingEffect, stacks: 2 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onDamage.execute(ctx, {
|
||||||
|
entityId: "player",
|
||||||
|
amount: 5,
|
||||||
|
sourceEntityId: "沙蝎-0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.player.hp).toBe(23);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should double damage with charge on attacker", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [createEnemyEntity("骑马枪手", 25, 25)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const chargeEffect = createEffect("charge", "lingering");
|
||||||
|
|
||||||
|
ctx._state.produce((draft) => {
|
||||||
|
const enemy = draft.enemies[0];
|
||||||
|
enemy.effects.charge = { data: chargeEffect, stacks: 2 };
|
||||||
|
});
|
||||||
|
|
||||||
|
await triggers.onDamage.execute(ctx, {
|
||||||
|
entityId: "player",
|
||||||
|
amount: 5,
|
||||||
|
sourceEntityId: "骑马枪手-0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.player.hp).toBe(20);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("crossbow chain", () => {
|
||||||
|
it("should replay other crossbows on same target", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [createEnemyEntity("仙人掌怪", 20, 20)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
const crossbowEffect = createEffect("crossbow");
|
||||||
|
|
||||||
|
addCardToHand(ctx, createCard("crossbow-1", "crossbow"));
|
||||||
|
addCardToHand(ctx, createCard("crossbow-2", "crossbow"));
|
||||||
|
|
||||||
|
await triggers.onEffectApplied.execute(ctx, {
|
||||||
|
effect: crossbowEffect,
|
||||||
|
entityId: "player",
|
||||||
|
stacks: 0,
|
||||||
|
cardId: "crossbow-1",
|
||||||
|
sourceEntityId: "player",
|
||||||
|
targetId: "仙人掌怪-0",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.enemies[0].hp).toBe(8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sandwormKing fatigue heal", () => {
|
||||||
|
it("should heal sandworm king when player discards fatigue", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [createEnemyEntity("沙虫王", 30, 40)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
|
||||||
|
addCardToHand(ctx, createCard("fatigue-1", "fatigue", "none", 0));
|
||||||
|
|
||||||
|
await triggers.onCardDiscarded.execute(ctx, {
|
||||||
|
cardId: "fatigue-1",
|
||||||
|
sourceEntityId: "player",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(ctx.value.enemies[0].hp).toBe(40);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("vulture on-damage", () => {
|
||||||
|
it("should give player vultureEye when vulture deals damage", async () => {
|
||||||
|
const ctx = createTestContext(
|
||||||
|
createCombatState({
|
||||||
|
enemies: [createEnemyEntity("秃鹫", 12, 12)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const triggers = getTriggers();
|
||||||
|
|
||||||
|
await triggers.onDamage.execute(ctx, {
|
||||||
|
entityId: "player",
|
||||||
|
amount: 5,
|
||||||
|
sourceEntityId: "秃鹫-0",
|
||||||
|
});
|
||||||
|
|
||||||
|
const vultureEyeCards = Object.values(ctx.value.player.deck.cards).filter(
|
||||||
|
(c: GameCard) => c.itemId === "vultureEye",
|
||||||
|
);
|
||||||
|
expect(vultureEyeCards.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,73 +1,13 @@
|
|||||||
import { describe, it, expect } from 'vitest';
|
import { describe, it, expect } from "vitest";
|
||||||
import { heroItemFighter1Data } from '@/samples/slay-the-spire-like/data';
|
import data from "@/samples/slay-the-spire-like/data";
|
||||||
|
import { CardData } from "@/samples/slay-the-spire-like";
|
||||||
|
|
||||||
describe('heroItemFighter1.csv import', () => {
|
describe("data import", () => {
|
||||||
it('should import data as an array', () => {
|
it("should import properly", () => {
|
||||||
expect(Array.isArray(heroItemFighter1Data)).toBe(true);
|
expect(data.desert.getEffects).toBeDefined();
|
||||||
expect(heroItemFighter1Data.length).toBeGreaterThan(0);
|
expect(
|
||||||
});
|
data.desert.getCards().find((c: CardData) => c.id === "crossbow")?.effects
|
||||||
|
?.length,
|
||||||
it('should have expected number of items', () => {
|
).toBe(2);
|
||||||
// CSV has 24 data rows (excluding header and type rows)
|
});
|
||||||
expect(heroItemFighter1Data.length).toBe(24);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct fields for each item', () => {
|
|
||||||
for (const item of heroItemFighter1Data) {
|
|
||||||
expect(item).toHaveProperty('type');
|
|
||||||
expect(item).toHaveProperty('name');
|
|
||||||
expect(item).toHaveProperty('shape');
|
|
||||||
expect(item).toHaveProperty('costType');
|
|
||||||
expect(item).toHaveProperty('costCount');
|
|
||||||
expect(item).toHaveProperty('targetType');
|
|
||||||
expect(item).toHaveProperty('desc');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should parse costCount as number', () => {
|
|
||||||
for (const item of heroItemFighter1Data) {
|
|
||||||
expect(typeof item.costCount).toBe('number');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should contain expected items by name', () => {
|
|
||||||
const names = heroItemFighter1Data.map(item => item.name);
|
|
||||||
expect(names).toContain('剑');
|
|
||||||
expect(names).toContain('盾');
|
|
||||||
expect(names).toContain('绷带');
|
|
||||||
expect(names).toContain('火把');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have valid type values', () => {
|
|
||||||
const validTypes = ['weapon', 'armor', 'consumable', 'tool'];
|
|
||||||
for (const item of heroItemFighter1Data) {
|
|
||||||
expect(validTypes).toContain(item.type);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have valid costType values', () => {
|
|
||||||
const validCostTypes = ['energy', 'uses'];
|
|
||||||
for (const item of heroItemFighter1Data) {
|
|
||||||
expect(validCostTypes).toContain(item.costType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have valid targetType values', () => {
|
|
||||||
const validTargetTypes = ['single', 'none'];
|
|
||||||
for (const item of heroItemFighter1Data) {
|
|
||||||
expect(validTargetTypes).toContain(item.targetType);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should have correct item counts by type', () => {
|
|
||||||
const typeCounts = heroItemFighter1Data.reduce((acc, item) => {
|
|
||||||
acc[item.type] = (acc[item.type] || 0) + 1;
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, number>);
|
|
||||||
|
|
||||||
expect(typeCounts['weapon']).toBe(6);
|
|
||||||
expect(typeCounts['armor']).toBe(6);
|
|
||||||
expect(typeCounts['consumable']).toBe(6);
|
|
||||||
expect(typeCounts['tool']).toBe(6);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,244 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import {
|
||||||
|
generateDeckFromInventory,
|
||||||
|
createCard,
|
||||||
|
createDeckRegions,
|
||||||
|
createPlayerDeck,
|
||||||
|
generateCardId,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/deck/factory";
|
||||||
|
import {
|
||||||
|
createGridInventory,
|
||||||
|
placeItem,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/grid-inventory";
|
||||||
|
import type {
|
||||||
|
GridInventory,
|
||||||
|
InventoryItem,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/grid-inventory";
|
||||||
|
import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
|
||||||
|
import { IDENTITY_TRANSFORM } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
|
||||||
|
import { parseShapeString } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
|
||||||
|
import type {
|
||||||
|
CardData,
|
||||||
|
ItemData,
|
||||||
|
} from "@/samples/slay-the-spire-like/system/types";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: create a minimal CardData for testing.
|
||||||
|
*/
|
||||||
|
function createTestCardData(id: string, name: string, desc: string): CardData {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
desc,
|
||||||
|
type: "item",
|
||||||
|
costType: "energy",
|
||||||
|
costCount: 1,
|
||||||
|
targetType: "enemy",
|
||||||
|
effects: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: create a minimal ItemData for testing.
|
||||||
|
*/
|
||||||
|
function createTestItemData(
|
||||||
|
id: string,
|
||||||
|
name: string,
|
||||||
|
shapeStr: string,
|
||||||
|
desc: string,
|
||||||
|
): ItemData {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type: "weapon",
|
||||||
|
name,
|
||||||
|
shape: shapeStr,
|
||||||
|
card: createTestCardData(id, name, desc),
|
||||||
|
price: 10,
|
||||||
|
description: desc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: create a minimal GameItemMeta for testing.
|
||||||
|
*/
|
||||||
|
function createTestMeta(
|
||||||
|
name: string,
|
||||||
|
desc: string,
|
||||||
|
shapeStr: string,
|
||||||
|
): GameItemMeta {
|
||||||
|
const shape = parseShapeString(shapeStr);
|
||||||
|
const itemData = createTestItemData(name.toLowerCase(), name, shapeStr, desc);
|
||||||
|
return {
|
||||||
|
itemData,
|
||||||
|
shape,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper: create a test inventory with some items.
|
||||||
|
*/
|
||||||
|
function createTestInventory(): GridInventory<GameItemMeta> {
|
||||||
|
const inv = createGridInventory<GameItemMeta>(6, 4);
|
||||||
|
|
||||||
|
// Item "短刀" with shape "oe" (2 cells)
|
||||||
|
const meta1 = createTestMeta("短刀", "【攻击3】【攻击3】", "oe");
|
||||||
|
const item1: InventoryItem<GameItemMeta> = {
|
||||||
|
id: "dagger-1",
|
||||||
|
shape: meta1.shape,
|
||||||
|
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||||
|
meta: meta1,
|
||||||
|
};
|
||||||
|
placeItem(inv, item1);
|
||||||
|
|
||||||
|
// Item "盾" with shape "oesw" (4 cells)
|
||||||
|
const meta2 = createTestMeta("盾", "【防御3】", "oesw");
|
||||||
|
const item2: InventoryItem<GameItemMeta> = {
|
||||||
|
id: "shield-1",
|
||||||
|
shape: meta2.shape,
|
||||||
|
transform: { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 0 } },
|
||||||
|
meta: meta2,
|
||||||
|
};
|
||||||
|
placeItem(inv, item2);
|
||||||
|
|
||||||
|
return inv;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("deck/factory", () => {
|
||||||
|
describe("generateCardId", () => {
|
||||||
|
it("should generate deterministic unique IDs", () => {
|
||||||
|
expect(generateCardId("item-1", 0)).toBe("card-item-1-0");
|
||||||
|
expect(generateCardId("item-1", 1)).toBe("card-item-1-1");
|
||||||
|
expect(generateCardId("item-2", 0)).toBe("card-item-2-0");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createCard", () => {
|
||||||
|
it("should create a card with itemId and cardData", () => {
|
||||||
|
const cardData = createTestCardData("wound", "伤口", "无法被弃牌");
|
||||||
|
const card = createCard("wound-1", cardData, 0);
|
||||||
|
|
||||||
|
expect(card.id).toBe("card-wound-1-0");
|
||||||
|
expect(card.itemId).toBe("wound-1");
|
||||||
|
expect(card.cardData).toBe(cardData);
|
||||||
|
expect(card.regionId).toBe("");
|
||||||
|
expect(card.position).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("generateDeckFromInventory", () => {
|
||||||
|
it("should generate correct number of cards based on shape cell counts", () => {
|
||||||
|
const inv = createTestInventory();
|
||||||
|
|
||||||
|
// "短刀" has 2 cells, "盾" has 4 cells = 6 total
|
||||||
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
|
expect(Object.keys(deck.cards).length).toBe(6);
|
||||||
|
expect(deck.regions.drawPile.childIds.length).toBe(6);
|
||||||
|
expect(deck.regions.hand.childIds).toEqual([]);
|
||||||
|
expect(deck.regions.discardPile.childIds).toEqual([]);
|
||||||
|
expect(deck.regions.exhaustPile.childIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should link cards to their source items", () => {
|
||||||
|
const inv = createTestInventory();
|
||||||
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
|
const daggerCards = Object.values(deck.cards).filter(
|
||||||
|
(c) => c.itemId === "dagger-1",
|
||||||
|
);
|
||||||
|
const shieldCards = Object.values(deck.cards).filter(
|
||||||
|
(c) => c.itemId === "shield-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(daggerCards.length).toBe(2);
|
||||||
|
expect(shieldCards.length).toBe(4);
|
||||||
|
|
||||||
|
// Verify card data
|
||||||
|
expect(daggerCards[0].cardData.name).toBe("短刀");
|
||||||
|
expect(shieldCards[0].cardData.name).toBe("盾");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should set displayName and description from item data", () => {
|
||||||
|
const inv = createTestInventory();
|
||||||
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
|
for (const card of Object.values(deck.cards)) {
|
||||||
|
expect(card.cardData.name).toBeTruthy();
|
||||||
|
expect(card.cardData.desc).toBeTruthy();
|
||||||
|
}
|
||||||
|
|
||||||
|
const daggerCard = Object.values(deck.cards).find(
|
||||||
|
(c) => c.cardData.name === "短刀",
|
||||||
|
);
|
||||||
|
expect(daggerCard?.cardData.name).toBe("短刀");
|
||||||
|
expect(daggerCard?.cardData.desc).toBe("【攻击3】【攻击3】");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should assign unique IDs to each card from same item", () => {
|
||||||
|
const inv = createTestInventory();
|
||||||
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
|
const daggerCards = Object.values(deck.cards).filter(
|
||||||
|
(c) => c.itemId === "dagger-1",
|
||||||
|
);
|
||||||
|
|
||||||
|
const ids = daggerCards.map((c) => c.id);
|
||||||
|
const uniqueIds = new Set(ids);
|
||||||
|
expect(uniqueIds.size).toBe(ids.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should handle empty inventory", () => {
|
||||||
|
const inv = createGridInventory<GameItemMeta>(6, 4);
|
||||||
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
|
expect(Object.keys(deck.cards).length).toBe(0);
|
||||||
|
expect(deck.regions.drawPile.childIds).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should place all cards in draw pile initially", () => {
|
||||||
|
const inv = createTestInventory();
|
||||||
|
const deck = generateDeckFromInventory(inv);
|
||||||
|
|
||||||
|
for (const cardId of deck.regions.drawPile.childIds) {
|
||||||
|
expect(deck.cards[cardId]).toBeDefined();
|
||||||
|
}
|
||||||
|
|
||||||
|
// All cards are in draw pile
|
||||||
|
expect(new Set(deck.regions.drawPile.childIds).size).toBe(
|
||||||
|
Object.keys(deck.cards).length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createDeckRegions", () => {
|
||||||
|
it("should create regions for all deck zones", () => {
|
||||||
|
const regions = createDeckRegions();
|
||||||
|
|
||||||
|
expect(regions.drawPile.id).toBe("drawPile");
|
||||||
|
expect(regions.hand.id).toBe("hand");
|
||||||
|
expect(regions.discardPile.id).toBe("discardPile");
|
||||||
|
expect(regions.exhaustPile.id).toBe("exhaustPile");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("should have empty childIds initially", () => {
|
||||||
|
const regions = createDeckRegions();
|
||||||
|
|
||||||
|
expect(regions.drawPile.childIds).toEqual([]);
|
||||||
|
expect(regions.hand.childIds).toEqual([]);
|
||||||
|
expect(regions.discardPile.childIds).toEqual([]);
|
||||||
|
expect(regions.exhaustPile.childIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("createPlayerDeck", () => {
|
||||||
|
it("should create an empty deck structure", () => {
|
||||||
|
const deck = createPlayerDeck();
|
||||||
|
|
||||||
|
expect(deck.cards).toEqual({});
|
||||||
|
expect(deck.regions.drawPile.childIds).toEqual([]);
|
||||||
|
expect(deck.regions.hand.childIds).toEqual([]);
|
||||||
|
expect(deck.regions.discardPile.childIds).toEqual([]);
|
||||||
|
expect(deck.regions.exhaustPile.childIds).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user