Compare commits

...
86 Commits
Author SHA1 Message Date
hypercross 9ed23295ae build: include samples in prepare script 2026-04-28 16:52:03 +08:00
hypercross ca86bfc427 refactor: use effect for assignSelector in BaseGameClient
Replace subscribe with effect in assignSelector to ensure the
selector value is tracked and the callback is executed within
the reactive context.
2026-04-28 15:50:00 +08:00
hypercross 09c3fc443b feat(core): wrap prompt middleware in try-finally block
Ensure the prompt status is cleared even if the middleware chain
throws an error.
2026-04-28 15:29:08 +08:00
hypercross a0b2003c65 refactor(samples/tic-tac-toe): simplify Client implementation
Remove generic EntityMap and TriggerMap types in favor of explicit
selector methods. This simplifies the Client class by removing
unnecessary interface implementations and conditional logic in
selectors.
2026-04-25 17:58:16 +08:00
hypercross 03db1e8a06 refactor: simplify GameClient API using signals
Replaces the middleware-based `useClient` system with a more direct
reactive API using Preact signals.

- Replace `_setStatus` middleware with a `_status` signal
- Replace `useClient` with `selectPrompt`, `selectStatus`, and
  `selectResult`
- Implement `selectPrompt` to provide a `tryAnswer` function for
  handling prompt resolutions
- Add `_result` signal to track game completion results
- Simplify `setStatus` logic to manage lifecycle and state resets
2026-04-25 17:54:21 +08:00
hypercross 20993d3b72 feat: add BaseGameClient for external integration
Introduces `BaseGameClient` to allow external environments (like C#)
to interact with the game engine. It provides mechanisms for:
- Reactive state selection via `select`
- Async event interception via `use`
- Lifecycle management and status tracking
- Interruption handling for async game loops

Updated `middleware.ts` to allow unregistering middleware via the
returned function from `use`.
2026-04-25 17:31:15 +08:00
hypercross 59391c74d9 refactor(samples): use produceAsync in tic-tac-toe
Update the tic-tac-toe sample to use `produceAsync` instead of `produce`
to ensure state mutations correctly await interruption promises.
2026-04-25 15:58:10 +08:00
hyper fb3c98b1ef refactor: update GameHost to use generic state and result types
Decouple GameHost from GameState and GameResult types by introducing
generic parameters for TState and TResult. This improves type safety
and flexibility when defining GameModules.
2026-04-23 16:03:49 +08:00
hyper 5c1ef232fd export GameState and GameResult types from game-host 2026-04-23 15:36:25 +08:00
hyper f5df1c26dc refactor: remove registry from Boop and Regicide index files 2026-04-23 15:29:14 +08:00
hyper 2014162819 refactor: update Regicide commands and improve code style 2026-04-23 15:28:45 +08:00
hyper d932c2f5e2 refactor: remove Boop command registry and simplify exports
Remove the command registry from the Boop sample and export command
functions directly. This simplifies the command structure and replaces
dynamic imports with static imports.
2026-04-23 15:21:13 +08:00
hyper 1bd49c32e0 refactor: format Onitama commands and Tic-Tac-Toe imports 2026-04-23 15:18:37 +08:00
hyper b009189b9a Extract PromptContext from GameHost into separate module
The prompt system is now managed by a dedicated PromptContext instead of
being embedded in GameHost. This simplifies the API by removing
individual prompt signals and consolidating prompt logic into a reusable
context that can be shared between components.
2026-04-23 15:09:06 +08:00
hyper 59b99d2042 Fix command prompt to properly return promise results
Add comprehensive tests for createPromptContext covering prompt,
tryCommit, cancel, and handleCall middleware.
2026-04-23 14:44:24 +08:00
hyper 32408c3cee feat: add command prompt context with middleware support 2026-04-23 14:22:17 +08:00
hypercross 23ac09ff21 refactor: simplify GameHost constructor to use GameModule
Update `GameHost` to accept a `GameModule` directly instead of
individual parameters. This simplifies the `createGameHost` factory
and improves type safety by preserving the module type.

Also apply `readonly` modifiers to `ContentModule` in the
slay-the-spire-like sample.
2026-04-23 10:37:28 +08:00
hypercross e8c995f74f feat: add spawner utility
Introduce `createSpawner` and `spawnerEffect` to manage the lifecycle
of side effects based on a collection of IDs. `spawnerEffect` uses
Preact signals to automatically trigger spawners when IDs are added
to an iterable and cleanup functions when they are removed.
2026-04-23 10:20:13 +08:00
hypercross bbab3cc43b feat(slay-the-spire-like): implement CombatGameHost
Introduce CombatGameHost to manage the combat lifecycle and
integrate ContentModule triggers with the combat run context.
2026-04-23 09:45:56 +08:00
hypercross 2e2ddebec4 refactor(slay-the-spire-like): simplify desert trigger registration
Remove the intermediate `effect.ts` file and consolidate the
`addTriggers` logic directly into the desert triggers index file.
2026-04-23 09:37:05 +08:00
hypercross 0d7a84edee test: update desert triggers test to use onEffectApplied 2026-04-23 01:04:27 +08:00
hypercross bedde1ef4b refactor(slay-the-spire-like): move spike damage logic to instant
effects
2026-04-23 00:58:15 +08:00
hypercross 359e7b7485 fix(slay-the-spire-like): pass correct sourceEntityId in instant
triggers

Simplify the sourceEntityId logic to use the context value directly.
2026-04-23 00:53:05 +08:00
hypercross 63e55a828e refactor: rename entityKey to entityId in combat triggers
Rename `entityKey` and `sourceEntityKey` to `entityId` and
`sourceEntityId` across the slay-the-spire-like sample to improve
naming consistency with the rest of the project.
2026-04-23 00:38:10 +08:00
hypercross 8ec95cbf81 refactor: rename 'user' target to 'source' in slay-the-spire-like
Updates the card effect and intent target types from 'user' to 'source'
within the desert data files and combat effect system to maintain
consistency. Also reformat command tests to use 2-space indentation.
2026-04-23 00:35:28 +08:00
hypercross f4c96a4a72 refactor: update turn start triggers in desert sample
Fixes logic in `defendNext` to ensure effects are applied after
buff updates by calling `next()` before mutation. Also updates
indentation and formatting to match project style guidelines.
2026-04-22 20:13:31 +08:00
hypercross a5e2e4888e refactor(slay-the-spire-like): update combat targeting and effect logic
Refactor combat targeting types and effect application logic in the
Slay the Spire-like sample.

- Update `getEffectTargets` to use `IntentEffectTarget` and more
  descriptive target keys (e.g., `eachEnemy`, `randomEnemy`).
- Update `promptMainAction` to use `enemy` instead of `single` for
  card target types.
- Refactor `addInstantEffectTriggers` to remove unused effect loading
  and improve enemy/card instantiation logic
2026-04-22 19:35:37 +08:00
hypercross 38fd46618e refactor(slay-the-spire-like): use data definitions in system types
Remove redundant type definitions in `system/types.ts` and instead
import them from the generated CSV declaration files. Also update
declaration files to use single quotes for consistency with project
style rules.
2026-04-22 19:26:58 +08:00
hypercross 72647a8268 refactor(samples): export desert data types
Export type definitions from desert CSV declaration files and
update the content module to use these exported types instead of
generic system types.
2026-04-22 16:48:54 +08:00
hypercross dbbbba14e2 refactor(slay-the-spire-like): update card effect and intent schemas
Refactor the data schemas for desert card effects and intents to use
dedicated type aliases for triggers, targets, and effect lists. This
improves type safety and consistency across the sample data.
2026-04-22 16:13:48 +08:00
hypercross 888b7b822d refactor(slay-the-spire-like): extract types in desert data schemas 2026-04-22 16:00:15 +08:00
hypercross bb4528cd27 refactor(samples): update desert card and effect schemas 2026-04-22 15:59:41 +08:00
hypercross 877cc9d779 refactor: update schema definitions in slay-the-spire-like data
Refactor CSV schema headers in the desert sample to use named type
aliases instead of inline union strings. This improves readability and
maintains consistency with the project's use of `inline-schema`.

Changes include:
- Updating `card.csv` to use `CardType`, `CardCostType`, and
  `CardTargetType`.
- Updating `encounter.csv` to use `EncounterType` and `EnemyList`.
- Updating `intent.csv` to use `IntentEffectTarget` and replacing
  `self` with `user` for effect targets.
2026-04-22 15:57:52 +08:00
hypercross 2eec668851 refactor: update damage triggers and combat start logic
- Update `defend` effect lifecycle to `temporary` in desert data
- Refactor `onDamage` triggers to improve readability and logic flow
- Implement shuffle and draw actions on `onCombatStart` trigger
2026-04-22 11:06:19 +08:00
hypercross 5235ba7def feat(slay-the-spire-like): support source entity in effect targeting
Update `getEffectTargets` to accept a `sourceEntityKey`. This allows
"self" targets to correctly resolve to the entity triggering the
effect (e.g., a player or a specific enemy) rather than defaulting
to a hardcoded player reference.
2026-04-22 08:56:06 +08:00
hypercross 6cb0626f05 refactor: update produce signature to return undefined
Change the return type of `produce` and `produceAsync` from `void` to
`undefined` in `MutableSignal` and `IGameContext` to more accurately
reflect that the mutation functions should not return a value.
2026-04-22 01:13:39 +08:00
hypercross 270b9b744e refactor(slay-the-spire-like): rename getNeighborItems to
getAdjacentItems
2026-04-21 23:22:19 +08:00
hypercross 113d240f71 refactor: simplify item effect handling in grid inventory
Update `getItemEffects` to directly use `startEffects` from item meta
instead of performing a manual lookup against the effect table. This
is made possible by updating `GameItemMeta` to store the full effect
object (data and stacks) instead of just the effect ID and stack count.
2026-04-21 23:17:19 +08:00
hypercross 0547180074 feat(slay-the-spire-like): add getItemEffects to grid inventory 2026-04-21 23:15:02 +08:00
hypercross d1605a9ee3 feat(samples/slay-the-spire-like): export data module 2026-04-21 23:02:27 +08:00
hypercross 093738cd42 test: expand shape-utils coverage and refactor style 2026-04-21 23:01:26 +08:00
hypercross 97ff61985a refactor(slay-the-spire-like): clean up barrel exports and formatting
- Convert explicit barrel exports to `export *` patterns in several
  systems
- Create a new `utils/index.ts` barrel for the system utils
- Rename `validatePlacement` to `validateShapePlacement` for clarity
- Reformat `shape-collision.ts` to use 2-space indentation and
  consistent
  styling
- Fix import paths in `index.ts`
2026-04-21 23:01:19 +08:00
hypercross 03d367c7b0 feat(samples/slay-the-spire-like): export system types 2026-04-21 22:58:17 +08:00
hypercross b509284bc6 refactor(slay-the-spire-like): simplify exports and reorganize system
Clean up the sample's entry point by using barrel exports and move
encounter logic into the grid-inventory factory to reduce duplication
and improve module structure.
2026-04-21 22:57:28 +08:00
hypercross 9b20f7ad6f refactor(slay-the-spire-like): reorganize combat system files
Move combat factory to a dedicated combat directory and update
`buildCombatState` to use `IPlayerState` and `IPlayerDeck` instead of
deriving them from `RunState` and `GridInventory`.
2026-04-21 22:35:14 +08:00
hypercross 2823089f06 feat(samples): update card effect schema in desert data
Update the `card.csv` schema to use `inject effects` instead of
`effects`
to match the expected property name for card effects.
2026-04-21 22:21:28 +08:00
hypercross f834e15412 refactor(slay-the-spire-like): update deck and inventory types
Refactor the player deck and inventory structures to use more explicit
interfaces and improve type safety for combat entities.
2026-04-21 22:17:31 +08:00
hypercross 2ec0e323c4 test: fix type assertion in grid-inventory tests 2026-04-20 16:32:57 +08:00
hypercross 140b7aed86 test: add createItemIn tests for grid-inventory 2026-04-20 16:28:14 +08:00
hypercross ca9912a5f5 feat(slay-the-spire-like): add automatic item placement to grid
inventory

Introduce `createItemIn` to automatically find the first valid
placement for a new item within the grid inventory.
2026-04-20 16:24:33 +08:00
hypercross 34cb828f60 test: refactor and format test files
- Update `game.test.ts` to use `_ctx` when `this` is not required
- Fix type assertion in `boop.test.ts`
- Update `command-runner.test.ts` to use `parseCommandSchema` in prompt
  tests
- Reformat `middleware.test.ts` to use double quotes and consistent
  indentation
2026-04-20 15:50:32 +08:00
hypercross 974c1a828c test: reformat and expand test coverage 2026-04-20 15:37:15 +08:00
hypercross b83ff28f60 style: format tic-tac-toe sample and boop tests 2026-04-20 15:19:27 +08:00
hypercross 0c94e6923a test: reformat tests to use double quotes and improve style 2026-04-20 15:16:03 +08:00
hypercross d5ec099540 feat(slay-the-spire-like): implement encounter system
Add logic for handling different encounter types including combat,
shop, curio, camp, and events. Includes shop item generation and
grid inventory placement for shop encounters.
2026-04-20 14:57:03 +08:00
hypercross 52b6cecd64 refactor(slay-the-spire-like): update GameItemMeta import path and
reformat tests

Update imports for `GameItemMeta` from `progress/types` to
`encounter/types`
in combat and deck tests. Reformat `deck/factory.test.ts` to use double
quotes and consistent indentation.
2026-04-20 13:01:40 +08:00
hypercross f336a989e2 refactor(slay-the-spire-like): restructure encounter and progress
systems

Refactor the sample game logic by moving encounter-related logic from
the monolithic `progress` system into a dedicated `encounter` module.
This includes:

- Moving combat state construction and run state management to
  `system/encounter`.
- Decoupling player stats and inventory from the general progress
  logic.
- Improving type safety for `EncounterData` using generics.
- Adding navigation helpers to the map system.
- Cleaning up imports and improving code formatting.
2026-04-20 13:00:30 +08:00
hypercross 423cc7c841 feat(slay-the-spire-like): add map navigation logic
Implement `canMoveTo` and `moveToNode` for navigating the point
crawl map, and add `PointCrawlMapNavigator` type. Also reformat
map module files to use double quotes.
2026-04-20 11:59:52 +08:00
hypercross 88d31430a6 test: refactor slay-the-spire-like combat tests
Update combat effect tests to use the new transform offset structure
and fix type mismatches in entity and inventory creation.
2026-04-20 11:37:18 +08:00
hypercross 90cb97e0ae chore: add @types/node and update tsconfig
Add @types/node as a dev dependency and include it in tsconfig types
to support Node.js globals. Also expand tsconfig include to cover
configuration files.
2026-04-20 11:34:40 +08:00
hypercross 5e172c61bb refactor(slay-the-spire-like): use IRunContext in combat system
Update the combat system to use `IRunContext` instead of passing
raw inventory objects. This provides a cleaner abstraction for
accessing item data and managing consumed uses.

- Update `canPlayCard` and `payCardCost` to use `IRunContext`
- Pass `IRunContext` through the trigger registration chain
- Fix logic in `canPlayCard` for "uses" cost validation
- Update combat effect tests to use a mock `IRunContext`
2026-04-20 11:25:52 +08:00
hypercross 5019bc6324 refactor(slay-the-spire-like): use IRunContext for combat logic
Decouple combat systems from the inventory and progress state by
introducing `IRunContext`. This replaces direct access to
`GridInventory` and `GameItemMeta` with abstract methods for
retrieving item data, neighbors, and managing consumed uses.
2026-04-20 11:03:44 +08:00
hypercross 9bed2ca13e refactor(slay-the-spire-like): rename depletion to consumedUses
Rename `depletion` to `consumedUses` in `GameItemMeta` to better reflect
its purpose. Update combat effect logic and tests to use the new field
name. Also apply consistent formatting and import organization to
combat effect modules.
2026-04-20 10:28:58 +08:00
hypercross 08c6a67d16 refactor: use English IDs for desert card effects
Replace Chinese card names with English IDs in the desert card effect
CSV to ensure consistency with the card identifiers. Update tests to
verify the data import using the new English identifiers.
2026-04-20 00:23:41 +08:00
hypercross dda8f4cfe9 style: reformat card-events.ts with 2-space indentation 2026-04-20 00:00:42 +08:00
hypercross 2f2e4e56b5 refactor: decouple card effects from card data in desert sample
Moves card effects from `card.csv` to a dedicated `cardEffect.csv` file.
This allows for more granular control over card triggers (onPlay,
onDraw, onDiscard) and targets, improving the data model for the
slay-the-spire-like sample. Also updates triggers and tests to
reflect this new structure.
2026-04-20 00:00:41 +08:00
hypercross 3840c3d739 test: expand slay-the-spire-like map generator tests 2026-04-20 00:00:41 +08:00
hypercross 43bb246ab9 build: set rootDir to current directory in tsconfig 2026-04-20 00:00:41 +08:00
hypercross 25b44fd6d1 build: update tsconfig and tsup sample configuration
- Include tests in tsconfig for better type checking
- Update tsup.samples.config.ts to use double quotes
- Add esbuild alias for `@` in sample builds
2026-04-20 00:00:41 +08:00
hypercross 601eb0f417 refactor: reformat code and introduce IGameContextExport
- Reformat `src/core/game.ts` and sample types to use 2-space
  indentation
- Add `IGameContextExport` to hide internal test properties
- Update `CombatGameContext` to use the exported context type
2026-04-20 00:00:41 +08:00
hyper a82b6b0685 docs: fleshed out agents.md 2026-04-19 18:36:10 +08:00
hypercross 1e1d04777f refactor: reformat code and fix type signatures in GameHost
Reformat `src/core/game-host.ts` to use double quotes and consistent
spacing. Update `createGameHost` to correctly propagate the `TResult`
generic from `GameModule`.
2026-04-19 15:44:59 +08:00
hypercross 8142fbfa60 refactor: simplify buildCombatState and fix formatting in encounter
system

Refactor `buildCombatState` to derive encounter data directly from
`runState` instead of requiring it as an argument. Also apply
consistent 2-space indentation and formatting to the encounter
lifecycle module.
2026-04-19 15:42:47 +08:00
hypercross 89d96d838b refactor(slay-the-spire-like): wrap content module getters in functions 2026-04-19 15:23:17 +08:00
hypercross 6b50e9014a feat(samples/slay-the-spire-like): make ContentModule required 2026-04-19 15:22:45 +08:00
hypercross 39ac37b5f2 style: format slay-the-spire-like index with double quotes 2026-04-19 15:22:02 +08:00
hypercross b264cea305 refactor(slay-the-spire-like): update data export pattern
Convert static data exports to getter functions and update the
ContentModule type to reflect these changes.
2026-04-19 15:20:31 +08:00
hypercross 6577897a42 feat(samples/slay-the-spire-like): add ContentModule type definition 2026-04-19 15:10:17 +08:00
hypercross 4e9e426519 feat(samples): add Item type to desert data
Export the Item type from item.csv and add explicit typing to
startingItems in the desert sample data.
2026-04-19 14:47:58 +08:00
hypercross 4f509da373 docs(samples): update intent.csv comment style 2026-04-19 14:39:54 +08:00
hypercross 63733bc62d refactor: update imports and add starting items to desert sample
- Convert single quotes to double quotes for local imports
- Reformat barrel exports in `src/index.ts` for better readability
- Add `startingItems` to the desert sample data in `slay-the-spire-like`
2026-04-19 13:51:03 +08:00
hyper 3aa92b2d2a refactor: rename that to desert triggers 2026-04-18 15:28:47 +08:00
hyper fa92b5d865 refactor: reorganize trigger code 2026-04-18 15:08:50 +08:00
hyper e46822b45b refactor: avoid globalThis 2026-04-18 14:40:56 +08:00
hyper 03add589c6 refactor: use stacks for the hp of summons 2026-04-18 14:30:20 +08:00
hypercross 4f5747d81d chore: tests for desert triggers 2026-04-17 18:07:49 +08:00
83 changed files with 10047 additions and 8097 deletions
+28 -7
View File
@@ -36,15 +36,36 @@ Run tests matching a name: `npx vitest run -t "should create"`
``` ```
src/ src/
core/ # game.ts, game-host.ts, part.ts, part-factory.ts, region.ts index.ts # Barrel export (single public API)
samples/ # tic-tac-toe.ts, boop/, onitama/, regicide/, slay-the-spire-like/ core/
utils/ # mutable-signal.ts, rng.ts, async-queue.ts, command/ (8 files) game.ts # Game core logic
index.ts # barrel export game-host.ts # GameHost lifecycle manager
global.d.ts # *.yarnproject module declaration part.ts # Part entity
tests/ # mirrors src/ with *.test.ts 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/utils/command/` is a directory: types.ts, command-parse.ts, command-registry.ts, command-runner.ts, command-validate.ts, command-apply.ts, schema-parse.ts, index.ts. `src/global.d.ts` — `*.yarnproject` module declaration.
`testsgames/` and `testsrules/` — empty directories (reserved).
## Samples Build ## Samples Build
+18
View File
@@ -10,6 +10,7 @@
"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",
@@ -958,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",
@@ -2469,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",
+2 -1
View File
@@ -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,6 +30,7 @@
}, },
"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",
+118
View File
@@ -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);
});
}
}
+102 -158
View File
@@ -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
this._activePromptHint = new Signal<string | null>(null); */
this.activePromptHint = this._activePromptHint; clearInterruptions(): void {
this._context._state.clearInterruptions();
}
this._setupPromptTracking(); start(seed?: number): Promise<TResult> {
if (this._isDisposed) {
throw new Error("GameHost is disposed");
} }
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 this._context._commands._tryCommit({ return () => {
name: def.schema.name, this._eventListeners.get(event)?.delete(listener);
params: args, };
options: {}, }
flags: {}
}); private _emitEvent(event: "start" | "dispose") {
} const listeners = this._eventListeners.get(event);
if (listeners) {
/** for (const listener of listeners) {
* 为下一个 produceAsync 注册中断 Promise(通常用于 UI 动画)。 listener();
* @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
);
} }
+55 -71
View File
@@ -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,
// test only ) => Promise<TResult>;
_state: MutableSignal<TState>;
_commands: CommandRunnerContextExport<IGameContext<TState>>; // test only
_rng: RNG; _state: MutableSignal<TState>;
_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,
_commands: commands,
_rng: new Mulberry32RNG(),
};
context._commands = commands = createCommandRunnerContext(commandRegistry, context); _state: state,
_rng: new Mulberry32RNG(),
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> {
schema = typeof schema === "string" ? parseCommandSchema(schema) : schema;
return { schema, hintText };
} }
export function createPromptDef<TArgs extends any[]=any[]>(schema: CommandSchema | string, hintText?: string): PromptDef<TArgs> {
schema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
return { schema, hintText };
}
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
return createCommandRegistry<IGameContext<TState>>();
}
+61 -20
View File
@@ -4,34 +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 type {MiddlewareChain} from './utils/middleware' export type { MiddlewareChain } from "./utils/middleware";
export { createMiddlewareChain } from './utils/middleware'; export { createMiddlewareChain } from "./utils/middleware";
export * from '@preact/signals-core'; export * from "./utils/spawner";
export * from "@preact/signals-core";
+22 -92
View File
@@ -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
View File
@@ -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";
+311 -281
View File
@@ -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,
const error = isValidMove(state, cardName, fromX, fromY, toX, toY, player); fromX: number,
if (error) { fromY: number,
throw new Error(error); toX: number,
} toY: number,
) {
const capturedPawnId = getPawnAtPosition(state, toX, toY)?.id || null; const state = game.value;
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);
}
// 移动棋子到目标位置
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>', const error = isValidMove(state, cardName, fromX, fromY, toX, toY, player);
run: handleMove if (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);
}
// 移动棋子到目标位置
moveToRegion(pawn, state.regions.board, state.regions.board, [toX, toY]);
});
// 交换卡牌
await swapCard(game, player, cardName);
return {
from: { x: fromX, y: fromY },
to: { x: toX, y: toY },
card: cardName,
captured: capturedPawnId,
};
}
/** /**
* 交换卡牌:将使用的卡牌与备用卡牌交换 * 交换卡牌:将使用的卡牌与备用卡牌交换
*/ */
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;
if (player === 'red') { const usedCardData = state.cards[usedCard];
state.redCards = state.redCards.filter(c => c !== usedCard); const spareCardData = state.cards[spareCard];
state.redCards.push(spareCard);
} else {
state.blackCards = state.blackCards.filter(c => c !== usedCard);
state.blackCards.push(spareCard);
}
// 更新卡牌区域
usedCardData.regionId = 'spare';
spareCardData.regionId = player;
// 更新备用卡牌
state.spareCard = usedCard;
});
}
const swapCard = registry.register({ // 从玩家手牌中移除使用的卡牌
schema: 'swap-card <player> <card:string>', if (player === "red") {
run: handleSwapCard state.redCards = state.redCards.filter((c) => c !== usedCard);
}); state.redCards.push(spareCard);
} else {
state.blackCards = state.blackCards.filter((c) => c !== usedCard);
state.blackCards.push(spareCard);
}
// 更新卡牌区域
usedCardData.regionId = "spare";
spareCardData.regionId = player;
// 更新备用卡牌
state.spareCard = usedCard;
});
}
/** /**
* 检查占领胜利条件:玩家的师父棋子到达对手的初始位置 * 检查占领胜利条件:玩家的师父棋子到达对手的初始位置
* 红色师父需要到达 (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'];
if (!redMaster || redMaster.regionId !== 'board') {
return 'black';
}
// 黑色师父不在棋盘上,红色获胜
const blackMaster = state.pawns['black-master'];
if (!blackMaster || blackMaster.regionId !== 'board') {
return 'red';
}
return null;
}
const checkCaptureWin = registry.register({ // 红色师父不在棋盘上,黑色获胜
schema: 'check-capture-win', const redMaster = state.pawns["red-master"];
run: handleCheckCaptureWin if (!redMaster || redMaster.regionId !== "board") {
}); return "black";
}
// 黑色师父不在棋盘上,红色获胜
const blackMaster = state.pawns["black-master"];
if (!blackMaster || blackMaster.regionId !== "board") {
return "red";
}
return null;
}
/** /**
* 综合胜利检测 * 综合胜利检测
*/ */
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);
if (captureWinner) {
return captureWinner;
}
return null;
}
const checkWin = registry.register({ const captureWinner = await checkCaptureWin(game);
schema: 'check-win', if (captureWinner) {
run: handleCheckWin return captureWinner;
}); }
return null;
}
/** /**
* 获取玩家可用的移动 * 获取玩家可用的移动
*/ */
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;
if (availableMoves.length === 0) {
// 没有可用移动,玩家必须交换一张卡牌
const cardToSwap = await game.prompt(
prompts.move,
(player, card, _fromX, _fromY, _toX, _toY) => {
if (player !== 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++;
}
});
return { winner, move: moveOutput };
}
const turn = registry.register({ let moveOutput;
schema: 'turn <player>',
run: handleTurn if (availableMoves.length === 0) {
}); // 没有可用移动,玩家必须交换一张卡牌
const cardToSwap = await game.prompt(
prompts.move,
(player, card, _fromX, _fromY, _toX, _toY) => {
if (player !== 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++;
}
});
return { winner, move: moveOutput };
}
/** /**
* 开始游戏主循环 * 开始游戏主循环
*/ */
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;
} }
+342 -346
View File
@@ -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
View File
@@ -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,
};
} }
+45 -49
View File
@@ -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";
+68
View File
@@ -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
@@ -2,39 +2,42 @@
# type: 'item' = inventory item card, 'status' = status effect card # type: 'item' = inventory item card, 'status' = status effect card
# costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free # costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
# targetType: 'single' = target one enemy, 'none' = no target # targetType: 'single' = target one enemy, 'none' = no target
# onPlay: effects triggered when card is played
# onDraw: effects triggered when card enters hand
# onDiscard: effects triggered when card is discarded
id,name,desc,type,costType,costCount,targetType,effects # type CardType = 'item' | 'status'
string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',['onPlay'|'onDraw'|'onDiscard';'self'|'target'|'all'|'random';@effect;number][] # type CardCostType = 'energy' | 'uses' | 'none'
sword,剑,【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2] # type CardTargetType = 'player' | 'enemy' | 'enemies'
greataxe,长斧,对全体【攻击5】,item,energy,2,none,[onPlay;all;attack;5]
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2];[onPlay;target;attack;2] # inject effects = ~cardEffect(card)
dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,[onPlay;target;attack;3];[onPlay;target;attack;3]
dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,[onPlay;target;attack;1];[onPlay;self;draw;1] id,name,desc,type,costType,costCount,targetType
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,[onPlay;target;attack;6];[onPlay;self;crossbow;0] string,string,string,CardType,CardCostType,int,CardTargetType
shield,盾,【防御3】,item,energy,1,none,[onPlay;self;defend;3] sword,剑,【攻击2】【攻击2】,item,energy,1,enemy
hat,斗笠,【防御8】,item,energy,2,none,[onPlay;self;defend;8] greataxe,长斧,对全体【攻击5】,item,energy,2,enemies
cape,披风,【防御2】下回合【防御2】,item,energy,1,none,[onPlay;self;defend;2];[onPlay;self;defendNext;2] spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,enemy
bracer,护腕,【防御1】抓1张牌,item,energy,0,none,[onPlay;self;defend;1];[onPlay;self;draw;1] dagger,短刀,【攻击3】【攻击3】,item,energy,1,enemy
greatshield,大盾,【防御5】,item,energy,1,none,[onPlay;self;defend;5] dart,飞镖,【攻击1】抓一张牌,item,energy,0,enemy
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,[onPlay;self;damageReduce;3] crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,enemy
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,[onPlay;self;removeWound;1] shield,盾,【防御3】,item,energy,1,player
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,[onPlay;self;attackBuff;2] hat,斗笠,【防御8】,item,energy,2,player
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,[onPlay;self;defendBuff;2] cape,披风,【防御2】下回合【防御2】,item,energy,1,player
vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,[onPlay;self;gainEnergy;1] bracer,护腕,【防御1】抓1张牌,item,energy,0,player
focusPotion,集中药剂,抓2张牌,item,uses,3,none,[onPlay;self;draw;2] greatshield,大盾,【防御5】,item,energy,1,player
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,[onPlay;self;removeWound;3] chainmail,锁子甲,本回合受到伤害-3,item,energy,1,player
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,[onPlay;self;energyNext;1];[onPlay;self;drawNext;2] bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,player
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,[onPlay;self;defendBuffUntilPlay;2] poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,player
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,[onPlay;self;drawChoice;1] fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,player
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,[onPlay;self;burnForEnergy;1] vitalityPotion,活力药剂,获得1点能量,item,uses,3,player
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,[onPlay;self;attackBuffUntilPlay;3] focusPotion,集中药剂,抓2张牌,item,uses,3,player
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,[onPlay;self;transformRandom;1] healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,player
wound,伤口,无效果占用手牌和牌堆,status,none,0,none, waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,player
venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,[onDiscard;self;attack;3] rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,player
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,[onDraw;self;curse;1] belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,player
static,静电,在手里时受电击伤害+1,status,none,0,none,[onDraw;self;static;1] torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,player
fatigue,疲劳,占用手牌,status,none,0,none, whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,player
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,[onDraw;self;expose;3] 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
1 # cardDesert: unified card definitions for item cards and status cards
2 # type: 'item' = inventory item card, 'status' = status effect card
3 # costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
4 # targetType: 'single' = target one enemy, 'none' = no target
# onPlay: effects triggered when card is played
# onDraw: effects triggered when card enters hand
# onDiscard: effects triggered when card is discarded
5 id,name,desc,type,costType,costCount,targetType,effects # type CardType = 'item' | 'status'
6 string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',['onPlay'|'onDraw'|'onDiscard';'self'|'target'|'all'|'random';@effect;number][] # type CardCostType = 'energy' | 'uses' | 'none'
7 sword,剑,【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2] # type CardTargetType = 'player' | 'enemy' | 'enemies'
8 greataxe,长斧,对全体【攻击5】,item,energy,2,none,[onPlay;all;attack;5] # inject effects = ~cardEffect(card)
9 spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2];[onPlay;target;attack;2] id,name,desc,type,costType,costCount,targetType
10 dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,[onPlay;target;attack;3];[onPlay;target;attack;3] string,string,string,CardType,CardCostType,int,CardTargetType
11 dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,[onPlay;target;attack;1];[onPlay;self;draw;1] sword,剑,【攻击2】【攻击2】,item,energy,1,enemy
12 crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,[onPlay;target;attack;6];[onPlay;self;crossbow;0] greataxe,长斧,对全体【攻击5】,item,energy,2,enemies
13 shield,盾,【防御3】,item,energy,1,none,[onPlay;self;defend;3] spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,enemy
14 hat,斗笠,【防御8】,item,energy,2,none,[onPlay;self;defend;8] dagger,短刀,【攻击3】【攻击3】,item,energy,1,enemy
15 cape,披风,【防御2】下回合【防御2】,item,energy,1,none,[onPlay;self;defend;2];[onPlay;self;defendNext;2] dart,飞镖,【攻击1】抓一张牌,item,energy,0,enemy
16 bracer,护腕,【防御1】抓1张牌,item,energy,0,none,[onPlay;self;defend;1];[onPlay;self;draw;1] crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,enemy
17 greatshield,大盾,【防御5】,item,energy,1,none,[onPlay;self;defend;5] shield,盾,【防御3】,item,energy,1,player
18 chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,[onPlay;self;damageReduce;3] hat,斗笠,【防御8】,item,energy,2,player
19 bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,[onPlay;self;removeWound;1] cape,披风,【防御2】下回合【防御2】,item,energy,1,player
20 poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,[onPlay;self;attackBuff;2] bracer,护腕,【防御1】抓1张牌,item,energy,0,player
21 fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,[onPlay;self;defendBuff;2] greatshield,大盾,【防御5】,item,energy,1,player
22 vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,[onPlay;self;gainEnergy;1] chainmail,锁子甲,本回合受到伤害-3,item,energy,1,player
23 focusPotion,集中药剂,抓2张牌,item,uses,3,none,[onPlay;self;draw;2] bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,player
24 healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,[onPlay;self;removeWound;3] poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,player
25 waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,[onPlay;self;energyNext;1];[onPlay;self;drawNext;2] fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,player
26 rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,[onPlay;self;defendBuffUntilPlay;2] vitalityPotion,活力药剂,获得1点能量,item,uses,3,player
27 belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,[onPlay;self;drawChoice;1] focusPotion,集中药剂,抓2张牌,item,uses,3,player
28 torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,[onPlay;self;burnForEnergy;1] healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,player
29 whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,[onPlay;self;attackBuffUntilPlay;3] waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,player
30 blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,[onPlay;self;transformRandom;1] rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,player
31 wound,伤口,无效果占用手牌和牌堆,status,none,0,none, belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,player
32 venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,[onDiscard;self;attack;3] torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,player
33 curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,[onDraw;self;curse;1] whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,player
34 static,静电,在手里时受电击伤害+1,status,none,0,none,[onDraw;self;static;1] blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,player
35 fatigue,疲劳,占用手牌,status,none,0,none, wound,伤口,无效果占用手牌和牌堆,status,none,0,player
36 vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,[onDraw;self;expose;3] venom,蛇毒,弃掉时受到3点伤害,status,none,0,player
37 curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,player
38 static,静电,在手里时受电击伤害+1,status,none,0,player
39 fatigue,疲劳,占用手牌,status,none,0,player
40 vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,player
41
42
43
+9 -5
View File
@@ -1,14 +1,18 @@
import type { Effect } from './effect.csv'; 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 { type CardTable = readonly {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly desc: string; readonly desc: string;
readonly type: "item" | "status"; readonly type: CardType;
readonly costType: "energy" | "uses" | "none"; readonly costType: CardCostType;
readonly costCount: number; readonly costCount: number;
readonly targetType: "single" | "none"; readonly targetType: CardTargetType;
readonly effects: ["onPlay" | "onDraw" | "onDiscard", "self" | "target" | "all" | "random", Effect, number][]; readonly effects: CardEffect[];
}[]; }[];
export type Card = CardTable[number]; export type Card = CardTable[number];
@@ -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]
1 # type CardEffectTrigger = 'onPlay' | 'onDraw' | 'onDiscard'
2 # type CardEffectTarget = 'source' | 'eachTarget' | 'eachEnemy' | 'randomEnemy' | 'player'
3 # type CardEffectList = [effect: @effect; stacks: number][]
4 id,card,trigger,target,effects
5 string,@card,CardEffectTrigger,CardEffectTarget,CardEffectList
6 sword,sword,onPlay,eachTarget,[attack;2];[attack;2]
7 greataxe,greataxe,onPlay,eachTarget,[attack;5]
8 spear,spear,onPlay,eachTarget,[attack;2];[attack;2];[attack;2]
9 dagger,dagger,onPlay,eachTarget,[attack;3];[attack;3]
10 dart,dart,onPlay,eachTarget,[attack;1]
11 dart-draw,dart,onPlay,source,[draw;1]
12 crossbow,crossbow,onPlay,eachTarget,[attack;6]
13 crossbow-combo,crossbow,onPlay,source,[crossbow;0]
14 shield,shield,onPlay,source,[defend;3]
15 hat,hat,onPlay,source,[defend;8]
16 cape,cape,onPlay,source,[defend;2];[defendNext;2]
17 bracer,bracer,onPlay,source,[defend;1];[draw;1]
18 greatshield,greatshield,onPlay,source,[defend;5]
19 chainmail,chainmail,onPlay,source,[damageReduce;3]
20 bandage,bandage,onPlay,source,[removeWound;1]
21 poisonPotion,poisonPotion,onPlay,source,[attackBuff;2]
22 fortifyPotion,fortifyPotion,onPlay,source,[defendBuff;2]
23 vitalityPotion,vitalityPotion,onPlay,source,[gainEnergy;1]
24 focusPotion,focusPotion,onPlay,source,[draw;2]
25 healingPotion,healingPotion,onPlay,source,[removeWound;3]
26 waterBag,waterBag,onPlay,source,[energyNext;1];[drawNext;2]
27 rope,rope,onPlay,source,[defendBuffUntilPlay;2]
28 belt,belt,onPlay,source,[drawChoice;1]
29 torch,torch,onPlay,source,[burnForEnergy;1]
30 whetstone,whetstone,onPlay,source,[attackBuffUntilPlay;3]
31 blacksmithHammer,blacksmithHammer,onPlay,source,[transformRandom;1]
32 venom,venom,onDiscard,source,[attack;3]
33 curse,curse,onDraw,source,[curse;1]
34 static,static,onDraw,source,[static;1]
35 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;
@@ -11,40 +11,42 @@
# itemUntilDiscard: 施加buff到周围物品,物品被弃掉后失效 # itemUntilDiscard: 施加buff到周围物品,物品被弃掉后失效
# itemPermanent: 施加buff到周围物品,持续整场冒险 # itemPermanent: 施加buff到周围物品,持续整场冒险
id, name, description, lifecycle # type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent'
string, string, string, 'instant'|'temporary'|'lingering'|'permanent'|'posture'|'item'|'itemTemporary'|'itemUntilPlay'|'itemUntilDiscard'|'itemPermanent'
attack, 攻击, 对对手造成伤害, instant id, name, description, lifecycle, emoji
defend, 防御, 抵消下次行动前受到的伤害, posture string, string, string, EffectLifecycle, string
spike, 尖刺, 对攻击者造成X点伤害, permanent attack, 攻击, 对对手造成伤害, instant, ⚔️
venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, instant defend, 防御, 抵消下次行动前受到的伤害, temporary, 🛡️
curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, lingering spike, 尖刺, 对攻击者造成X点伤害, permanent, 🌵
aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, instant, 🧪
roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, lingering, 💀
rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture, 🎯
vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), instant roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture, 🎲
tailSting, 尾刺, 攻击时,伤害提升X, posture rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant, 💥
energyDrain, 能量吸取, 受伤时,玩家失去X点能量, lingering vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), instant, 👁️
molt, 脱皮, 若脱皮达到生命上限则怪物逃跑, posture tailSting, 尾刺, 攻击时,伤害提升X, posture, 🦂
discard, 劫掠, 回合开始时随机弃掉一张手牌, lingering energyDrain, 能量吸取, 受伤时,玩家失去X点能量, lingering, 🔋
storm, 风暴, 攻击时给玩家塞入1张静电, permanent molt, 脱皮, 若脱皮达到生命上限则怪物逃跑, posture, 🐚
static, 静电, 在手里时受电击伤害+1, permanent discard, 劫掠, 回合开始时随机弃掉一张手牌, lingering, 🗑️
charge, 冲锋, 受到或造成的伤害翻倍并消耗等量冲锋, lingering storm, 风暴, 攻击时给玩家塞入1张静电, permanent, ⚡
summonMummy, 召唤木乃伊, 召唤1个木乃伊, instant static, 静电, 在手里时受电击伤害+1, permanent, ⚡
summonSandwormLarva, 召唤幼沙虫, 召唤1个幼沙虫, instant charge, 冲锋, 受到或造成的伤害翻倍并消耗等量冲锋, lingering, 🐎
reviveMummy, 复活木乃伊, 复活1个已死亡的木乃伊, instant summonMummy, 召唤木乃伊, 召唤1个木乃伊, instant, 🧟
draw, 抓牌, 抓X张牌, instant summonSandwormLarva, 召唤幼沙虫, 召唤1个幼沙虫, instant, 🐛
crossbow, 十字弩连击, 对同一目标打出其他十字弩, instant reviveMummy, 复活木乃伊, 复活1个已死亡的木乃伊, instant, 🌅
defendNext, 下回合防御, 下回合开始时获得防御, temporary draw, 抓牌, 抓X张牌, instant, 🃏
damageReduce, 减伤, 本回合受到的伤害减少X, temporary crossbow, 十字弩连击, 对同一目标打出其他十字弩, instant, 🏹
removeWound, 移除伤口, 从牌堆或弃牌堆移除X张伤口, instant defendNext, 下回合防御, 下回合开始时获得防御, temporary, 🛡️
attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlay damageReduce, 减伤, 本回合受到的伤害减少X, temporary, 📉
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlay removeWound, 移除伤口, 从牌堆或弃牌堆移除X张伤口, instant, 🩹
gainEnergy, 获得能量, 获得X点能量, instant attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlay, ⬆️
energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlay, ⬆️
drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary gainEnergy, 获得能量, 获得X点能量, instant, ⚡
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlay energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary, ⚡
drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary, 🃏
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlay defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlay, 🛡️
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlay drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant, 🔍
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlay, 🔥
expose, 暴露, 受到的伤害+1/每层, temporary attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlay, ⚔️
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant, 🌀
expose, 暴露, 受到的伤害+1/每层, temporary, 👁️
1 # instant: 不施加buff,瞬间生效
11 id # type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent'
12 string id
13 attack string
14 defend attack
15 spike defend
16 venom spike
17 curse venom
18 aim curse
19 roll aim
20 rollDamage roll
21 vultureEye rollDamage
22 tailSting vultureEye
23 energyDrain tailSting
24 molt energyDrain
25 discard molt
26 storm discard
27 static storm
28 charge static
29 summonMummy charge
30 summonSandwormLarva summonMummy
31 reviveMummy summonSandwormLarva
32 draw reviveMummy
33 crossbow draw
34 defendNext crossbow
35 damageReduce defendNext
36 removeWound damageReduce
37 attackBuff removeWound
38 defendBuff attackBuff
39 gainEnergy defendBuff
40 energyNext gainEnergy
41 drawNext energyNext
42 defendBuffUntilPlay drawNext
43 drawChoice defendBuffUntilPlay
44 burnForEnergy drawChoice
45 attackBuffUntilPlay burnForEnergy
46 transformRandom attackBuffUntilPlay
47 expose transformRandom
48 expose
49
50
51
52
@@ -1,8 +1,11 @@
export type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent';
type EffectTable = readonly { type EffectTable = readonly {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly description: string; readonly description: string;
readonly lifecycle: "instant" | "temporary" | "lingering" | "permanent" | "posture" | "item" | "itemTemporary" | "itemUntilPlay" | "itemUntilDiscard" | "itemPermanent"; readonly lifecycle: EffectLifecycle;
readonly emoji: string;
}[]; }[];
export type Effect = EffectTable[number]; export type Effect = EffectTable[number];
@@ -6,8 +6,11 @@
# curio (8): random pickup of treasure or resources # curio (8): random pickup of treasure or resources
# enemies: array of [enemyId; initialHp; buffs[]] # 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 id,type,name,description,enemies,dialogue
string,'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[data: @enemy; hp: int; effects: [effect: @effect;stacks: int][]][],string string,EncounterType,string,string,EnemyList,string
cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;12;[]];[仙人掌怪;12;[]], cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;12;[]];[仙人掌怪;12;[]],
snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[]], snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[]],
mummy_cactus,minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;14;[]];[仙人掌怪;12;[]], mummy_cactus,minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;14;[]];[仙人掌怪;12;[]],
1 # minion (10): minor enemies
6 # curio (8): random pickup of treasure or resources
7 # enemies: array of [enemyId; initialHp; buffs[]]
8 id,type,name,description,enemies,dialogue # type EncounterType = 'minion' | 'elite' | 'event' | 'shop' | 'camp' | 'curio'
9 # type EnemyList = [data: @enemy; hp: int; effects: [effect: @effect; stacks: int][]][]
10 id
11 string
12 string,'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[data: @enemy; hp: int; effects: [effect: @effect;stacks: int][]][],string cactus_pair
13 cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;12;[]];[仙人掌怪;12;[]], snake_pair
14 snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[]], mummy_cactus
15 mummy_cactus,minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;14;[]];[仙人掌怪;12;[]], gunslinger
16 gunslinger,minion,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】,[枪手;12;[]], tumbleweed_pair
@@ -1,12 +1,15 @@
import type { Enemy } from './enemy.csv'; import type { Enemy } from './enemy.csv';
import type { Effect } from './effect.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 { type EncounterTable = readonly {
readonly id: string; readonly id: string;
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio"; readonly type: EncounterType;
readonly name: string; readonly name: string;
readonly description: string; readonly description: string;
readonly enemies: [data: Enemy, hp: number, effects: [effect: Effect, stacks: number][]][]; readonly enemies: EnemyList;
readonly dialogue: string; readonly dialogue: string;
}[]; }[];
@@ -1,15 +1,25 @@
import getCards from './card.csv'; import getItems, { Item } from "./item.csv";
import getEffects from './effect.csv';
import getEncounters from './encounter.csv';
import getEnemies from './enemy.csv';
import getIntents from './intent.csv';
import getItems from './item.csv';
export { default as dialogues } from './dialogues/dialogues.yarnproject';
export { addTriggers } from './triggers';
export const cards = getCards(); export type * from "./card.csv";
export const effects = getEffects(); export type * from "./cardEffect.csv";
export const encounters = getEncounters(); export type * from "./effect.csv";
export const enemies = getEnemies(); export type * from "./encounter.csv";
export const intents = getIntents(); export type * from "./enemy.csv";
export const items = getItems(); 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;
});
}
@@ -1,4 +1,4 @@
# intentId: unique intent state ID (e.g. "仙人掌怪-boost") # intentId: unique intent state ID (e.g. '仙人掌怪-boost')
# enemy: enemy template this intent belongs to # enemy: enemy template this intent belongs to
# initialIntent: true if this is the enemy's starting intent # initialIntent: true if this is the enemy's starting intent
# nextIntents: possible next intent IDs after this intent resolves # nextIntents: possible next intent IDs after this intent resolves
@@ -6,46 +6,50 @@
# initBuffs: initial buffs for this intent (applied when intent becomes active) # initBuffs: initial buffs for this intent (applied when intent becomes active)
# effects: effects executed when this intent is 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 id,enemy,initialIntent,nextIntents,brokenIntent,effects
string,@enemy,boolean,@intent[],@intent[],['self'|'player'|'team';@effect;number][] string,@enemy,boolean,@intent[],@intent[],IntentEffectList
仙人掌怪-boost,仙人掌怪,true,仙人掌怪-boost;仙人掌怪-defend,,[self;spike;1];[self;defend;4] 仙人掌怪-boost,仙人掌怪,true,仙人掌怪-boost;仙人掌怪-defend,,[source;spike;1];[source;defend;4]
仙人掌怪-defend,仙人掌怪,false,仙人掌怪-attack,,[self;defend;8] 仙人掌怪-defend,仙人掌怪,false,仙人掌怪-attack,,[source;defend;8]
仙人掌怪-attack,仙人掌怪,false,仙人掌怪-boost,,[player;attack;5] 仙人掌怪-attack,仙人掌怪,false,仙人掌怪-boost,,[player;attack;5]
蛇-poison,蛇,true,蛇-attack;蛇-attack,,[player;venom;1];[player;attack;4] 蛇-poison,蛇,true,蛇-attack;蛇-attack,,[player;venom;1];[player;attack;4]
蛇-attack,蛇,false,蛇-poison;蛇-boost,,[player;attack;6] 蛇-attack,蛇,false,蛇-poison;蛇-boost,,[player;attack;6]
蛇-boost,蛇,false,蛇-poison;蛇-attack,,[self;defend;3];[player;venom;1] 蛇-boost,蛇,false,蛇-poison;蛇-attack,,[source;defend;3];[player;venom;1]
木乃伊-attack,木乃伊,true,木乃伊-defend;木乃伊-curse,,[player;attack;6] 木乃伊-attack,木乃伊,true,木乃伊-defend;木乃伊-curse,,[player;attack;6]
木乃伊-defend,木乃伊,false,木乃伊-attack,,[self;defend;6] 木乃伊-defend,木乃伊,false,木乃伊-attack,,[source;defend;6]
木乃伊-curse,木乃伊,false,木乃伊-defend;木乃伊-attack,木乃伊-attack,[player;curse;1] 木乃伊-curse,木乃伊,false,木乃伊-defend;木乃伊-attack,木乃伊-attack,[player;curse;1]
枪手-aim,枪手,true,枪手-attack,,[self;aim;2] 枪手-aim,枪手,true,枪手-attack,,[source;aim;2]
枪手-attack,枪手,false,枪手-aim;枪手-defend,枪手-aim,[player;attack;8] 枪手-attack,枪手,false,枪手-aim;枪手-defend,枪手-aim,[player;attack;8]
枪手-defend,枪手,false,枪手-aim,枪手-aim,[self;defend;5] 枪手-defend,枪手,false,枪手-aim,枪手-aim,[source;defend;5]
风卷草-boost,风卷草,true,风卷草-defend;风卷草-defend;风卷草-boost,,[self;roll;5];[self;defend;4] 风卷草-boost,风卷草,true,风卷草-defend;风卷草-defend;风卷草-boost,,[source;roll;5];[source;defend;4]
风卷草-defend,风卷草,false,风卷草-boost;风卷草-attack,,[self;defend;8] 风卷草-defend,风卷草,false,风卷草-boost;风卷草-attack,,[source;defend;8]
风卷草-attack,风卷草,false,风卷草-boost,,[player;rollDamage;0] 风卷草-attack,风卷草,false,风卷草-boost,,[player;rollDamage;0]
秃鹫-attack,秃鹫,true,秃鹫-defend;秃鹫-defend,,[player;attack;6];[player;vultureEye;1] 秃鹫-attack,秃鹫,true,秃鹫-defend;秃鹫-defend,,[player;attack;6];[player;vultureEye;1]
秃鹫-defend,秃鹫,false,秃鹫-attack;秃鹫-attack,,[self;defend;5] 秃鹫-defend,秃鹫,false,秃鹫-attack;秃鹫-attack,,[source;defend;5]
沙蝎-boost,沙蝎,true,沙蝎-attack;沙蝎-attack,,[self;tailSting;2] 沙蝎-boost,沙蝎,true,沙蝎-attack;沙蝎-attack,,[source;tailSting;2]
沙蝎-attack,沙蝎,false,沙蝎-boost;沙蝎-attack,,[player;attack;6] 沙蝎-attack,沙蝎,false,沙蝎-boost;沙蝎-attack,,[player;attack;6]
幼沙虫-defend,幼沙虫,true,幼沙虫-defend;幼沙虫-boost,,[self;defend;6] 幼沙虫-defend,幼沙虫,true,幼沙虫-defend;幼沙虫-boost,,[source;defend;6]
幼沙虫-boost,幼沙虫,false,幼沙虫-attack;幼沙虫-defend,,[self;energyDrain;1];[self;defend;4] 幼沙虫-boost,幼沙虫,false,幼沙虫-attack;幼沙虫-defend,,[source;energyDrain;1];[source;defend;4]
幼沙虫-attack,幼沙虫,false,幼沙虫-defend;幼沙虫-defend,,[player;attack;5] 幼沙虫-attack,幼沙虫,false,幼沙虫-defend;幼沙虫-defend,,[player;attack;5]
蜥蜴-attack,蜥蜴,true,蜥蜴-defend;蜥蜴-molt,,[player;attack;5] 蜥蜴-attack,蜥蜴,true,蜥蜴-defend;蜥蜴-molt,,[player;attack;5]
蜥蜴-defend,蜥蜴,false,蜥蜴-attack;蜥蜴-attack,,[self;defend;6] 蜥蜴-defend,蜥蜴,false,蜥蜴-attack;蜥蜴-attack,,[source;defend;6]
蜥蜴-molt,蜥蜴,false,蜥蜴-defend;蜥蜴-attack,,[self;molt;3] 蜥蜴-molt,蜥蜴,false,蜥蜴-defend;蜥蜴-attack,,[source;molt;3]
沙匪-attack,沙匪,true,沙匪-attack;沙匪-heavyAttack,,[player;attack;6] 沙匪-attack,沙匪,true,沙匪-attack;沙匪-heavyAttack,,[player;attack;6]
沙匪-heavyAttack,沙匪,false,沙匪-attack;沙匪-attack;沙匪-debuff,,[player;attack;10] 沙匪-heavyAttack,沙匪,false,沙匪-attack;沙匪-attack;沙匪-debuff,,[player;attack;10]
沙匪-debuff,沙匪,false,沙匪-attack;沙匪-attack,,[player;discard;1] 沙匪-debuff,沙匪,false,沙匪-attack;沙匪-attack,,[player;discard;1]
风暴之灵-storm,风暴之灵,true,风暴之灵-attack;风暴之灵-storm,,[self;storm;2];[self;defend;3] 风暴之灵-storm,风暴之灵,true,风暴之灵-attack;风暴之灵-storm,,[source;storm;2];[source;defend;3]
风暴之灵-attack,风暴之灵,false,风暴之灵-storm;风暴之灵-defend,,[player;attack;8];[player;static;1] 风暴之灵-attack,风暴之灵,false,风暴之灵-storm;风暴之灵-defend,,[player;attack;8];[player;static;1]
风暴之灵-defend,风暴之灵,false,风暴之灵-storm;风暴之灵-attack,,[self;defend;8] 风暴之灵-defend,风暴之灵,false,风暴之灵-storm;风暴之灵-attack,,[source;defend;8]
骑马枪手-charge,骑马枪手,true,骑马枪手-attack,,[self;charge;2] 骑马枪手-charge,骑马枪手,true,骑马枪手-attack,,[source;charge;2]
骑马枪手-attack,骑马枪手,false,骑马枪手-charge;骑马枪手-defend,骑马枪手-charge,[player;attack;6] 骑马枪手-attack,骑马枪手,false,骑马枪手-charge;骑马枪手-defend,骑马枪手-charge,[player;attack;6]
骑马枪手-defend,骑马枪手,false,骑马枪手-charge;骑马枪手-attack,骑马枪手-charge,[self;defend;5] 骑马枪手-defend,骑马枪手,false,骑马枪手-charge;骑马枪手-attack,骑马枪手-charge,[source;defend;5]
沙虫王-summon,沙虫王,true,沙虫王-attack;沙虫王-defend,,[self;summonSandwormLarva;1] 沙虫王-summon,沙虫王,true,沙虫王-attack;沙虫王-defend,,[source;summonSandwormLarva;18]
沙虫王-attack,沙虫王,false,沙虫王-summon;沙虫王-defend,,[player;attack;9] 沙虫王-attack,沙虫王,false,沙虫王-summon;沙虫王-defend,,[player;attack;9]
沙虫王-defend,沙虫王,false,沙虫王-attack;沙虫王-summon,,[self;defend;6] 沙虫王-defend,沙虫王,false,沙虫王-attack;沙虫王-summon,,[source;defend;6]
沙漠守卫-summon,沙漠守卫,true,沙漠守卫-attack;沙漠守卫-defend,,[self;summonMummy;1] 沙漠守卫-summon,沙漠守卫,true,沙漠守卫-attack;沙漠守卫-defend,,[source;summonMummy;14]
沙漠守卫-attack,沙漠守卫,false,沙漠守卫-defend;沙漠守卫-summon,,[player;attack;8] 沙漠守卫-attack,沙漠守卫,false,沙漠守卫-defend;沙漠守卫-summon,,[player;attack;8]
沙漠守卫-defend,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-revive,,[self;defend;8] 沙漠守卫-defend,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-revive,,[source;defend;8]
沙漠守卫-revive,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-summon,,[self;reviveMummy;1] 沙漠守卫-revive,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-summon,,[source;reviveMummy;1]
Can't render this file because it contains an unexpected character in line 1 and column 42.
@@ -1,13 +1,17 @@
import type { Enemy } from './enemy.csv'; import type { Enemy } from './enemy.csv';
import type { Effect } from './effect.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 { type IntentTable = readonly {
readonly id: string; readonly id: string;
readonly enemy: Enemy; readonly enemy: Enemy;
readonly initialIntent: boolean; readonly initialIntent: boolean;
readonly nextIntents: Intent[]; readonly nextIntents: Intent[];
readonly brokenIntent: Intent[]; readonly brokenIntent: Intent[];
readonly effects: ["self" | "player" | "team", Effect, number][]; readonly effects: IntentEffectList;
}[]; }[];
export type Intent = IntentTable[number]; export type Intent = IntentTable[number];
@@ -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();
});
}
@@ -1,542 +0,0 @@
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 { EffectData } from "@/samples/slay-the-spire-like/system/types";
import { GameCard } from "@/samples/slay-the-spire-like/system/deck";
import { getAdjacentItems } from "@/samples/slay-the-spire-like/system/grid-inventory";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress";
export function addEffectTriggers(triggers: Triggers) {
// ========== instant effects ==========
triggers.onEffectApplied.use(async (ctx, next) => {
if (ctx.effect.id === "attack") {
await triggers.onDamage.execute(ctx.game, {
entityKey: ctx.entityKey,
amount: ctx.stacks,
sourceEntityKey: ctx.sourceEntityKey ?? ctx.entityKey === "player" ? undefined : "player",
});
} 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 => {
const cardId = `venom-${draft.player.deck.regions.drawPile.childIds.length}-${draft.player.deck.regions.discardPile.childIds.length}`;
const card: GameCard = {
id: cardId,
regionId: "",
position: [],
itemId: "venom",
cardData: {
id: "venom",
name: "蛇毒",
desc: "弃掉时受到3点伤害",
type: "status",
costType: "energy",
costCount: 1,
targetType: "none",
effects: [["onDiscard", "self", ctx.effect, 3]],
},
};
draft.player.deck.cards[cardId] = card;
moveToRegion(card, null, draft.player.deck.regions.drawPile);
});
} else if (ctx.effect.id === "vultureEye") {
await ctx.game.produceAsync(draft => {
const cardId = `vultureEye-${draft.player.deck.regions.drawPile.childIds.length}-${draft.player.deck.regions.discardPile.childIds.length}`;
const card: GameCard = {
id: cardId,
regionId: "",
position: [],
itemId: "vultureEye",
cardData: {
id: "vultureEye",
name: "秃鹫之眼",
desc: "抓到时获得3层暴露",
type: "status",
costType: "none",
costCount: 0,
targetType: "none",
effects: [["onDraw", "self", ctx.effect, 3]],
},
};
draft.player.deck.cards[cardId] = card;
moveToRegion(card, null, draft.player.deck.regions.drawPile);
});
} else if (ctx.effect.id === "static") {
await ctx.game.produceAsync(draft => {
const cardId = `static-${draft.player.deck.regions.drawPile.childIds.length}-${draft.player.deck.regions.discardPile.childIds.length}`;
const card: GameCard = {
id: cardId,
regionId: "",
position: [],
itemId: "static",
cardData: {
id: "static",
name: "静电",
desc: "在手里时受电击伤害+1",
type: "status",
costType: "none",
costCount: 0,
targetType: "none",
effects: [["onDraw", "self", ctx.effect, 1]],
},
};
draft.player.deck.cards[cardId] = card;
moveToRegion(card, null, draft.player.deck.regions.drawPile);
});
} else if (ctx.effect.id === "summonMummy") {
await ctx.game.produceAsync(draft => {
for (const enemyData of getAllEnemyData(ctx.game)) {
if (enemyData.id === "木乃伊") {
const existingMummy = draft.enemies.find(e => e.enemy.id === "木乃伊" && !e.isAlive);
if (existingMummy) {
existingMummy.isAlive = true;
existingMummy.hp = existingMummy.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: 14,
maxHp: 14,
isAlive: true,
effects: {},
intents,
currentIntent: intent,
});
break;
}
}
});
} else if (ctx.effect.id === "summonSandwormLarva") {
await ctx.game.produceAsync(draft => {
for (const enemyData of getAllEnemyData(ctx.game)) {
if (enemyData.id === "幼沙虫") {
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: 18,
maxHp: 18,
isAlive: true,
effects: {},
intents,
currentIntent: intent,
});
break;
}
}
});
} 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();
});
// ========== block / damage prevention ==========
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
if (!entity) return;
let preventable = ctx.amount - (ctx.prevented ?? 0);
const blocks = entity.effects.defend?.stacks ?? 0;
const blocked = Math.min(blocks, preventable);
if (blocked) {
ctx.prevented = (ctx.prevented ?? 0) + blocked;
preventable -= blocked;
}
const damageReduce = entity.effects.damageReduce?.stacks ?? 0;
if (damageReduce > 0) {
const reduced = Math.min(damageReduce, preventable);
ctx.prevented = (ctx.prevented ?? 0) + reduced;
preventable -= reduced;
}
const expose = entity.effects.expose?.stacks ?? 0;
if (expose > 0) {
ctx.amount += expose;
}
await next();
});
// ========== spike: damage attacker ==========
triggers.onDamage.use(async (ctx, next) => {
await next();
if (ctx.amount - (ctx.prevented ?? 0) <= 0) return;
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
if (!entity || !entity.isAlive) return;
const spike = entity.effects.spike?.stacks ?? 0;
if (spike > 0 && ctx.sourceEntityKey) {
await triggers.onDamage.execute(ctx.game, {
entityKey: ctx.sourceEntityKey,
amount: spike,
sourceEntityKey: ctx.entityKey,
});
}
});
// ========== storm: give static card to player on attack ==========
triggers.onDamage.use(async (ctx, next) => {
await next();
if (ctx.amount - (ctx.prevented ?? 0) <= 0) return;
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
if (!entity || !entity.isAlive) return;
const storm = entity.effects.storm?.stacks ?? 0;
if (storm > 0 && ctx.entityKey !== "player") {
for (let i = 0; i < storm; i++) {
await triggers.onEffectApplied.execute(ctx.game, {
effect: findEffect(ctx.game, "static"),
entityKey: "player",
stacks: 1,
});
}
}
});
// ========== energyDrain: player loses energy when enemy takes damage ==========
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
if (!entity) return;
const energyDrain = entity.effects.energyDrain?.stacks ?? 0;
if (energyDrain > 0 && ctx.entityKey !== "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) => {
await next();
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
if (!entity || !entity.isAlive) 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.entityKey);
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;
}
});
// ========== discard: random discard at turn start ==========
triggers.onTurnStart.use(async (ctx, next) => {
await next();
if (ctx.entityKey !== "player") return;
const discard = ctx.game.value.player.effects.discard;
if (!discard || discard.stacks <= 0) return;
const handIds = [...ctx.game.value.player.deck.regions.hand.childIds];
if (handIds.length === 0) return;
const randomIndex = ctx.game.rng.nextInt(handIds.length);
const randomCardId = handIds[randomIndex];
await triggers.onCardDiscarded.execute(ctx.game, { cardId: randomCardId });
});
// ========== defendNext: gain block next turn ==========
triggers.onTurnStart.use(async (ctx, next) => {
await next();
if (ctx.entityKey !== "player") return;
const defendNext = ctx.game.value.player.effects.defendNext;
if (!defendNext || defendNext.stacks <= 0) return;
await ctx.game.produceAsync(draft => {
addEntityEffect(draft.player, findEffect(ctx.game, "defend"), defendNext.stacks);
addEntityEffect(draft.player, defendNext.data, -defendNext.stacks);
});
});
// ========== energyNext: gain energy next turn ==========
triggers.onTurnStart.use(async (ctx, next) => {
await next();
if (ctx.entityKey !== "player") return;
const energyNext = ctx.game.value.player.effects.energyNext;
if (!energyNext || energyNext.stacks <= 0) return;
await ctx.game.produceAsync(draft => {
draft.player.energy += energyNext.stacks;
addEntityEffect(draft.player, energyNext.data, -energyNext.stacks);
});
});
// ========== drawNext: draw extra cards next turn ==========
triggers.onTurnStart.use(async (ctx, next) => {
await next();
if (ctx.entityKey !== "player") return;
const drawNext = ctx.game.value.player.effects.drawNext;
if (!drawNext || drawNext.stacks <= 0) return;
await ctx.game.produceAsync(draft => {
addEntityEffect(draft.player, drawNext.data, -drawNext.stacks);
});
await triggers.onDraw.execute(ctx.game, { count: drawNext.stacks });
});
// ========== aim: double damage, lose aim on damage ==========
triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityKey === "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.sourceEntityKey === "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(ctx.game, "roll"), -rollDamage);
});
}
}
await next();
});
// ========== tailSting: bonus damage on attack ==========
triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityKey && ctx.sourceEntityKey !== "player") {
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityKey);
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.entityKey);
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.entityKey);
if (e) addEntityEffect(e, findEffect(ctx.game, "charge"), -consumed);
});
}
}
}
if (ctx.sourceEntityKey) {
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityKey);
if (attacker) {
const charge = attacker.effects.charge?.stacks ?? 0;
if (charge > 0) {
const baseAmount = ctx.amount;
const targetEntity = getCombatEntity(ctx.game.value, ctx.entityKey);
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.sourceEntityKey!);
if (a) addEntityEffect(a, findEffect(ctx.game, "charge"), -consumed);
});
}
}
}
}
await next();
});
// ========== 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,
sourceEntityKey: "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 = getAdjacentItems<GameItemMeta>(ctx.game.value.inventory, 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;
await ctx.game.produceAsync(draft => {
const item = draft.inventory.items.get(adjItemId);
if (item) {
draft.inventory.items.delete(adjItemId);
}
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.sourceEntityKey) return;
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityKey);
if (!attacker || !("enemy" in attacker) || attacker.enemy.id !== "秃鹫") return;
await triggers.onEffectApplied.execute(ctx.game, {
effect: findEffect(ctx.game, "vultureEye"),
entityKey: "player",
stacks: 1,
sourceEntityKey: ctx.sourceEntityKey,
});
});
}
function getAllEnemyData(game: CombatGameContext) {
const seen = new Set<string>();
const result: typeof game.value.enemies[number]["enemy"][] = [];
for (const enemy of game.value.enemies) {
if (!seen.has(enemy.enemy.id)) {
seen.add(enemy.enemy.id);
result.push(enemy.enemy);
}
}
return result;
}
function findEffect(game: CombatGameContext | { value: CombatGameContext["value"] }, id: string): EffectData {
const value = "value" in game ? game.value : game;
const dataModule = (globalThis as any).__desertEffects;
if (dataModule) {
const found = dataModule.find((e: EffectData) => e.id === id);
if (found) return found;
}
return { id, name: id, description: "", lifecycle: "instant" } as EffectData;
}
@@ -1,6 +1,15 @@
import {addEffectTriggers} from './effect'; import { addInstantEffectTriggers } from "./instant";
import {Triggers} from "@/samples/slay-the-spire-like/system/combat/triggers"; 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){ export function addTriggers(triggers: Triggers, run: IRunContext) {
addEffectTriggers(triggers); 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,5 +1,6 @@
import * as desert from './desert'; import * as desert from "./desert";
export type { ContentModule } from "./types";
export default { export default {
desert desert,
} };
@@ -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;
};
+8 -135
View File
@@ -1,135 +1,8 @@
// Types export * from "./system/combat";
export type { export * from "./system/deck";
EffectData, export * from "./system/encounter";
EffectLifecycle, export * from "./system/grid-inventory";
EnemyData, export * from "./system/map";
CardType, export * from "./system/utils";
CardCostType, export * from "./system/types";
CardTargetType, export { default as data } from "./data";
EffectTarget,
CardData,
CardEffectTrigger,
CardEffectTarget,
EncounterType,
EncounterData,
IntentData,
ItemData,
} from './system/types';
// Deck
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './system/deck';
export {
generateDeckFromInventory,
createCard,
createPlayerDeck,
generateCardId,
} from './system/deck';
// Grid Inventory
export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './system/grid-inventory';
export {
createGridInventory,
flipItem,
getAdjacentItems,
getItemAtCell,
getOccupiedCellSet,
moveItem,
placeItem,
removeItem as removeItemFromGrid,
rotateItem,
validatePlacement,
} from './system/grid-inventory';
// Map
export { MapNodeType, MapLayerType } from './system/map';
export type { MapNode, MapLayer, PointCrawlMap, MapGenerationConfig } from './system/map';
export { generatePointCrawlMap, getNode, getChildren, getParents, findAllPaths } from './system/map';
// Progress / Run
export type {
EncounterResult,
EncounterState,
GameItem,
GameItemMeta,
PlayerState,
RunMutationResult,
RunState,
} from './system/progress';
export {
assignEncounterToNode,
assignEncountersFromPool,
assignAllEncounters,
buildCombatState,
createEnemyEntities,
getCurrentEncounterData,
isCombatEncounter,
startEncounter,
resolveCombatEncounter,
createRunState,
canMoveTo,
moveToNode,
resolveEncounter,
isEncounterResolved,
damagePlayer,
healPlayer,
setMaxHp,
addGold,
spendGold,
addItem,
removeItem,
getCurrentNode,
getReachableChildren,
getUnresolvedChildren,
isAtStartNode,
isAtEndNode,
} from './system/progress';
// Combat
export type {
EffectTable,
CombatEntity,
PlayerEntity,
EnemyEntity,
CombatPhase,
CombatResult,
LootEntry,
CombatState,
CombatGameContext,
} from './system/combat/types';
export {
addEffect,
addEntityEffect,
addItemEffect,
onEntityEffectUpkeep,
onEntityPostureDamage,
onPlayerItemEffectUpkeep,
onItemPlay,
onItemDiscard,
getAliveEnemies,
getEffectTargets,
getCombatEntity,
canPlayCard,
payCardCost,
} from './system/combat/effects';
export { prompts as combatPrompts, promptMainAction } from './system/combat/prompts';
export { createStartWith, type Triggers } from './system/combat/triggers';
// Utils
export { parseShapeString, type ParsedShape } from './system/utils/parse-shape';
export {
IDENTITY_TRANSFORM,
type Transform2D,
type Point2D,
getOccupiedCells,
transformPoint,
transformShape,
checkCollision,
checkBoardCollision,
checkBounds,
rotateTransform,
flipXTransform,
flipYTransform,
} from './system/utils/shape-collision';
// Data
export { default as data } from './data';
@@ -1,134 +1,171 @@
import {CombatEntity, CombatGameContext, CombatState, EffectTable, PlayerEntity} from "./types";
import { import {
CardData, CombatEntity,
CardEffectTarget, CombatGameContext,
CardTargetType, CombatState,
EffectData, EffectTable,
EffectTarget IRunContext,
PlayerEntity,
} from "./types";
import {
CardData,
CardEffectTarget,
EffectData,
IntentEffectTarget,
} from "@/samples/slay-the-spire-like/system/types"; } from "@/samples/slay-the-spire-like/system/types";
import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress/types";
import {GridInventory} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
export function addEffect(effects: EffectTable, effect: EffectData, stacks: number){ export function addEffect(
let current = effects[effect.id]; effects: EffectTable,
effect: EffectData,
if(!current) current = {data: effect, stacks}; stacks: number,
else current.stacks += stacks; ) {
let current = effects[effect.id];
if(current.stacks === 0 && effects[effect.id])
delete effects[effect.id]; if (!current) current = { data: effect, stacks };
else if(current.stacks !== 0 && !effects[effect.id]) else current.stacks += stacks;
effects[effect.id] = current;
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){ export function addEntityEffect(
addEffect(entity.effects, effect, stacks); entity: CombatEntity,
effect: EffectData,
stacks: number,
) {
addEffect(entity.effects, effect, stacks);
} }
export function addItemEffect(entity: PlayerEntity, itemKey: string, effect: EffectData, stacks: number){ export function addItemEffect(
entity.itemEffects[itemKey] = entity.itemEffects[itemKey] || {}; entity: PlayerEntity,
addEffect(entity.itemEffects[itemKey], effect, stacks); itemKey: string,
effect: EffectData,
stacks: number,
) {
entity.itemEffects[itemKey] = entity.itemEffects[itemKey] || {};
addEffect(entity.itemEffects[itemKey], effect, stacks);
} }
export function onEntityEffectUpkeep(entity: CombatEntity){ export function onEntityEffectUpkeep(entity: CombatEntity) {
for(const effect of Object.values(entity.effects)){ for (const effect of Object.values(entity.effects)) {
const lifecycle = effect.data.lifecycle; const lifecycle = effect.data.lifecycle;
if(lifecycle === 'temporary') if (lifecycle === "temporary")
addEntityEffect(entity, effect.data, -effect.stacks); addEntityEffect(entity, effect.data, -effect.stacks);
else if(lifecycle === 'lingering') else if (lifecycle === "lingering")
addEntityEffect(entity, effect.data, effect.stacks >= 0 ? -1 : 1); 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 onEntityPostureDamage(entity: CombatEntity, damage: number){ export function onItemPlay(entity: PlayerEntity, itemKey: string) {
for(const effect of Object.values(entity.effects)){ const effects = entity.itemEffects[itemKey];
const lifecycle = effect.data.lifecycle; if (!effects) return;
if(lifecycle === 'posture') for (const effect of Object.values(effects)) {
addEntityEffect(entity, effect.data, -Math.min(damage, effect.stacks)); if (effect.data.lifecycle === "itemUntilPlay") {
addItemEffect(entity, itemKey, effect.data, -effect.stacks);
} }
}
} }
export function onPlayerItemEffectUpkeep(entity: PlayerEntity){ export function onItemDiscard(entity: PlayerEntity, itemKey: string) {
for(const [itemKey, itemEffects] of Object.entries(entity.itemEffects)){ const effects = entity.itemEffects[itemKey];
for(const effect of Object.values(itemEffects)){ if (!effects) return;
const lifecycle = effect.data.lifecycle; for (const effect of Object.values(effects)) {
if(lifecycle === 'itemTemporary') if (effect.data.lifecycle === "itemUntilDiscard") {
addItemEffect(entity, itemKey, effect.data, -effect.stacks); 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) { export function* getAliveEnemies(state: CombatState) {
for (let enemy of state.enemies) { for (let enemy of state.enemies) {
if (enemy.isAlive) { if (enemy.isAlive) {
yield enemy; yield enemy;
}
} }
}
} }
export function* getEffectTargets(target: CardEffectTarget | EffectTarget, game: CombatGameContext, targetId?: string){ export function* getEffectTargets(
if(target === 'all' || target === 'team'){ target: CardEffectTarget | IntentEffectTarget,
for(const enemy of getAliveEnemies(game.value)){ game: CombatGameContext,
yield enemy; targetId?: string,
} sourceEntityKey: "player" | string = "player",
} else if(target === 'self') { ) {
yield game.value.player; if (target === "eachEnemy") {
} else if(target === 'target'){ for (const enemy of getAliveEnemies(game.value)) {
if(!targetId) return; yield enemy;
const entity = getCombatEntity(game.value, targetId);
if(entity) yield entity;
} else if(target === 'random'){
const aliveEnemies = [...getAliveEnemies(game.value)];
if(aliveEnemies.length === 0) return;
const index = game.rng.nextInt(aliveEnemies.length);
yield aliveEnemies[index];
} }
} 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){ export function getCombatEntity(state: CombatState, entityKey: string) {
return entityKey === 'player' ? state.player : state.enemies.find(e => e.id === entityKey); return entityKey === "player"
? state.player
: state.enemies.find((e) => e.id === entityKey);
} }
export function canPlayCard(player: PlayerEntity, costType: CardData['costType'], costCount: number, itemId: string, inventory: GridInventory<GameItemMeta>): boolean { export function canPlayCard(
if (costType === 'energy') { player: PlayerEntity,
return player.energy >= costCount; costType: CardData["costType"],
} costCount: number,
if (costType === 'uses') { itemId: string,
const item = inventory.items.get(itemId); run: IRunContext,
if (!item || !item.meta) return false; ): boolean {
const depletion = item.meta.depletion ?? 0; if (costType === "energy") {
return depletion < costCount; return player.energy >= costCount;
} }
return true; 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 function payCardCost(player: PlayerEntity, costType: CardData['costType'], costCount: number, itemId: string, inventory: GridInventory<GameItemMeta>): void { export async function payCardCost(
if (costType === 'energy') { player: PlayerEntity,
player.energy -= costCount; costType: CardData["costType"],
} else if (costType === 'uses') { costCount: number,
const item = inventory.items.get(itemId); itemId: string,
if (item && item.meta) { run: IRunContext,
item.meta.depletion = (item.meta.depletion ?? 0) + costCount; ): 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";
@@ -1,43 +1,56 @@
import { createPromptDef } from "@/core/game"; import { createPromptDef } from "@/core/game";
import {CombatGameContext} from "./types"; import { CombatGameContext, IRunContext } from "./types";
import {canPlayCard} from "@/samples/slay-the-spire-like/system/combat/effects"; import { canPlayCard } from "@/samples/slay-the-spire-like/system/combat/effects";
export const prompts = { export const prompts = {
mainAction: createPromptDef<[string, string?]>( mainAction: createPromptDef<[string, string?]>(
"main-action <cardId:string> [targetId:string]", "main-action <cardId:string> [targetId:string]",
"选择卡牌并指定目标" "选择卡牌并指定目标",
), ),
}; };
export async function promptMainAction(game: CombatGameContext){ export async function promptMainAction(
return await game.prompt(prompts.mainAction, (cardId, targetId) => { game: CombatGameContext,
if(cardId === 'end-turn') return { run: IRunContext,
action: 'end-turn' as 'end-turn' ) {
}; return await game.prompt(prompts.mainAction, (cardId, targetId) => {
if (cardId === "end-turn")
const exists = game.value.player.deck.regions.hand.childIds.includes(cardId); return {
if(!exists) throw `卡牌"${cardId}"不在手牌中`; action: "end-turn" as "end-turn",
};
const card = game.value.player.deck.cards[cardId];
const {cardData, itemId} = card; const exists =
if(!canPlayCard(game.value.player, cardData.costType, cardData.costCount, itemId, game.value.inventory)){ game.value.player.deck.regions.hand.childIds.includes(cardId);
throw `无法支付卡牌"${cardId}"的费用`; if (!exists) throw `卡牌"${cardId}"不在手牌中`;
}
const card = game.value.player.deck.cards[cardId];
const {targetType} = cardData; const { cardData, itemId } = card;
if(targetType === 'single'){ if (
if(!targetId) throw `请指定目标`; !canPlayCard(
const target = game.value.enemies.find(e => e.id === targetId); game.value.player,
if(!target) throw `目标"${targetId}"不存在`; cardData.costType,
if(!target.isAlive) throw `目标"${targetId}"已死亡`; cardData.costCount,
}else if(targetType === 'none'){ itemId,
if(targetId) throw `目标"${targetId}"无效`; run,
} )
) {
return { throw `无法支付卡牌"${cardId}"的费用`;
action: 'play' as 'play', }
cardId,
targetId 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,
};
});
}
@@ -1,240 +1,343 @@
import {CombatGameContext} from "./types"; import { CombatGameContext, IRunContext } from "./types";
import { import {
addEntityEffect, addEntityEffect,
addItemEffect, addItemEffect,
getAliveEnemies, onEntityPostureDamage, getAliveEnemies,
onEntityEffectUpkeep, onEntityPostureDamage,
onPlayerItemEffectUpkeep, onItemDiscard, onItemPlay, payCardCost, getCombatEntity, getEffectTargets onEntityEffectUpkeep,
onPlayerItemEffectUpkeep,
onItemDiscard,
onItemPlay,
payCardCost,
getCombatEntity,
getEffectTargets,
} from "@/samples/slay-the-spire-like/system/combat/effects"; } from "@/samples/slay-the-spire-like/system/combat/effects";
import {promptMainAction} from "@/samples/slay-the-spire-like/system/combat/prompts"; import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/prompts";
import {moveToRegion, shuffle} from "@/core/region"; import { moveToRegion, shuffle } from "@/core/region";
import {createMiddlewareChain} from "@/utils/middleware"; import { createMiddlewareChain } from "@/utils/middleware";
import {EffectData} from "@/samples/slay-the-spire-like/system/types"; import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import {getAdjacentItems} from "@/samples/slay-the-spire-like/system/grid-inventory";
import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress";
type TriggerTypes = { type TriggerTypes = {
onCombatStart: {}, onCombatStart: {};
onTurnStart: { entityKey: "player" | string, }, onTurnStart: { entityId: "player" | string };
onTurnEnd: { entityKey: "player" | string, }, onTurnEnd: { entityId: "player" | string };
onShuffle: {}, onShuffle: {};
onCardPlayed: { cardId: string, targetId?: string, sourceEntityKey?: "player" | string }, onCardPlayed: {
onCardDiscarded: { cardId: string, sourceEntityKey?: "player" | string }, cardId: string;
onCardDrawn: { cardId: string, sourceEntityKey?: "player" | string }, targetId?: string;
onDraw: {count: number}, sourceEntityId?: "player" | string;
onEffectApplied: { effect: EffectData, entityKey: "player" | string, stacks: number, cardId?: string, sourceEntityKey?: "player" | string, targetId?: string }, };
onHpChange: { entityKey: "player" | string, amount: number}, onCardDiscarded: { cardId: string; sourceEntityId?: "player" | string };
onDamage: { entityKey: "player" | string, amount: number, prevented?: number, sourceEntityKey?: "player" | string}, onCardDrawn: { cardId: string; sourceEntityId?: "player" | string };
onEnemyIntent: { enemyId: string, sourceEntityKey?: "player" | string }, onDraw: { count: number };
onIntentUpdate: { enemyId: string }, 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 };
};
function createTriggers(){ export function createTriggers(run: IRunContext) {
const triggers = { const triggers = {
onCombatStart: createTrigger("onCombatStart"), onCombatStart: createTrigger("onCombatStart", async (ctx) => {
onTurnStart: createTrigger("onTurnStart", async ctx => { await triggers.onShuffle.execute(ctx.game, {});
await ctx.game.produceAsync(draft => { await triggers.onDraw.execute(ctx.game, { count: 5 });
const entity = getCombatEntity(draft, ctx.entityKey); }),
if(entity) onEntityEffectUpkeep(entity); onTurnStart: createTrigger("onTurnStart", async (ctx) => {
if(entity === draft.player) await ctx.game.produceAsync((draft) => {
onPlayerItemEffectUpkeep(draft.player); const entity = getCombatEntity(draft, ctx.entityId);
}) if (entity) onEntityEffectUpkeep(entity);
}), if (entity === draft.player) onPlayerItemEffectUpkeep(draft.player);
onTurnEnd: createTrigger("onTurnEnd", async ctx => { });
if(ctx.entityKey !== "player")return; }),
const {regions} = ctx.game.value.player.deck; onTurnEnd: createTrigger("onTurnEnd", async (ctx) => {
for(const cardId of Object.values(regions.hand.childIds)){ if (ctx.entityId !== "player") return;
await triggers.onCardDiscarded.execute(ctx.game,{cardId}); const { regions } = ctx.game.value.player.deck;
} for (const cardId of Object.values(regions.hand.childIds)) {
await ctx.game.produceAsync(draft => draft.player.energy = draft.player.maxEnergy); await triggers.onCardDiscarded.execute(ctx.game, { cardId });
await triggers.onDraw.execute(ctx.game,{count: 5}); }
}), await ctx.game.produceAsync((draft) => {
onShuffle: createTrigger("onShuffle", async ctx => { draft.player.energy = draft.player.maxEnergy;
await ctx.game.produceAsync(draft => { });
const {cards, regions} = draft.player.deck; await triggers.onDraw.execute(ctx.game, { count: 5 });
for(const cardId of Object.values(regions.discardPile.childIds)) }),
moveToRegion(cards[cardId], regions.discardPile, regions.drawPile); onShuffle: createTrigger("onShuffle", async (ctx) => {
shuffle(regions.drawPile, cards, ctx.game.rng); 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,
}); });
}), }
onCardPlayed: createTrigger("onCardPlayed", async ctx => { }),
await ctx.game.produceAsync(draft => { onCardDiscarded: createTrigger("onCardDiscarded", async (ctx) => {
const {cards, regions} = draft.player.deck; await ctx.game.produceAsync((draft) => {
const card = cards[ctx.cardId]; const { cards, regions } = draft.player.deck;
payCardCost(draft.player, card.cardData.costType, card.cardData.costCount, card.itemId, draft.inventory); moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile);
moveToRegion(card, regions.hand, regions.discardPile); onItemDiscard(draft.player, cards[ctx.cardId].itemId);
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 !== "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,
}); });
const {cards, regions} = ctx.game.value.player.deck; }
const card = cards[ctx.cardId]; }),
const source = ctx.sourceEntityKey ?? "player"; onCardDrawn: createTrigger("onCardDrawn", async (ctx) => {
for(const [trigger, target, effect, stacks] of card.cardData.effects){ await ctx.game.produceAsync((draft) => {
if(trigger !== 'onPlay') continue; const { cards, regions } = draft.player.deck;
for(const entity of getEffectTargets(target, ctx.game, ctx.targetId)) moveToRegion(cards[ctx.cardId], regions.drawPile, regions.hand);
await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: source, targetId: ctx.targetId}); });
} const { cards, regions } = ctx.game.value.player.deck;
}), const card = cards[ctx.cardId];
onCardDiscarded: createTrigger("onCardDiscarded", async ctx => { const source = ctx.sourceEntityId ?? "player";
await ctx.game.produceAsync(draft => { for (const { trigger, target, effects } of card.cardData.effects) {
const {cards, regions} = draft.player.deck; if (trigger !== "onDraw") continue;
moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile); for (const [effect, stacks] of effects)
onItemDiscard(draft.player, cards[ctx.cardId].itemId); 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,
}); });
const {cards, regions} = ctx.game.value.player.deck; }
const card = cards[ctx.cardId]; }),
const source = ctx.sourceEntityKey ?? "player"; onDraw: createTrigger("onDraw", async (ctx) => {
for(const [trigger, target, effect, stacks] of card.cardData.effects){ let toDraw = ctx.count;
if(trigger !== 'onDiscard') continue; while (toDraw > 0) {
for(const entity of getEffectTargets(target, ctx.game)) let inDraw =
await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: source}); ctx.game.value.player.deck.regions.drawPile.childIds.length;
} if (inDraw <= 0) await triggers.onShuffle.execute(ctx.game, {});
}),
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.sourceEntityKey ?? "player";
for(const [trigger, target, effect, stacks] of card.cardData.effects){
if(trigger !== 'onDraw') continue;
for(const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: 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; inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length;
if(inDraw <= 0) break; if (inDraw <= 0) break;
const children = ctx.game.value.player.deck.regions.drawPile.childIds; const children = ctx.game.value.player.deck.regions.drawPile.childIds;
const cardId = children[children.length - 1]; const cardId = children[children.length - 1];
await triggers.onCardDrawn.execute(ctx.game,{cardId}); await triggers.onCardDrawn.execute(ctx.game, { cardId });
toDraw--; toDraw--;
} }
}), }),
onEffectApplied: createTrigger("onEffectApplied", async ctx => { onEffectApplied: createTrigger("onEffectApplied", async (ctx) => {
if(ctx.effect.lifecycle === 'instant') return; if (ctx.effect.lifecycle === "instant") return;
if(ctx.effect.lifecycle.startsWith("item")) { if (ctx.effect.lifecycle.startsWith("item")) {
if(ctx.cardId){ if (ctx.cardId) {
const card = ctx.game.value.player.deck.cards[ctx.cardId]; const card = ctx.game.value.player.deck.cards[ctx.cardId];
const nearby = getAdjacentItems<GameItemMeta>(ctx.game.value.inventory, card.itemId); const nearby = run.getAdjacentItems(card.itemId);
for(const itemId of nearby.keys()){ for (const itemId of nearby) {
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks); addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks);
});
}
}
return;
}
await ctx.game.produceAsync(draft => {
const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey);
if(entity) addEntityEffect(entity, ctx.effect, ctx.stacks);
})
}),
onHpChange: createTrigger("onHpChange", async ctx => {
await ctx.game.produceAsync(draft => {
const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey);
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.entityKey === "player" ? ctx.game.value.player : ctx.game.value.enemies.find(e => e.id === ctx.entityKey);
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,{entityKey: ctx.entityKey, 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.sourceEntityKey ?? enemy.id;
for(const [target, effect, stacks] of intent.effects){
for(const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game, { effect, entityKey: entity.id, stacks, sourceEntityKey: 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 createStartWith(build: (triggers: Triggers) => void){
const triggers = createTriggers();
build(triggers);
return async function(game: CombatGameContext){
await triggers.onCombatStart.execute(game,{});
try {
while (true) {
await triggers.onTurnStart.execute(game, {entityKey: "player"});
while (true) {
const action = await promptMainAction(game);
if (action.action === "end-turn") break;
if (action.action === "play") {
await triggers.onCardPlayed.execute(game, action);
}
}
await triggers.onTurnEnd.execute(game, {entityKey: "player"});
for (const enemy of getAliveEnemies(game.value)) {
await triggers.onTurnStart.execute(game, {entityKey: 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, {entityKey: enemy.id});
}
}
}catch(e){
if(e === game.value) return game.value.result;
throw e;
} }
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 }; type TriggerContext<TKey extends keyof TriggerTypes> = TriggerTypes[TKey] & {
function createTrigger<TKey extends keyof TriggerTypes>(event: TKey, fallback?: (ctx: TriggerContext<TKey>) => Promise<void>) { event: TKey;
const {use, execute} = createMiddlewareChain<TriggerContext<TKey>,void>(fallback); game: CombatGameContext;
return { };
use, function createTrigger<TKey extends keyof TriggerTypes>(
execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => { event: TKey,
const param = {...ctx, game, event}; fallback?: (ctx: TriggerContext<TKey>) => Promise<void>,
await execute(param); ) {
return param; 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;
},
};
}
@@ -1,53 +1,83 @@
import type { PlayerDeck } from "../deck/types"; import { Part } from "@/core/part";
import {EnemyData, IntentData} from "@/samples/slay-the-spire-like/system/types"; import { Region } from "@/core/region";
import {EffectData} from "@/samples/slay-the-spire-like/system/types"; import {
import {GridInventory} from "@/samples/slay-the-spire-like/system/grid-inventory"; CardData,
import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress"; 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 EffectTable = Record<string, { data: EffectData; stacks: number }>;
export type CombatEntity = { export type CombatEntity = {
id: string; // player is just "player" id: string; // player is just "player"
effects: EffectTable; effects: EffectTable;
hp: number; hp: number;
maxHp: number; maxHp: number;
isAlive: boolean; isAlive: boolean;
}; };
export type PlayerEntity = CombatEntity & { export interface IPlayerState {
energy: number; hp: number;
maxEnergy: number; maxHp: number;
deck: PlayerDeck; itemEffects: Record<string, EffectTable>;
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 & { export type EnemyEntity = CombatEntity & {
enemy: EnemyData; enemy: EnemyData;
intents: Record<string, IntentData>; intents: Record<string, IntentData>;
currentIntent: IntentData; currentIntent: IntentData;
}; };
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd"; export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
export type CombatResult = "victory" | "defeat"; export type CombatResult = "victory" | "defeat";
export type LootEntry = { export type LootEntry =
type: "gold"; | {
amount: number; type: "gold";
} | { amount: number;
type: "item", }
itemId: string; | {
}; type: "item";
itemId: string;
};
export type CombatState = { export type CombatState = {
enemies: EnemyEntity[]; enemies: EnemyEntity[];
player: PlayerEntity; player: PlayerEntity;
inventory: GridInventory<GameItemMeta>;
phase: CombatPhase; phase: CombatPhase;
turnNumber: number; turnNumber: number;
result: CombatResult | null; result: CombatResult | null;
loot: LootEntry[]; loot: LootEntry[];
}; };
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>; 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>;
@@ -1,66 +1,68 @@
import {moveToRegion } from '@/core/region'; import { moveToRegion } from "@/core/region";
import { createRegion } from '@/core/region'; import { createRegion } from "@/core/region";
import type { GridInventory } from '../grid-inventory/types'; import type { CardData } from "../types";
import type { GameItemMeta } from '../progress/types'; import type { DeckRegions, GameCard, IInventory, PlayerDeck } from "./types";
import type { CardData } from '../types';
import type {DeckRegions, GameCard, PlayerDeck} from './types';
function generateCardId(itemId: string, cellIndex: number): string { function generateCardId(itemId: string, cellIndex: number): string {
return `card-${itemId}-${cellIndex}`; return `card-${itemId}-${cellIndex}`;
} }
function createCard( itemId: string, cardData: CardData, cellIndex: number): GameCard { function createCard(
return { itemId: string,
id: generateCardId(itemId, cellIndex), cardData: CardData,
regionId: '', cellIndex: number,
position: [], ): GameCard {
itemId, return {
cardData id: generateCardId(itemId, cellIndex),
}; regionId: "",
position: [],
itemId,
cardData,
};
} }
function createDeckRegions(): DeckRegions { function createDeckRegions(): DeckRegions {
return { return {
drawPile: createRegion('drawPile', []), drawPile: createRegion("drawPile", []),
hand: createRegion('hand', []), hand: createRegion("hand", []),
discardPile: createRegion('discardPile', []), discardPile: createRegion("discardPile", []),
exhaustPile: createRegion('exhaustPile', []), exhaustPile: createRegion("exhaustPile", []),
}; };
} }
function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): PlayerDeck { function generateDeckFromInventory(inventory: IInventory): PlayerDeck {
const cards: Record<string, GameCard> = {}; const cards: Record<string, GameCard> = {};
const regions = createDeckRegions(); const regions = createDeckRegions();
for (const item of inventory.items.values()) { for (const item of inventory.items.values()) {
const itemData = item.meta?.itemData; const itemData = item.meta?.itemData;
if (!itemData) continue; if (!itemData) continue;
const count = item.shape.count; const count = item.shape.count;
for (let i = 0; i < count; i++) { for (let i = 0; i < count; i++) {
const card = createCard(item.id, itemData.card, i); const card = createCard(item.id, itemData.card, i);
cards[card.id] = card; cards[card.id] = card;
moveToRegion(card, null, regions.drawPile); moveToRegion(card, null, regions.drawPile);
}
} }
}
return { return {
cards, cards,
regions regions,
}; };
} }
function createPlayerDeck(): PlayerDeck { function createPlayerDeck(): PlayerDeck {
return { return {
cards: {}, cards: {},
regions: createDeckRegions(), regions: createDeckRegions(),
}; };
} }
export { export {
generateDeckFromInventory, generateDeckFromInventory,
createCard, createCard,
createPlayerDeck, createPlayerDeck,
createDeckRegions, createDeckRegions,
generateCardId, generateCardId,
}; };
@@ -1,7 +1,2 @@
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types'; export * from "./factory";
export { export * from "./types";
generateDeckFromInventory,
createCard,
createPlayerDeck,
generateCardId,
} from './factory';
@@ -1,14 +1,26 @@
import type { Part } from '@/core/part'; import type { Part } from "@/core/part";
import {CardData} from "@/samples/slay-the-spire-like/system/types"; import { CardData, ItemData } from "@/samples/slay-the-spire-like/system/types";
import {Region} from "@/core/region"; import { Region } from "@/core/region";
import { ParsedShape } from "../utils/parse-shape";
/** /**
* Metadata for a game card. * Metadata for a game card.
* Bridges inventory item data with the card system. * Bridges inventory item data with the card system.
*/ */
export interface GameCardMeta { export interface GameCardMeta {
cardData: CardData; cardData: CardData;
itemId: string; itemId: string;
}
export interface IInventory {
items: Map<
string,
{
id: string;
meta?: { itemData: ItemData };
shape: ParsedShape;
}
>;
} }
/** /**
@@ -21,19 +33,19 @@ export type GameCard = Part<GameCardMeta>;
* Player deck structure containing card pools. * Player deck structure containing card pools.
*/ */
export interface PlayerDeck { export interface PlayerDeck {
/** All cards indexed by ID */ /** All cards indexed by ID */
cards: Record<string, GameCard>; cards: Record<string, GameCard>;
regions: DeckRegions; regions: DeckRegions;
} }
export interface DeckRegions{ export interface DeckRegions {
/** Card IDs in the draw pile */ /** Card IDs in the draw pile */
drawPile: Region; drawPile: Region;
/** Card IDs in the player's hand */ /** Card IDs in the player's hand */
hand: Region; hand: Region;
/** Card IDs in the discard pile */ /** Card IDs in the discard pile */
discardPile: Region; discardPile: Region;
/** Card IDs in the exhaust pile (removed from combat) */ /** Card IDs in the exhaust pile (removed from combat) */
exhaustPile: Region; 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;
}
@@ -1,13 +1,3 @@
export type { CellCoordinate, CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './types'; export * from "./types";
export { export * from "./transform";
createGridInventory, export * from "./factory";
flipItem,
getAdjacentItems,
getItemAtCell,
getOccupiedCellSet,
moveItem,
placeItem,
removeItem,
rotateItem,
validatePlacement,
} from './transform';
@@ -1,34 +1,48 @@
import type { ParsedShape } from '../utils/parse-shape'; import { EffectData } from "../types";
import type { Transform2D } from '../utils/shape-collision'; import type { ParsedShape } from "../utils/parse-shape";
import type { Transform2D } from "../utils/shape-collision";
import { import {
checkBoardCollision, checkBoardCollision,
checkBounds, checkBounds,
flipXTransform, flipXTransform,
flipYTransform, flipYTransform,
rotateTransform, rotateTransform,
transformShape, transformShape,
} from '../utils/shape-collision'; } from "../utils/shape-collision";
import type { CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './types'; import type {
CellKey,
GameItemMeta,
GridInventory,
InventoryItem,
MutationResult,
PlacementResult,
} from "./types";
/** /**
* Creates a new empty grid inventory. * Creates a new empty grid inventory.
* Note: When used inside `.produce()`, call this before returning the draft. * Note: When used inside `.produce()`, call this before returning the draft.
*/ */
export function createGridInventory<TMeta = Record<string, unknown>>(width: number, height: number): GridInventory<TMeta> { export function createGridInventory<TMeta = Record<string, unknown>>(
return { width: number,
width, height: number,
height, ): GridInventory<TMeta> {
items: new Map<string, InventoryItem<TMeta>>(), return {
occupiedCells: new Set<CellKey>(), width,
}; height,
items: new Map<string, InventoryItem<TMeta>>(),
occupiedCells: new Set<CellKey>(),
};
} }
/** /**
* Builds a Set of occupied cell keys from a shape and transform. * Builds a Set of occupied cell keys from a shape and transform.
*/ */
function getShapeCellKeys(shape: ParsedShape, transform: Transform2D): Set<CellKey> { function getShapeCellKeys(
const cells = transformShape(shape, transform); shape: ParsedShape,
return new Set(cells.map(c => `${c.x},${c.y}` as CellKey)); transform: Transform2D,
): Set<CellKey> {
const cells = transformShape(shape, transform);
return new Set(cells.map((c) => `${c.x},${c.y}` as CellKey));
} }
/** /**
@@ -36,19 +50,19 @@ function getShapeCellKeys(shape: ParsedShape, transform: Transform2D): Set<CellK
* Checks bounds and collision with all other items. * Checks bounds and collision with all other items.
*/ */
export function validatePlacement<TMeta = Record<string, unknown>>( export function validatePlacement<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>, inventory: GridInventory<TMeta>,
shape: InventoryItem<TMeta>['shape'], shape: InventoryItem<TMeta>["shape"],
transform: Transform2D transform: Transform2D,
): PlacementResult { ): PlacementResult {
if (!checkBounds(shape, transform, inventory.width, inventory.height)) { if (!checkBounds(shape, transform, inventory.width, inventory.height)) {
return { valid: false, reason: '超出边界' }; return { valid: false, reason: "超出边界" };
} }
if (checkBoardCollision(shape, transform, inventory.occupiedCells)) { if (checkBoardCollision(shape, transform, inventory.occupiedCells)) {
return { valid: false, reason: '与已有物品重叠' }; return { valid: false, reason: "与已有物品重叠" };
} }
return { valid: true }; return { valid: true };
} }
/** /**
@@ -56,27 +70,33 @@ export function validatePlacement<TMeta = Record<string, unknown>>(
* **Mutates directly** — call inside a `.produce()` callback. * **Mutates directly** — call inside a `.produce()` callback.
* Does not validate; call `validatePlacement` first. * Does not validate; call `validatePlacement` first.
*/ */
export function placeItem<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>, item: InventoryItem<TMeta>): void { export function placeItem<TMeta = Record<string, unknown>>(
const cells = getShapeCellKeys(item.shape, item.transform); inventory: GridInventory<TMeta>,
for (const cellKey of cells) { item: InventoryItem<TMeta>,
inventory.occupiedCells.add(cellKey); ): void {
} const cells = getShapeCellKeys(item.shape, item.transform);
inventory.items.set(item.id, item); for (const cellKey of cells) {
inventory.occupiedCells.add(cellKey);
}
inventory.items.set(item.id, item);
} }
/** /**
* Removes an item from the grid by its ID. * Removes an item from the grid by its ID.
* **Mutates directly** — call inside a `.produce()` callback. * **Mutates directly** — call inside a `.produce()` callback.
*/ */
export function removeItem<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>, itemId: string): void { export function removeItem<TMeta = Record<string, unknown>>(
const item = inventory.items.get(itemId); inventory: GridInventory<TMeta>,
if (!item) return; itemId: string,
): void {
const item = inventory.items.get(itemId);
if (!item) return;
const cells = getShapeCellKeys(item.shape, item.transform); const cells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of cells) { for (const cellKey of cells) {
inventory.occupiedCells.delete(cellKey); inventory.occupiedCells.delete(cellKey);
} }
inventory.items.delete(itemId); inventory.items.delete(itemId);
} }
/** /**
@@ -85,41 +105,41 @@ export function removeItem<TMeta = Record<string, unknown>>(inventory: GridInven
* Validates before applying; returns result indicating success. * Validates before applying; returns result indicating success.
*/ */
export function moveItem<TMeta = Record<string, unknown>>( export function moveItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>, inventory: GridInventory<TMeta>,
itemId: string, itemId: string,
newTransform: Transform2D newTransform: Transform2D,
): MutationResult { ): MutationResult {
const item = inventory.items.get(itemId); const item = inventory.items.get(itemId);
if (!item) { if (!item) {
return { success: false, reason: '物品不存在' }; return { success: false, reason: "物品不存在" };
} }
// Temporarily remove item's cells for validation // Temporarily remove item's cells for validation
const oldCells = getShapeCellKeys(item.shape, item.transform); 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) { for (const cellKey of oldCells) {
inventory.occupiedCells.delete(cellKey); inventory.occupiedCells.add(cellKey);
} }
return { success: false, reason: validation.reason };
}
// Validate new position // Apply new transform
const validation = validatePlacement(inventory, item.shape, newTransform); item.transform = 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 // Add new cells
item.transform = newTransform; const newCells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of newCells) {
inventory.occupiedCells.add(cellKey);
}
// Add new cells return { success: true };
const newCells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of newCells) {
inventory.occupiedCells.add(cellKey);
}
return { success: true };
} }
/** /**
@@ -128,17 +148,17 @@ export function moveItem<TMeta = Record<string, unknown>>(
* Validates before applying; returns result indicating success. * Validates before applying; returns result indicating success.
*/ */
export function rotateItem<TMeta = Record<string, unknown>>( export function rotateItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>, inventory: GridInventory<TMeta>,
itemId: string, itemId: string,
degrees: number degrees: number,
): MutationResult { ): MutationResult {
const item = inventory.items.get(itemId); const item = inventory.items.get(itemId);
if (!item) { if (!item) {
return { success: false, reason: '物品不存在' }; return { success: false, reason: "物品不存在" };
} }
const rotatedTransform = rotateTransform(item.transform, degrees); const rotatedTransform = rotateTransform(item.transform, degrees);
return moveItem(inventory, itemId, rotatedTransform); return moveItem(inventory, itemId, rotatedTransform);
} }
/** /**
@@ -147,50 +167,53 @@ export function rotateItem<TMeta = Record<string, unknown>>(
* Validates before applying; returns result indicating success. * Validates before applying; returns result indicating success.
*/ */
export function flipItem<TMeta = Record<string, unknown>>( export function flipItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>, inventory: GridInventory<TMeta>,
itemId: string, itemId: string,
axis: 'x' | 'y' axis: "x" | "y",
): MutationResult { ): MutationResult {
const item = inventory.items.get(itemId); const item = inventory.items.get(itemId);
if (!item) { if (!item) {
return { success: false, reason: '物品不存在' }; return { success: false, reason: "物品不存在" };
} }
const flippedTransform = axis === 'x' const flippedTransform =
? flipXTransform(item.transform) axis === "x"
: flipYTransform(item.transform); ? flipXTransform(item.transform)
: flipYTransform(item.transform);
return moveItem(inventory, itemId, flippedTransform); return moveItem(inventory, itemId, flippedTransform);
} }
/** /**
* Returns a copy of the occupied cells set. * Returns a copy of the occupied cells set.
*/ */
export function getOccupiedCellSet<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>): Set<CellKey> { export function getOccupiedCellSet<TMeta = Record<string, unknown>>(
return new Set(inventory.occupiedCells); inventory: GridInventory<TMeta>,
): Set<CellKey> {
return new Set(inventory.occupiedCells);
} }
/** /**
* Finds the item occupying the given cell, if any. * Finds the item occupying the given cell, if any.
*/ */
export function getItemAtCell<TMeta = Record<string, unknown>>( export function getItemAtCell<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>, inventory: GridInventory<TMeta>,
x: number, x: number,
y: number y: number,
): InventoryItem<TMeta> | undefined { ): InventoryItem<TMeta> | undefined {
const cellKey = `${x},${y}` as CellKey; const cellKey = `${x},${y}` as CellKey;
if (!inventory.occupiedCells.has(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; return undefined;
}
for (const item of inventory.items.values()) {
const cells = getShapeCellKeys(item.shape, item.transform);
if (cells.has(cellKey)) {
return item;
}
}
return undefined;
} }
/** /**
@@ -198,36 +221,57 @@ export function getItemAtCell<TMeta = Record<string, unknown>>(
* Returns a Map of itemId -> item for deduplication. * Returns a Map of itemId -> item for deduplication.
*/ */
export function getAdjacentItems<TMeta>( export function getAdjacentItems<TMeta>(
inventory: GridInventory<TMeta>, inventory: GridInventory<TMeta>,
itemId: string itemId: string,
): Map<string, InventoryItem<TMeta>> { ): Map<string, InventoryItem<TMeta>> {
const item = inventory.items.get(itemId); const item = inventory.items.get(itemId);
if (!item) { if (!item) {
return new Map(); return new Map();
} }
const ownCells = getShapeCellKeys(item.shape, item.transform); const ownCells = getShapeCellKeys(item.shape, item.transform);
const adjacent = new Map<string, InventoryItem<TMeta>>(); const adjacent = new Map<string, InventoryItem<TMeta>>();
for (const cellKey of ownCells) { for (const cellKey of ownCells) {
const [cx, cy] = cellKey.split(',').map(Number); const [cx, cy] = cellKey.split(",").map(Number);
const neighbors: CellKey[] = [ const neighbors: CellKey[] = [
`${cx + 1},${cy}` as CellKey, `${cx + 1},${cy}` as CellKey,
`${cx - 1},${cy}` as CellKey, `${cx - 1},${cy}` as CellKey,
`${cx},${cy + 1}` as CellKey, `${cx},${cy + 1}` as CellKey,
`${cx},${cy - 1}` as CellKey, `${cx},${cy - 1}` as CellKey,
]; ];
for (const neighborKey of neighbors) { for (const neighborKey of neighbors) {
if (inventory.occupiedCells.has(neighborKey) && !ownCells.has(neighborKey)) { if (
const [nx, ny] = neighborKey.split(',').map(Number); inventory.occupiedCells.has(neighborKey) &&
const neighborItem = getItemAtCell(inventory, nx, ny); !ownCells.has(neighborKey)
if (neighborItem) { ) {
adjacent.set(neighborItem.id, neighborItem); const [nx, ny] = neighborKey.split(",").map(Number);
} const neighborItem = getItemAtCell(inventory, nx, ny);
} if (neighborItem) {
adjacent.set(neighborItem.id, neighborItem);
} }
}
} }
}
return adjacent; 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;
} }
@@ -1,5 +1,6 @@
import type { ParsedShape } from '../utils/parse-shape'; import { EffectData, ItemData } from "../types";
import type { Transform2D } from '../utils/shape-collision'; import type { ParsedShape } from "../utils/parse-shape";
import type { Transform2D } from "../utils/shape-collision";
/** /**
* String key representing a grid cell in "x,y" format. * String key representing a grid cell in "x,y" format.
@@ -10,8 +11,8 @@ export type CellKey = `${number},${number}`;
* Simple 2D coordinate for grid cells. * Simple 2D coordinate for grid cells.
*/ */
export interface CellCoordinate { export interface CellCoordinate {
x: number; x: number;
y: number; y: number;
} }
/** /**
@@ -19,25 +20,29 @@ export interface CellCoordinate {
* @template TMeta - Optional metadata type for game-specific data * @template TMeta - Optional metadata type for game-specific data
*/ */
export interface InventoryItem<TMeta> { export interface InventoryItem<TMeta> {
/** Unique item identifier */ /** Unique item identifier */
id: string; id: string;
/** Reference to the item's shape definition */ /** Reference to the item's shape definition */
shape: ParsedShape; shape: ParsedShape;
/** Current transformation (position, rotation, flips) */ /** Current transformation (position, rotation, flips) */
transform: Transform2D; transform: Transform2D;
/** Optional metadata for game-specific data */ /** Optional metadata for game-specific data */
meta?: TMeta; meta?: TMeta;
} }
/** /**
* Result of a placement validation check. * Result of a placement validation check.
*/ */
export type PlacementResult = { valid: true } | { valid: false; reason: string }; export type PlacementResult =
| { valid: true }
| { valid: false; reason: string };
/** /**
* Result of a mutation operation (move, rotate, flip). * Result of a mutation operation (move, rotate, flip).
*/ */
export type MutationResult = { success: true } | { success: false; reason: string }; export type MutationResult =
| { success: true }
| { success: false; reason: string };
/** /**
* Grid inventory state. * Grid inventory state.
@@ -45,12 +50,21 @@ export type MutationResult = { success: true } | { success: false; reason: strin
* @template TMeta - Optional metadata type for items * @template TMeta - Optional metadata type for items
*/ */
export interface GridInventory<TMeta> { export interface GridInventory<TMeta> {
/** Board width in cells */ /** Board width in cells */
width: number; width: number;
/** Board height in cells */ /** Board height in cells */
height: number; height: number;
/** Map of itemId -> InventoryItem for all placed items */ /** Map of itemId -> InventoryItem for all placed items */
items: Map<string, InventoryItem<TMeta>>; items: Map<string, InventoryItem<TMeta>>;
/** Set of occupied cells in "x,y" format for O(1) collision lookups */ /** Set of occupied cells in "x,y" format for O(1) collision lookups */
occupiedCells: Set<CellKey>; 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>;
@@ -1,5 +1,3 @@
export { MapNodeType, MapLayerType } from './types'; export * from "./generator";
export type { MapNode, MapLayer, PointCrawlMap, MapGenerationConfig } from './types'; export * from "./navigation";
export * from "./types";
export { generatePointCrawlMap } from './generator';
export { getNode, getChildren, getParents, hasPath, findAllPaths } from './generator';
@@ -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;
}
@@ -1,83 +1,88 @@
import {EncounterData} from "@/samples/slay-the-spire-like/system/types"; import { EncounterData } from "@/samples/slay-the-spire-like/system/types";
/** /**
* Types of nodes that can appear on the point crawl map. * Types of nodes that can appear on the point crawl map.
*/ */
export enum MapNodeType { export enum MapNodeType {
Start = 'start', Start = "start",
End = 'end', End = "end",
Minion = 'minion', Minion = "minion",
Elite = 'elite', Elite = "elite",
Event = 'event', Event = "event",
Camp = 'camp', Camp = "camp",
Shop = 'shop', Shop = "shop",
Curio = 'curio', Curio = "curio",
} }
/** /**
* Semantic type of a layer. * Semantic type of a layer.
*/ */
export enum MapLayerType { export enum MapLayerType {
Wild = 'wild', Wild = "wild",
Settlement = 'settlement', Settlement = "settlement",
} }
/** /**
* A single node on the map. * A single node on the map.
*/ */
export interface MapNode { export interface MapNode {
/** Unique identifier */ /** Unique identifier */
id: string; id: string;
/** Which layer this node belongs to */ /** Which layer this node belongs to */
layerIndex: number; layerIndex: number;
/** Semantic type of the node */ /** Semantic type of the node */
type: MapNodeType; type: MapNodeType;
/** IDs of nodes in the next layer this node connects to */ /** IDs of nodes in the next layer this node connects to */
childIds: string[]; childIds: string[];
/** Encounter data assigned to this node (from encounter CSV) */ /** Encounter data assigned to this node (from encounter CSV) */
encounter?: EncounterData; encounter?: EncounterData;
} }
/** /**
* A horizontal layer of nodes at the same progression stage. * A horizontal layer of nodes at the same progression stage.
*/ */
export interface MapLayer { export interface MapLayer {
/** Layer index (0 = start, last = end) */ /** Layer index (0 = start, last = end) */
index: number; index: number;
/** Ordered IDs of nodes in this layer */ /** Ordered IDs of nodes in this layer */
nodeIds: string[]; nodeIds: string[];
/** Semantic type of the layer */ /** Semantic type of the layer */
layerType: MapLayerType | 'start' | 'end'; layerType: MapLayerType | "start" | "end";
/** Direct references to nodes in this layer (for performance) */ /** Direct references to nodes in this layer (for performance) */
nodes: MapNode[]; nodes: MapNode[];
} }
/** /**
* A fully generated point crawl map. * A fully generated point crawl map.
*/ */
export interface PointCrawlMap { export interface PointCrawlMap {
/** Layers from start to end */ /** Layers from start to end */
layers: MapLayer[]; layers: MapLayer[];
/** All nodes keyed by ID */ /** All nodes keyed by ID */
nodes: Map<string, MapNode>; nodes: Map<string, MapNode>;
/** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */ /** Reverse index: nodeId → parent node IDs (for fast getParent lookup) */
parentIndex?: Map<string, string[]>; parentIndex?: Map<string, string[]>;
}
export interface PointCrawlMapNavigator {
currentNodeId: string;
visitedNodes: Set<string>;
} }
/** /**
* Configuration for map generation. * Configuration for map generation.
*/ */
export interface MapGenerationConfig { export interface MapGenerationConfig {
/** Total number of layers (including start and end) */ /** Total number of layers (including start and end) */
totalLayers: number; totalLayers: number;
/** Number of nodes in each wild layer */ /** Number of nodes in each wild layer */
wildLayerNodeCount: number; wildLayerNodeCount: number;
/** Number of nodes in each settlement layer */ /** Number of nodes in each settlement layer */
settlementLayerNodeCount: number; settlementLayerNodeCount: number;
/** Probability weights for wild node types (should sum to 100) */ /** Probability weights for wild node types (should sum to 100) */
wildNodeTypeWeights: { wildNodeTypeWeights: {
[MapNodeType.Minion]: number; [MapNodeType.Minion]: number;
[MapNodeType.Elite]: number; [MapNodeType.Elite]: number;
[MapNodeType.Event]: number; [MapNodeType.Event]: number;
}; };
} }
@@ -1,245 +0,0 @@
import type { PointCrawlMap } from '../map/types';
import type { CombatState, EnemyEntity, PlayerEntity, EffectTable } from '../combat/types';
import type { EncounterData, EnemyData, EffectData, IntentData } from '../types';
import type { RunState } from './types';
import { generateDeckFromInventory } from '../deck/factory';
import { ReadonlyRNG } from '@/utils/rng';
// -- Encounter assignment to nodes --
/**
* Assigns an encounter from a pool to a specific node.
* Replaces any existing encounter on that node.
*/
export function assignEncounterToNode(
map: PointCrawlMap,
nodeId: string,
encounter: EncounterData
): void {
const node = map.nodes.get(nodeId);
if (!node) {
throw new Error(`Node "${nodeId}" not found`);
}
node.encounter = encounter;
}
/**
* Assigns encounters from a typed pool to all unassigned nodes of matching type.
* Uses RNG for random selection; each encounter can be assigned multiple times.
*/
export function assignEncountersFromPool(
map: PointCrawlMap,
encounterPool: EncounterData[],
rng: ReadonlyRNG
): void {
if (encounterPool.length === 0) return;
for (const node of map.nodes.values()) {
if (node.type === 'start' || node.type === 'end') continue;
if (node.encounter) continue;
const assigned = encounterPool[rng.nextInt(encounterPool.length)];
node.encounter = assigned;
}
}
/**
* Batch-assigns encounters for all node types from a typed index.
* Keys in the index should match encounter type strings (e.g. 'minion', 'elite').
*/
export function assignAllEncounters(
map: PointCrawlMap,
encounterIndex: Map<string, EncounterData[]>,
rng: ReadonlyRNG
): void {
for (const node of map.nodes.values()) {
if (node.type === 'start' || node.type === 'end') continue;
if (node.encounter) continue;
const encounterType = node.type;
const pool = encounterIndex.get(encounterType);
if (!pool || pool.length === 0) continue;
node.encounter = pool[rng.nextInt(pool.length)];
}
}
// -- CombatState construction --
/**
* Builds a full CombatState from an encounter and the current run state.
* - Creates EnemyEntity instances with HP, initial buffs, and intents
* - Creates PlayerEntity with energy (3), deck from inventory, and HP from run state
* - Sets initial phase to 'playerTurn', turn 1
*/
export function buildCombatState(
encounter: EncounterData,
runState: RunState,
): CombatState {
const enemies = createEnemyEntities(encounter);
const deck = generateDeckFromInventory(runState.inventory);
const player = createPlayerEntity(runState, deck);
return {
enemies,
player,
inventory: runState.inventory,
phase: 'playerTurn',
turnNumber: 1,
result: null,
loot: [],
};
}
/**
* Creates EnemyEntity instances from encounter enemy definitions.
* Each enemy gets: HP from encounter tuple, initial buffs from encounter, intents from enemy definition.
*/
export 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];
}
/**
* Builds an EffectTable from encounter buff definitions.
*/
function buildEffectTable(buffs: readonly [EffectData, number][]): EffectTable {
const table: EffectTable = {};
for (const [effect, stacks] of buffs) {
table[effect.id] = { data: effect, stacks };
}
return table;
}
/**
* Creates a PlayerEntity from the run state and deck.
*/
function createPlayerEntity(runState: RunState, deck: ReturnType<typeof generateDeckFromInventory>): PlayerEntity {
return {
id: "player",
hp: runState.player.currentHp,
maxHp: runState.player.maxHp,
isAlive: runState.player.currentHp > 0,
energy: 3,
maxEnergy: 3,
deck,
itemEffects: {},
effects: {},
};
}
// -- Encounter lifecycle --
/**
* Gets the encounter data for the current node.
*/
export function getCurrentEncounterData(runState: RunState): EncounterData | undefined {
const node = runState.map.nodes.get(runState.currentNodeId);
return node?.encounter;
}
/**
* Checks if the current node has a combat encounter.
*/
export function isCombatEncounter(runState: RunState): boolean {
const encounter = getCurrentEncounterData(runState);
return encounter !== undefined && encounter.enemies.length > 0;
}
/**
* Starts the encounter at the current node.
* Returns the constructed CombatState, or null if no combat encounter.
*/
export function startEncounter(runState: RunState): CombatState | null {
const encounter = getCurrentEncounterData(runState);
if (!encounter || encounter.enemies.length === 0) {
return null;
}
return buildCombatState(encounter, runState);
}
/**
* Resolves a completed combat and applies rewards to the run state.
* Handles: gold loot, item rewards, HP changes.
* Marks the encounter as resolved.
*/
export function resolveCombatEncounter(
runState: RunState,
combatState: CombatState
): { success: true } | { success: false; reason: string } {
if (runState.currentEncounter.resolved) {
return { success: false, reason: '该遭遇已解决' };
}
// Apply HP from combat state back to run state
runState.player.currentHp = Math.max(0, combatState.player.hp);
// Apply loot
for (const loot of combatState.loot) {
if (loot.type === 'gold') {
runState.player.gold += loot.amount;
}
// Item rewards are handled by the caller via addItem()
}
// Mark as resolved
runState.currentEncounter.resolved = true;
runState.currentEncounter.result = {
hpLost: runState.player.maxHp - runState.player.currentHp,
};
runState.resolvedNodeIds.add(runState.currentNodeId);
return { success: true };
}
@@ -1,326 +0,0 @@
import { getNode } from '../map/generator';
import type {MapNode, PointCrawlMap} from '../map/types';
import { placeItem, validatePlacement, createGridInventory, removeItem as gridRemoveItem } from '../grid-inventory/transform';
import type { GameItem, GameItemMeta, RunMutationResult, RunState } from './types';
import type { GridInventory } from '../grid-inventory/types';
import { IDENTITY_TRANSFORM, type Transform2D } from '../utils/shape-collision';
import { parseShapeString, type ParsedShape } from '../utils/parse-shape';
import {ItemData} from "@/samples/slay-the-spire-like/system/types";
// Re-export types
export type {
EncounterResult,
EncounterState,
GameItem,
GameItemMeta,
PlayerState,
RunMutationResult,
RunState,
} from './types';
// Re-export encounter construction functions
export {
assignEncounterToNode,
assignEncountersFromPool,
assignAllEncounters,
buildCombatState,
createEnemyEntities,
getCurrentEncounterData,
isCombatEncounter,
startEncounter,
resolveCombatEncounter,
} from './encounter';
// -- Constants --
const INVENTORY_WIDTH = 6;
const INVENTORY_HEIGHT = 4;
const DEFAULT_MAX_HP = 50;
const DEFAULT_GOLD = 50;
// -- Run creation --
/**
* Creates a new run state with a generated map, player stats, and starter inventory.
*
*/
export function createRunState(map: PointCrawlMap, starterItems: ItemData[]): RunState {
// Find the start node
const startNode = map.layers[0].nodes[0];
// Create empty inventory
const inventory = createGridInventory<GameItemMeta>(INVENTORY_WIDTH, INVENTORY_HEIGHT);
const idCounter = { value: 0 };
// Place starter items
for (const itemData of starterItems) {
const shape = parseShapeString(itemData.shape);
const itemInstance = tryPlaceItemInInventory(inventory, itemData, shape, idCounter);
if (!itemInstance) {
// Inventory too small; item skipped
}
}
return {
map,
player: {
maxHp: DEFAULT_MAX_HP,
currentHp: DEFAULT_MAX_HP,
gold: DEFAULT_GOLD,
},
inventory,
currentNodeId: startNode.id,
currentEncounter: {
nodeId: startNode.id,
resolved: false,
},
resolvedNodeIds: new Set<string>(),
_idCounter: idCounter,
};
}
// -- Movement --
/**
* Checks whether the player can move to the target node.
* The target must be a child of the current node.
*/
export function canMoveTo(runState: RunState, targetNodeId: string): boolean {
const currentNode = getNode(runState.map, runState.currentNodeId);
if (!currentNode) return false;
return currentNode.childIds.includes(targetNodeId);
}
/**
* Moves the player to the target node.
* The target must be a direct child of the current node.
*
* @returns `{ success: true }` on success, or `{ success: false, reason: string }` on failure.
*/
export function moveToNode(runState: RunState, targetNodeId: string): RunMutationResult {
if (!canMoveTo(runState, targetNodeId)) {
return { success: false, reason: '无法移动到该节点' };
}
const targetNode = getNode(runState.map, targetNodeId);
if (!targetNode) {
return { success: false, reason: '目标节点不存在' };
}
// Update current position
runState.currentNodeId = targetNodeId;
// Create new encounter state for the target node
const isResolved = runState.resolvedNodeIds.has(targetNodeId);
runState.currentEncounter = {
nodeId: targetNodeId,
resolved: isResolved,
};
return { success: true };
}
// -- Encounter management --
/**
* Marks the current encounter as resolved with optional result data.
*/
export function resolveEncounter(runState: RunState, result?: { goldEarned?: number; hpLost?: number; hpGained?: number; itemRewards?: string[] }): RunMutationResult {
if (runState.currentEncounter.resolved) {
return { success: false, reason: '该遭遇已解决' };
}
runState.currentEncounter.resolved = true;
runState.currentEncounter.result = result;
runState.resolvedNodeIds.add(runState.currentNodeId);
// Apply result effects
if (result) {
if (result.goldEarned) {
runState.player.gold += result.goldEarned;
}
if (result.hpLost) {
runState.player.currentHp = Math.max(0, runState.player.currentHp - result.hpLost);
}
if (result.hpGained) {
runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + result.hpGained);
}
}
return { success: true };
}
/**
* Checks whether the encounter at the given node has been resolved.
*/
export function isEncounterResolved(runState: RunState, nodeId: string): boolean {
return runState.resolvedNodeIds.has(nodeId);
}
// -- Player stats --
/**
* Damages the player. Clamps HP to 0 minimum.
*/
export function damagePlayer(runState: RunState, amount: number): void {
runState.player.currentHp = Math.max(0, runState.player.currentHp - amount);
}
/**
* Heals the player. Clamps HP to maxHp.
*/
export function healPlayer(runState: RunState, amount: number): void {
runState.player.currentHp = Math.min(runState.player.maxHp, runState.player.currentHp + amount);
}
/**
* Sets the player's maximum HP and adjusts current HP proportionally.
*/
export function setMaxHp(runState: RunState, newMax: number): void {
const diff = newMax - runState.player.maxHp;
runState.player.maxHp = newMax;
runState.player.currentHp = Math.max(0, Math.min(newMax, runState.player.currentHp + diff));
}
/**
* Adds gold to the player.
*/
export function addGold(runState: RunState, amount: number): void {
runState.player.gold += amount;
}
/**
* Spends gold. Returns false if the player doesn't have enough gold.
*/
export function spendGold(runState: RunState, amount: number): RunMutationResult {
if (amount <= 0) {
return { success: false, reason: '金额必须大于零' };
}
if (runState.player.gold < amount) {
return { success: false, reason: '金币不足' };
}
runState.player.gold -= amount;
return { success: true };
}
// -- Inventory management --
/**
* Adds an item from CSV data to the inventory.
* Finds the first available valid position.
*
* @returns The placed item instance, or undefined if no valid position exists.
*/
export function addItem(
runState: RunState,
itemData: ItemData
): GameItem | undefined {
const shape = parseShapeString(itemData.shape);
return tryPlaceItemInInventory(runState.inventory, itemData, shape, runState._idCounter);
}
/**
* Removes an item from the inventory by its instance ID.
*/
export function removeItem(runState: RunState, instanceId: string): void {
gridRemoveItem(runState.inventory, instanceId);
}
// -- Query helpers --
/**
* Returns the node the player is currently at.
*/
export function getCurrentNode(runState: RunState): MapNode | undefined {
return getNode(runState.map, runState.currentNodeId);
}
/**
* Returns all reachable child nodes from the current position.
*/
export function getReachableChildren(runState: RunState): MapNode[] {
const currentNode = getNode(runState.map, runState.currentNodeId);
if (!currentNode) return [];
return currentNode.childIds
.map(id => getNode(runState.map, id))
.filter((n): n is MapNode => n !== undefined);
}
/**
* Returns available children that haven't been resolved yet.
*/
export function getUnresolvedChildren(runState: RunState): MapNode[] {
return getReachableChildren(runState).filter(
node => !runState.resolvedNodeIds.has(node.id)
);
}
/**
* Checks if the current encounter is the start node (no encounter).
*/
export function isAtStartNode(runState: RunState): boolean {
return runState.currentNodeId === runState.map.layers[0]?.nodes[0]?.id;
}
/**
* Checks if the current encounter is the end node.
*/
export function isAtEndNode(runState: RunState): boolean {
const endLayer = runState.map.layers[runState.map.layers.length - 1];
return endLayer?.nodes[0]?.id === runState.currentNodeId;
}
// -- Internal helpers --
/**
* Generates a unique item instance ID.
*/
function generateInstanceId(counter: { value: number }): string {
counter.value++;
return `item-${counter.value}`;
}
/**
* Tries to place an item in the inventory by scanning all possible positions.
* Returns the placed item instance, or undefined if no valid position exists.
*/
function tryPlaceItemInInventory(
inventory: GridInventory<GameItemMeta>,
itemData: ItemData,
shape: ParsedShape,
idCounter: { value: number }
): GameItem | undefined {
const instanceId = generateInstanceId(idCounter);
// Try to find a valid position
for (let y = 0; y <= inventory.height - shape.height; y++) {
for (let x = 0; x <= inventory.width - shape.width; x++) {
const transform = {
...IDENTITY_TRANSFORM,
offset: { x, y },
};
const validation = validatePlacement(inventory, shape, transform);
if (validation.valid) {
const item: GameItem = {
id: instanceId,
shape,
transform,
meta: {
itemData,
shape,
},
};
placeItem(inventory, item);
return item;
}
}
}
return undefined;
}
@@ -1,87 +0,0 @@
import type { PointCrawlMap } from '../map/types';
import type { GridInventory, InventoryItem } from '../grid-inventory/types';
import type { ParsedShape } from '../utils/parse-shape';
import {ItemData} from "@/samples/slay-the-spire-like/system/types";
/**
* Result of an encounter (combat, event, etc.).
*/
export interface EncounterResult {
/** Gold earned from the encounter */
goldEarned?: number;
/** HP lost during the encounter */
hpLost?: number;
/** HP gained (e.g., from camp heal) */
hpGained?: number;
/** Item IDs rewarded */
itemRewards?: string[];
}
/**
* Runtime state of an encounter at a specific node.
*/
export interface EncounterState {
/** The node ID where this encounter is located */
nodeId: string;
/** Whether the encounter has been resolved */
resolved: boolean;
/** Optional result data after resolution */
result?: EncounterResult;
}
/**
* Metadata attached to each inventory item instance.
* Bridges CSV item data with the grid inventory system.
*/
export interface GameItemMeta {
/** Original CSV item data */
itemData: ItemData;
/** Parsed shape for grid placement */
shape: ParsedShape;
/** Consumed uses, if card cost type is uses**/
depletion?: number;
}
/**
* An item instance in the game inventory.
* Extends InventoryItem with game-specific metadata.
*/
export type GameItem = InventoryItem<GameItemMeta>;
/**
* Player runtime state.
*/
export interface PlayerState {
/** Maximum HP */
maxHp: number;
/** Current HP */
currentHp: number;
/** Current gold */
gold: number;
}
/**
* Full run state for a game session.
* Designed to be used inside `MutableSignal.produce()` callbacks.
*/
export interface RunState {
/** Generated point crawl map */
map: PointCrawlMap;
/** Player HP and gold */
player: PlayerState;
/** Grid inventory with placed items */
inventory: GridInventory<GameItemMeta>;
/** Current node ID where the player is located */
currentNodeId: string;
/** State of the encounter at the current node */
currentEncounter: EncounterState;
/** Set of node IDs whose encounters have been resolved */
resolvedNodeIds: Set<string>;
/** Internal counter for generating unique item instance IDs */
_idCounter: { value: number };
}
/**
* Result of a mutation operation on the run state.
*/
export type RunMutationResult = { success: true } | { success: false; reason: string };
+22 -58
View File
@@ -1,61 +1,25 @@
export type EffectData = { import {
readonly id: string; Card,
readonly name: string; Effect,
readonly description: string; Encounter,
readonly lifecycle: EffectLifecycle; EncounterType,
}; Enemy,
export type EffectLifecycle = "instant" | "temporary" | "lingering" | "permanent" | "posture" | "item" | "itemTemporary" | "itemUntilPlay" | "itemUntilDiscard" | "itemPermanent"; Intent,
Item,
} from "../data/desert";
export type EnemyData = { export type CardData = Card;
readonly id: string; export type ItemData = Item;
readonly name: string; export type EffectData = Effect;
readonly intents: readonly IntentData[]; export type IntentData = Intent;
readonly description: string; export type EnemyData = Enemy;
}; export type EncounterData<T extends EncounterType = EncounterType> =
Encounter & { type: T };
export type CardType = "item" | "status"; export {
export type CardCostType = "energy" | "uses" | "none"; CardTargetType,
export type CardTargetType = "single" | "none"; CardEffectTarget,
export type EffectTarget = "self" | "player" | "team"; IntentEffectTarget,
} from "../data/desert";
export type CardData = { export { ContentModule } from "../data";
readonly id: string;
readonly name: string;
readonly desc: string;
readonly type: CardType;
readonly costType: CardCostType;
readonly costCount: number;
readonly targetType: CardTargetType;
readonly effects: readonly [CardEffectTrigger, CardEffectTarget, EffectData, number][];
};
export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard";
export type CardEffectTarget = "self" | "target" | "all" | "random"
export type EncounterType = "minion" | "elite" | "event" | "shop" | "camp" | "curio";
export type EncounterData = {
readonly id: string;
readonly type: EncounterType;
readonly name: string;
readonly description: string;
readonly enemies: readonly [data: EnemyData, hp: number, effects: [EffectData, stacks: number][]][];
readonly dialogue: string;
};
export type IntentData = {
readonly id: string;
readonly enemy: EnemyData;
readonly initialIntent: boolean;
readonly nextIntents: readonly IntentData[];
readonly brokenIntent: readonly IntentData[];
readonly effects: readonly [EffectTarget, EffectData, number][];
};
export type ItemData = {
readonly id: string;
readonly type: string;
readonly name: string;
readonly shape: string;
readonly card: CardData;
readonly price: number;
readonly description: string;
};
@@ -0,0 +1,2 @@
export * from "./parse-shape";
export * from "./shape-collision";
@@ -1,129 +1,132 @@
import type { ParsedShape } from './parse-shape'; import type { ParsedShape } from "./parse-shape";
/** /**
* Represents a 2D point in grid coordinates. * Represents a 2D point in grid coordinates.
*/ */
export interface Point2D { export interface Point2D {
x: number; x: number;
y: number; y: number;
} }
/** /**
* 2D transformation to apply to a shape. * 2D transformation to apply to a shape.
*/ */
export interface Transform2D { export interface Transform2D {
/** Translation offset in grid units */ /** Translation offset in grid units */
offset: Point2D; offset: Point2D;
/** Rotation in degrees (0, 90, 180, 270) */ /** Rotation in degrees (0, 90, 180, 270) */
rotation: number; rotation: number;
/** Whether to flip horizontally */ /** Whether to flip horizontally */
flipX: boolean; flipX: boolean;
/** Whether to flip vertically */ /** Whether to flip vertically */
flipY: boolean; flipY: boolean;
} }
/** /**
* Default transform (identity). * Default transform (identity).
*/ */
export const IDENTITY_TRANSFORM: Transform2D = { export const IDENTITY_TRANSFORM: Transform2D = {
offset: { x: 0, y: 0 }, offset: { x: 0, y: 0 },
rotation: 0, rotation: 0,
flipX: false, flipX: false,
flipY: false, flipY: false,
}; };
/** /**
* Gets all occupied cell coordinates from a shape. * Gets all occupied cell coordinates from a shape.
*/ */
export function getOccupiedCells(shape: ParsedShape): Point2D[] { export function getOccupiedCells(shape: ParsedShape): Point2D[] {
const cells: Point2D[] = []; const cells: Point2D[] = [];
for (let y = 0; y < shape.height; y++) { for (let y = 0; y < shape.height; y++) {
for (let x = 0; x < shape.width; x++) { for (let x = 0; x < shape.width; x++) {
if (shape.grid[y]?.[x]) { if (shape.grid[y]?.[x]) {
cells.push({ x, y }); cells.push({ x, y });
} }
}
} }
return cells; }
return cells;
} }
/** /**
* Applies a 2D transformation to a point. * Applies a 2D transformation to a point.
*/ */
export function transformPoint( export function transformPoint(
point: Point2D, point: Point2D,
transform: Transform2D, transform: Transform2D,
shapeWidth: number, shapeWidth: number,
shapeHeight: number shapeHeight: number,
): Point2D { ): Point2D {
let { x, y } = point; let { x, y } = point;
// Apply flips // Apply flips
if (transform.flipX) { if (transform.flipX) {
x = shapeWidth - 1 - x; x = shapeWidth - 1 - x;
} }
if (transform.flipY) { if (transform.flipY) {
y = shapeHeight - 1 - y; y = shapeHeight - 1 - y;
} }
// Apply rotation (around origin 0,0) // Apply rotation (around origin 0,0)
const rotation = ((transform.rotation % 360) + 360) % 360; const rotation = ((transform.rotation % 360) + 360) % 360;
let rotatedX = x; let rotatedX = x;
let rotatedY = y; let rotatedY = y;
switch (rotation) { switch (rotation) {
case 90: case 90:
rotatedX = y; rotatedX = y;
rotatedY = -x; rotatedY = -x;
break; break;
case 180: case 180:
rotatedX = -x; rotatedX = -x;
rotatedY = -y; rotatedY = -y;
break; break;
case 270: case 270:
rotatedX = -y; rotatedX = -y;
rotatedY = x; rotatedY = x;
break; break;
} }
// Apply offset // Apply offset
return { return {
x: rotatedX + transform.offset.x, x: rotatedX + transform.offset.x,
y: rotatedY + transform.offset.y, y: rotatedY + transform.offset.y,
}; };
} }
/** /**
* Transforms a shape and returnss its occupied cells in world coordinates. * Transforms a shape and returnss its occupied cells in world coordinates.
*/ */
export function transformShape(shape: ParsedShape, transform: Transform2D): Point2D[] { export function transformShape(
const cells = getOccupiedCells(shape); shape: ParsedShape,
return cells.map(cell => transform: Transform2D,
transformPoint(cell, transform, shape.width, shape.height) ): 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). * Checks if two transformed shapes collide (share any occupied cell).
*/ */
export function checkCollision( export function checkCollision(
shapeA: ParsedShape, shapeA: ParsedShape,
transformA: Transform2D, transformA: Transform2D,
shapeB: ParsedShape, shapeB: ParsedShape,
transformB: Transform2D transformB: Transform2D,
): boolean { ): boolean {
const cellsA = transformShape(shapeA, transformA); const cellsA = transformShape(shapeA, transformA);
const cellsB = transformShape(shapeB, transformB); const cellsB = transformShape(shapeB, transformB);
const setA = new Set(cellsA.map(c => `${c.x},${c.y}`)); const setA = new Set(cellsA.map((c) => `${c.x},${c.y}`));
for (const cell of cellsB) { for (const cell of cellsB) {
if (setA.has(`${cell.x},${cell.y}`)) { if (setA.has(`${cell.x},${cell.y}`)) {
return true; return true;
}
} }
}
return false; return false;
} }
/** /**
@@ -133,19 +136,19 @@ export function checkCollision(
* @param occupiedCells Set of occupied board cells in "x,y" format * @param occupiedCells Set of occupied board cells in "x,y" format
*/ */
export function checkBoardCollision( export function checkBoardCollision(
shape: ParsedShape, shape: ParsedShape,
transform: Transform2D, transform: Transform2D,
occupiedCells: Set<string> occupiedCells: Set<string>,
): boolean { ): boolean {
const cells = transformShape(shape, transform); const cells = transformShape(shape, transform);
for (const cell of cells) { for (const cell of cells) {
if (occupiedCells.has(`${cell.x},${cell.y}`)) { if (occupiedCells.has(`${cell.x},${cell.y}`)) {
return true; return true;
}
} }
}
return false; return false;
} }
/** /**
@@ -156,42 +159,47 @@ export function checkBoardCollision(
* @param boardHeight Board height * @param boardHeight Board height
*/ */
export function checkBounds( export function checkBounds(
shape: ParsedShape, shape: ParsedShape,
transform: Transform2D, transform: Transform2D,
boardWidth: number, boardWidth: number,
boardHeight: number boardHeight: number,
): boolean { ): boolean {
const cells = transformShape(shape, transform); const cells = transformShape(shape, transform);
for (const cell of cells) { for (const cell of cells) {
if (cell.x < 0 || cell.x >= boardWidth || cell.y < 0 || cell.y >= boardHeight) { if (
return false; cell.x < 0 ||
} cell.x >= boardWidth ||
cell.y < 0 ||
cell.y >= boardHeight
) {
return false;
} }
}
return true; return true;
} }
/** /**
* Validates that a placement is both in bounds and collision-free. * Validates that a placement is both in bounds and collision-free.
* @returns Object with `valid` flag and optional `reason` string * @returns Object with `valid` flag and optional `reason` string
*/ */
export function validatePlacement( export function validateShapePlacement(
shape: ParsedShape, shape: ParsedShape,
transform: Transform2D, transform: Transform2D,
boardWidth: number, boardWidth: number,
boardHeight: number, boardHeight: number,
occupiedCells: Set<string> occupiedCells: Set<string>,
): { valid: true } | { valid: false; reason: string } { ): { valid: true } | { valid: false; reason: string } {
if (!checkBounds(shape, transform, boardWidth, boardHeight)) { if (!checkBounds(shape, transform, boardWidth, boardHeight)) {
return { valid: false, reason: '超出边界' }; return { valid: false, reason: "超出边界" };
} }
if (checkBoardCollision(shape, transform, occupiedCells)) { if (checkBoardCollision(shape, transform, occupiedCells)) {
return { valid: false, reason: '与已有形状重叠' }; return { valid: false, reason: "与已有形状重叠" };
} }
return { valid: true }; return { valid: true };
} }
/** /**
@@ -199,29 +207,32 @@ export function validatePlacement(
* @param current The current transform * @param current The current transform
* @param degrees Degrees to rotate (typically 90, 180, or 270) * @param degrees Degrees to rotate (typically 90, 180, or 270)
*/ */
export function rotateTransform(current: Transform2D, degrees: number): Transform2D { export function rotateTransform(
return { current: Transform2D,
...current, degrees: number,
rotation: ((current.rotation + degrees) % 360 + 360) % 360, ): Transform2D {
}; return {
...current,
rotation: (((current.rotation + degrees) % 360) + 360) % 360,
};
} }
/** /**
* Flips a transform horizontally. * Flips a transform horizontally.
*/ */
export function flipXTransform(current: Transform2D): Transform2D { export function flipXTransform(current: Transform2D): Transform2D {
return { return {
...current, ...current,
flipX: !current.flipX, flipX: !current.flipX,
}; };
} }
/** /**
* Flips a transform vertically. * Flips a transform vertically.
*/ */
export function flipYTransform(current: Transform2D): Transform2D { export function flipYTransform(current: Transform2D): Transform2D {
return { return {
...current, ...current,
flipY: !current.flipY, flipY: !current.flipY,
}; };
} }
+191 -93
View File
@@ -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);
}
} }
+105
View File
@@ -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
View File
@@ -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";
+15 -5
View File
@@ -1,28 +1,38 @@
type Middleware<TContext, TReturn> = ( type Middleware<TContext, TReturn> = (
context: TContext, context: TContext,
next: () => Promise<TReturn> next: () => Promise<TReturn>,
) => Promise<TReturn>; ) => Promise<TReturn>;
export type MiddlewareChain<TContext, TReturn> = { export type MiddlewareChain<TContext, TReturn> = {
use: (middleware: Middleware<TContext, TReturn>) => void; use: (middleware: Middleware<TContext, TReturn>) => () => void;
execute: (context: TContext) => Promise<TReturn>; execute: (context: TContext) => Promise<TReturn>;
}; };
export function createMiddlewareChain<TContext extends object, TReturn=TContext>( export function createMiddlewareChain<
fallback?: (context: TContext) => Promise<TReturn> TContext extends object,
TReturn = TContext,
>(
fallback?: (context: TContext) => Promise<TReturn>,
): MiddlewareChain<TContext, TReturn> { ): MiddlewareChain<TContext, TReturn> {
const middlewares: Middleware<TContext, TReturn>[] = []; const middlewares: Middleware<TContext, TReturn>[] = [];
return { return {
use(middleware: Middleware<TContext, TReturn>) { use(middleware: Middleware<TContext, TReturn>) {
middlewares.push(middleware); middlewares.push(middleware);
return function () {
const index = middlewares.indexOf(middleware);
if (index !== -1) {
middlewares.splice(index, 1);
}
};
}, },
async execute(context: TContext) { async execute(context: TContext) {
let index = 0; let index = 0;
async function dispatch(ctx: TContext): Promise<TReturn> { async function dispatch(ctx: TContext): Promise<TReturn> {
if (index >= middlewares.length) { if (index >= middlewares.length) {
return fallback ? fallback(ctx) : ctx as unknown as TReturn; return fallback ? fallback(ctx) : (ctx as unknown as TReturn);
} }
const current = middlewares[index++]; const current = middlewares[index++];
return current(ctx, () => dispatch(ctx)); return current(ctx, () => dispatch(ctx));
+17 -7
View File
@@ -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);
} }
+39
View File
@@ -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);
});
}
File diff suppressed because it is too large Load Diff
+183 -161
View File
@@ -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', const promptPromise = new Promise<PromptEvent>((resolve) => {
params: [], ctx._commands.on("prompt", resolve);
options: {},
flags: {}
};
const promptDef = createPromptDef<[]>(schemaObj);
expect(promptDef.schema).toEqual(schemaObj);
}); });
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 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;
});
const ctx = createGameContext(registry, { score: 0 }); const error = promptEvent.tryCommit({
name: "input",
const promptPromise = new Promise<PromptEvent>(resolve => { params: [42],
ctx._commands.on('prompt', resolve); options: {},
}); flags: {},
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);
}
});
}); });
+729 -605
View File
File diff suppressed because it is too large Load Diff
+302 -282
View File
@@ -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);
});
}); });
File diff suppressed because it is too large Load Diff
@@ -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,8 +1,13 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import data 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('data import', () => { describe("data import", () => {
it('should import properly', () => { it("should import properly", () => {
expect(data.desert.effects).toBeDefined(); expect(data.desert.getEffects).toBeDefined();
}); expect(
data.desert.getCards().find((c: CardData) => c.id === "crossbow")?.effects
?.length,
).toBe(2);
});
}); });
@@ -1,224 +1,244 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { import {
generateDeckFromInventory, generateDeckFromInventory,
createCard, createCard,
createDeckRegions, createDeckRegions,
createPlayerDeck, createPlayerDeck,
generateCardId, generateCardId,
} from '@/samples/slay-the-spire-like/system/deck/factory'; } from "@/samples/slay-the-spire-like/system/deck/factory";
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/system/grid-inventory'; import {
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory'; createGridInventory,
import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types'; placeItem,
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/system/utils/shape-collision'; } from "@/samples/slay-the-spire-like/system/grid-inventory";
import { parseShapeString } from '@/samples/slay-the-spire-like/system/utils/parse-shape'; import type {
import type { CardData, ItemData } from '@/samples/slay-the-spire-like/system/types'; 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. * Helper: create a minimal CardData for testing.
*/ */
function createTestCardData(id: string, name: string, desc: string): CardData { function createTestCardData(id: string, name: string, desc: string): CardData {
return { return {
id, id,
name, name,
desc, desc,
type: 'item', type: "item",
costType: 'energy', costType: "energy",
costCount: 1, costCount: 1,
targetType: 'single', targetType: "enemy",
effects: [], effects: [],
}; };
} }
/** /**
* Helper: create a minimal ItemData for testing. * Helper: create a minimal ItemData for testing.
*/ */
function createTestItemData(id: string, name: string, shapeStr: string, desc: string): ItemData { function createTestItemData(
return { id: string,
id, name: string,
type: 'weapon', shapeStr: string,
name, desc: string,
shape: shapeStr, ): ItemData {
card: createTestCardData(id, name, desc), return {
price: 10, id,
description: desc, type: "weapon",
}; name,
shape: shapeStr,
card: createTestCardData(id, name, desc),
price: 10,
description: desc,
};
} }
/** /**
* Helper: create a minimal GameItemMeta for testing. * Helper: create a minimal GameItemMeta for testing.
*/ */
function createTestMeta(name: string, desc: string, shapeStr: string): GameItemMeta { function createTestMeta(
const shape = parseShapeString(shapeStr); name: string,
const itemData = createTestItemData(name.toLowerCase(), name, shapeStr, desc); desc: string,
return { shapeStr: string,
itemData, ): GameItemMeta {
shape, const shape = parseShapeString(shapeStr);
}; const itemData = createTestItemData(name.toLowerCase(), name, shapeStr, desc);
return {
itemData,
shape,
};
} }
/** /**
* Helper: create a test inventory with some items. * Helper: create a test inventory with some items.
*/ */
function createTestInventory(): GridInventory<GameItemMeta> { function createTestInventory(): GridInventory<GameItemMeta> {
const inv = createGridInventory<GameItemMeta>(6, 4); const inv = createGridInventory<GameItemMeta>(6, 4);
// Item "短刀" with shape "oe" (2 cells) // Item "短刀" with shape "oe" (2 cells)
const meta1 = createTestMeta('短刀', '【攻击3】【攻击3】', 'oe'); const meta1 = createTestMeta("短刀", "【攻击3】【攻击3】", "oe");
const item1: InventoryItem<GameItemMeta> = { const item1: InventoryItem<GameItemMeta> = {
id: 'dagger-1', id: "dagger-1",
shape: meta1.shape, shape: meta1.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } }, transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
meta: meta1, meta: meta1,
}; };
placeItem(inv, item1); placeItem(inv, item1);
// Item "盾" with shape "oesw" (4 cells) // Item "盾" with shape "oesw" (4 cells)
const meta2 = createTestMeta('盾', '【防御3】', 'oesw'); const meta2 = createTestMeta("盾", "【防御3】", "oesw");
const item2: InventoryItem<GameItemMeta> = { const item2: InventoryItem<GameItemMeta> = {
id: 'shield-1', id: "shield-1",
shape: meta2.shape, shape: meta2.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 0 } }, transform: { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 0 } },
meta: meta2, meta: meta2,
}; };
placeItem(inv, item2); placeItem(inv, item2);
return inv; return inv;
} }
describe('deck/factory', () => { describe("deck/factory", () => {
describe('generateCardId', () => { describe("generateCardId", () => {
it('should generate deterministic unique IDs', () => { it("should generate deterministic unique IDs", () => {
expect(generateCardId('item-1', 0)).toBe('card-item-1-0'); expect(generateCardId("item-1", 0)).toBe("card-item-1-0");
expect(generateCardId('item-1', 1)).toBe('card-item-1-1'); expect(generateCardId("item-1", 1)).toBe("card-item-1-1");
expect(generateCardId('item-2', 0)).toBe('card-item-2-0'); 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([]);
}); });
describe('createCard', () => { it("should link cards to their source items", () => {
it('should create a card with itemId and cardData', () => { const inv = createTestInventory();
const cardData = createTestCardData('wound', '伤口', '无法被弃牌'); const deck = generateDeckFromInventory(inv);
const card = createCard('wound-1', cardData, 0);
expect(card.id).toBe('card-wound-1-0'); const daggerCards = Object.values(deck.cards).filter(
expect(card.itemId).toBe('wound-1'); (c) => c.itemId === "dagger-1",
expect(card.cardData).toBe(cardData); );
expect(card.regionId).toBe(''); const shieldCards = Object.values(deck.cards).filter(
expect(card.position).toEqual([]); (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("盾");
}); });
describe('generateDeckFromInventory', () => { it("should set displayName and description from item data", () => {
it('should generate correct number of cards based on shape cell counts', () => { const inv = createTestInventory();
const inv = createTestInventory(); const deck = generateDeckFromInventory(inv);
// "短刀" has 2 cells, "盾" has 4 cells = 6 total for (const card of Object.values(deck.cards)) {
const deck = generateDeckFromInventory(inv); expect(card.cardData.name).toBeTruthy();
expect(card.cardData.desc).toBeTruthy();
}
expect(Object.keys(deck.cards).length).toBe(6); const daggerCard = Object.values(deck.cards).find(
expect(deck.regions.drawPile.childIds.length).toBe(6); (c) => c.cardData.name === "短刀",
expect(deck.regions.hand.childIds).toEqual([]); );
expect(deck.regions.discardPile.childIds).toEqual([]); expect(daggerCard?.cardData.name).toBe("短刀");
expect(deck.regions.exhaustPile.childIds).toEqual([]); expect(daggerCard?.cardData.desc).toBe("【攻击3】【攻击3】");
});
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 assign unique IDs to each card from same item", () => {
it('should create regions for all deck zones', () => { const inv = createTestInventory();
const regions = createDeckRegions(); const deck = generateDeckFromInventory(inv);
expect(regions.drawPile.id).toBe('drawPile'); const daggerCards = Object.values(deck.cards).filter(
expect(regions.hand.id).toBe('hand'); (c) => c.itemId === "dagger-1",
expect(regions.discardPile.id).toBe('discardPile'); );
expect(regions.exhaustPile.id).toBe('exhaustPile');
});
it('should have empty childIds initially', () => { const ids = daggerCards.map((c) => c.id);
const regions = createDeckRegions(); const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
expect(regions.drawPile.childIds).toEqual([]);
expect(regions.hand.childIds).toEqual([]);
expect(regions.discardPile.childIds).toEqual([]);
expect(regions.exhaustPile.childIds).toEqual([]);
});
}); });
describe('createPlayerDeck', () => { it("should handle empty inventory", () => {
it('should create an empty deck structure', () => { const inv = createGridInventory<GameItemMeta>(6, 4);
const deck = createPlayerDeck(); const deck = generateDeckFromInventory(inv);
expect(deck.cards).toEqual({}); expect(Object.keys(deck.cards).length).toBe(0);
expect(deck.regions.drawPile.childIds).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([]);
});
}); });
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
@@ -1,386 +1,405 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/system/map/generator'; import {
import { MapNodeType, MapLayerType } from '@/samples/slay-the-spire-like/system/map/types'; generatePointCrawlMap,
import { createRNG } from '@/utils/rng'; hasPath,
import { encounters } from '@/samples/slay-the-spire-like/data/desert'; } from "@/samples/slay-the-spire-like/system/map/generator";
import {
MapNodeType,
MapLayerType,
} from "@/samples/slay-the-spire-like/system/map/types";
import { createRNG } from "@/utils/rng";
import { getEncounters } from "@/samples/slay-the-spire-like/data/desert";
const encounters = getEncounters();
describe('generatePointCrawlMap', () => { describe("generatePointCrawlMap", () => {
it('should generate a map with 10 layers', () => { it("should generate a map with 10 layers", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(123), encounters);
expect(map.layers.length).toBe(10); expect(map.layers.length).toBe(10);
}); });
it('should have correct layer structure', () => { it("should have correct layer structure", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(123), encounters);
const expectedStructure = [ const expectedStructure = [
'start', "start",
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Settlement, MapLayerType.Settlement,
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Settlement, MapLayerType.Settlement,
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Wild, MapLayerType.Wild,
'end', "end",
]; ];
for (let i = 0; i < expectedStructure.length; i++) { for (let i = 0; i < expectedStructure.length; i++) {
expect(map.layers[i].layerType).toBe(expectedStructure[i]); expect(map.layers[i].layerType).toBe(expectedStructure[i]);
}
});
it("should have correct node counts per layer", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const expectedCounts = [1, 3, 3, 4, 3, 3, 4, 3, 3, 1];
for (let i = 0; i < expectedCounts.length; i++) {
expect(map.layers[i].nodeIds.length).toBe(expectedCounts[i]);
}
});
it("should have Start and End nodes with correct types", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const startNode = map.nodes.get("node-0-0");
const endNode = map.nodes.get("node-9-0");
expect(startNode?.type).toBe(MapNodeType.Start);
expect(endNode?.type).toBe(MapNodeType.End);
});
it("should have wild layers with minion/elite/event types", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const wildLayerIndices = [1, 2, 4, 5, 7, 8];
const validWildTypes = new Set([
MapNodeType.Minion,
MapNodeType.Elite,
MapNodeType.Event,
]);
for (const layerIdx of wildLayerIndices) {
const layer = map.layers[layerIdx];
for (const nodeId of layer.nodeIds) {
const node = map.nodes.get(nodeId);
expect(node).toBeDefined();
expect(validWildTypes.has(node!.type)).toBe(true);
}
}
});
it("should have settlement layers with at least 1 camp, 1 shop, 1 curio", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const settlementLayerIndices = [3, 6];
for (const layerIdx of settlementLayerIndices) {
const layer = map.layers[layerIdx];
const nodeTypes = layer.nodeIds.map((id) => map.nodes.get(id)!.type);
expect(nodeTypes).toContain(MapNodeType.Camp);
expect(nodeTypes).toContain(MapNodeType.Shop);
expect(nodeTypes).toContain(MapNodeType.Curio);
expect(nodeTypes.length).toBe(4);
}
});
it("should have Start connected to all 3 wild nodes", () => {
const map = generatePointCrawlMap(createRNG(42), encounters);
const startNode = map.nodes.get("node-0-0");
const wildLayer = map.layers[1];
expect(startNode?.childIds.length).toBe(3);
expect(startNode?.childIds).toEqual(
expect.arrayContaining(wildLayer.nodeIds),
);
});
it("should have each wild node connect to 1 wild node in wild→wild layers", () => {
const map = generatePointCrawlMap(createRNG(42), encounters);
const wildToWildTransitions = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
{ src: 7, tgt: 8 },
];
for (const transition of wildToWildTransitions) {
const srcLayer = map.layers[transition.src];
for (const srcId of srcLayer.nodeIds) {
const srcNode = map.nodes.get(srcId);
expect(srcNode?.childIds.length).toBe(1);
const childLayer = map.layers[transition.tgt];
expect(childLayer.nodeIds).toContain(srcNode!.childIds[0]);
}
}
});
it("should have each wild node connect to 2 settlement nodes in wild→settlement layers", () => {
const map = generatePointCrawlMap(createRNG(42), encounters);
const wildToSettlementTransitions = [
{ src: 2, tgt: 3 },
{ src: 5, tgt: 6 },
];
for (const transition of wildToSettlementTransitions) {
const srcLayer = map.layers[transition.src];
for (const srcId of srcLayer.nodeIds) {
const srcNode = map.nodes.get(srcId);
expect(srcNode?.childIds.length).toBe(2);
const childLayer = map.layers[transition.tgt];
for (const childId of srcNode!.childIds) {
expect(childLayer.nodeIds).toContain(childId);
} }
}); }
}
});
it('should have correct node counts per layer', () => { it("should have settlement nodes connect correctly (1-2-2-1 pattern)", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(42), encounters);
const expectedCounts = [1, 3, 3, 4, 3, 3, 4, 3, 3, 1]; const settlementToWildTransitions = [
{ src: 3, tgt: 4 },
{ src: 6, tgt: 7 },
];
for (let i = 0; i < expectedCounts.length; i++) { for (const transition of settlementToWildTransitions) {
expect(map.layers[i].nodeIds.length).toBe(expectedCounts[i]); const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt];
// First and last settlement connect to 1 wild
const firstSettlement = map.nodes.get(srcLayer.nodeIds[0]);
expect(firstSettlement?.childIds.length).toBe(1);
const lastSettlement = map.nodes.get(srcLayer.nodeIds[3]);
expect(lastSettlement?.childIds.length).toBe(1);
// Middle two settlements connect to 2 wilds
for (let i = 1; i <= 2; i++) {
const midSettlement = map.nodes.get(srcLayer.nodeIds[i]);
expect(midSettlement?.childIds.length).toBe(2);
for (const childId of midSettlement!.childIds) {
expect(tgtLayer.nodeIds).toContain(childId);
} }
}); }
}
});
it('should have Start and End nodes with correct types', () => { it("should have all 3 wild nodes connect to End", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(42), encounters);
const startNode = map.nodes.get('node-0-0'); const lastWildLayer = map.layers[8];
const endNode = map.nodes.get('node-9-0'); const endNode = map.nodes.get("node-9-0");
expect(startNode?.type).toBe(MapNodeType.Start); for (const wildId of lastWildLayer.nodeIds) {
expect(endNode?.type).toBe(MapNodeType.End); const wildNode = map.nodes.get(wildId);
}); expect(wildNode?.childIds).toEqual([endNode!.id]);
}
});
it('should have wild layers with minion/elite/event types', () => { it("should have all nodes reachable from Start and can reach End", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(123), encounters);
const wildLayerIndices = [1, 2, 4, 5, 7, 8]; const startId = "node-0-0";
const validWildTypes = new Set([MapNodeType.Minion, MapNodeType.Elite, MapNodeType.Event]); const endId = "node-9-0";
for (const layerIdx of wildLayerIndices) { for (const nodeId of map.nodes.keys()) {
const layer = map.layers[layerIdx]; if (nodeId === startId || nodeId === endId) continue;
for (const nodeId of layer.nodeIds) { expect(hasPath(map, startId, nodeId)).toBe(true);
const node = map.nodes.get(nodeId); expect(hasPath(map, nodeId, endId)).toBe(true);
expect(node).toBeDefined(); }
expect(validWildTypes.has(node!.type)).toBe(true); });
}
it("should not have crossing edges in wild→wild transitions", () => {
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToWildTransitions = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
{ src: 7, tgt: 8 },
];
for (const transition of wildToWildTransitions) {
const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt];
// Collect edges as pairs of indices
const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
for (let s = 0; s < srcLayer.nodeIds.length; s++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
for (const tgtId of srcNode!.childIds) {
const t = tgtLayer.nodeIds.indexOf(tgtId);
edges.push({ srcIndex: s, tgtIndex: t });
} }
}); }
it('should have settlement layers with at least 1 camp, 1 shop, 1 curio', () => { // Check for crossings
const map = generatePointCrawlMap(createRNG(123), encounters); for (let e1 = 0; e1 < edges.length; e1++) {
const settlementLayerIndices = [3, 6]; for (let e2 = e1 + 1; e2 < edges.length; e2++) {
const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
for (const layerIdx of settlementLayerIndices) { if (s1 === s2) continue;
const layer = map.layers[layerIdx]; if (t1 === t2) continue;
const nodeTypes = layer.nodeIds.map(id => map.nodes.get(id)!.type);
expect(nodeTypes).toContain(MapNodeType.Camp); const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(nodeTypes).toContain(MapNodeType.Shop); expect(crosses).toBe(false);
expect(nodeTypes).toContain(MapNodeType.Curio);
expect(nodeTypes.length).toBe(4);
} }
}); }
}
});
it('should have Start connected to all 3 wild nodes', () => { it("should not have crossing edges in wild→settlement transitions", () => {
const map = generatePointCrawlMap(createRNG(42), encounters); const map = generatePointCrawlMap(createRNG(12345), encounters);
const startNode = map.nodes.get('node-0-0'); const wildToSettlementTransitions = [
const wildLayer = map.layers[1]; { src: 2, tgt: 3 },
{ src: 5, tgt: 6 },
];
expect(startNode?.childIds.length).toBe(3); for (const transition of wildToSettlementTransitions) {
expect(startNode?.childIds).toEqual(expect.arrayContaining(wildLayer.nodeIds)); const srcLayer = map.layers[transition.src];
}); const tgtLayer = map.layers[transition.tgt];
it('should have each wild node connect to 1 wild node in wild→wild layers', () => { // Collect edges as pairs of indices
const map = generatePointCrawlMap(createRNG(42), encounters); const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
const wildToWildTransitions = [ for (let s = 0; s < srcLayer.nodeIds.length; s++) {
{ src: 1, tgt: 2 }, const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
{ src: 4, tgt: 5 }, for (const tgtId of srcNode!.childIds) {
{ src: 7, tgt: 8 }, const t = tgtLayer.nodeIds.indexOf(tgtId);
]; edges.push({ srcIndex: s, tgtIndex: t });
for (const transition of wildToWildTransitions) {
const srcLayer = map.layers[transition.src];
for (const srcId of srcLayer.nodeIds) {
const srcNode = map.nodes.get(srcId);
expect(srcNode?.childIds.length).toBe(1);
const childLayer = map.layers[transition.tgt];
expect(childLayer.nodeIds).toContain(srcNode!.childIds[0]);
}
} }
}); }
it('should have each wild node connect to 2 settlement nodes in wild→settlement layers', () => { // Check for crossings
const map = generatePointCrawlMap(createRNG(42), encounters); for (let e1 = 0; e1 < edges.length; e1++) {
const wildToSettlementTransitions = [ for (let e2 = e1 + 1; e2 < edges.length; e2++) {
{ src: 2, tgt: 3 }, const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
{ src: 5, tgt: 6 }, const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
];
for (const transition of wildToSettlementTransitions) { if (s1 === s2) continue;
const srcLayer = map.layers[transition.src]; if (t1 === t2) continue;
for (const srcId of srcLayer.nodeIds) {
const srcNode = map.nodes.get(srcId); const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(srcNode?.childIds.length).toBe(2); expect(crosses).toBe(false);
const childLayer = map.layers[transition.tgt];
for (const childId of srcNode!.childIds) {
expect(childLayer.nodeIds).toContain(childId);
}
}
} }
}); }
}
});
it('should have settlement nodes connect correctly (1-2-2-1 pattern)', () => { it("should not have crossing edges in settlement→wild transitions", () => {
const map = generatePointCrawlMap(createRNG(42), encounters); const map = generatePointCrawlMap(createRNG(12345), encounters);
const settlementToWildTransitions = [ const settlementToWildTransitions = [
{ src: 3, tgt: 4 }, { src: 3, tgt: 4 },
{ src: 6, tgt: 7 }, { src: 6, tgt: 7 },
]; ];
for (const transition of settlementToWildTransitions) { for (const transition of settlementToWildTransitions) {
const srcLayer = map.layers[transition.src]; const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt]; const tgtLayer = map.layers[transition.tgt];
// First and last settlement connect to 1 wild // Collect edges as pairs of indices
const firstSettlement = map.nodes.get(srcLayer.nodeIds[0]); const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
expect(firstSettlement?.childIds.length).toBe(1); for (let s = 0; s < srcLayer.nodeIds.length; s++) {
const lastSettlement = map.nodes.get(srcLayer.nodeIds[3]); const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
expect(lastSettlement?.childIds.length).toBe(1); for (const tgtId of srcNode!.childIds) {
const t = tgtLayer.nodeIds.indexOf(tgtId);
// Middle two settlements connect to 2 wilds edges.push({ srcIndex: s, tgtIndex: t });
for (let i = 1; i <= 2; i++) {
const midSettlement = map.nodes.get(srcLayer.nodeIds[i]);
expect(midSettlement?.childIds.length).toBe(2);
for (const childId of midSettlement!.childIds) {
expect(tgtLayer.nodeIds).toContain(childId);
}
}
} }
}); }
it('should have all 3 wild nodes connect to End', () => { // Check for crossings
const map = generatePointCrawlMap(createRNG(42), encounters); for (let e1 = 0; e1 < edges.length; e1++) {
const lastWildLayer = map.layers[8]; for (let e2 = e1 + 1; e2 < edges.length; e2++) {
const endNode = map.nodes.get('node-9-0'); const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
for (const wildId of lastWildLayer.nodeIds) { if (s1 === s2) continue;
const wildNode = map.nodes.get(wildId); if (t1 === t2) continue;
expect(wildNode?.childIds).toEqual([endNode!.id]);
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(crosses).toBe(false);
} }
}); }
}
});
it('should have all nodes reachable from Start and can reach End', () => { it("should assign encounters to all non-Start/End nodes", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(456), encounters);
const startId = 'node-0-0';
const endId = 'node-9-0';
for (const nodeId of map.nodes.keys()) { for (const node of map.nodes.values()) {
if (nodeId === startId || nodeId === endId) continue; if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
expect(hasPath(map, startId, nodeId)).toBe(true); // Start and End nodes should not have encounters
expect(hasPath(map, nodeId, endId)).toBe(true); expect(node.encounter).toBeUndefined();
} else {
// All other nodes (minion/elite/event/camp/shop/curio) must have encounters
expect(
node.encounter,
`Node ${node.id} (${node.type}) should have encounter data`,
).toBeDefined();
expect(node.encounter!.name).toBeTruthy();
expect(node.encounter!.description).toBeTruthy();
}
}
});
it("should assign encounters to all nodes across multiple seeds", () => {
// Test multiple seeds to ensure no random failure
for (let seed = 0; seed < 20; seed++) {
const map = generatePointCrawlMap(createRNG(seed), encounters);
for (const node of map.nodes.values()) {
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
continue;
} }
}); expect(
node.encounter,
`Seed ${seed}: Node ${node.id} (${node.type}) missing encounter`,
).toBeDefined();
expect(node.encounter!.name).toBeTruthy();
expect(node.encounter!.description).toBeTruthy();
}
}
});
it('should not have crossing edges in wild→wild transitions', () => { it("should minimize same-layer repetitions in wild layer pairs", () => {
const map = generatePointCrawlMap(createRNG(12345), encounters); // Test that wild layers in pairs (1-2, 4-5, 7-8) have minimal duplicate types within each layer
const wildToWildTransitions = [ const map = generatePointCrawlMap(createRNG(12345), encounters);
{ src: 1, tgt: 2 }, const wildPairIndices = [
{ src: 4, tgt: 5 }, [1, 2],
{ src: 7, tgt: 8 }, [4, 5],
]; [7, 8],
];
for (const transition of wildToWildTransitions) { for (const [layer1Idx, layer2Idx] of wildPairIndices) {
const srcLayer = map.layers[transition.src]; const layer1 = map.layers[layer1Idx];
const tgtLayer = map.layers[transition.tgt]; const layer2 = map.layers[layer2Idx];
// Collect edges as pairs of indices // Count repetitions in layer 1
const edges: Array<{ srcIndex: number; tgtIndex: number }> = []; const layer1Types = layer1.nodeIds.map((id) => map.nodes.get(id)!.type);
for (let s = 0; s < srcLayer.nodeIds.length; s++) { const layer1Unique = new Set(layer1Types).size;
const srcNode = map.nodes.get(srcLayer.nodeIds[s]); const layer1Repetitions = layer1Types.length - layer1Unique;
for (const tgtId of srcNode!.childIds) {
const t = tgtLayer.nodeIds.indexOf(tgtId);
edges.push({ srcIndex: s, tgtIndex: t });
}
}
// Check for crossings // Count repetitions in layer 2
for (let e1 = 0; e1 < edges.length; e1++) { const layer2Types = layer2.nodeIds.map((id) => map.nodes.get(id)!.type);
for (let e2 = e1 + 1; e2 < edges.length; e2++) { const layer2Unique = new Set(layer2Types).size;
const { srcIndex: s1, tgtIndex: t1 } = edges[e1]; const layer2Repetitions = layer2Types.length - layer2Unique;
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
if (s1 === s2) continue; // With optimal selection, we expect fewer repetitions than pure random
if (t1 === t2) continue; // On average, random would have ~1.5 repetitions per 3-node layer
// With 3 attempts, we should typically get 0-1 repetitions
expect(layer1Repetitions + layer2Repetitions).toBeLessThanOrEqual(2);
}
});
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2); it("should minimize adjacent repetitions in wild→wild connections", () => {
expect(crosses).toBe(false); // Test that wild nodes connected by wild→wild edges have different types
} const map = generatePointCrawlMap(createRNG(12345), encounters);
} const wildToWildPairs = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
{ src: 7, tgt: 8 },
];
let totalAdjacentRepetitions = 0;
for (const pair of wildToWildPairs) {
const srcLayer = map.layers[pair.src];
const tgtLayer = map.layers[pair.tgt];
// Each wild node connects to exactly 1 wild node in next layer (1-to-1)
for (let i = 0; i < srcLayer.nodeIds.length; i++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[i])!;
const tgtId = srcNode.childIds[0];
const tgtNode = map.nodes.get(tgtId)!;
if (srcNode.type === tgtNode.type) {
totalAdjacentRepetitions++;
} }
}); }
}
it('should not have crossing edges in wild→settlement transitions', () => { // With 3 wild pairs and 3 nodes each, that's 9 connections total
const map = generatePointCrawlMap(createRNG(12345), encounters); // Random would have ~3 repetitions (1/3 chance per connection)
const wildToSettlementTransitions = [ // With optimal selection of 3 attempts, should be much lower (0-2)
{ src: 2, tgt: 3 }, expect(totalAdjacentRepetitions).toBeLessThanOrEqual(3);
{ src: 5, tgt: 6 }, });
];
for (const transition of wildToSettlementTransitions) {
const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt];
// Collect edges as pairs of indices
const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
for (let s = 0; s < srcLayer.nodeIds.length; s++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
for (const tgtId of srcNode!.childIds) {
const t = tgtLayer.nodeIds.indexOf(tgtId);
edges.push({ srcIndex: s, tgtIndex: t });
}
}
// Check for crossings
for (let e1 = 0; e1 < edges.length; e1++) {
for (let e2 = e1 + 1; e2 < edges.length; e2++) {
const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
if (s1 === s2) continue;
if (t1 === t2) continue;
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(crosses).toBe(false);
}
}
}
});
it('should not have crossing edges in settlement→wild transitions', () => {
const map = generatePointCrawlMap(createRNG(12345), encounters);
const settlementToWildTransitions = [
{ src: 3, tgt: 4 },
{ src: 6, tgt: 7 },
];
for (const transition of settlementToWildTransitions) {
const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt];
// Collect edges as pairs of indices
const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
for (let s = 0; s < srcLayer.nodeIds.length; s++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
for (const tgtId of srcNode!.childIds) {
const t = tgtLayer.nodeIds.indexOf(tgtId);
edges.push({ srcIndex: s, tgtIndex: t });
}
}
// Check for crossings
for (let e1 = 0; e1 < edges.length; e1++) {
for (let e2 = e1 + 1; e2 < edges.length; e2++) {
const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
if (s1 === s2) continue;
if (t1 === t2) continue;
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(crosses).toBe(false);
}
}
}
});
it('should assign encounters to all non-Start/End nodes', () => {
const map = generatePointCrawlMap(createRNG(456), encounters);
for (const node of map.nodes.values()) {
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
// Start and End nodes should not have encounters
expect(node.encounter).toBeUndefined();
} else {
// All other nodes (minion/elite/event/camp/shop/curio) must have encounters
expect(node.encounter, `Node ${node.id} (${node.type}) should have encounter data`).toBeDefined();
expect(node.encounter!.name).toBeTruthy();
expect(node.encounter!.description).toBeTruthy();
}
}
});
it('should assign encounters to all nodes across multiple seeds', () => {
// Test multiple seeds to ensure no random failure
for (let seed = 0; seed < 20; seed++) {
const map = generatePointCrawlMap(createRNG(seed), encounters);
for (const node of map.nodes.values()) {
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
continue;
}
expect(node.encounter, `Seed ${seed}: Node ${node.id} (${node.type}) missing encounter`).toBeDefined();
expect(node.encounter!.name).toBeTruthy();
expect(node.encounter!.description).toBeTruthy();
}
}
});
it('should minimize same-layer repetitions in wild layer pairs', () => {
// Test that wild layers in pairs (1-2, 4-5, 7-8) have minimal duplicate types within each layer
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildPairIndices = [
[1, 2],
[4, 5],
[7, 8],
];
for (const [layer1Idx, layer2Idx] of wildPairIndices) {
const layer1 = map.layers[layer1Idx];
const layer2 = map.layers[layer2Idx];
// Count repetitions in layer 1
const layer1Types = layer1.nodeIds.map(id => map.nodes.get(id)!.type);
const layer1Unique = new Set(layer1Types).size;
const layer1Repetitions = layer1Types.length - layer1Unique;
// Count repetitions in layer 2
const layer2Types = layer2.nodeIds.map(id => map.nodes.get(id)!.type);
const layer2Unique = new Set(layer2Types).size;
const layer2Repetitions = layer2Types.length - layer2Unique;
// With optimal selection, we expect fewer repetitions than pure random
// On average, random would have ~1.5 repetitions per 3-node layer
// With 3 attempts, we should typically get 0-1 repetitions
expect(layer1Repetitions + layer2Repetitions).toBeLessThanOrEqual(2);
}
});
it('should minimize adjacent repetitions in wild→wild connections', () => {
// Test that wild nodes connected by wild→wild edges have different types
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToWildPairs = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
{ src: 7, tgt: 8 },
];
let totalAdjacentRepetitions = 0;
for (const pair of wildToWildPairs) {
const srcLayer = map.layers[pair.src];
const tgtLayer = map.layers[pair.tgt];
// Each wild node connects to exactly 1 wild node in next layer (1-to-1)
for (let i = 0; i < srcLayer.nodeIds.length; i++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[i])!;
const tgtId = srcNode.childIds[0];
const tgtNode = map.nodes.get(tgtId)!;
if (srcNode.type === tgtNode.type) {
totalAdjacentRepetitions++;
}
}
}
// With 3 wild pairs and 3 nodes each, that's 9 connections total
// Random would have ~3 repetitions (1/3 chance per connection)
// With optimal selection of 3 attempts, should be much lower (0-2)
expect(totalAdjacentRepetitions).toBeLessThanOrEqual(3);
});
}); });
@@ -1,355 +1,367 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { parseShapeString } from '@/samples/slay-the-spire-like/system/utils/parse-shape'; import { parseShapeString } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
import { import {
checkCollision, checkCollision,
checkBoardCollision, checkBoardCollision,
checkBounds, checkBounds,
validatePlacement, validateShapePlacement,
transformShape, transformShape,
getOccupiedCells, getOccupiedCells,
IDENTITY_TRANSFORM, IDENTITY_TRANSFORM,
} from '@/samples/slay-the-spire-like/system/utils/shape-collision'; } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
describe('parseShapeString', () => { describe("parseShapeString", () => {
it('should parse a single cell with o', () => { it("should parse a single cell with o", () => {
const result = parseShapeString('o'); const result = parseShapeString("o");
expect(result.grid).toEqual([[true]]); expect(result.grid).toEqual([[true]]);
expect(result.width).toBe(1); expect(result.width).toBe(1);
expect(result.height).toBe(1); expect(result.height).toBe(1);
expect(result.count).toBe(1); expect(result.count).toBe(1);
expect(result.originX).toBe(0); expect(result.originX).toBe(0);
expect(result.originY).toBe(0); expect(result.originY).toBe(0);
}); });
it('should parse a horizontal line', () => { it("should parse a horizontal line", () => {
const result = parseShapeString('oee'); const result = parseShapeString("oee");
expect(result.width).toBe(3); expect(result.width).toBe(3);
expect(result.height).toBe(1); expect(result.height).toBe(1);
expect(result.count).toBe(3); expect(result.count).toBe(3);
expect(result.grid).toEqual([[true, true, true]]); expect(result.grid).toEqual([[true, true, true]]);
expect(result.originX).toBe(0); expect(result.originX).toBe(0);
expect(result.originY).toBe(0); expect(result.originY).toBe(0);
}); });
it('should parse a vertical line', () => { it("should parse a vertical line", () => {
const result = parseShapeString('oss'); const result = parseShapeString("oss");
expect(result.width).toBe(1); expect(result.width).toBe(1);
expect(result.height).toBe(3); expect(result.height).toBe(3);
expect(result.count).toBe(3); expect(result.count).toBe(3);
expect(result.grid).toEqual([[true], [true], [true]]); expect(result.grid).toEqual([[true], [true], [true]]);
expect(result.originX).toBe(0); expect(result.originX).toBe(0);
expect(result.originY).toBe(0); expect(result.originY).toBe(0);
}); });
it('should parse an L shape', () => { it("should parse an L shape", () => {
const result = parseShapeString('oes'); const result = parseShapeString("oes");
expect(result.width).toBe(2); expect(result.width).toBe(2);
expect(result.height).toBe(2); expect(result.height).toBe(2);
expect(result.count).toBe(3); expect(result.count).toBe(3);
expect(result.grid).toEqual([ expect(result.grid).toEqual([
[true, true], [true, true],
[false, true], [false, true],
]); ]);
}); });
it('should handle return command', () => { it("should handle return command", () => {
const result = parseShapeString('oeerww'); const result = parseShapeString("oeerww");
expect(result.width).toBe(4); expect(result.width).toBe(4);
expect(result.height).toBe(1); expect(result.height).toBe(1);
expect(result.count).toBe(4); expect(result.count).toBe(4);
expect(result.grid).toEqual([[true, true, true, true]]); expect(result.grid).toEqual([[true, true, true, true]]);
}); });
it('should handle case insensitivity', () => { it("should handle case insensitivity", () => {
const resultLower = parseShapeString('oes'); const resultLower = parseShapeString("oes");
const resultUpper = parseShapeString('OES'); const resultUpper = parseShapeString("OES");
expect(resultLower.grid).toEqual(resultUpper.grid); expect(resultLower.grid).toEqual(resultUpper.grid);
expect(resultLower.count).toBe(resultUpper.count); expect(resultLower.count).toBe(resultUpper.count);
}); });
it('should return empty grid for empty input', () => { it("should return empty grid for empty input", () => {
const result = parseShapeString(''); const result = parseShapeString("");
expect(result.grid).toEqual([[]]); expect(result.grid).toEqual([[]]);
expect(result.width).toBe(0); expect(result.width).toBe(0);
expect(result.height).toBe(1); expect(result.height).toBe(1);
expect(result.count).toBe(0); expect(result.count).toBe(0);
}); });
it('should track origin correctly', () => { it("should track origin correctly", () => {
// eeso: e(1,0), e(2,0), s(2,1), o sets origin at (2,1) // eeso: e(1,0), e(2,0), s(2,1), o sets origin at (2,1)
// After normalization: minX=1, minY=0, so originX = 2-1 = 1, originY = 1-0 = 1 // After normalization: minX=1, minY=0, so originX = 2-1 = 1, originY = 1-0 = 1
const result = parseShapeString('eeso'); const result = parseShapeString("eeso");
expect(result.originX).toBe(1); expect(result.originX).toBe(1);
expect(result.originY).toBe(1); expect(result.originY).toBe(1);
}); });
it('should track origin at first o only', () => { it("should track origin at first o only", () => {
const result = parseShapeString('oes'); const result = parseShapeString("oes");
expect(result.originX).toBe(0); expect(result.originX).toBe(0);
expect(result.originY).toBe(0); expect(result.originY).toBe(0);
}); });
it('should handle complex T shape', () => { it("should handle complex T shape", () => {
// oewers: o(0,0), e(1,0), w(0,0), e(1,0), r->(0,0), s(0,1) // oewers: o(0,0), e(1,0), w(0,0), e(1,0), r->(0,0), s(0,1)
// Filled: (0,0), (1,0), (0,1) - 3 cells // Filled: (0,0), (1,0), (0,1) - 3 cells
const result = parseShapeString('oewers'); const result = parseShapeString("oewers");
expect(result.width).toBe(2); expect(result.width).toBe(2);
expect(result.height).toBe(2); expect(result.height).toBe(2);
expect(result.count).toBe(3); expect(result.count).toBe(3);
expect(result.grid).toEqual([ expect(result.grid).toEqual([
[true, true], [true, true],
[true, false], [true, false],
]); ]);
}); });
}); });
describe('shape-collision', () => { describe("shape-collision", () => {
describe('getOccupiedCells', () => { describe("getOccupiedCells", () => {
it('should return cells for a single cell shape', () => { it("should return cells for a single cell shape", () => {
const shape = parseShapeString('o'); const shape = parseShapeString("o");
const cells = getOccupiedCells(shape); const cells = getOccupiedCells(shape);
expect(cells).toEqual([{ x: 0, y: 0 }]); expect(cells).toEqual([{ x: 0, y: 0 }]);
});
it('should return cells for a horizontal line', () => {
const shape = parseShapeString('oe');
const cells = getOccupiedCells(shape);
expect(cells).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
]);
});
it('should return cells for an L shape', () => {
const shape = parseShapeString('oes');
const cells = getOccupiedCells(shape);
expect(cells).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
]);
});
}); });
describe('checkCollision', () => { it("should return cells for a horizontal line", () => {
it('should detect collision between overlapping shapes', () => { const shape = parseShapeString("oe");
const shapeA = parseShapeString('o'); const cells = getOccupiedCells(shape);
const shapeB = parseShapeString('o'); expect(cells).toEqual([
{ x: 0, y: 0 },
const result = checkCollision( { x: 1, y: 0 },
shapeA, ]);
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
shapeB,
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } }
);
expect(result).toBe(true);
});
it('should not detect collision between non-overlapping shapes', () => {
const shapeA = parseShapeString('o');
const shapeB = parseShapeString('o');
const result = checkCollision(
shapeA,
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
shapeB,
{ ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } }
);
expect(result).toBe(false);
});
it('should detect collision with adjacent shapes', () => {
const shapeA = parseShapeString('o');
const shapeB = parseShapeString('o');
const result = checkCollision(
shapeA,
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
shapeB,
{ ...IDENTITY_TRANSFORM, offset: { x: 1, y: 0 } }
);
expect(result).toBe(false);
});
it('should detect collision with rotation', () => {
const shapeA = parseShapeString('oe');
const shapeB = parseShapeString('os');
// shapeA is horizontal at (0,0)-(1,0)
// shapeB rotated 90° becomes vertical at (0,0)-(0,1)
// They should collide at (0,0)
const result = checkCollision(
shapeA,
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
shapeB,
{ ...IDENTITY_TRANSFORM, rotation: 90, offset: { x: 0, y: 0 } }
);
expect(result).toBe(true);
});
}); });
describe('checkBoardCollision', () => { it("should return cells for an L shape", () => {
it('should detect collision with occupied cells', () => { const shape = parseShapeString("oes");
const shape = parseShapeString('oe'); const cells = getOccupiedCells(shape);
const occupied = new Set(['0,0', '1,0']); expect(cells).toEqual([
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
]);
});
});
const result = checkBoardCollision(shape, IDENTITY_TRANSFORM, occupied); describe("checkCollision", () => {
expect(result).toBe(true); it("should detect collision between overlapping shapes", () => {
}); const shapeA = parseShapeString("o");
const shapeB = parseShapeString("o");
it('should not detect collision with empty board', () => { const result = checkCollision(
const shape = parseShapeString('oe'); shapeA,
const occupied = new Set<string>(); { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
shapeB,
const result = checkBoardCollision(shape, IDENTITY_TRANSFORM, occupied); { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
expect(result).toBe(false); );
}); expect(result).toBe(true);
it('should detect collision after translation', () => {
const shape = parseShapeString('oe');
const occupied = new Set(['5,5', '6,5']);
const result = checkBoardCollision(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 5, y: 5 } },
occupied
);
expect(result).toBe(true);
});
}); });
describe('checkBounds', () => { it("should not detect collision between non-overlapping shapes", () => {
it('should return true for shape within bounds', () => { const shapeA = parseShapeString("o");
const shape = parseShapeString('oe'); const shapeB = parseShapeString("o");
const result = checkBounds(shape, IDENTITY_TRANSFORM, 10, 10); const result = checkCollision(
expect(result).toBe(true); shapeA,
}); { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
shapeB,
it('should return false for shape outside bounds', () => { { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } },
const shape = parseShapeString('oe'); );
expect(result).toBe(false);
const result = checkBounds(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 0 } },
10,
10
);
expect(result).toBe(false);
});
it('should return false for negative coordinates', () => {
const shape = parseShapeString('oe');
const result = checkBounds(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: -1, y: 0 } },
10,
10
);
expect(result).toBe(false);
});
it('should return true for shape at boundary edge', () => {
const shape = parseShapeString('o');
const result = checkBounds(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 9 } },
10,
10
);
expect(result).toBe(true);
});
}); });
describe('validatePlacement', () => { it("should detect collision with adjacent shapes", () => {
it('should return valid for good placement', () => { const shapeA = parseShapeString("o");
const shape = parseShapeString('oe'); const shapeB = parseShapeString("o");
const occupied = new Set<string>();
const result = validatePlacement(shape, IDENTITY_TRANSFORM, 10, 10, occupied); const result = checkCollision(
expect(result).toEqual({ valid: true }); shapeA,
}); { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
shapeB,
it('should return invalid for out of bounds', () => { { ...IDENTITY_TRANSFORM, offset: { x: 1, y: 0 } },
const shape = parseShapeString('oe'); );
const occupied = new Set<string>(); expect(result).toBe(false);
const result = validatePlacement(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 0 } },
10,
10,
occupied
);
expect(result).toEqual({ valid: false, reason: '超出边界' });
});
it('should return invalid for collision', () => {
const shape = parseShapeString('oe');
const occupied = new Set(['0,0', '1,0']);
const result = validatePlacement(shape, IDENTITY_TRANSFORM, 10, 10, occupied);
expect(result).toEqual({ valid: false, reason: '与已有形状重叠' });
});
}); });
describe('transformShape', () => { it("should detect collision with rotation", () => {
it('should apply translation correctly', () => { const shapeA = parseShapeString("oe");
const shape = parseShapeString('o'); const shapeB = parseShapeString("os");
const transform = { ...IDENTITY_TRANSFORM, offset: { x: 5, y: 3 } };
const cells = transformShape(shape, transform); // shapeA is horizontal at (0,0)-(1,0)
expect(cells).toEqual([{ x: 5, y: 3 }]); // shapeB rotated 90° becomes vertical at (0,0)-(0,1)
}); // They should collide at (0,0)
const result = checkCollision(
it('should apply 90° rotation correctly', () => { shapeA,
const shape = parseShapeString('oe'); { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
const transform = { ...IDENTITY_TRANSFORM, rotation: 90 }; shapeB,
{ ...IDENTITY_TRANSFORM, rotation: 90, offset: { x: 0, y: 0 } },
const cells = transformShape(shape, transform); );
expect(cells).toEqual([ expect(result).toBe(true);
{ x: 0, y: 0 },
{ x: 0, y: -1 },
]);
});
it('should apply horizontal flip correctly', () => {
const shape = parseShapeString('oe');
const transform = { ...IDENTITY_TRANSFORM, flipX: true };
const cells = transformShape(shape, transform);
expect(cells).toEqual([
{ x: 1, y: 0 },
{ x: 0, y: 0 },
]);
});
it('should apply vertical flip correctly', () => {
const shape = parseShapeString('os');
const transform = { ...IDENTITY_TRANSFORM, flipY: true };
const cells = transformShape(shape, transform);
expect(cells).toEqual([
{ x: 0, y: 1 },
{ x: 0, y: 0 },
]);
});
it('should combine rotation and translation', () => {
const shape = parseShapeString('os');
const transform = {
...IDENTITY_TRANSFORM,
rotation: 90,
offset: { x: 10, y: 10 },
};
const cells = transformShape(shape, transform);
expect(cells).toEqual([
{ x: 10, y: 10 },
{ x: 11, y: 10 },
]);
});
}); });
});
describe("checkBoardCollision", () => {
it("should detect collision with occupied cells", () => {
const shape = parseShapeString("oe");
const occupied = new Set(["0,0", "1,0"]);
const result = checkBoardCollision(shape, IDENTITY_TRANSFORM, occupied);
expect(result).toBe(true);
});
it("should not detect collision with empty board", () => {
const shape = parseShapeString("oe");
const occupied = new Set<string>();
const result = checkBoardCollision(shape, IDENTITY_TRANSFORM, occupied);
expect(result).toBe(false);
});
it("should detect collision after translation", () => {
const shape = parseShapeString("oe");
const occupied = new Set(["5,5", "6,5"]);
const result = checkBoardCollision(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 5, y: 5 } },
occupied,
);
expect(result).toBe(true);
});
});
describe("checkBounds", () => {
it("should return true for shape within bounds", () => {
const shape = parseShapeString("oe");
const result = checkBounds(shape, IDENTITY_TRANSFORM, 10, 10);
expect(result).toBe(true);
});
it("should return false for shape outside bounds", () => {
const shape = parseShapeString("oe");
const result = checkBounds(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 0 } },
10,
10,
);
expect(result).toBe(false);
});
it("should return false for negative coordinates", () => {
const shape = parseShapeString("oe");
const result = checkBounds(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: -1, y: 0 } },
10,
10,
);
expect(result).toBe(false);
});
it("should return true for shape at boundary edge", () => {
const shape = parseShapeString("o");
const result = checkBounds(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 9 } },
10,
10,
);
expect(result).toBe(true);
});
});
describe("validatePlacement", () => {
it("should return valid for good placement", () => {
const shape = parseShapeString("oe");
const occupied = new Set<string>();
const result = validateShapePlacement(
shape,
IDENTITY_TRANSFORM,
10,
10,
occupied,
);
expect(result).toEqual({ valid: true });
});
it("should return invalid for out of bounds", () => {
const shape = parseShapeString("oe");
const occupied = new Set<string>();
const result = validateShapePlacement(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 0 } },
10,
10,
occupied,
);
expect(result).toEqual({ valid: false, reason: "超出边界" });
});
it("should return invalid for collision", () => {
const shape = parseShapeString("oe");
const occupied = new Set(["0,0", "1,0"]);
const result = validateShapePlacement(
shape,
IDENTITY_TRANSFORM,
10,
10,
occupied,
);
expect(result).toEqual({ valid: false, reason: "与已有形状重叠" });
});
});
describe("transformShape", () => {
it("should apply translation correctly", () => {
const shape = parseShapeString("o");
const transform = { ...IDENTITY_TRANSFORM, offset: { x: 5, y: 3 } };
const cells = transformShape(shape, transform);
expect(cells).toEqual([{ x: 5, y: 3 }]);
});
it("should apply 90° rotation correctly", () => {
const shape = parseShapeString("oe");
const transform = { ...IDENTITY_TRANSFORM, rotation: 90 };
const cells = transformShape(shape, transform);
expect(cells).toEqual([
{ x: 0, y: 0 },
{ x: 0, y: -1 },
]);
});
it("should apply horizontal flip correctly", () => {
const shape = parseShapeString("oe");
const transform = { ...IDENTITY_TRANSFORM, flipX: true };
const cells = transformShape(shape, transform);
expect(cells).toEqual([
{ x: 1, y: 0 },
{ x: 0, y: 0 },
]);
});
it("should apply vertical flip correctly", () => {
const shape = parseShapeString("os");
const transform = { ...IDENTITY_TRANSFORM, flipY: true };
const cells = transformShape(shape, transform);
expect(cells).toEqual([
{ x: 0, y: 1 },
{ x: 0, y: 0 },
]);
});
it("should combine rotation and translation", () => {
const shape = parseShapeString("os");
const transform = {
...IDENTITY_TRANSFORM,
rotation: 90,
offset: { x: 10, y: 10 },
};
const cells = transformShape(shape, transform);
expect(cells).toEqual([
{ x: 10, y: 10 },
{ x: 11, y: 10 },
]);
});
});
}); });
+219
View File
@@ -0,0 +1,219 @@
import { describe, it, expect } from "vitest";
import { createPromptContext } from "@/utils/command/command-prompt";
import type { CommandSchema } from "@/utils/command/types";
const mockSchema: CommandSchema = {
name: "test",
params: [{ name: "value", required: true, variadic: false }],
options: {},
flags: {},
};
describe("createPromptContext", () => {
describe("prompt", () => {
it("should store prompt and return promise", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise = ctx.prompt({ schema: mockSchema }, validator);
await new Promise((r) => setTimeout(r, 0));
const result = ctx.tryCommit("global", "hello");
expect(result).toEqual({ ok: true });
await expect(promise).resolves.toBe("hello");
});
it("should associate prompt with specific player", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise = ctx.prompt({ schema: mockSchema }, validator, "player1");
await new Promise((r) => setTimeout(r, 0));
const result = ctx.tryCommit("player1", "hello");
expect(result).toEqual({ ok: true });
await expect(promise).resolves.toBe("hello");
});
it("should cancel existing prompt when new prompt starts for same player", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise1 = ctx.prompt({ schema: mockSchema }, validator, "player1");
await new Promise((r) => setTimeout(r, 0));
const promise2 = ctx.prompt({ schema: mockSchema }, validator, "player1");
// Resolve promise2 first, then both will settle
ctx.tryCommit("player1", "test");
const [result1, result2] = await Promise.allSettled([promise1, promise2]);
expect(result1.status).toBe("rejected");
expect((result1 as PromiseRejectedResult).reason).toBe(
"Prompt overriden",
);
expect(result2.status).toBe("fulfilled");
});
it("should allow different players to have independent prompts", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise1 = ctx.prompt({ schema: mockSchema }, validator, "player1");
const promise2 = ctx.prompt({ schema: mockSchema }, validator, "player2");
await new Promise((r) => setTimeout(r, 0));
ctx.tryCommit("player1", "hello");
ctx.tryCommit("player2", "world");
await expect(promise1).resolves.toBe("hello");
await expect(promise2).resolves.toBe("world");
});
});
describe("tryCommit", () => {
it("should return ok:true when prompt exists and validation passes", () => {
const ctx = createPromptContext();
const validator = (value: string) => value.toUpperCase();
ctx.prompt({ schema: mockSchema }, validator, "player1");
const result = ctx.tryCommit("player1", "hello");
expect(result).toEqual({ ok: true });
});
it("should return ok:false with reason when no prompt exists", () => {
const ctx = createPromptContext();
const result = ctx.tryCommit("nonexistent", "hello");
expect(result).toEqual({ ok: false, reason: "No Prompt" });
});
it("should return ok:false when validator throws string", () => {
const ctx = createPromptContext();
const validator = (value: string) => {
if (value.length < 3) throw "Value too short";
return value;
};
ctx.prompt({ schema: mockSchema }, validator, "player1");
const result = ctx.tryCommit("player1", "ab");
expect(result).toEqual({ ok: false, reason: "Value too short" });
});
it("should throw when validator throws non-string error", () => {
const ctx = createPromptContext();
const validator = () => {
throw new Error("unexpected");
};
ctx.prompt({ schema: mockSchema }, validator, "player1");
expect(() => ctx.tryCommit("player1", "test")).toThrow("unexpected");
});
it("should pass validator return value to promise resolver", async () => {
const ctx = createPromptContext();
const validator = (a: number, b: number) => a + b;
const promise = ctx.prompt({ schema: mockSchema }, validator, "player1");
await new Promise((r) => setTimeout(r, 0));
ctx.tryCommit("player1", 2, 3);
await expect(promise).resolves.toBe(5);
});
});
describe("cancel", () => {
it("should reject promise with default message", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise = ctx.prompt({ schema: mockSchema }, validator, "player1");
await new Promise((r) => setTimeout(r, 0));
ctx.cancel("player1");
const result = await Promise.allSettled([promise]);
expect(result[0].status).toBe("rejected");
expect((result[0] as PromiseRejectedResult).reason).toBe(
"Prompt Cancelled",
);
});
it("should reject promise with custom reason", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise = ctx.prompt({ schema: mockSchema }, validator, "player1");
await new Promise((r) => setTimeout(r, 0));
ctx.cancel("player1", "Custom cancel reason");
const result = await Promise.allSettled([promise]);
expect(result[0].status).toBe("rejected");
expect((result[0] as PromiseRejectedResult).reason).toBe(
"Custom cancel reason",
);
});
it("should do nothing when no prompt exists for player", () => {
const ctx = createPromptContext();
ctx.cancel("nonexistent");
});
it("should only cancel prompt for specific player", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise1 = ctx.prompt({ schema: mockSchema }, validator, "player1");
const promise2 = ctx.prompt({ schema: mockSchema }, validator, "player2");
await new Promise((r) => setTimeout(r, 0));
ctx.cancel("player1");
ctx.tryCommit("player2", "test");
const [result1, result2] = await Promise.allSettled([promise1, promise2]);
expect(result1.status).toBe("rejected");
expect((result1 as PromiseRejectedResult).reason).toBe(
"Prompt Cancelled",
);
expect(result2.status).toBe("fulfilled");
});
});
describe("handleCall", () => {
it("should be a middleware chain that can be extended", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
let middlewareCalled = false;
ctx.handleCall.use(async (call, next) => {
middlewareCalled = true;
return next();
});
const promise = ctx.prompt({ schema: mockSchema }, validator);
await new Promise((r) => setTimeout(r, 0));
expect(middlewareCalled).toBe(true);
ctx.tryCommit("global", "test");
await expect(promise).resolves.toBe("test");
});
it("should remove prompt from map after resolution", async () => {
const ctx = createPromptContext();
const validator = (value: string) => value;
const promise = ctx.prompt({ schema: mockSchema }, validator);
await new Promise((r) => setTimeout(r, 0));
ctx.tryCommit("global", "test");
await promise;
const result = ctx.tryCommit("global", "another");
expect(result).toEqual({ ok: false, reason: "No Prompt" });
});
});
});
File diff suppressed because it is too large Load Diff
+141 -139
View File
@@ -1,174 +1,176 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { parseCommand, type Command } from '@/utils/command'; import { parseCommand, type Command } from "@/utils/command";
describe('parseCommand', () => { describe("parseCommand", () => {
it('should parse empty string', () => { it("should parse empty string", () => {
const result = parseCommand(''); const result = parseCommand("");
expect(result).toEqual({ expect(result).toEqual({
name: '', name: "",
flags: {}, flags: {},
options: {}, options: {},
params: [] params: [],
});
}); });
});
it('should parse command name only', () => { it("should parse command name only", () => {
const result = parseCommand('move'); const result = parseCommand("move");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: {}, flags: {},
options: {}, options: {},
params: [] params: [],
});
}); });
});
it('should parse command with params', () => { it("should parse command with params", () => {
const result = parseCommand('move meeple1 region1'); const result = parseCommand("move meeple1 region1");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: {}, flags: {},
options: {}, options: {},
params: ['meeple1', 'region1'] params: ["meeple1", "region1"],
});
}); });
});
it('should parse command with long flags', () => { it("should parse command with long flags", () => {
const result = parseCommand('move meeple1 --force --quiet'); const result = parseCommand("move meeple1 --force --quiet");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: { force: true, quiet: true }, flags: { force: true, quiet: true },
options: {}, options: {},
params: ['meeple1'] params: ["meeple1"],
});
}); });
});
it('should parse command with short flags', () => { it("should parse command with short flags", () => {
const result = parseCommand('move meeple1 -f -q'); const result = parseCommand("move meeple1 -f -q");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: { f: true, q: true }, flags: { f: true, q: true },
options: {}, options: {},
params: ['meeple1'] params: ["meeple1"],
});
}); });
});
it('should parse command with long options', () => { it("should parse command with long options", () => {
const result = parseCommand('move meeple1 --x 10 --y 20'); const result = parseCommand("move meeple1 --x 10 --y 20");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: {}, flags: {},
options: { x: '10', y: '20' }, options: { x: "10", y: "20" },
params: ['meeple1'] params: ["meeple1"],
});
}); });
});
it('should parse command with short options', () => { it("should parse command with short options", () => {
const result = parseCommand('move meeple1 -x 10 -y 20'); const result = parseCommand("move meeple1 -x 10 -y 20");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: {}, flags: {},
options: { x: '10', y: '20' }, options: { x: "10", y: "20" },
params: ['meeple1'] params: ["meeple1"],
});
}); });
});
it('should parse command with mixed flags和选项', () => { it("should parse command with mixed flags和选项", () => {
const result = parseCommand('move meeple1 region1 --force -x 10 -q'); const result = parseCommand("move meeple1 region1 --force -x 10 -q");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: { force: true, q: true }, flags: { force: true, q: true },
options: { x: '10' }, options: { x: "10" },
params: ['meeple1', 'region1'] params: ["meeple1", "region1"],
});
}); });
});
it('should handle extra whitespace', () => { it("should handle extra whitespace", () => {
const result = parseCommand(' move meeple1 --force '); const result = parseCommand(" move meeple1 --force ");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: { force: true }, flags: { force: true },
options: {}, options: {},
params: ['meeple1'] params: ["meeple1"],
});
}); });
});
it('should parse complex command', () => { it("should parse complex command", () => {
const result = parseCommand('place meeple1 board --x 5 --y 3 --rotate 90 --force'); const result = parseCommand(
expect(result).toEqual({ "place meeple1 board --x 5 --y 3 --rotate 90 --force",
name: 'place', );
flags: { force: true }, expect(result).toEqual({
options: { x: '5', y: '3', rotate: '90' }, name: "place",
params: ['meeple1', 'board'] flags: { force: true },
}); options: { x: "5", y: "3", rotate: "90" },
params: ["meeple1", "board"],
}); });
});
it('should treat negative number as option value', () => { it("should treat negative number as option value", () => {
const result = parseCommand('set --value -10'); const result = parseCommand("set --value -10");
expect(result).toEqual({ expect(result).toEqual({
name: 'set', name: "set",
flags: {}, flags: {},
options: { value: '-10' }, options: { value: "-10" },
params: [] params: [],
});
}); });
});
it('should parse quoted string with double quotes', () => { it("should parse quoted string with double quotes", () => {
const result = parseCommand('place tile "large castle" --x 5'); const result = parseCommand('place tile "large castle" --x 5');
expect(result).toEqual({ expect(result).toEqual({
name: 'place', name: "place",
flags: {}, flags: {},
options: { x: '5' }, options: { x: "5" },
params: ['tile', 'large castle'] params: ["tile", "large castle"],
});
}); });
});
it('should parse quoted string with single quotes', () => { it("should parse quoted string with single quotes", () => {
const result = parseCommand("place tile 'large castle' --x 5"); const result = parseCommand("place tile 'large castle' --x 5");
expect(result).toEqual({ expect(result).toEqual({
name: 'place', name: "place",
flags: {}, flags: {},
options: { x: '5' }, options: { x: "5" },
params: ['tile', 'large castle'] params: ["tile", "large castle"],
});
}); });
});
it('should handle escaped quotes', () => { it("should handle escaped quotes", () => {
const result = parseCommand('say "hello \\"world\\""'); const result = parseCommand('say "hello \\"world\\""');
expect(result).toEqual({ expect(result).toEqual({
name: 'say', name: "say",
flags: {}, flags: {},
options: {}, options: {},
params: ['hello "world"'] params: ['hello "world"'],
});
}); });
});
it('should handle escaped backslash', () => { it("should handle escaped backslash", () => {
const result = parseCommand('set path "C:\\\\Users"'); const result = parseCommand('set path "C:\\\\Users"');
expect(result).toEqual({ expect(result).toEqual({
name: 'set', name: "set",
flags: {}, flags: {},
options: {}, options: {},
params: ['path', 'C:\\Users'] params: ["path", "C:\\Users"],
});
}); });
});
it('should handle mixed quotes', () => { it("should handle mixed quotes", () => {
const result = parseCommand('cmd "hello world" \'foo bar\' --flag'); const result = parseCommand("cmd \"hello world\" 'foo bar' --flag");
expect(result).toEqual({ expect(result).toEqual({
name: 'cmd', name: "cmd",
flags: { flag: true }, flags: { flag: true },
options: {}, options: {},
params: ['hello world', 'foo bar'] params: ["hello world", "foo bar"],
});
}); });
});
it('should handle quote in middle of argument', () => { it("should handle quote in middle of argument", () => {
const result = parseCommand('cmd "hello\'s world"'); const result = parseCommand('cmd "hello\'s world"');
expect(result).toEqual({ expect(result).toEqual({
name: 'cmd', name: "cmd",
flags: {}, flags: {},
options: {}, options: {},
params: ["hello's world"] params: ["hello's world"],
});
}); });
});
}); });
+396 -387
View File
@@ -1,436 +1,445 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { createMiddlewareChain, type MiddlewareChain } from '@/utils/middleware'; import {
createMiddlewareChain,
type MiddlewareChain,
} from "@/utils/middleware";
describe('createMiddlewareChain', () => { describe("createMiddlewareChain", () => {
describe('basic execution', () => { describe("basic execution", () => {
it('should return context when no middlewares and no fallback', async () => { it("should return context when no middlewares and no fallback", async () => {
const chain = createMiddlewareChain<{ value: number }>(); const chain = createMiddlewareChain<{ value: number }>();
const result = await chain.execute({ value: 42 }); const result = await chain.execute({ value: 42 });
expect(result).toEqual({ value: 42 }); expect(result).toEqual({ value: 42 });
});
it('should call fallback when no middlewares', async () => {
const chain = createMiddlewareChain<{ value: number }, string>(
async ctx => `value is ${ctx.value}`
);
const result = await chain.execute({ value: 42 });
expect(result).toBe('value is 42');
});
it('should pass context to fallback', async () => {
const chain = createMiddlewareChain<{ a: number; b: number }, number>(
async ctx => ctx.a + ctx.b
);
const result = await chain.execute({ a: 3, b: 7 });
expect(result).toBe(10);
});
}); });
describe('single middleware', () => { it("should call fallback when no middlewares", async () => {
it('should execute a single middleware', async () => { const chain = createMiddlewareChain<{ value: number }, string>(
const chain = createMiddlewareChain<{ count: number }>(); async (ctx) => `value is ${ctx.value}`,
chain.use(async (ctx, next) => { );
ctx.count *= 2; const result = await chain.execute({ value: 42 });
return next();
});
const result = await chain.execute({ count: 5 }); expect(result).toBe("value is 42");
expect(result.count).toBe(10);
});
it('should allow middleware to modify return value', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => ctx.value
);
chain.use(async (ctx, next) => {
const result = await next();
return result * 2;
});
const result = await chain.execute({ value: 21 });
expect(result).toBe(42);
});
it('should allow middleware to short-circuit without calling next', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, _next) => {
return { value: 999 };
});
const result = await chain.execute({ value: 1 });
expect(result.value).toBe(999);
});
}); });
describe('multiple middlewares', () => { it("should pass context to fallback", async () => {
it('should execute middlewares in order', async () => { const chain = createMiddlewareChain<{ a: number; b: number }, number>(
const order: number[] = []; async (ctx) => ctx.a + ctx.b,
const chain = createMiddlewareChain<{ value: number }>(); );
const result = await chain.execute({ a: 3, b: 7 });
chain.use(async (_ctx, next) => { expect(result).toBe(10);
order.push(1); });
const result = await next(); });
order.push(4);
return result;
});
chain.use(async (_ctx, next) => {
order.push(2);
const result = await next();
order.push(3);
return result;
});
await chain.execute({ value: 0 }); describe("single middleware", () => {
it("should execute a single middleware", async () => {
const chain = createMiddlewareChain<{ count: number }>();
chain.use(async (ctx, next) => {
ctx.count *= 2;
return next();
});
expect(order).toEqual([1, 2, 3, 4]); const result = await chain.execute({ count: 5 });
});
it('should accumulate modifications through the chain', async () => { expect(result.count).toBe(10);
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (ctx, next) => {
ctx.value += 1;
return next();
});
chain.use(async (ctx, next) => {
ctx.value *= 2;
return next();
});
chain.use(async (ctx, next) => {
ctx.value += 3;
return next();
});
const result = await chain.execute({ value: 0 });
expect(result.value).toBe(5);
});
it('should allow middleware to modify result on the way back', async () => {
const chain = createMiddlewareChain<{ base: number }, number>(
async ctx => ctx.base
);
chain.use(async (_ctx, next) => {
const result = await next();
return result + 10;
});
chain.use(async (_ctx, next) => {
const result = await next();
return result * 2;
});
const result = await chain.execute({ base: 5 });
expect(result).toBe(20);
});
it('should allow middleware to short-circuit in the middle', async () => {
const executed: number[] = [];
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, next) => {
executed.push(1);
return next();
});
chain.use(async () => {
executed.push(2);
return { value: -1 };
});
chain.use(async (_ctx, next) => {
executed.push(3);
return next();
});
const result = await chain.execute({ value: 100 });
expect(result.value).toBe(-1);
expect(executed).toEqual([1, 2]);
});
}); });
describe('nested next calls', () => { it("should allow middleware to modify return value", async () => {
it('should advance index on each next call, skipping remaining middlewares', async () => { const chain = createMiddlewareChain<{ value: number }, number>(
const chain = createMiddlewareChain<{ counter: number }>(); async (ctx) => ctx.value,
);
chain.use(async (ctx, next) => {
const result = await next();
return result * 2;
});
chain.use(async (_ctx, next) => { const result = await chain.execute({ value: 21 });
await next();
await next();
});
const result = await chain.execute({ counter: 0 }); expect(result).toBe(42);
expect(result).toBeUndefined();
});
it('should allow middleware to call next conditionally', async () => {
const chain = createMiddlewareChain<{ skip: boolean; value: number }>();
chain.use(async (ctx, next) => {
if (ctx.skip) {
return { value: -1 };
}
return next();
});
chain.use(async (ctx, next) => {
ctx.value += 10;
return next();
});
const resultA = await chain.execute({ skip: true, value: 0 });
const resultB = await chain.execute({ skip: false, value: 0 });
expect(resultA.value).toBe(-1);
expect(resultB.value).toBe(10);
});
it('should handle middleware that awaits next multiple times with a fallback', async () => {
const log: string[] = [];
const chain = createMiddlewareChain<{ value: number }, string[]>(
async _ctx => log
);
chain.use(async (_ctx, next) => {
log.push('before');
await next();
log.push('after-first');
await next();
log.push('after-second');
return log;
});
chain.use(async (_ctx, next) => {
log.push('mw2');
return next();
});
const result = await chain.execute({ value: 0 });
expect(result).toEqual(['before', 'mw2', 'after-first', 'after-second']);
});
it('should return fallback result on second next call when no more middlewares remain', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => ctx.value * 10
);
chain.use(async (_ctx, next) => {
await next();
return await next();
});
chain.use(async (_ctx, next) => {
return next();
});
const result = await chain.execute({ value: 5 });
expect(result).toBe(50);
});
it('should return fallback result on second next call when no more middlewares remain', async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => ctx.value * 10
);
chain.use(async (_ctx, next) => {
await next();
return await next();
});
chain.use(async (_ctx, next) => {
return next();
});
const result = await chain.execute({ value: 5 });
expect(result).toBe(50);
});
}); });
describe('async behavior', () => { it("should allow middleware to short-circuit without calling next", async () => {
it('should handle async middlewares', async () => { const chain = createMiddlewareChain<{ value: number }>();
const chain = createMiddlewareChain<{ value: number }>(); chain.use(async (_ctx, _next) => {
return { value: 999 };
});
chain.use(async (ctx, next) => { const result = await chain.execute({ value: 1 });
await new Promise(resolve => setTimeout(resolve, 10));
ctx.value += 1;
return next();
});
chain.use(async (ctx, next) => {
await new Promise(resolve => setTimeout(resolve, 10));
ctx.value += 2;
return next();
});
const result = await chain.execute({ value: 0 }); expect(result.value).toBe(999);
});
});
expect(result.value).toBe(3); describe("multiple middlewares", () => {
}); it("should execute middlewares in order", async () => {
const order: number[] = [];
const chain = createMiddlewareChain<{ value: number }>();
it('should handle async fallback', async () => { chain.use(async (_ctx, next) => {
const chain = createMiddlewareChain<{ value: number }, number>( order.push(1);
async ctx => { const result = await next();
await new Promise(resolve => setTimeout(resolve, 10)); order.push(4);
return ctx.value * 10; return result;
} });
); chain.use(async (_ctx, next) => {
order.push(2);
const result = await next();
order.push(3);
return result;
});
const result = await chain.execute({ value: 5 }); await chain.execute({ value: 0 });
expect(result).toBe(50); expect(order).toEqual([1, 2, 3, 4]);
});
}); });
describe('error handling', () => { it("should accumulate modifications through the chain", async () => {
it('should propagate errors from middleware', async () => { const chain = createMiddlewareChain<{ value: number }>();
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async () => { chain.use(async (ctx, next) => {
throw new Error('middleware error'); ctx.value += 1;
}); return next();
});
chain.use(async (ctx, next) => {
ctx.value *= 2;
return next();
});
chain.use(async (ctx, next) => {
ctx.value += 3;
return next();
});
await expect(chain.execute({ value: 1 })).rejects.toThrow('middleware error'); const result = await chain.execute({ value: 0 });
});
it('should propagate errors from fallback', async () => { expect(result.value).toBe(5);
const chain = createMiddlewareChain<{ value: number }, number>(
async () => {
throw new Error('fallback error');
}
);
await expect(chain.execute({ value: 1 })).rejects.toThrow('fallback error');
});
it('should allow middleware to catch errors from downstream', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, next) => {
try {
return await next();
} catch {
return { value: -1 };
}
});
chain.use(async () => {
throw new Error('downstream error');
});
const result = await chain.execute({ value: 1 });
expect(result.value).toBe(-1);
});
}); });
describe('return type override', () => { it("should allow middleware to modify result on the way back", async () => {
it('should support different TReturn type than TContext', async () => { const chain = createMiddlewareChain<{ base: number }, number>(
const chain = createMiddlewareChain<{ name: string }, string>( async (ctx) => ctx.base,
async ctx => `Hello, ${ctx.name}!` );
);
const result = await chain.execute({ name: 'World' }); chain.use(async (_ctx, next) => {
const result = await next();
return result + 10;
});
chain.use(async (_ctx, next) => {
const result = await next();
return result * 2;
});
expect(result).toBe('Hello, World!'); const result = await chain.execute({ base: 5 });
});
it('should allow middleware to transform return type', async () => { expect(result).toBe(20);
const chain = createMiddlewareChain<{ items: number[] }, number>(
async ctx => ctx.items.reduce((a, b) => a + b, 0)
);
chain.use(async (_ctx, next) => {
const sum = await next();
return sum * 2;
});
const result = await chain.execute({ items: [1, 2, 3] });
expect(result).toBe(12);
});
}); });
describe('reusability', () => { it("should allow middleware to short-circuit in the middle", async () => {
it('should reset index on each execute call', async () => { const executed: number[] = [];
const chain = createMiddlewareChain<{ count: number }>(); const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (ctx, next) => { chain.use(async (_ctx, next) => {
ctx.count += 1; executed.push(1);
return next(); return next();
}); });
chain.use(async () => {
executed.push(2);
return { value: -1 };
});
chain.use(async (_ctx, next) => {
executed.push(3);
return next();
});
const resultA = await chain.execute({ count: 0 }); const result = await chain.execute({ value: 100 });
const resultB = await chain.execute({ count: 0 });
expect(resultA.count).toBe(1); expect(result.value).toBe(-1);
expect(resultB.count).toBe(1); expect(executed).toEqual([1, 2]);
}); });
});
it('should share middlewares across execute calls', async () => { describe("nested next calls", () => {
const chain = createMiddlewareChain<{ log: string[] }>(); it("should advance index on each next call, skipping remaining middlewares", async () => {
const chain = createMiddlewareChain<{ counter: number }>();
chain.use(async (ctx, next) => { chain.use(async (_ctx, next) => {
ctx.log.push('always'); await next();
return next(); await next();
}); return undefined as unknown as { counter: number };
});
await chain.execute({ log: [] }); const result = await chain.execute({ counter: 0 });
await chain.execute({ log: [] });
expect(chain).toBeDefined(); expect(result).toBeUndefined();
});
}); });
describe('edge cases', () => { it("should allow middleware to call next conditionally", async () => {
it('should handle empty context object', async () => { const chain = createMiddlewareChain<{ skip: boolean; value: number }>();
const chain = createMiddlewareChain<Record<string, never>>();
const result = await chain.execute({});
expect(result).toEqual({}); chain.use(async (ctx, next) => {
}); if (ctx.skip) {
return { skip: ctx.skip, value: -1 };
}
return next();
});
chain.use(async (ctx, next) => {
ctx.value += 10;
return next();
});
it('should handle middleware that returns a completely different object', async () => { const resultA = await chain.execute({ skip: true, value: 0 });
const chain = createMiddlewareChain<{ x: number }, { y: string }>( const resultB = await chain.execute({ skip: false, value: 0 });
async () => ({ y: 'default' })
);
chain.use(async (_ctx, next) => { expect(resultA.value).toBe(-1);
return next(); expect(resultB.value).toBe(10);
});
const result = await chain.execute({ x: 42 });
expect(result).toEqual({ y: 'default' });
});
it('should handle middleware that mutates context without returning', async () => {
const chain = createMiddlewareChain<{ value: number }>(
async ctx => ctx
);
chain.use(async (ctx, next) => {
ctx.value = 100;
return next();
});
const result = await chain.execute({ value: 0 });
expect(result.value).toBe(100);
});
it('should return undefined when middleware does not call next or return', async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (ctx) => {
ctx.value = 100;
});
const result = await chain.execute({ value: 0 });
expect(result).toBeUndefined();
});
}); });
it("should handle middleware that awaits next multiple times with a fallback", async () => {
const log: string[] = [];
const chain = createMiddlewareChain<{ value: number }, string[]>(
async (_ctx) => log,
);
chain.use(async (_ctx, next) => {
log.push("before");
await next();
log.push("after-first");
await next();
log.push("after-second");
return log;
});
chain.use(async (_ctx, next) => {
log.push("mw2");
return next();
});
const result = await chain.execute({ value: 0 });
expect(result).toEqual(["before", "mw2", "after-first", "after-second"]);
});
it("should return fallback result on second next call when no more middlewares remain", async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async (ctx) => ctx.value * 10,
);
chain.use(async (_ctx, next) => {
await next();
return await next();
});
chain.use(async (_ctx, next) => {
return next();
});
const result = await chain.execute({ value: 5 });
expect(result).toBe(50);
});
it("should return fallback result on second next call when no more middlewares remain", async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async (ctx) => ctx.value * 10,
);
chain.use(async (_ctx, next) => {
await next();
return await next();
});
chain.use(async (_ctx, next) => {
return next();
});
const result = await chain.execute({ value: 5 });
expect(result).toBe(50);
});
});
describe("async behavior", () => {
it("should handle async middlewares", async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (ctx, next) => {
await new Promise((resolve) => setTimeout(resolve, 10));
ctx.value += 1;
return next();
});
chain.use(async (ctx, next) => {
await new Promise((resolve) => setTimeout(resolve, 10));
ctx.value += 2;
return next();
});
const result = await chain.execute({ value: 0 });
expect(result.value).toBe(3);
});
it("should handle async fallback", async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async (ctx) => {
await new Promise((resolve) => setTimeout(resolve, 10));
return ctx.value * 10;
},
);
const result = await chain.execute({ value: 5 });
expect(result).toBe(50);
});
});
describe("error handling", () => {
it("should propagate errors from middleware", async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async () => {
throw new Error("middleware error");
});
await expect(chain.execute({ value: 1 })).rejects.toThrow(
"middleware error",
);
});
it("should propagate errors from fallback", async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async () => {
throw new Error("fallback error");
},
);
await expect(chain.execute({ value: 1 })).rejects.toThrow(
"fallback error",
);
});
it("should allow middleware to catch errors from downstream", async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, next) => {
try {
return await next();
} catch {
return { value: -1 };
}
});
chain.use(async () => {
throw new Error("downstream error");
});
const result = await chain.execute({ value: 1 });
expect(result.value).toBe(-1);
});
});
describe("return type override", () => {
it("should support different TReturn type than TContext", async () => {
const chain = createMiddlewareChain<{ name: string }, string>(
async (ctx) => `Hello, ${ctx.name}!`,
);
const result = await chain.execute({ name: "World" });
expect(result).toBe("Hello, World!");
});
it("should allow middleware to transform return type", async () => {
const chain = createMiddlewareChain<{ items: number[] }, number>(
async (ctx) => ctx.items.reduce((a, b) => a + b, 0),
);
chain.use(async (_ctx, next) => {
const sum = await next();
return sum * 2;
});
const result = await chain.execute({ items: [1, 2, 3] });
expect(result).toBe(12);
});
});
describe("reusability", () => {
it("should reset index on each execute call", async () => {
const chain = createMiddlewareChain<{ count: number }>();
chain.use(async (ctx, next) => {
ctx.count += 1;
return next();
});
const resultA = await chain.execute({ count: 0 });
const resultB = await chain.execute({ count: 0 });
expect(resultA.count).toBe(1);
expect(resultB.count).toBe(1);
});
it("should share middlewares across execute calls", async () => {
const chain = createMiddlewareChain<{ log: string[] }>();
chain.use(async (ctx, next) => {
ctx.log.push("always");
return next();
});
await chain.execute({ log: [] });
await chain.execute({ log: [] });
expect(chain).toBeDefined();
});
});
describe("edge cases", () => {
it("should handle empty context object", async () => {
const chain = createMiddlewareChain<Record<string, never>>();
const result = await chain.execute({});
expect(result).toEqual({});
});
it("should handle middleware that returns a completely different object", async () => {
const chain = createMiddlewareChain<{ x: number }, { y: string }>(
async () => ({ y: "default" }),
);
chain.use(async (_ctx, next) => {
return next();
});
const result = await chain.execute({ x: 42 });
expect(result).toEqual({ y: "default" });
});
it("should handle middleware that mutates context without returning", async () => {
const chain = createMiddlewareChain<{ value: number }>(
async (ctx) => ctx,
);
chain.use(async (ctx, next) => {
ctx.value = 100;
return next();
});
const result = await chain.execute({ value: 0 });
expect(result.value).toBe(100);
});
it("should return undefined when middleware does not call next or return", async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (ctx) => {
ctx.value = 100;
return undefined as unknown as { value: number };
});
const result = await chain.execute({ value: 0 });
expect(result).toBeUndefined();
});
});
}); });
+7 -5
View File
@@ -5,18 +5,20 @@
"lib": ["ES2020"], "lib": ["ES2020"],
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"types": ["node"],
"esModuleInterop": true, "esModuleInterop": true,
"skipLibCheck": true, "skipLibCheck": true,
"forceConsistentCasingInFileNames": true, "forceConsistentCasingInFileNames": true,
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src",
"rootDir": ".",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"] "@/*": ["src/*"],
} },
}, },
"include": ["src/**/*"], "include": ["src/**/*", "tests/**/*", "*.config.ts"],
"exclude": ["node_modules", "dist", "tests"] "exclude": ["node_modules", "dist"],
} }
+8 -8
View File
@@ -1,20 +1,20 @@
import { defineConfig } from 'tsup'; import { defineConfig } from "tsup";
import { fileURLToPath } from 'url'; import { fileURLToPath } from "url";
import { csvLoader } from 'inline-schema/csv-loader/rollup'; import { csvLoader } from "inline-schema/csv-loader/rollup";
const srcDir = fileURLToPath(new URL('./src', import.meta.url)); const srcDir = fileURLToPath(new URL("./src", import.meta.url));
export default defineConfig({ export default defineConfig({
entry: ['src/index.ts'], entry: ["src/index.ts"],
format: ['esm'], format: ["esm"],
dts: true, dts: true,
clean: true, clean: true,
sourcemap: true, sourcemap: true,
external: ['@preact/signals-core', 'mutative', 'inline-schema'], external: ["@preact/signals-core", "mutative", "inline-schema"],
plugins: [csvLoader()], plugins: [csvLoader()],
esbuildOptions(options) { esbuildOptions(options) {
options.alias = { options.alias = {
'@': srcDir, "@": srcDir,
}; };
}, },
}); });
+36 -22
View File
@@ -1,13 +1,13 @@
import { defineConfig } from 'tsup'; import { defineConfig } from "tsup";
import { fileURLToPath } from 'url'; import { fileURLToPath } from "url";
import * as fs from 'fs'; import * as fs from "fs";
import * as path from 'path'; import * as path from "path";
import {csvLoader} from 'inline-schema/csv-loader/esbuild'; import { csvLoader } from "inline-schema/csv-loader/esbuild";
import {yarnSpinnerPlugin} from 'yarn-spinner-loader/esbuild'; import { yarnSpinnerPlugin } from "yarn-spinner-loader/esbuild";
import type { Plugin } from 'esbuild'; import type { Plugin } from "esbuild";
const srcDir = fileURLToPath(new URL('./src', import.meta.url)); const srcDir = fileURLToPath(new URL("./src", import.meta.url));
const samplesDir = fileURLToPath(new URL('./src/samples', import.meta.url)); const samplesDir = fileURLToPath(new URL("./src/samples", import.meta.url));
// Auto-discover samples entry points // Auto-discover samples entry points
function getSamplesEntries(): Record<string, string> { function getSamplesEntries(): Record<string, string> {
@@ -18,13 +18,13 @@ function getSamplesEntries(): Record<string, string> {
const fullPath = path.join(samplesDir, item); const fullPath = path.join(samplesDir, item);
if (fs.statSync(fullPath).isDirectory()) { if (fs.statSync(fullPath).isDirectory()) {
// Directory sample (e.g. boop) - look for index.ts // Directory sample (e.g. boop) - look for index.ts
const indexPath = path.join(fullPath, 'index.ts'); const indexPath = path.join(fullPath, "index.ts");
if (fs.existsSync(indexPath)) { if (fs.existsSync(indexPath)) {
entries[item] = indexPath; entries[item] = indexPath;
} }
} else if (item.endsWith('.ts')) { } else if (item.endsWith(".ts")) {
// Single file sample (e.g. tic-tac-toe.ts) // Single file sample (e.g. tic-tac-toe.ts)
entries[item.replace('.ts', '')] = fullPath; entries[item.replace(".ts", "")] = fullPath;
} }
} }
return entries; return entries;
@@ -37,20 +37,20 @@ const samplesEntries = getSamplesEntries();
*/ */
function rewriteBoardgameImports(): Plugin { function rewriteBoardgameImports(): Plugin {
return { return {
name: 'rewrite-boardgame-imports', name: "rewrite-boardgame-imports",
setup(build) { setup(build) {
build.onResolve({ filter: /^@\/(core|utils)\// }, args => { build.onResolve({ filter: /^@\/(core|utils)\// }, (args) => {
// Mark these as external and rewrite to 'boardgame-core' // Mark these as external and rewrite to 'boardgame-core'
return { return {
path: 'boardgame-core', path: "boardgame-core",
external: true, external: true,
}; };
}); });
// Also handle @/index imports // Also handle @/index imports
build.onResolve({ filter: /^@\/index$/ }, args => { build.onResolve({ filter: /^@\/index$/ }, (args) => {
return { return {
path: 'boardgame-core', path: "boardgame-core",
external: true, external: true,
}; };
}); });
@@ -60,11 +60,25 @@ function rewriteBoardgameImports(): Plugin {
export default defineConfig({ export default defineConfig({
entry: samplesEntries, entry: samplesEntries,
format: ['esm'], format: ["esm"],
dts: true, dts: true,
clean: true, clean: true,
sourcemap: true, sourcemap: true,
outDir: 'dist/samples', outDir: "dist/samples",
external: ['@preact/signals-core', 'mutative', 'inline-schema', 'boardgame-core'], external: [
esbuildPlugins: [csvLoader({ writeToDisk: true }), rewriteBoardgameImports(), yarnSpinnerPlugin()], "@preact/signals-core",
"mutative",
"inline-schema",
"boardgame-core",
],
esbuildPlugins: [
csvLoader({ writeToDisk: true }),
rewriteBoardgameImports(),
yarnSpinnerPlugin(),
],
esbuildOptions(options) {
options.alias = {
"@": srcDir,
};
},
}); });