Compare commits

...
58 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
74 changed files with 7421 additions and 6796 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
"scripts": {
"build": "tsup",
"build:samples": "tsup --config tsup.samples.config.ts",
"prepare": "npm run build",
"prepare": "npm run build && npm run build:samples",
"test": "vitest",
"test:run": "vitest run",
"typecheck": "tsc --noEmit"
+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);
});
}
}
+16 -80
View File
@@ -1,99 +1,39 @@
import { createPromptContext } from "@/utils/command";
import { PromptContext } from "@/utils/command/command-prompt";
import { ReadonlySignal, Signal } from "@preact/signals-core";
import { CommandSchema, CommandRegistry, PromptEvent } from "@/utils/command";
import {
createGameCommandRegistry,
createGameContext,
IGameContext,
PromptDef,
} from "./game";
import { createGameContext, IGameContext } from "./game";
export type GameHostStatus = "created" | "running" | "disposed";
export class GameHost<
TState extends Record<string, unknown>,
TResult = unknown,
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
> {
readonly state: ReadonlySignal<TState>;
readonly status: ReadonlySignal<GameHostStatus>;
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>;
readonly activePromptPlayer: ReadonlySignal<string | null>;
readonly activePromptHint: ReadonlySignal<string | null>;
readonly prompts: PromptContext;
private _context: IGameContext<TState>;
private _start: (ctx: IGameContext<TState>) => Promise<TResult>;
private _status: Signal<GameHostStatus>;
private _activePromptSchema: Signal<CommandSchema | null>;
private _activePromptPlayer: Signal<string | null>;
private _activePromptHint: Signal<string | null>;
private _createInitialState: () => TState;
private _eventListeners: Map<"start" | "dispose", Set<() => void>>;
private _isDisposed = false;
constructor(
registry: CommandRegistry<IGameContext<TState>>,
createInitialState: () => TState,
start: (ctx: IGameContext<TState>) => Promise<TResult>,
) {
constructor(public readonly gameModule: TModule) {
const { createInitialState, start } = gameModule;
this._createInitialState = createInitialState;
this._eventListeners = new Map();
this.prompts = createPromptContext();
const initialState = createInitialState();
this._context = createGameContext(registry, initialState);
this._context = createGameContext(this.prompts, initialState);
this._start = start;
this.state = this._context._state;
this._status = new Signal<GameHostStatus>("created");
this.status = this._status;
this._activePromptSchema = new Signal<CommandSchema | null>(null);
this.activePromptSchema = this._activePromptSchema;
this._activePromptPlayer = new Signal<string | null>(null);
this.activePromptPlayer = this._activePromptPlayer;
this._activePromptHint = new Signal<string | null>(null);
this.activePromptHint = this._activePromptHint;
this._setupPromptTracking();
}
private _setupPromptTracking() {
let currentPromptEvent: PromptEvent | null = null;
this._context._commands.on("prompt", (e) => {
currentPromptEvent = e as PromptEvent;
this._activePromptSchema.value = currentPromptEvent.schema;
this._activePromptPlayer.value = currentPromptEvent.currentPlayer;
this._activePromptHint.value = currentPromptEvent.hintText || null;
});
this._context._commands.on("promptEnd", () => {
currentPromptEvent = null;
this._activePromptSchema.value = null;
this._activePromptPlayer.value = null;
this._activePromptHint.value = null;
});
// Initial state
this._activePromptSchema.value = null;
this._activePromptPlayer.value = null;
this._activePromptHint.value = null;
}
tryInput(input: string): string | null {
if (this._isDisposed) {
return "GameHost is disposed";
}
return this._context._commands._tryCommit(input);
}
tryAnswerPrompt<TArgs extends any[]>(def: PromptDef<TArgs>, ...args: TArgs) {
return this._context._commands._tryCommit({
name: def.schema.name,
params: args,
options: {},
flags: {},
});
}
/**
@@ -117,7 +57,7 @@ export class GameHost<
throw new Error("GameHost is disposed");
}
this._context._commands._cancel();
this.prompts.reset();
const initialState = this._createInitialState();
this._context._state.value = initialState as any;
@@ -138,7 +78,7 @@ export class GameHost<
}
this._isDisposed = true;
this._context._commands._cancel();
this.prompts.reset();
this._status.value = "disposed";
// Emit dispose event BEFORE clearing listeners
@@ -168,21 +108,17 @@ export class GameHost<
}
export type GameModule<
TState extends Record<string, unknown>,
TState extends Record<string, unknown> = Record<string, unknown>,
TResult = unknown,
> = {
registry?: CommandRegistry<IGameContext<TState>>;
createInitialState: () => TState;
start: (ctx: IGameContext<TState>) => Promise<TResult>;
};
export function createGameHost<
TState extends Record<string, unknown>,
TState extends Record<string, unknown> = Record<string, unknown>,
TResult = unknown,
>(gameModule: GameModule<TState, TResult>): GameHost<TState, TResult> {
return new GameHost(
gameModule.registry || createGameCommandRegistry(),
gameModule.createInitialState,
gameModule.start,
);
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
>(gameModule: TModule): GameHost<TState, TResult, TModule> {
return new GameHost(gameModule);
}
+10 -50
View File
@@ -1,40 +1,29 @@
import { MutableSignal, mutableSignal } from "@/utils/mutable-signal";
import {
Command,
CommandRegistry,
CommandResult,
CommandRunnerContextExport,
CommandSchema,
createCommandRegistry,
createCommandRunnerContext,
parseCommandSchema,
} from "@/utils/command";
import { CommandSchema, parseCommandSchema, PromptDef } from "@/utils/command";
import { PromptValidator } from "@/utils/command/command-runner";
import { Mulberry32RNG, ReadonlyRNG, RNG } from "@/utils/rng";
import { PromptContext } from "@/utils/command/command-prompt";
export interface IGameContext<TState extends Record<string, unknown> = {}> {
get value(): TState;
get rng(): ReadonlyRNG;
produce(fn: (draft: TState) => void): void;
produceAsync(fn: (draft: TState) => void): Promise<void>;
run<T>(input: string): Promise<CommandResult<T>>;
runParsed<T>(command: Command): Promise<CommandResult<T>>;
produce(fn: (draft: TState) => undefined): void;
produceAsync(fn: (draft: TState) => undefined): Promise<void>;
prompt: <TResult, TArgs extends any[] = any[]>(
def: PromptDef<TArgs>,
validator: PromptValidator<TResult, TArgs>,
currentPlayer?: string | null,
player?: string,
) => Promise<TResult>;
// test only
_state: MutableSignal<TState>;
_commands: CommandRunnerContextExport<IGameContext<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> = {}>(
commandRegistry: CommandRegistry<IGameContext<TState>>,
promptContext: PromptContext,
initialState?: TState | (() => TState),
): IGameContext<TState> {
const stateValue =
@@ -42,7 +31,7 @@ export function createGameContext<TState extends Record<string, unknown> = {}>(
? initialState()
: (initialState ?? ({} as TState));
const state = mutableSignal(stateValue);
let commands: CommandRunnerContextExport<IGameContext<TState>> = null as any;
const { prompt } = promptContext;
const context: IGameContext<TState> = {
get value(): TState {
@@ -51,44 +40,21 @@ export function createGameContext<TState extends Record<string, unknown> = {}>(
get rng() {
return this._rng;
},
produce(fn) {
produce(fn: (draft: TState) => undefined) {
return state.produce(fn);
},
produceAsync(fn) {
produceAsync(fn: (draft: TState) => undefined) {
return state.produceAsync(fn);
},
run<T>(input: string) {
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,
);
},
prompt,
_state: state,
_commands: commands,
_rng: new Mulberry32RNG(),
};
context._commands = commands = createCommandRunnerContext(
commandRegistry,
context,
);
return context;
}
export type PromptDef<TArgs extends any[] = any[]> = {
schema: CommandSchema;
hintText?: string;
};
export function createPromptDef<TArgs extends any[] = any[]>(
schema: CommandSchema | string,
hintText?: string,
@@ -96,9 +62,3 @@ export function createPromptDef<TArgs extends any[] = any[]>(
schema = typeof schema === "string" ? parseCommandSchema(schema) : schema;
return { schema, hintText };
}
export function createGameCommandRegistry<
TState extends Record<string, unknown> = {},
>() {
return createCommandRegistry<IGameContext<TState>>();
}
+5 -1
View File
@@ -5,7 +5,7 @@
// Core types
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 { createGameHost } from "./core/game-host";
@@ -24,6 +24,8 @@ export {
moveToRegion,
} from "./core/region";
export * from "./core/game-client";
// Utils
export type {
Command,
@@ -71,4 +73,6 @@ export { createRNG, Mulberry32RNG } from "./utils/rng";
export type { MiddlewareChain } from "./utils/middleware";
export { createMiddlewareChain } from "./utils/middleware";
export * from "./utils/spawner";
export * from "@preact/signals-core";
+9 -79
View File
@@ -1,75 +1,11 @@
import { BoopGame } from "@/samples/boop/types-extensions";
import { PlayerType } from "@/samples/boop/types";
import { turn } from "@/samples/boop/commands/turn";
import {createGameCommandRegistry} from "@/core/game";
export const registry = createGameCommandRegistry<BoopGame['value']>();
/**
* 放置棋子到棋盘
*/
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);
}
});
import { boop } from "./boop";
import { checkFullBoard } from "./full-board";
import { checkGraduates } from "./graduate";
import { place } from "./place";
import { checkWin } from "./win";
/**
* 启动游戏主循环
@@ -79,10 +15,11 @@ export async function start(game: BoopGame) {
const currentPlayer = game.value.currentPlayer;
const turnOutput = await turn(game, currentPlayer);
await game.produceAsync(state => {
await game.produceAsync((state) => {
state.winner = turnOutput.winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white';
state.currentPlayer =
state.currentPlayer === "white" ? "black" : "white";
}
});
if (game.value.winner) break;
@@ -91,11 +28,4 @@ export async function start(game: BoopGame) {
return game.value;
}
export {
placeCmd as place,
boopCmd as boop,
checkWinCmd as checkWin,
checkGraduatesCmd as checkGraduates,
checkFullBoardCmd as checkFullBoard,
turnCmd as turn
};
export { place, boop, checkWin, checkGraduates, checkFullBoard, turn };
+10 -18
View File
@@ -5,38 +5,30 @@ export type {
WinnerType,
RegionType,
BoopPartMeta,
BoopPart
} from './types';
BoopPart,
} from "./types";
export type {BoopGame} from './types-extensions';
export type { BoopGame } from "./types-extensions";
// Constants
export {
BOARD_SIZE,
MAX_PIECES_PER_PLAYER,
WIN_LENGTH
} from './constants';
export { BOARD_SIZE, MAX_PIECES_PER_PLAYER, WIN_LENGTH } from "./constants";
// State
export {
createInitialState,
type BoopState
} from './state';
export { createInitialState, type BoopState } from "./state";
// Prompts
export {prompts} from './prompts';
export { prompts } from "./prompts";
// Commands
export {
registry,
start,
place as placeCmd,
boop as boopCmd,
checkWin as checkWinCmd,
checkGraduates as checkGraduatesCmd,
checkFullBoard as checkFullBoardCmd,
turn as turnCmd
} from './commands';
turn as turnCmd,
} from "./commands";
// Utils
export {
@@ -45,5 +37,5 @@ export {
isCellOccupied,
getNeighborPositions,
findPartInRegion,
findPartAtPosition
} from './utils';
findPartAtPosition,
} from "./utils";
+108 -78
View File
@@ -3,13 +3,10 @@ import {
OnitamaState,
PlayerType,
prompts,
initializeCards
initializeCards,
} from "./types";
import {createGameCommandRegistry} from "@/core/game";
import { moveToRegion } from "@/core/region";
export const registry = createGameCommandRegistry<OnitamaState>();
/**
* 检查位置是否在棋盘范围内
*/
@@ -29,8 +26,12 @@ function getPawnAtPosition(state: OnitamaState, x: number, y: number) {
/**
* 检查玩家是否拥有某张卡牌
*/
function playerHasCard(state: OnitamaState, player: PlayerType, cardName: string): boolean {
const cardList = player === 'red' ? state.redCards : state.blackCards;
function playerHasCard(
state: OnitamaState,
player: PlayerType,
cardName: string,
): boolean {
const cardList = player === "red" ? state.redCards : state.blackCards;
return cardList.includes(cardName);
}
@@ -38,13 +39,17 @@ function playerHasCard(state: OnitamaState, player: PlayerType, cardName: string
* 获取卡牌的移动候选项,根据玩家视角进行旋转
* 黑方需要将卡牌旋转180度
*/
export function getCardMoveCandidates(state: OnitamaState, cardName: string, player: PlayerType) {
export function getCardMoveCandidates(
state: OnitamaState,
cardName: string,
player: PlayerType,
) {
const card = state.cards[cardName];
const candidates = card.moveCandidates;
// 黑方需要将卡牌旋转180度
if (player === 'black') {
return candidates.map(m => ({ dx: -m.dx, dy: -m.dy }));
if (player === "black") {
return candidates.map((m) => ({ dx: -m.dx, dy: -m.dy }));
}
return candidates;
@@ -53,7 +58,15 @@ export function getCardMoveCandidates(state: OnitamaState, cardName: string, pla
/**
* 检查移动是否合法
*/
export function isValidMove(state: OnitamaState, cardName: string, fromX: number, fromY: number, toX: number, toY: number, player: PlayerType): string | null {
export function isValidMove(
state: OnitamaState,
cardName: string,
fromX: number,
fromY: number,
toX: number,
toY: number,
player: PlayerType,
): string | null {
// 检查玩家是否拥有该卡牌
if (!playerHasCard(state, player, cardName)) {
return `玩家 ${player} 不拥有卡牌 ${cardName}`;
@@ -80,7 +93,7 @@ export function isValidMove(state: OnitamaState, cardName: string, fromX: number
// 检查移动是否在卡牌的移动候选项中(黑方需要旋转180度)
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) {
return `卡牌 ${cardName} 不支持移动 (${dx}, ${dy})`;
}
@@ -102,7 +115,15 @@ export function isValidMove(state: OnitamaState, cardName: string, fromX: number
/**
* 执行移动
*/
async function handleMove(game: OnitamaGame, player: PlayerType, cardName: string, fromX: number, fromY: number, toX: number, toY: number) {
async function move(
game: OnitamaGame,
player: PlayerType,
cardName: string,
fromX: number,
fromY: number,
toX: number,
toY: number,
) {
const state = game.value;
// 验证移动
@@ -113,7 +134,7 @@ async function handleMove(game: OnitamaGame, player: PlayerType, cardName: strin
const capturedPawnId = getPawnAtPosition(state, toX, toY)?.id || null;
await game.produceAsync(state => {
await game.produceAsync((state) => {
const pawn = state.pawns[getPawnAtPosition(state, fromX, fromY)!.id];
// 如果目标位置有敌方棋子,将其移除(吃掉)
@@ -127,41 +148,40 @@ async function handleMove(game: OnitamaGame, player: PlayerType, cardName: strin
});
// 交换卡牌
await handleSwapCard(game, player, cardName);
await swapCard(game, player, cardName);
return {
from: { x: fromX, y: fromY },
to: { x: toX, y: toY },
card: cardName,
captured: capturedPawnId
captured: capturedPawnId,
};
}
const move = registry.register({
schema: 'move <player> <card:string> <fromX:number> <fromY:number> <toX:number> <toY:number>',
run: handleMove
});
/**
* 交换卡牌:将使用的卡牌与备用卡牌交换
*/
async function handleSwapCard(game: OnitamaGame, player: PlayerType, usedCard: string) {
await game.produceAsync(state => {
async function swapCard(
game: OnitamaGame,
player: PlayerType,
usedCard: string,
) {
await game.produceAsync((state) => {
const spareCard = state.spareCard;
const usedCardData = state.cards[usedCard];
const spareCardData = state.cards[spareCard];
// 从玩家手牌中移除使用的卡牌
if (player === 'red') {
state.redCards = state.redCards.filter(c => c !== usedCard);
if (player === "red") {
state.redCards = state.redCards.filter((c) => c !== usedCard);
state.redCards.push(spareCard);
} else {
state.blackCards = state.blackCards.filter(c => c !== usedCard);
state.blackCards = state.blackCards.filter((c) => c !== usedCard);
state.blackCards.push(spareCard);
}
// 更新卡牌区域
usedCardData.regionId = 'spare';
usedCardData.regionId = "spare";
spareCardData.regionId = player;
// 更新备用卡牌
@@ -169,75 +189,70 @@ async function handleSwapCard(game: OnitamaGame, player: PlayerType, usedCard: s
});
}
const swapCard = registry.register({
schema: 'swap-card <player> <card:string>',
run: handleSwapCard
});
/**
* 检查占领胜利条件:玩家的师父棋子到达对手的初始位置
* 红色师父需要到达 (2, 4) - 黑色师父的初始位置
* 黑色师父需要到达 (2, 0) - 红色师父的初始位置
*/
async function handleCheckConquestWin(game: OnitamaGame): Promise<PlayerType | null> {
async function checkConquestWin(game: OnitamaGame): Promise<PlayerType | null> {
const state = game.value;
// 红色师父到达 (2, 4)(黑色师父的初始位置)
const redMaster = state.pawns['red-master'];
if (redMaster && redMaster.regionId === 'board' && redMaster.position[0] === 2 && redMaster.position[1] === 4) {
return 'red';
const redMaster = state.pawns["red-master"];
if (
redMaster &&
redMaster.regionId === "board" &&
redMaster.position[0] === 2 &&
redMaster.position[1] === 4
) {
return "red";
}
// 黑色师父到达 (2, 0)(红色师父的初始位置)
const blackMaster = state.pawns['black-master'];
if (blackMaster && blackMaster.regionId === 'board' && blackMaster.position[0] === 2 && blackMaster.position[1] === 0) {
return 'black';
const blackMaster = state.pawns["black-master"];
if (
blackMaster &&
blackMaster.regionId === "board" &&
blackMaster.position[0] === 2 &&
blackMaster.position[1] === 0
) {
return "black";
}
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 redMaster = state.pawns['red-master'];
if (!redMaster || redMaster.regionId !== 'board') {
return 'black';
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';
const blackMaster = state.pawns["black-master"];
if (!blackMaster || blackMaster.regionId !== "board") {
return "red";
}
return null;
}
const checkCaptureWin = registry.register({
schema: 'check-capture-win',
run: handleCheckCaptureWin
});
/**
* 综合胜利检测
*/
async function handleCheckWin(game: OnitamaGame): Promise<PlayerType | null> {
const conquestWinner = await handleCheckConquestWin(game);
async function checkWin(game: OnitamaGame): Promise<PlayerType | null> {
const conquestWinner = await checkConquestWin(game);
if (conquestWinner) {
return conquestWinner;
}
const captureWinner = await handleCheckCaptureWin(game);
const captureWinner = await checkCaptureWin(game);
if (captureWinner) {
return captureWinner;
}
@@ -245,22 +260,34 @@ async function handleCheckWin(game: OnitamaGame): Promise<PlayerType | null> {
return null;
}
const checkWin = registry.register({
schema: 'check-win',
run: handleCheckWin
});
/**
* 获取玩家可用的移动
*/
export function getAvailableMoves(state: OnitamaState, player: PlayerType): Array<{card: string, fromX: number, fromY: number, toX: number, toY: number}> {
const moves: Array<{card: string, fromX: number, fromY: number, toX: number, toY: number}> = [];
export function getAvailableMoves(
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) {
@@ -294,7 +321,7 @@ export function getAvailableMoves(state: OnitamaState, player: PlayerType): Arra
/**
* 处理回合
*/
async function handleTurn(game: OnitamaGame, turnPlayer: PlayerType) {
async function turn(game: OnitamaGame, turnPlayer: PlayerType) {
const state = game.value;
const availableMoves = getAvailableMoves(state, turnPlayer);
@@ -313,7 +340,7 @@ async function handleTurn(game: OnitamaGame, turnPlayer: PlayerType) {
}
return card;
},
turnPlayer
turnPlayer,
);
await swapCard(game, turnPlayer, cardToSwap);
@@ -334,19 +361,27 @@ async function handleTurn(game: OnitamaGame, turnPlayer: PlayerType) {
return { player, card, fromX, fromY, toX, toY };
},
turnPlayer
turnPlayer,
);
await move(game, moveOutput.player, moveOutput.card, moveOutput.fromX, moveOutput.fromY, moveOutput.toX, moveOutput.toY);
await move(
game,
moveOutput.player,
moveOutput.card,
moveOutput.fromX,
moveOutput.fromY,
moveOutput.toX,
moveOutput.toY,
);
}
// 检查胜利
const winner = await checkWin(game);
await game.produceAsync(state => {
await game.produceAsync((state) => {
state.winner = winner;
if (!winner) {
state.currentPlayer = state.currentPlayer === 'red' ? 'black' : 'red';
state.currentPlayer = state.currentPlayer === "red" ? "black" : "red";
state.turn++;
}
});
@@ -354,11 +389,6 @@ async function handleTurn(game: OnitamaGame, turnPlayer: PlayerType) {
return { winner, move: moveOutput };
}
const turn = registry.register({
schema: 'turn <player>',
run: handleTurn
});
/**
* 开始游戏主循环
*/
+86 -90
View File
@@ -1,20 +1,15 @@
import { IGameContext } from "@/core/game";
import { RegicideState } from "@/samples/regicide/state";
import {createGameCommandRegistry} from "@/core/game";
import { PlayerType, RegicideCard } from "@/samples/regicide/types";
import { CARD_VALUES, FACE_CARDS } from "@/samples/regicide/constants";
import { isEnemyDefeated } from "@/samples/regicide/utils";
export type RegicideGame = IGameContext<RegicideState>;
export const registry = createGameCommandRegistry<RegicideState>();
/**
* 打出一张牌(对当前敌人造成伤害)
*/
const playCmd = registry.register({
schema: 'play <player:string> <cardId:string>',
run: async (game: RegicideGame, player: string, cardId: string) => {
async function playCmd(game: RegicideGame, player: string, cardId: string) {
const state = game.value;
const card = state.cards[cardId];
@@ -26,12 +21,15 @@ const playCmd = registry.register({
const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey];
if (!playerHand || !playerHand.includes(cardId)) {
return {success: false, error: `卡牌 ${cardId} 不在玩家 ${player} 的手牌中`};
return {
success: false,
error: `卡牌 ${cardId} 不在玩家 ${player} 的手牌中`,
};
}
// 检查是否有当前敌人
if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'};
return { success: false, error: "没有活跃的敌人" };
}
// 计算伤害(基础伤害为卡牌面值)
@@ -39,24 +37,27 @@ const playCmd = registry.register({
let attackReduction = 0;
// 梅花双倍伤害
if (card.suit === 'clubs') {
if (card.suit === "clubs") {
damage *= 2;
}
// 黑桃降低敌人攻击力
if (card.suit === 'spades') {
if (card.suit === "spades") {
attackReduction = card.value;
}
const enemyHpBefore = state.currentEnemy.hp;
await game.produce(state => {
await game.produceAsync((state) => {
// 对敌人造成伤害
state.currentEnemy!.hp -= damage;
// 记录黑桃的攻击力降低
if (attackReduction > 0) {
state.currentEnemy!.value = Math.max(0, state.currentEnemy!.value - attackReduction);
state.currentEnemy!.value = Math.max(
0,
state.currentEnemy!.value - attackReduction,
);
}
// 从手牌移除卡牌
@@ -67,15 +68,17 @@ const playCmd = registry.register({
}
// 将卡牌移到弃牌堆
state.cards[cardId].regionId = 'discardPile';
state.cards[cardId].regionId = "discardPile";
// 红心能力:将弃牌堆洗回酒馆牌堆
if (card.suit === 'hearts') {
const discardIds = state.regions.discardPile.childIds.filter(id => id !== state.currentEnemy!.id);
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.cards[discardId].regionId = "tavernDeck";
}
state.regions.tavernDeck.childIds.push(...discardIds);
state.regions.discardPile.childIds = [state.currentEnemy!.id];
@@ -83,7 +86,7 @@ const playCmd = registry.register({
}
// 方块能力:从酒馆牌堆抓牌
if (card.suit === 'diamonds') {
if (card.suit === "diamonds") {
const tavernDeckCount = state.regions.tavernDeck.childIds.length;
if (tavernDeckCount > 0) {
const drawCardId = state.regions.tavernDeck.childIds.shift()!;
@@ -104,28 +107,30 @@ const playCmd = registry.register({
enemyHpBefore,
enemyHpAfter: game.value.currentEnemy!.hp,
enemyDefeated,
suitAbility: card.suit
}
suitAbility: card.suit,
},
};
}
});
/**
* 打出A配合另一张牌
*/
const playWithACmd = registry.register({
schema: 'play-with-a <player:string> <aceCardId:string> <otherCardId:string>',
run: async (game: RegicideGame, player: string, aceCardId: string, otherCardId: string) => {
async function playWithACmd(
game: RegicideGame,
player: string,
aceCardId: string,
otherCardId: string,
) {
const state = game.value;
const aceCard = state.cards[aceCardId];
const otherCard = state.cards[otherCardId];
if (!aceCard || !otherCard) {
return {success: false, error: '卡牌不存在'};
return { success: false, error: "卡牌不存在" };
}
// 检查是否是A牌
if (aceCard.rank !== 'A') {
if (aceCard.rank !== "A") {
return { success: false, error: `第一张牌必须是A` };
}
@@ -134,36 +139,39 @@ const playWithACmd = registry.register({
// 检查两张牌都在手牌中
if (!playerHand.includes(aceCardId) || !playerHand.includes(otherCardId)) {
return {success: false, error: '卡牌不在手牌中'};
return { success: false, error: "卡牌不在手牌中" };
}
if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'};
return { success: false, error: "没有活跃的敌人" };
}
// 计算两张牌的总伤害
let totalDamage = aceCard.value + otherCard.value;
// 如果另一张牌是梅花,双倍伤害
if (otherCard.suit === 'clubs') {
if (otherCard.suit === "clubs") {
totalDamage *= 2;
}
let attackReduction = 0;
if (aceCard.suit === 'spades') {
if (aceCard.suit === "spades") {
attackReduction += aceCard.value;
}
if (otherCard.suit === 'spades') {
if (otherCard.suit === "spades") {
attackReduction += otherCard.value;
}
await game.produce(state => {
await game.produceAsync((state) => {
// 对敌人造成伤害
state.currentEnemy!.hp -= totalDamage;
// 记录黑桃的攻击力降低
if (attackReduction > 0) {
state.currentEnemy!.value = Math.max(0, state.currentEnemy!.value - attackReduction);
state.currentEnemy!.value = Math.max(
0,
state.currentEnemy!.value - attackReduction,
);
}
// 从手牌移除两张牌
@@ -174,8 +182,8 @@ const playWithACmd = registry.register({
if (otherIndex !== -1) hand.splice(otherIndex, 1);
// 将卡牌移到弃牌堆
state.cards[aceCardId].regionId = 'discardPile';
state.cards[otherCardId].regionId = 'discardPile';
state.cards[aceCardId].regionId = "discardPile";
state.cards[otherCardId].regionId = "discardPile";
});
const enemyDefeated = isEnemyDefeated(state.currentEnemy);
@@ -186,33 +194,31 @@ const playWithACmd = registry.register({
damage: totalDamage,
attackReduction,
enemyHp: state.currentEnemy!.hp,
enemyDefeated
}
enemyDefeated,
},
};
}
});
/**
* 让过(不出牌)
*/
const passCmd = registry.register({
schema: 'pass <player:string>',
run: async (game: RegicideGame, player: string) => {
async function passCmd(game: RegicideGame, player: string) {
// 即使让过,也会受到敌人反击(在回合结束时处理)
return { success: true, result: { message: `${player} 让过` } };
}
});
/**
* 敌人反击 - 玩家必须弃掉点数和 >= 敌人攻击力的牌
*/
const enemyCounterattackCmd = registry.register({
schema: 'counterattack <player:string> <discardCards:string[]>',
run: async (game: RegicideGame, player: string, discardCards: string[]) => {
async function enemyCounterattackCmd(
game: RegicideGame,
player: string,
discardCards: string[],
) {
const state = game.value;
if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'};
return { success: false, error: "没有活跃的敌人" };
}
const playerKey = player as PlayerType;
@@ -240,19 +246,19 @@ const enemyCounterattackCmd = registry.register({
if (totalValue < enemyAttack) {
return {
success: false,
error: `弃牌点数和 (${totalValue}) 小于敌人攻击力 (${enemyAttack}),游戏失败`
error: `弃牌点数和 (${totalValue}) 小于敌人攻击力 (${enemyAttack}),游戏失败`,
};
}
// 执行弃牌
await game.produce(state => {
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';
state.cards[cardId].regionId = "discardPile";
}
});
@@ -261,22 +267,19 @@ const enemyCounterattackCmd = registry.register({
result: {
discardedCards: discardCards,
totalValue,
enemyAttack
}
enemyAttack,
},
};
}
});
/**
* 检查敌人是否被击败,如果击败则翻开下一个敌人
*/
const checkEnemyDefeatedCmd = registry.register({
schema: 'check-enemy',
run: async (game: RegicideGame) => {
async function checkEnemyDefeatedCmd(game: RegicideGame) {
const state = game.value;
if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'};
return { success: false as const, error: "没有活跃的敌人" };
}
const defeated = state.currentEnemy.hp <= 0;
@@ -284,7 +287,7 @@ const checkEnemyDefeatedCmd = registry.register({
if (defeated) {
const defeatedEnemy = { ...state.currentEnemy };
await game.produce(state => {
await game.produceAsync((state) => {
// 将当前敌人移到弃牌堆
state.regions.discardPile.childIds.push(state.currentEnemy!.id);
@@ -300,20 +303,20 @@ const checkEnemyDefeatedCmd = registry.register({
// 检查是否胜利(没有更多敌人)
if (!game.value.currentEnemy) {
await game.produce(state => {
state.phase = 'victory';
await game.produceAsync((state) => {
state.phase = "victory";
state.winner = true;
});
}
return {
success: true,
success: true as const,
result: {
defeated: true,
defeatedEnemy,
nextEnemy: game.value.currentEnemy,
enemiesRemaining: game.value.enemyDeck.length
}
enemiesRemaining: game.value.enemyDeck.length,
},
};
}
@@ -322,26 +325,23 @@ const checkEnemyDefeatedCmd = registry.register({
result: {
defeated: false,
currentEnemy: { ...state.currentEnemy },
enemiesRemaining: state.enemyDeck.length
}
enemiesRemaining: state.enemyDeck.length,
},
};
}
});
/**
* 检查玩家是否有可出的牌
*/
const checkCanPlayCmd = registry.register({
schema: 'check-can-play <player:string>',
run: async (game: RegicideGame, player: string) => {
async function checkCanPlayCmd(game: RegicideGame, player: string) {
const state = game.value;
const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey];
const canPlay = playerHand.length > 0;
const canPlayWithA = playerHand.some(cardId => {
const canPlayWithA = playerHand.some((cardId) => {
const card = state.cards[cardId];
return card && card.rank === 'A' && playerHand.length > 1;
return card && card.rank === "A" && playerHand.length > 1;
});
return {
@@ -349,27 +349,26 @@ const checkCanPlayCmd = registry.register({
result: {
canPlay,
canPlayWithA,
handSize: playerHand.length
}
handSize: playerHand.length,
},
};
}
});
/**
* 检查酒馆牌堆是否为空
*/
const checkTavernDeckCmd = registry.register({
schema: 'check-tavern-deck',
run: async (game: RegicideGame) => {
async function checkTavernDeckCmd(game: RegicideGame) {
const state = game.value;
const isEmpty = state.regions.tavernDeck.childIds.length === 0;
// 如果酒馆牌堆为空且所有玩家手牌也为空,则游戏失败
if (isEmpty) {
const allHandsEmpty = Object.values(state.playerHands).every(hand => hand.length === 0);
const allHandsEmpty = Object.values(state.playerHands).every(
(hand) => hand.length === 0,
);
if (allHandsEmpty) {
await game.produce(state => {
state.phase = 'defeat';
await game.produceAsync((state) => {
state.phase = "defeat";
state.winner = false;
});
}
@@ -379,35 +378,32 @@ const checkTavernDeckCmd = registry.register({
success: true,
result: {
isEmpty,
cardsRemaining: state.regions.tavernDeck.childIds.length
}
cardsRemaining: state.regions.tavernDeck.childIds.length,
},
};
}
});
/**
* 下一个玩家回合
*/
const nextTurnCmd = registry.register({
schema: 'next-turn',
run: async (game: RegicideGame) => {
async function nextTurnCmd(game: RegicideGame) {
const state = game.value;
await game.produce(state => {
state.currentPlayerIndex = (state.currentPlayerIndex + 1) % state.playerCount;
await game.produce((state) => {
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];
return {
success: true,
result: {
currentPlayer,
currentPlayerIndex: game.value.currentPlayerIndex
}
currentPlayerIndex: game.value.currentPlayerIndex,
},
};
}
});
export {
playCmd as play,
+78 -44
View File
@@ -1,8 +1,15 @@
import { IGameContext } from "@/core/game";
import { RegicideState } from "@/samples/regicide/state";
import {buildEnemyDeck, buildTavernDeck, createAllCards, getPlayerHandRegionId} from "@/samples/regicide/utils";
import {
buildEnemyDeck,
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>;
@@ -12,9 +19,13 @@ export type RegicideGame = IGameContext<RegicideState>;
* @param playerCount 玩家数量(1-4)
* @param seed 随机种子(可选)
*/
export async function setupGame(game: RegicideGame, playerCount: number, seed?: number) {
export async function setupGame(
game: RegicideGame,
playerCount: number,
seed?: number,
) {
if (playerCount < 1 || playerCount > 4) {
throw new Error('玩家数量必须为 1-4 人');
throw new Error("玩家数量必须为 1-4 人");
}
if (seed) {
@@ -31,7 +42,7 @@ export async function setupGame(game: RegicideGame, playerCount: number, seed?:
const tavernDeck = buildTavernDeck(game._rng);
// 初始化游戏状态
await game.produceAsync(state => {
await game.produceAsync((state) => {
state.cards = allCards;
state.playerCount = playerCount;
state.currentPlayerIndex = 0;
@@ -39,15 +50,15 @@ export async function setupGame(game: RegicideGame, playerCount: number, seed?:
// 设置酒馆牌堆区域
for (const card of tavernDeck) {
card.regionId = 'tavernDeck';
card.regionId = "tavernDeck";
state.regions.tavernDeck.childIds.push(card.id);
}
// 设置敌人牌堆区域(只存储ID,敌人是独立对象)
state.regions.enemyDeck.childIds = enemyDeck.map(e => e.id);
state.regions.enemyDeck.childIds = enemyDeck.map((e) => e.id);
// 给每个玩家发牌
const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4'];
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
for (let i = 0; i < playerCount; i++) {
const player = players[i];
const regionId = getPlayerHandRegionId(player);
@@ -78,13 +89,13 @@ export async function start(game: RegicideGame) {
// 检查游戏是否已设置
if (!state.currentEnemy) {
throw new Error('请先调用 setupGame 初始化游戏');
throw new Error("请先调用 setupGame 初始化游戏");
}
const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4'];
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
// 主游戏循环
while (state.phase === 'playing') {
while (state.phase === "playing") {
const currentPlayerIndex = state.currentPlayerIndex;
const currentPlayer = players[currentPlayerIndex];
@@ -92,8 +103,9 @@ export async function start(game: RegicideGame) {
const currentHand = state.playerHands[currentPlayer];
if (currentHand.length === 0) {
// 玩家没有手牌,跳过回合
await game.produceAsync(state => {
state.currentPlayerIndex = (state.currentPlayerIndex + 1) % state.playerCount;
await game.produceAsync((state) => {
state.currentPlayerIndex =
(state.currentPlayerIndex + 1) % state.playerCount;
});
continue;
}
@@ -113,31 +125,37 @@ export async function start(game: RegicideGame) {
/**
* 处理完整的玩家回合
*/
export async function playTurn(game: RegicideGame, player: PlayerType, action: 'play' | 'pass', cardId?: string, secondCardId?: string) {
export async function playTurn(
game: RegicideGame,
player: PlayerType,
action: "play" | "pass",
cardId?: string,
secondCardId?: string,
) {
const state = game.value;
if (state.phase !== 'playing') {
return {success: false, error: '游戏已结束'};
if (state.phase !== "playing") {
return { success: false, error: "游戏已结束" };
}
if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'};
return { success: false, error: "没有活跃的敌人" };
}
let playResult: any;
// 执行玩家动作
if (action === 'play' && cardId) {
if (action === "play" && cardId) {
// 检查是否是A配合另一张牌
const card = state.cards[cardId];
if (card.rank === 'A' && secondCardId) {
playResult = await game.run(`play-with-a ${player} ${cardId} ${secondCardId}`);
if (card.rank === "A" && secondCardId) {
playResult = await playWithA(game, player, cardId, secondCardId);
} else {
playResult = await game.run(`play ${player} ${cardId}`);
playResult = await playCmd(game, player, cardId);
}
} else {
// 让过
playResult = await game.run(`pass ${player}`);
playResult = await pass(game, player);
}
if (!playResult.success) {
@@ -145,7 +163,7 @@ export async function playTurn(game: RegicideGame, player: PlayerType, action: '
}
// 检查敌人是否被击败
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) {
return checkResult;
}
@@ -160,15 +178,19 @@ export async function playTurn(game: RegicideGame, player: PlayerType, action: '
enemyDefeated: false,
needsDiscard: true,
enemyAttack: state.currentEnemy.value,
playerHand: state.playerHands[player]
}
playerHand: state.playerHands[player],
},
};
}
// 敌人被击败,检查是否还有更多敌人
if (state.enemyDeck.length === 0 && state.currentEnemy && state.currentEnemy.hp <= 0) {
await game.produceAsync(state => {
state.phase = 'victory';
if (
state.enemyDeck.length === 0 &&
state.currentEnemy &&
state.currentEnemy.hp <= 0
) {
await game.produceAsync((state) => {
state.phase = "victory";
state.winner = true;
});
return {
@@ -176,41 +198,44 @@ export async function playTurn(game: RegicideGame, player: PlayerType, action: '
result: {
playResult: playResult.result,
enemyDefeated: true,
gameWon: true
}
gameWon: true,
},
};
}
// 切换到下一个玩家
await game.run('next-turn');
await nextTurn(game);
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: true,
nextEnemy: state.currentEnemy
}
nextEnemy: state.currentEnemy,
},
};
}
/**
* 处理反击阶段的弃牌
*/
export async function handleCounterattack(game: RegicideGame, player: PlayerType, discardCardIds: string[]) {
const result = await game.run(`counterattack ${player} ${JSON.stringify(discardCardIds)}`);
export async function handleCounterattack(
game: RegicideGame,
player: PlayerType,
discardCardIds: string[],
) {
const result = await enemyCounterattackCmd(game, player, discardCardIds);
if (!result.success) {
// 弃牌失败(点数和不足),游戏失败
await game.produceAsync(state => {
state.phase = 'defeat';
await game.produceAsync((state) => {
state.phase = "defeat";
state.winner = false;
});
return result;
}
// 弃牌成功,切换到下一个玩家
await game.run('next-turn');
await nextTurn(game);
return result;
}
@@ -223,17 +248,26 @@ export function getGameStatus(game: RegicideGame) {
return {
phase: state.phase,
currentPlayer: ['player1', 'player2', 'player3', 'player4'][state.currentPlayerIndex],
currentEnemy: state.currentEnemy ? {
currentPlayer: ["player1", "player2", "player3", "player4"][
state.currentPlayerIndex
],
currentEnemy: state.currentEnemy
? {
...state.currentEnemy,
hpPercent: Math.round((state.currentEnemy.hp / state.currentEnemy.maxHp) * 100)
} : null,
hpPercent: Math.round(
(state.currentEnemy.hp / state.currentEnemy.maxHp) * 100,
),
}
: null,
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])
Object.entries(state.playerHands).map(([player, hand]) => [
player,
hand.length,
]),
),
winner: state.winner
winner: state.winner,
};
}
+11 -15
View File
@@ -7,8 +7,8 @@ export type {
RegicideCardMeta,
RegicideCard,
Enemy,
GamePhase
} from './types';
GamePhase,
} from "./types";
// Constants
export {
@@ -18,21 +18,17 @@ export {
ALL_SUITS,
ALL_RANKS,
FACE_CARDS,
NUMBER_CARDS
} from './constants';
NUMBER_CARDS,
} from "./constants";
// State
export {
createInitialState,
type RegicideState
} from './state';
export { createInitialState, type RegicideState } from "./state";
// Prompts
export {prompts} from './prompts';
export { prompts } from "./prompts";
// Commands
export {
registry,
play as playCmd,
playWithA as playWithACmd,
pass as passCmd,
@@ -41,7 +37,7 @@ export {
checkCanPlay as checkCanPlayCmd,
checkTavernDeck as checkTavernDeckCmd,
nextTurn as nextTurnCmd,
} from './commands';
} from "./commands";
// Game
export {
@@ -50,8 +46,8 @@ export {
playTurn,
handleCounterattack,
getGameStatus,
type RegicideGame
} from './game';
type RegicideGame,
} from "./game";
// Utils
export {
@@ -63,5 +59,5 @@ export {
buildTavernDeck,
drawFromDeck,
isEnemyDefeated,
getPlayerHandRegionId
} from './utils';
getPlayerHandRegionId,
} from "./utils";
@@ -2,37 +2,42 @@
# type: 'item' = inventory item card, 'status' = status effect card
# costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
# targetType: 'single' = target one enemy, 'none' = no target
# effects := ~cardEffect(card)
# type CardType = 'item' | 'status'
# type CardCostType = 'energy' | 'uses' | 'none'
# type CardTargetType = 'player' | 'enemy' | 'enemies'
# inject effects = ~cardEffect(card)
id,name,desc,type,costType,costCount,targetType
string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none'
sword,剑,【攻击2】【攻击2】,item,energy,1,single
greataxe,长斧,对全体【攻击5】,item,energy,2,none
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single
dagger,短刀,【攻击3】【攻击3】,item,energy,1,single
dart,飞镖,【攻击1】抓一张牌,item,energy,0,single
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single
shield,盾,【防御3】,item,energy,1,none
hat,斗笠,【防御8】,item,energy,2,none
cape,披风,【防御2】下回合【防御2】,item,energy,1,none
bracer,护腕,【防御1】抓1张牌,item,energy,0,none
greatshield,大盾,【防御5】,item,energy,1,none
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none
vitalityPotion,活力药剂,获得1点能量,item,uses,3,none
focusPotion,集中药剂,抓2张牌,item,uses,3,none
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none
wound,伤口,无效果占用手牌和牌堆,status,none,0,none
venom,蛇毒,弃掉时受到3点伤害,status,none,0,none
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none
static,静电,在手里时受电击伤害+1,status,none,0,none
fatigue,疲劳,占用手牌,status,none,0,none
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none
string,string,string,CardType,CardCostType,int,CardTargetType
sword,剑,【攻击2】【攻击2】,item,energy,1,enemy
greataxe,长斧,对全体【攻击5】,item,energy,2,enemies
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,enemy
dagger,短刀,【攻击3】【攻击3】,item,energy,1,enemy
dart,飞镖,【攻击1】抓一张牌,item,energy,0,enemy
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,enemy
shield,盾,【防御3】,item,energy,1,player
hat,斗笠,【防御8】,item,energy,2,player
cape,披风,【防御2】下回合【防御2】,item,energy,1,player
bracer,护腕,【防御1】抓1张牌,item,energy,0,player
greatshield,大盾,【防御5】,item,energy,1,player
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,player
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,player
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,player
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,player
vitalityPotion,活力药剂,获得1点能量,item,uses,3,player
focusPotion,集中药剂,抓2张牌,item,uses,3,player
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,player
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,player
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,player
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,player
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,player
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,player
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,player
wound,伤口,无效果占用手牌和牌堆,status,none,0,player
venom,蛇毒,弃掉时受到3点伤害,status,none,0,player
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,player
static,静电,在手里时受电击伤害+1,status,none,0,player
fatigue,疲劳,占用手牌,status,none,0,player
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,player
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
5 # effects := ~cardEffect(card) # type CardType = 'item' | 'status'
6 # type CardCostType = 'energy' | 'uses' | 'none'
7 # type CardTargetType = 'player' | 'enemy' | 'enemies'
8 # inject effects = ~cardEffect(card)
9 id,name,desc,type,costType,costCount,targetType
10 string,string,string,CardType,CardCostType,int,CardTargetType
11 id,name,desc,type,costType,costCount,targetType sword,剑,【攻击2】【攻击2】,item,energy,1,enemy
12 string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none' greataxe,长斧,对全体【攻击5】,item,energy,2,enemies
13 sword,剑,【攻击2】【攻击2】,item,energy,1,single spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,enemy
14 greataxe,长斧,对全体【攻击5】,item,energy,2,none dagger,短刀,【攻击3】【攻击3】,item,energy,1,enemy
15 spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single dart,飞镖,【攻击1】抓一张牌,item,energy,0,enemy
16 dagger,短刀,【攻击3】【攻击3】,item,energy,1,single crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,enemy
17 dart,飞镖,【攻击1】抓一张牌,item,energy,0,single shield,盾,【防御3】,item,energy,1,player
18 crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single hat,斗笠,【防御8】,item,energy,2,player
19 shield,盾,【防御3】,item,energy,1,none cape,披风,【防御2】下回合【防御2】,item,energy,1,player
20 hat,斗笠,【防御8】,item,energy,2,none bracer,护腕,【防御1】抓1张牌,item,energy,0,player
21 cape,披风,【防御2】下回合【防御2】,item,energy,1,none greatshield,大盾,【防御5】,item,energy,1,player
22 bracer,护腕,【防御1】抓1张牌,item,energy,0,none chainmail,锁子甲,本回合受到伤害-3,item,energy,1,player
23 greatshield,大盾,【防御5】,item,energy,1,none bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,player
24 chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,player
25 bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,player
26 poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none vitalityPotion,活力药剂,获得1点能量,item,uses,3,player
27 fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none focusPotion,集中药剂,抓2张牌,item,uses,3,player
28 vitalityPotion,活力药剂,获得1点能量,item,uses,3,none healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,player
29 focusPotion,集中药剂,抓2张牌,item,uses,3,none waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,player
30 healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,player
31 waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,player
32 rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,player
33 belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,player
34 torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,player
35 whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none wound,伤口,无效果占用手牌和牌堆,status,none,0,player
36 blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none venom,蛇毒,弃掉时受到3点伤害,status,none,0,player
37 wound,伤口,无效果占用手牌和牌堆,status,none,0,none curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,player
38 venom,蛇毒,弃掉时受到3点伤害,status,none,0,none static,静电,在手里时受电击伤害+1,status,none,0,player
39 curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none fatigue,疲劳,占用手牌,status,none,0,player
40 static,静电,在手里时受电击伤害+1,status,none,0,none vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,player
41
42
43
+7 -3
View File
@@ -1,13 +1,17 @@
import type { CardEffect } from './cardEffect.csv';
export type CardType = 'item' | 'status';
export type CardCostType = 'energy' | 'uses' | 'none';
export type CardTargetType = 'player' | 'enemy' | 'enemies';
type CardTable = readonly {
readonly id: string;
readonly name: string;
readonly desc: string;
readonly type: "item" | "status";
readonly costType: "energy" | "uses" | "none";
readonly type: CardType;
readonly costType: CardCostType;
readonly costCount: number;
readonly targetType: "single" | "none";
readonly targetType: CardTargetType;
readonly effects: CardEffect[];
}[];
@@ -1,32 +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,'onPlay'|'onDraw'|'onDiscard','self'|'target'|'all'|'random',[@effect;number][]
sword,sword,onPlay,target,[attack;2];[attack;2]
greataxe,greataxe,onPlay,all,[attack;5]
spear,spear,onPlay,target,[attack;2];[attack;2];[attack;2]
dagger,dagger,onPlay,target,[attack;3];[attack;3]
dart,dart,onPlay,target,[attack;1]
dart-draw,dart,onPlay,self,[draw;1]
crossbow,crossbow,onPlay,target,[attack;6]
crossbow-combo,crossbow,onPlay,self,[crossbow;0]
shield,shield,onPlay,self,[defend;3]
hat,hat,onPlay,self,[defend;8]
cape,cape,onPlay,self,[defend;2];[defendNext;2]
bracer,bracer,onPlay,self,[defend;1];[draw;1]
greatshield,greatshield,onPlay,self,[defend;5]
chainmail,chainmail,onPlay,self,[damageReduce;3]
bandage,bandage,onPlay,self,[removeWound;1]
poisonPotion,poisonPotion,onPlay,self,[attackBuff;2]
fortifyPotion,fortifyPotion,onPlay,self,[defendBuff;2]
vitalityPotion,vitalityPotion,onPlay,self,[gainEnergy;1]
focusPotion,focusPotion,onPlay,self,[draw;2]
healingPotion,healingPotion,onPlay,self,[removeWound;3]
waterBag,waterBag,onPlay,self,[energyNext;1];[drawNext;2]
rope,rope,onPlay,self,[defendBuffUntilPlay;2]
belt,belt,onPlay,self,[drawChoice;1]
torch,torch,onPlay,self,[burnForEnergy;1]
whetstone,whetstone,onPlay,self,[attackBuffUntilPlay;3]
blacksmithHammer,blacksmithHammer,onPlay,self,[transformRandom;1]
venom,venom,onDiscard,self,[attack;3]
curse,curse,onDraw,self,[curse;1]
static,static,onDraw,self,[static;1]
vultureEye,vultureEye,onDraw,self,[expose;3]
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 id # type CardEffectTrigger = 'onPlay' | 'onDraw' | 'onDiscard' card trigger target effects
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 id string,@card,CardEffectTrigger,CardEffectTarget,CardEffectList card trigger target effects
6 string sword,sword,onPlay,eachTarget,[attack;2];[attack;2] @card 'onPlay'|'onDraw'|'onDiscard' 'self'|'target'|'all'|'random' [@effect;number][]
7 sword greataxe,greataxe,onPlay,eachTarget,[attack;5] sword onPlay target [attack;2];[attack;2]
8 greataxe spear,spear,onPlay,eachTarget,[attack;2];[attack;2];[attack;2] greataxe onPlay all [attack;5]
9 spear dagger,dagger,onPlay,eachTarget,[attack;3];[attack;3] spear onPlay target [attack;2];[attack;2];[attack;2]
10 dagger dart,dart,onPlay,eachTarget,[attack;1] dagger onPlay target [attack;3];[attack;3]
11 dart dart-draw,dart,onPlay,source,[draw;1] dart onPlay target [attack;1]
12 dart-draw crossbow,crossbow,onPlay,eachTarget,[attack;6] dart onPlay self [draw;1]
13 crossbow crossbow-combo,crossbow,onPlay,source,[crossbow;0] crossbow onPlay target [attack;6]
14 crossbow-combo shield,shield,onPlay,source,[defend;3] crossbow onPlay self [crossbow;0]
15 shield hat,hat,onPlay,source,[defend;8] shield onPlay self [defend;3]
16 hat cape,cape,onPlay,source,[defend;2];[defendNext;2] hat onPlay self [defend;8]
17 cape bracer,bracer,onPlay,source,[defend;1];[draw;1] cape onPlay self [defend;2];[defendNext;2]
18 bracer greatshield,greatshield,onPlay,source,[defend;5] bracer onPlay self [defend;1];[draw;1]
19 greatshield chainmail,chainmail,onPlay,source,[damageReduce;3] greatshield onPlay self [defend;5]
20 chainmail bandage,bandage,onPlay,source,[removeWound;1] chainmail onPlay self [damageReduce;3]
21 bandage poisonPotion,poisonPotion,onPlay,source,[attackBuff;2] bandage onPlay self [removeWound;1]
22 poisonPotion fortifyPotion,fortifyPotion,onPlay,source,[defendBuff;2] poisonPotion onPlay self [attackBuff;2]
23 fortifyPotion vitalityPotion,vitalityPotion,onPlay,source,[gainEnergy;1] fortifyPotion onPlay self [defendBuff;2]
24 vitalityPotion focusPotion,focusPotion,onPlay,source,[draw;2] vitalityPotion onPlay self [gainEnergy;1]
25 focusPotion healingPotion,healingPotion,onPlay,source,[removeWound;3] focusPotion onPlay self [draw;2]
26 healingPotion waterBag,waterBag,onPlay,source,[energyNext;1];[drawNext;2] healingPotion onPlay self [removeWound;3]
27 waterBag rope,rope,onPlay,source,[defendBuffUntilPlay;2] waterBag onPlay self [energyNext;1];[drawNext;2]
28 rope belt,belt,onPlay,source,[drawChoice;1] rope onPlay self [defendBuffUntilPlay;2]
29 belt torch,torch,onPlay,source,[burnForEnergy;1] belt onPlay self [drawChoice;1]
30 torch whetstone,whetstone,onPlay,source,[attackBuffUntilPlay;3] torch onPlay self [burnForEnergy;1]
31 whetstone blacksmithHammer,blacksmithHammer,onPlay,source,[transformRandom;1] whetstone onPlay self [attackBuffUntilPlay;3]
32 blacksmithHammer venom,venom,onDiscard,source,[attack;3] blacksmithHammer onPlay self [transformRandom;1]
33 venom curse,curse,onDraw,source,[curse;1] venom onDiscard self [attack;3]
34 curse static,static,onDraw,source,[static;1] curse onDraw self [curse;1]
35 static vultureEye,vultureEye,onDraw,source,[expose;3] static onDraw self [static;1]
36 vultureEye vultureEye onDraw self [expose;3]
@@ -1,12 +1,16 @@
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: "onPlay" | "onDraw" | "onDiscard";
readonly target: "self" | "target" | "all" | "random";
readonly effects: [Effect, number][];
readonly trigger: CardEffectTrigger;
readonly target: CardEffectTarget;
readonly effects: CardEffectList;
}[];
export type CardEffect = CardEffectTable[number];
@@ -11,40 +11,42 @@
# itemUntilDiscard: 施加buff到周围物品,物品被弃掉后失效
# itemPermanent: 施加buff到周围物品,持续整场冒险
id, name, description, lifecycle
string, string, string, 'instant'|'temporary'|'lingering'|'permanent'|'posture'|'item'|'itemTemporary'|'itemUntilPlay'|'itemUntilDiscard'|'itemPermanent'
attack, 攻击, 对对手造成伤害, instant
defend, 防御, 抵消下次行动前受到的伤害, posture
spike, 尖刺, 对攻击者造成X点伤害, permanent
venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, instant
curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, lingering
aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture
roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture
rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant
vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), instant
tailSting, 尾刺, 攻击时,伤害提升X, posture
energyDrain, 能量吸取, 受伤时,玩家失去X点能量, lingering
molt, 脱皮, 若脱皮达到生命上限则怪物逃跑, posture
discard, 劫掠, 回合开始时随机弃掉一张手牌, lingering
storm, 风暴, 攻击时给玩家塞入1张静电, permanent
static, 静电, 在手里时受电击伤害+1, permanent
charge, 冲锋, 受到或造成的伤害翻倍并消耗等量冲锋, lingering
summonMummy, 召唤木乃伊, 召唤1个木乃伊, instant
summonSandwormLarva, 召唤幼沙虫, 召唤1个幼沙虫, instant
reviveMummy, 复活木乃伊, 复活1个已死亡的木乃伊, instant
draw, 抓牌, 抓X张牌, instant
crossbow, 十字弩连击, 对同一目标打出其他十字弩, instant
defendNext, 下回合防御, 下回合开始时获得防御, temporary
damageReduce, 减伤, 本回合受到的伤害减少X, temporary
removeWound, 移除伤口, 从牌堆或弃牌堆移除X张伤口, instant
attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlay
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlay
gainEnergy, 获得能量, 获得X点能量, instant
energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary
drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlay
drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlay
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlay
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant
expose, 暴露, 受到的伤害+1/每层, temporary
# type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent'
id, name, description, lifecycle, emoji
string, string, string, EffectLifecycle, string
attack, 攻击, 对对手造成伤害, instant, ⚔️
defend, 防御, 抵消下次行动前受到的伤害, temporary, 🛡️
spike, 尖刺, 对攻击者造成X点伤害, permanent, 🌵
venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, instant, 🧪
curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, lingering, 💀
aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture, 🎯
roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture, 🎲
rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant, 💥
vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), instant, 👁️
tailSting, 尾刺, 攻击时,伤害提升X, posture, 🦂
energyDrain, 能量吸取, 受伤时,玩家失去X点能量, lingering, 🔋
molt, 脱皮, 若脱皮达到生命上限则怪物逃跑, posture, 🐚
discard, 劫掠, 回合开始时随机弃掉一张手牌, lingering, 🗑️
storm, 风暴, 攻击时给玩家塞入1张静电, permanent, ⚡
static, 静电, 在手里时受电击伤害+1, permanent, ⚡
charge, 冲锋, 受到或造成的伤害翻倍并消耗等量冲锋, lingering, 🐎
summonMummy, 召唤木乃伊, 召唤1个木乃伊, instant, 🧟
summonSandwormLarva, 召唤幼沙虫, 召唤1个幼沙虫, instant, 🐛
reviveMummy, 复活木乃伊, 复活1个已死亡的木乃伊, instant, 🌅
draw, 抓牌, 抓X张牌, instant, 🃏
crossbow, 十字弩连击, 对同一目标打出其他十字弩, instant, 🏹
defendNext, 下回合防御, 下回合开始时获得防御, temporary, 🛡️
damageReduce, 减伤, 本回合受到的伤害减少X, temporary, 📉
removeWound, 移除伤口, 从牌堆或弃牌堆移除X张伤口, instant, 🩹
attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlay, ⬆️
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlay, ⬆️
gainEnergy, 获得能量, 获得X点能量, instant, ⚡
energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary, ⚡
drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary, 🃏
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlay, 🛡️
drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant, 🔍
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlay, 🔥
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlay, ⚔️
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant, 🌀
expose, 暴露, 受到的伤害+1/每层, temporary, 👁️
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 {
readonly id: string;
readonly name: 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];
@@ -6,8 +6,11 @@
# curio (8): random pickup of treasure or resources
# enemies: array of [enemyId; initialHp; buffs[]]
# type EncounterType = 'minion' | 'elite' | 'event' | 'shop' | 'camp' | 'curio'
# type EnemyList = [data: @enemy; hp: int; effects: [effect: @effect; stacks: int][]][]
id,type,name,description,enemies,dialogue
string,'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;[]],
snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[]],
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 { Effect } from './effect.csv';
export type EncounterType = 'minion' | 'elite' | 'event' | 'shop' | 'camp' | 'curio';
export type EnemyList = [data: Enemy, hp: int, effects: [effect: Effect, stacks: int][]][];
type EncounterTable = readonly {
readonly id: string;
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio";
readonly type: EncounterType;
readonly name: string;
readonly description: string;
readonly enemies: [data: Enemy, hp: number, effects: [effect: Effect, stacks: number][]][];
readonly enemies: EnemyList;
readonly dialogue: string;
}[];
@@ -1,5 +1,13 @@
import getItems, { Item } from "./item.csv";
export type * from "./card.csv";
export type * from "./cardEffect.csv";
export type * from "./effect.csv";
export type * from "./encounter.csv";
export type * from "./enemy.csv";
export type * from "./intent.csv";
export type * from "./item.csv";
export { default as getCards } from "./card.csv";
export { default as getEffects } from "./effect.csv";
export { default as getEncounters } from "./encounter.csv";
@@ -6,46 +6,50 @@
# initBuffs: initial buffs for this intent (applied when intent becomes active)
# effects: effects executed when this intent is active
# type IntentEffectTarget = 'source' | 'eachEnemy' | 'randomEnemy' | 'player'
# type IntentEffect = [IntentEffectTarget;@effect;number]
# type IntentEffectList = IntentEffect[]
id,enemy,initialIntent,nextIntents,brokenIntent,effects
string,@enemy,boolean,@intent[],@intent[],['self'|'player'|'team';@effect;number][]
仙人掌怪-boost,仙人掌怪,true,仙人掌怪-boost;仙人掌怪-defend,,[self;spike;1];[self;defend;4]
仙人掌怪-defend,仙人掌怪,false,仙人掌怪-attack,,[self;defend;8]
string,@enemy,boolean,@intent[],@intent[],IntentEffectList
仙人掌怪-boost,仙人掌怪,true,仙人掌怪-boost;仙人掌怪-defend,,[source;spike;1];[source;defend;4]
仙人掌怪-defend,仙人掌怪,false,仙人掌怪-attack,,[source;defend;8]
仙人掌怪-attack,仙人掌怪,false,仙人掌怪-boost,,[player;attack;5]
蛇-poison,蛇,true,蛇-attack;蛇-attack,,[player;venom;1];[player;attack;4]
蛇-attack,蛇,false,蛇-poison;蛇-boost,,[player;attack;6]
蛇-boost,蛇,false,蛇-poison;蛇-attack,,[self;defend;3];[player;venom;1]
蛇-boost,蛇,false,蛇-poison;蛇-attack,,[source;defend;3];[player;venom;1]
木乃伊-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]
枪手-aim,枪手,true,枪手-attack,,[self;aim;2]
枪手-aim,枪手,true,枪手-attack,,[source;aim;2]
枪手-attack,枪手,false,枪手-aim;枪手-defend,枪手-aim,[player;attack;8]
枪手-defend,枪手,false,枪手-aim,枪手-aim,[self;defend;5]
风卷草-boost,风卷草,true,风卷草-defend;风卷草-defend;风卷草-boost,,[self;roll;5];[self;defend;4]
风卷草-defend,风卷草,false,风卷草-boost;风卷草-attack,,[self;defend;8]
枪手-defend,枪手,false,枪手-aim,枪手-aim,[source;defend;5]
风卷草-boost,风卷草,true,风卷草-defend;风卷草-defend;风卷草-boost,,[source;roll;5];[source;defend;4]
风卷草-defend,风卷草,false,风卷草-boost;风卷草-attack,,[source;defend;8]
风卷草-attack,风卷草,false,风卷草-boost,,[player;rollDamage;0]
秃鹫-attack,秃鹫,true,秃鹫-defend;秃鹫-defend,,[player;attack;6];[player;vultureEye;1]
秃鹫-defend,秃鹫,false,秃鹫-attack;秃鹫-attack,,[self;defend;5]
沙蝎-boost,沙蝎,true,沙蝎-attack;沙蝎-attack,,[self;tailSting;2]
秃鹫-defend,秃鹫,false,秃鹫-attack;秃鹫-attack,,[source;defend;5]
沙蝎-boost,沙蝎,true,沙蝎-attack;沙蝎-attack,,[source;tailSting;2]
沙蝎-attack,沙蝎,false,沙蝎-boost;沙蝎-attack,,[player;attack;6]
幼沙虫-defend,幼沙虫,true,幼沙虫-defend;幼沙虫-boost,,[self;defend;6]
幼沙虫-boost,幼沙虫,false,幼沙虫-attack;幼沙虫-defend,,[self;energyDrain;1];[self;defend;4]
幼沙虫-defend,幼沙虫,true,幼沙虫-defend;幼沙虫-boost,,[source;defend;6]
幼沙虫-boost,幼沙虫,false,幼沙虫-attack;幼沙虫-defend,,[source;energyDrain;1];[source;defend;4]
幼沙虫-attack,幼沙虫,false,幼沙虫-defend;幼沙虫-defend,,[player;attack;5]
蜥蜴-attack,蜥蜴,true,蜥蜴-defend;蜥蜴-molt,,[player;attack;5]
蜥蜴-defend,蜥蜴,false,蜥蜴-attack;蜥蜴-attack,,[self;defend;6]
蜥蜴-molt,蜥蜴,false,蜥蜴-defend;蜥蜴-attack,,[self;molt;3]
蜥蜴-defend,蜥蜴,false,蜥蜴-attack;蜥蜴-attack,,[source;defend;6]
蜥蜴-molt,蜥蜴,false,蜥蜴-defend;蜥蜴-attack,,[source;molt;3]
沙匪-attack,沙匪,true,沙匪-attack;沙匪-heavyAttack,,[player;attack;6]
沙匪-heavyAttack,沙匪,false,沙匪-attack;沙匪-attack;沙匪-debuff,,[player;attack;10]
沙匪-debuff,沙匪,false,沙匪-attack;沙匪-attack,,[player;discard;1]
风暴之灵-storm,风暴之灵,true,风暴之灵-attack;风暴之灵-storm,,[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]
风暴之灵-defend,风暴之灵,false,风暴之灵-storm;风暴之灵-attack,,[self;defend;8]
骑马枪手-charge,骑马枪手,true,骑马枪手-attack,,[self;charge;2]
风暴之灵-defend,风暴之灵,false,风暴之灵-storm;风暴之灵-attack,,[source;defend;8]
骑马枪手-charge,骑马枪手,true,骑马枪手-attack,,[source;charge;2]
骑马枪手-attack,骑马枪手,false,骑马枪手-charge;骑马枪手-defend,骑马枪手-charge,[player;attack;6]
骑马枪手-defend,骑马枪手,false,骑马枪手-charge;骑马枪手-attack,骑马枪手-charge,[self;defend;5]
沙虫王-summon,沙虫王,true,沙虫王-attack;沙虫王-defend,,[self;summonSandwormLarva;18]
骑马枪手-defend,骑马枪手,false,骑马枪手-charge;骑马枪手-attack,骑马枪手-charge,[source;defend;5]
沙虫王-summon,沙虫王,true,沙虫王-attack;沙虫王-defend,,[source;summonSandwormLarva;18]
沙虫王-attack,沙虫王,false,沙虫王-summon;沙虫王-defend,,[player;attack;9]
沙虫王-defend,沙虫王,false,沙虫王-attack;沙虫王-summon,,[self;defend;6]
沙漠守卫-summon,沙漠守卫,true,沙漠守卫-attack;沙漠守卫-defend,,[self;summonMummy;14]
沙虫王-defend,沙虫王,false,沙虫王-attack;沙虫王-summon,,[source;defend;6]
沙漠守卫-summon,沙漠守卫,true,沙漠守卫-attack;沙漠守卫-defend,,[source;summonMummy;14]
沙漠守卫-attack,沙漠守卫,false,沙漠守卫-defend;沙漠守卫-summon,,[player;attack;8]
沙漠守卫-defend,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-revive,,[self;defend;8]
沙漠守卫-revive,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-summon,,[self;reviveMummy;1]
沙漠守卫-defend,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-revive,,[source;defend;8]
沙漠守卫-revive,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-summon,,[source;reviveMummy;1]
1 # intentId: unique intent state ID (e.g. '仙人掌怪-boost')
6 # initBuffs: initial buffs for this intent (applied when intent becomes active)
7 # effects: effects executed when this intent is active
8 id,enemy,initialIntent,nextIntents,brokenIntent,effects # type IntentEffectTarget = 'source' | 'eachEnemy' | 'randomEnemy' | 'player'
9 # type IntentEffect = [IntentEffectTarget;@effect;number]
10 # type IntentEffectList = IntentEffect[]
11 id
12 string
13 string,@enemy,boolean,@intent[],@intent[],['self'|'player'|'team';@effect;number][] 仙人掌怪-boost
14 仙人掌怪-boost,仙人掌怪,true,仙人掌怪-boost;仙人掌怪-defend,,[self;spike;1];[self;defend;4] 仙人掌怪-defend
15 仙人掌怪-defend,仙人掌怪,false,仙人掌怪-attack,,[self;defend;8] 仙人掌怪-attack
16 仙人掌怪-attack,仙人掌怪,false,仙人掌怪-boost,,[player;attack;5] 蛇-poison
17 蛇-poison,蛇,true,蛇-attack;蛇-attack,,[player;venom;1];[player;attack;4] 蛇-attack
18 蛇-attack,蛇,false,蛇-poison;蛇-boost,,[player;attack;6] 蛇-boost
19 蛇-boost,蛇,false,蛇-poison;蛇-attack,,[self;defend;3];[player;venom;1] 木乃伊-attack
20 木乃伊-attack,木乃伊,true,木乃伊-defend;木乃伊-curse,,[player;attack;6] 木乃伊-defend
21 木乃伊-defend,木乃伊,false,木乃伊-attack,,[self;defend;6] 木乃伊-curse
22 木乃伊-curse,木乃伊,false,木乃伊-defend;木乃伊-attack,木乃伊-attack,[player;curse;1] 枪手-aim
23 枪手-aim,枪手,true,枪手-attack,,[self;aim;2] 枪手-attack
24 枪手-attack,枪手,false,枪手-aim;枪手-defend,枪手-aim,[player;attack;8] 枪手-defend
25 枪手-defend,枪手,false,枪手-aim,枪手-aim,[self;defend;5] 风卷草-boost
26 风卷草-boost,风卷草,true,风卷草-defend;风卷草-defend;风卷草-boost,,[self;roll;5];[self;defend;4] 风卷草-defend
27 风卷草-defend,风卷草,false,风卷草-boost;风卷草-attack,,[self;defend;8] 风卷草-attack
28 风卷草-attack,风卷草,false,风卷草-boost,,[player;rollDamage;0] 秃鹫-attack
29 秃鹫-attack,秃鹫,true,秃鹫-defend;秃鹫-defend,,[player;attack;6];[player;vultureEye;1] 秃鹫-defend
30 秃鹫-defend,秃鹫,false,秃鹫-attack;秃鹫-attack,,[self;defend;5] 沙蝎-boost
31 沙蝎-boost,沙蝎,true,沙蝎-attack;沙蝎-attack,,[self;tailSting;2] 沙蝎-attack
32 沙蝎-attack,沙蝎,false,沙蝎-boost;沙蝎-attack,,[player;attack;6] 幼沙虫-defend
33 幼沙虫-defend,幼沙虫,true,幼沙虫-defend;幼沙虫-boost,,[self;defend;6] 幼沙虫-boost
34 幼沙虫-boost,幼沙虫,false,幼沙虫-attack;幼沙虫-defend,,[self;energyDrain;1];[self;defend;4] 幼沙虫-attack
35 幼沙虫-attack,幼沙虫,false,幼沙虫-defend;幼沙虫-defend,,[player;attack;5] 蜥蜴-attack
36 蜥蜴-attack,蜥蜴,true,蜥蜴-defend;蜥蜴-molt,,[player;attack;5] 蜥蜴-defend
37 蜥蜴-defend,蜥蜴,false,蜥蜴-attack;蜥蜴-attack,,[self;defend;6] 蜥蜴-molt
38 蜥蜴-molt,蜥蜴,false,蜥蜴-defend;蜥蜴-attack,,[self;molt;3] 沙匪-attack
39 沙匪-attack,沙匪,true,沙匪-attack;沙匪-heavyAttack,,[player;attack;6] 沙匪-heavyAttack
40 沙匪-heavyAttack,沙匪,false,沙匪-attack;沙匪-attack;沙匪-debuff,,[player;attack;10] 沙匪-debuff
41 沙匪-debuff,沙匪,false,沙匪-attack;沙匪-attack,,[player;discard;1] 风暴之灵-storm
42 风暴之灵-storm,风暴之灵,true,风暴之灵-attack;风暴之灵-storm,,[self;storm;2];[self;defend;3] 风暴之灵-attack
43 风暴之灵-attack,风暴之灵,false,风暴之灵-storm;风暴之灵-defend,,[player;attack;8];[player;static;1] 风暴之灵-defend
44 风暴之灵-defend,风暴之灵,false,风暴之灵-storm;风暴之灵-attack,,[self;defend;8] 骑马枪手-charge
45 骑马枪手-charge,骑马枪手,true,骑马枪手-attack,,[self;charge;2] 骑马枪手-attack
46 骑马枪手-attack,骑马枪手,false,骑马枪手-charge;骑马枪手-defend,骑马枪手-charge,[player;attack;6] 骑马枪手-defend
47 骑马枪手-defend,骑马枪手,false,骑马枪手-charge;骑马枪手-attack,骑马枪手-charge,[self;defend;5] 沙虫王-summon
48 沙虫王-summon,沙虫王,true,沙虫王-attack;沙虫王-defend,,[self;summonSandwormLarva;18] 沙虫王-attack
49 沙虫王-attack,沙虫王,false,沙虫王-summon;沙虫王-defend,,[player;attack;9] 沙虫王-defend
50 沙虫王-defend,沙虫王,false,沙虫王-attack;沙虫王-summon,,[self;defend;6] 沙漠守卫-summon
51 沙漠守卫-summon,沙漠守卫,true,沙漠守卫-attack;沙漠守卫-defend,,[self;summonMummy;14] 沙漠守卫-attack
52 沙漠守卫-attack,沙漠守卫,false,沙漠守卫-defend;沙漠守卫-summon,,[player;attack;8] 沙漠守卫-defend
53 沙漠守卫-defend,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-revive,,[self;defend;8] 沙漠守卫-revive
54
55
@@ -1,13 +1,17 @@
import type { Enemy } from './enemy.csv';
import type { Effect } from './effect.csv';
export type IntentEffectTarget = 'source' | 'eachEnemy' | 'randomEnemy' | 'player';
export type IntentEffect = [IntentEffectTarget, Effect, number];
export type IntentEffectList = IntentEffect[];
type IntentTable = readonly {
readonly id: string;
readonly enemy: Enemy;
readonly initialIntent: boolean;
readonly nextIntents: Intent[];
readonly brokenIntent: Intent[];
readonly effects: ["self" | "player" | "team", Effect, number][];
readonly effects: IntentEffectList;
}[];
export type Intent = IntentTable[number];
@@ -30,9 +30,9 @@ export function addCardEventTriggers(triggers: Triggers, run: IRunContext) {
for (let i = 0; i < storm; i++) {
await triggers.onEffectApplied.execute(ctx.game, {
effect: findEffect("static"),
entityKey: "player",
entityId: "player",
stacks: 1,
sourceEntityKey: ctx.enemyId,
sourceEntityId: ctx.enemyId,
});
}
}
@@ -52,7 +52,7 @@ export function addCardEventTriggers(triggers: Triggers, run: IRunContext) {
await triggers.onCardPlayed.execute(ctx.game, {
cardId: id,
targetId: ctx.targetId,
sourceEntityKey: "player",
sourceEntityId: "player",
});
}
}
@@ -66,7 +66,7 @@ export function addCardEventTriggers(triggers: Triggers, run: IRunContext) {
if (!card) return;
const playedItemId = card.itemId;
const adjacent = run.getNeighborItems(playedItemId);
const adjacent = run.getAdjacentItems(playedItemId);
for (const adjItemId of adjacent) {
const adjEffects = ctx.game.value.player.itemEffects[adjItemId];
if (!adjEffects) continue;
@@ -113,17 +113,17 @@ export function addCardEventTriggers(triggers: Triggers, run: IRunContext) {
await next();
const dealt = ctx.amount - (ctx.prevented ?? 0);
if (dealt <= 0 || !ctx.sourceEntityKey) return;
if (dealt <= 0 || !ctx.sourceEntityId) return;
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityKey);
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"),
entityKey: "player",
entityId: "player",
stacks: 1,
sourceEntityKey: ctx.sourceEntityKey,
sourceEntityId: ctx.sourceEntityId,
});
});
}
@@ -3,7 +3,6 @@ import {
addEntityEffect,
getCombatEntity,
} from "@/samples/slay-the-spire-like/system/combat/effects";
import { CombatGameContext } from "@/samples/slay-the-spire-like/system/combat/types";
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import getEffects from "../effect.csv";
@@ -13,23 +12,22 @@ export function addDamageTriggers(triggers: Triggers) {
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;
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.entityKey);
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
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;
}
// 1. apply damageReduce before block
const damageReduce = entity.effects.damageReduce?.stacks ?? 0;
if (damageReduce > 0) {
const reduced = Math.min(damageReduce, preventable);
@@ -37,6 +35,18 @@ export function addDamageTriggers(triggers: Triggers) {
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;
@@ -45,35 +55,19 @@ export function addDamageTriggers(triggers: Triggers) {
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,
});
}
});
// energyDrain: player loses energy when enemy takes damage
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
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 (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 => {
await ctx.game.produceAsync((draft) => {
draft.player.energy = Math.max(0, draft.player.energy - energyDrain);
});
}
@@ -84,7 +78,7 @@ export function addDamageTriggers(triggers: Triggers) {
// molt: enemy flees if molt >= maxHp
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
if (!entity || !entity.isAlive) {
await next();
return;
@@ -92,13 +86,15 @@ export function addDamageTriggers(triggers: Triggers) {
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);
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;
draft.result = draft.enemies.every((en) => !en.isAlive)
? "victory"
: null;
});
if (ctx.game.value.result) throw ctx.game.value;
return;
@@ -109,7 +105,7 @@ export function addDamageTriggers(triggers: Triggers) {
// aim: double damage, lose aim on damage
triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityKey === "player") {
if (ctx.sourceEntityId === "player") {
const player = ctx.game.value.player;
const aim = player.effects.aim?.stacks ?? 0;
if (aim > 0) {
@@ -121,13 +117,13 @@ export function addDamageTriggers(triggers: Triggers) {
// roll: consume 10 roll per 10 damage
triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityKey === "player") {
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 => {
await ctx.game.produceAsync((draft) => {
addEntityEffect(draft.player, findEffect("roll"), -rollDamage);
});
}
@@ -137,8 +133,8 @@ export function addDamageTriggers(triggers: Triggers) {
// 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 (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) {
@@ -151,35 +147,41 @@ export function addDamageTriggers(triggers: Triggers) {
// charge: double damage dealt/received, consume equal charge
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
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 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);
await ctx.game.produceAsync((draft) => {
const e = getCombatEntity(draft, ctx.entityId);
if (e) addEntityEffect(e, findEffect("charge"), -consumed);
});
}
}
}
if (ctx.sourceEntityKey) {
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityKey);
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.entityKey);
const dealt = Math.min(Math.max(0, targetEntity?.hp ?? 0), baseAmount - (ctx.prevented ?? 0));
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.sourceEntityKey!);
await ctx.game.produceAsync((draft) => {
const a = getCombatEntity(draft, ctx.sourceEntityId!);
if (a) addEntityEffect(a, findEffect("charge"), -consumed);
});
}
@@ -1,13 +0,0 @@
import { IRunContext } from "@/samples/slay-the-spire-like/system/combat/types";
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
import { addInstantEffectTriggers } from "./instant";
import { addDamageTriggers } from "./damage";
import { addTurnStartTriggers } from "./turn-start";
import { addCardEventTriggers } from "./card-events";
export function addDesertTriggers(triggers: Triggers, run: IRunContext) {
addInstantEffectTriggers(triggers);
addDamageTriggers(triggers);
addTurnStartTriggers(triggers);
addCardEventTriggers(triggers, run);
}
@@ -1 +1,15 @@
export { addDesertTriggers as addTriggers } from './effect';
import { addInstantEffectTriggers } from "./instant";
import { addDamageTriggers } from "./damage";
import { addTurnStartTriggers } from "./turn-start";
import { addCardEventTriggers } from "./card-events";
import {
IRunContext,
Triggers,
} from "@/samples/slay-the-spire-like/system/combat";
export function addTriggers(triggers: Triggers, run: IRunContext) {
addInstantEffectTriggers(triggers);
addDamageTriggers(triggers);
addTurnStartTriggers(triggers);
addCardEventTriggers(triggers, run);
}
@@ -1,27 +1,24 @@
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
import { addEntityEffect } from "@/samples/slay-the-spire-like/system/combat/effects";
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 getEffects from "../effect.csv";
import getCards from "../card.csv";
export function addInstantEffectTriggers(triggers: Triggers) {
const effects = getEffects();
const cards = getCards();
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;
}
function findCard(id: string) {
return cards.find(c => c.id === id);
return cards.find((c) => c.id === id);
}
function createStatusCard(draft: CombatGameContext["value"], cardId: string): void {
function createStatusCard(
draft: CombatGameContext["value"],
cardId: string,
): void {
const cardData = findCard(cardId);
if (!cardData) return;
@@ -48,7 +45,7 @@ export function addInstantEffectTriggers(triggers: Triggers) {
function getAllEnemyDataFromState(state: CombatGameContext["value"]) {
const seen = new Set<string>();
const result: typeof state.enemies[number]["enemy"][] = [];
const result: (typeof state.enemies)[number]["enemy"][] = [];
for (const enemy of state.enemies) {
if (!seen.has(enemy.enemy.id)) {
seen.add(enemy.enemy.id);
@@ -58,16 +55,24 @@ export function addInstantEffectTriggers(triggers: Triggers) {
return result;
}
function summonEnemy(draft: CombatGameContext["value"], enemyId: string, hp: number) {
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);
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 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) {
@@ -91,18 +96,28 @@ export function addInstantEffectTriggers(triggers: Triggers) {
triggers.onEffectApplied.use(async (ctx, next) => {
if (ctx.effect.id === "attack") {
await triggers.onDamage.execute(ctx.game, {
entityKey: ctx.entityKey,
entityId: ctx.entityId,
amount: ctx.stacks,
sourceEntityKey: ctx.sourceEntityKey ?? ctx.entityKey === "player" ? undefined : "player",
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 => {
await ctx.game.produceAsync((draft) => {
draft.player.energy += ctx.stacks;
});
} else if (ctx.effect.id === "removeWound") {
await ctx.game.produceAsync(draft => {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
let removed = 0;
const allPileIds = [
@@ -113,7 +128,10 @@ export function addInstantEffectTriggers(triggers: Triggers) {
if (removed >= ctx.stacks) break;
const card = cards[cardId];
if (card && card.cardData.id === "wound") {
const sourceRegion = card.regionId === "drawPile" ? regions.drawPile : regions.discardPile;
const sourceRegion =
card.regionId === "drawPile"
? regions.drawPile
: regions.discardPile;
moveToRegion(card, sourceRegion, null);
delete cards[cardId];
removed++;
@@ -121,35 +139,37 @@ export function addInstantEffectTriggers(triggers: Triggers) {
}
});
} else if (ctx.effect.id === "venom") {
await ctx.game.produceAsync(draft => {
await ctx.game.produceAsync((draft) => {
createStatusCard(draft, "venom");
});
} else if (ctx.effect.id === "vultureEye") {
await ctx.game.produceAsync(draft => {
await ctx.game.produceAsync((draft) => {
createStatusCard(draft, "vultureEye");
});
} else if (ctx.effect.id === "static") {
await ctx.game.produceAsync(draft => {
await ctx.game.produceAsync((draft) => {
createStatusCard(draft, "static");
});
} else if (ctx.effect.id === "summonMummy") {
await ctx.game.produceAsync(draft => {
await ctx.game.produceAsync((draft) => {
summonEnemy(draft, "木乃伊", ctx.stacks);
});
} else if (ctx.effect.id === "summonSandwormLarva") {
await ctx.game.produceAsync(draft => {
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);
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 => {
await ctx.game.produceAsync((draft) => {
addEntityEffect(draft.player, ctx.effect, ctx.stacks);
});
}
@@ -7,14 +7,19 @@ export function addTurnStartTriggers(triggers: Triggers) {
const effects = getEffects();
function findEffect(id: string): EffectData {
const found = effects.find(e => e.id === id);
const found = effects.find((e) => e.id === id);
if (found) return found;
return { id, name: id, description: "", lifecycle: "instant" } as EffectData;
return {
id,
name: id,
description: "",
lifecycle: "instant",
} as EffectData;
}
// discard: random discard at turn start
triggers.onTurnStart.use(async (ctx, next) => {
if (ctx.entityKey !== "player") {
if (ctx.entityId !== "player") {
await next();
return;
}
@@ -25,7 +30,9 @@ export function addTurnStartTriggers(triggers: Triggers) {
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 triggers.onCardDiscarded.execute(ctx.game, {
cardId: randomCardId,
});
}
}
@@ -33,33 +40,30 @@ export function addTurnStartTriggers(triggers: Triggers) {
});
// 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) => {
if (ctx.entityKey !== "player") {
const defendNext = ctx.game.value.player.effects.defendNext;
if (!defendNext || defendNext.stacks <= 0) {
await next();
return;
}
const defendNext = ctx.game.value.player.effects.defendNext;
if (defendNext && defendNext.stacks > 0) {
await ctx.game.produceAsync(draft => {
addEntityEffect(draft.player, findEffect("defend"), defendNext.stacks);
addEntityEffect(draft.player, defendNext.data, -defendNext.stacks);
});
}
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.entityKey !== "player") {
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 => {
await ctx.game.produceAsync((draft) => {
draft.player.energy += energyNext.stacks;
addEntityEffect(draft.player, energyNext.data, -energyNext.stacks);
});
@@ -70,14 +74,14 @@ export function addTurnStartTriggers(triggers: Triggers) {
// drawNext: draw extra cards next turn
triggers.onTurnStart.use(async (ctx, next) => {
if (ctx.entityKey !== "player") {
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 => {
await ctx.game.produceAsync((draft) => {
addEntityEffect(draft.player, drawNext.data, -drawNext.stacks);
});
await triggers.onDraw.execute(ctx.game, { count: drawNext.stacks });
+13 -18
View File
@@ -1,23 +1,18 @@
import { LoadResult as YarnDialogues } from "yarn-spinner-loader";
import {
CardData,
EffectData,
EncounterData,
EnemyData,
IntentData,
ItemData,
} from "../system/types";
import { Triggers } from "../system/combat/triggers";
export type ContentModule = {
getCards: () => CardData[];
getEffects: () => EffectData[];
getEncounters: () => EncounterData[];
getEnemies: () => EnemyData[];
getIntents: () => IntentData[];
getItems: () => ItemData[];
getStartingItems: () => ItemData[];
import type * as desert from "./desert";
import { IRunContext } from "../system/combat";
dialogues: YarnDialogues;
addTriggers: (triggers: Triggers) => void;
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;
};
+7 -161
View File
@@ -1,162 +1,8 @@
// Types
export type {
EffectData,
EffectLifecycle,
EnemyData,
CardType,
CardCostType,
CardTargetType,
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 type { ContentModule } from "./data";
export * from "./system/combat";
export * from "./system/deck";
export * from "./system/encounter";
export * from "./system/grid-inventory";
export * from "./system/map";
export * from "./system/utils";
export * from "./system/types";
export { default as data } from "./data";
@@ -9,9 +9,8 @@ import {
import {
CardData,
CardEffectTarget,
CardTargetType,
EffectData,
EffectTarget,
IntentEffectTarget,
} from "@/samples/slay-the-spire-like/system/types";
export function addEffect(
@@ -104,21 +103,25 @@ export function* getAliveEnemies(state: CombatState) {
}
export function* getEffectTargets(
target: CardEffectTarget | EffectTarget,
target: CardEffectTarget | IntentEffectTarget,
game: CombatGameContext,
targetId?: string,
sourceEntityKey: "player" | string = "player",
) {
if (target === "all" || target === "team") {
if (target === "eachEnemy") {
for (const enemy of getAliveEnemies(game.value)) {
yield enemy;
}
} else if (target === "self") {
} 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 === "target") {
} else if (target === "eachTarget") {
if (!targetId) return;
const entity = getCombatEntity(game.value, targetId);
if (entity) yield entity;
} else if (target === "random") {
} else if (target === "randomEnemy") {
const aliveEnemies = [...getAliveEnemies(game.value)];
if (aliveEnemies.length === 0) return;
const index = game.rng.nextInt(aliveEnemies.length);
@@ -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";
@@ -38,12 +38,12 @@ export async function promptMainAction(
}
const { targetType } = cardData;
if (targetType === "single") {
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 === "none") {
} else if (targetType === "enemies" || targetType === "player") {
if (targetId) throw `目标"${targetId}"无效`;
}
@@ -16,60 +16,61 @@ import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/pr
import { moveToRegion, shuffle } from "@/core/region";
import { createMiddlewareChain } from "@/utils/middleware";
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 = {
onCombatStart: {};
onTurnStart: { entityKey: "player" | string };
onTurnEnd: { entityKey: "player" | string };
onTurnStart: { entityId: "player" | string };
onTurnEnd: { entityId: "player" | string };
onShuffle: {};
onCardPlayed: {
cardId: string;
targetId?: string;
sourceEntityKey?: "player" | string;
sourceEntityId?: "player" | string;
};
onCardDiscarded: { cardId: string; sourceEntityKey?: "player" | string };
onCardDrawn: { cardId: string; sourceEntityKey?: "player" | string };
onCardDiscarded: { cardId: string; sourceEntityId?: "player" | string };
onCardDrawn: { cardId: string; sourceEntityId?: "player" | string };
onDraw: { count: number };
onEffectApplied: {
effect: EffectData;
entityKey: "player" | string;
entityId: "player" | string;
stacks: number;
cardId?: string;
sourceEntityKey?: "player" | string;
sourceEntityId?: "player" | string;
targetId?: string;
};
onHpChange: { entityKey: "player" | string; amount: number };
onHpChange: { entityId: "player" | string; amount: number };
onDamage: {
entityKey: "player" | string;
entityId: "player" | string;
amount: number;
prevented?: number;
sourceEntityKey?: "player" | string;
sourceEntityId?: "player" | string;
};
onEnemyIntent: { enemyId: string; sourceEntityKey?: "player" | string };
onEnemyIntent: { enemyId: string; sourceEntityId?: "player" | string };
onIntentUpdate: { enemyId: string };
};
export function createTriggers(run: IRunContext) {
const triggers = {
onCombatStart: createTrigger("onCombatStart"),
onCombatStart: createTrigger("onCombatStart", async (ctx) => {
await triggers.onShuffle.execute(ctx.game, {});
await triggers.onDraw.execute(ctx.game, { count: 5 });
}),
onTurnStart: createTrigger("onTurnStart", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const entity = getCombatEntity(draft, ctx.entityKey);
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;
if (ctx.entityId !== "player") return;
const { regions } = ctx.game.value.player.deck;
for (const cardId of Object.values(regions.hand.childIds)) {
await triggers.onCardDiscarded.execute(ctx.game, { cardId });
}
await ctx.game.produceAsync(
(draft) => (draft.player.energy = draft.player.maxEnergy),
);
await ctx.game.produceAsync((draft) => {
draft.player.energy = draft.player.maxEnergy;
});
await triggers.onDraw.execute(ctx.game, { count: 5 });
}),
onShuffle: createTrigger("onShuffle", async (ctx) => {
@@ -96,17 +97,22 @@ export function createTriggers(run: IRunContext) {
});
const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player";
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))
for (const entity of getEffectTargets(
target,
ctx.game,
ctx.targetId,
source,
))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: entity.id,
entityId: entity.id,
stacks,
cardId: ctx.cardId,
sourceEntityKey: source,
sourceEntityId: source,
targetId: ctx.targetId,
});
}
@@ -119,17 +125,22 @@ export function createTriggers(run: IRunContext) {
});
const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player";
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))
for (const entity of getEffectTargets(
target,
ctx.game,
undefined,
source,
))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: entity.id,
entityId: entity.id,
stacks,
cardId: ctx.cardId,
sourceEntityKey: source,
sourceEntityId: source,
});
}
}),
@@ -140,17 +151,22 @@ export function createTriggers(run: IRunContext) {
});
const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player";
const source = ctx.sourceEntityId ?? "player";
for (const { trigger, target, effects } of card.cardData.effects) {
if (trigger !== "onDraw") continue;
for (const [effect, stacks] of effects)
for (const entity of getEffectTargets(target, ctx.game))
for (const entity of getEffectTargets(
target,
ctx.game,
undefined,
source,
))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: entity.id,
entityId: entity.id,
stacks,
cardId: ctx.cardId,
sourceEntityKey: source,
sourceEntityId: source,
});
}
}),
@@ -176,7 +192,7 @@ export function createTriggers(run: IRunContext) {
if (ctx.effect.lifecycle.startsWith("item")) {
if (ctx.cardId) {
const card = ctx.game.value.player.deck.cards[ctx.cardId];
const nearby = run.getNeighborItems(card.itemId);
const nearby = run.getAdjacentItems(card.itemId);
for (const itemId of nearby) {
await ctx.game.produceAsync((draft) => {
addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks);
@@ -188,18 +204,18 @@ export function createTriggers(run: IRunContext) {
await ctx.game.produceAsync((draft) => {
const entity =
ctx.entityKey === "player"
ctx.entityId === "player"
? draft.player
: draft.enemies.find((e) => e.id === ctx.entityKey);
: 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.entityKey === "player"
ctx.entityId === "player"
? draft.player
: draft.enemies.find((e) => e.id === ctx.entityKey);
: draft.enemies.find((e) => e.id === ctx.entityId);
if (!entity) return;
entity.hp += ctx.amount;
entity.isAlive = entity.hp > 0;
@@ -213,9 +229,9 @@ export function createTriggers(run: IRunContext) {
}),
onDamage: createTrigger("onDamage", async (ctx) => {
const entity =
ctx.entityKey === "player"
ctx.entityId === "player"
? ctx.game.value.player
: ctx.game.value.enemies.find((e) => e.id === ctx.entityKey);
: ctx.game.value.enemies.find((e) => e.id === ctx.entityId);
if (!entity || !entity.isAlive) return;
const dealt = Math.min(
Math.max(0, entity.hp),
@@ -225,7 +241,7 @@ export function createTriggers(run: IRunContext) {
onEntityPostureDamage(entity, dealt);
});
await triggers.onHpChange.execute(ctx.game, {
entityKey: ctx.entityKey,
entityId: ctx.entityId,
amount: -dealt,
});
}),
@@ -236,14 +252,19 @@ export function createTriggers(run: IRunContext) {
const intent = enemy.currentIntent;
if (!intent) return;
const source = ctx.sourceEntityKey ?? enemy.id;
const source = ctx.sourceEntityId ?? enemy.id;
for (const [target, effect, stacks] of intent.effects) {
for (const entity of getEffectTargets(target, ctx.game))
for (const entity of getEffectTargets(
target,
ctx.game,
undefined,
source,
))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: entity.id,
entityId: entity.id,
stacks,
sourceEntityKey: source,
sourceEntityId: source,
});
}
}),
@@ -266,18 +287,13 @@ export function createTriggers(run: IRunContext) {
return triggers;
}
export type Triggers = ReturnType<typeof createTriggers>;
export function createStartWith(
build: (triggers: Triggers, run: IRunContext) => void,
run: IRunContext,
) {
const triggers = createTriggers(run);
build(triggers, run);
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, { entityKey: "player" });
await triggers.onTurnStart.execute(game, { entityId: "player" });
while (true) {
const action = await promptMainAction(game, run);
if (action.action === "end-turn") break;
@@ -285,17 +301,17 @@ export function createStartWith(
await triggers.onCardPlayed.execute(game, action);
}
}
await triggers.onTurnEnd.execute(game, { entityKey: "player" });
await triggers.onTurnEnd.execute(game, { entityId: "player" });
for (const enemy of getAliveEnemies(game.value)) {
await triggers.onTurnStart.execute(game, { entityKey: enemy.id });
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, { entityKey: enemy.id });
await triggers.onTurnEnd.execute(game, { entityId: enemy.id });
}
}
} catch (e) {
@@ -1,5 +1,7 @@
import type { PlayerDeck } from "../deck/types";
import { Part } from "@/core/part";
import { Region } from "@/core/region";
import {
CardData,
EnemyData,
IntentData,
ItemData,
@@ -16,10 +18,26 @@ export type CombatEntity = {
isAlive: boolean;
};
export interface IPlayerState {
hp: number;
maxHp: number;
itemEffects: Record<string, EffectTable>;
}
export interface IPlayerDeck {
cards: Record<string, Part<{ cardData: CardData; itemId: string }>>;
regions: {
hand: Region;
discardPile: Region;
drawPile: Region;
exhaustPile: Region;
};
}
export type PlayerEntity = CombatEntity & {
energy: number;
maxEnergy: number;
deck: PlayerDeck;
deck: IPlayerDeck;
itemEffects: Record<string, EffectTable>;
};
@@ -55,7 +73,7 @@ export type CombatState = {
export interface IRunContext {
getItemData(id: string): ItemData | null;
getNeighborItems(id: string): Iterable<string>;
getAdjacentItems(id: string): Iterable<string>;
getConsumedUses(id: string): number;
setConsumedUsesAsync(id: string, uses: number): Promise<void>;
@@ -1,34 +1,36 @@
import {moveToRegion } from '@/core/region';
import { createRegion } from '@/core/region';
import type { GridInventory } from '../grid-inventory/types';
import type { GameItemMeta } from '../progress/types';
import type { CardData } from '../types';
import type {DeckRegions, GameCard, PlayerDeck} from './types';
import { moveToRegion } from "@/core/region";
import { createRegion } from "@/core/region";
import type { CardData } from "../types";
import type { DeckRegions, GameCard, IInventory, PlayerDeck } from "./types";
function generateCardId(itemId: string, cellIndex: number): string {
return `card-${itemId}-${cellIndex}`;
}
function createCard( itemId: string, cardData: CardData, cellIndex: number): GameCard {
function createCard(
itemId: string,
cardData: CardData,
cellIndex: number,
): GameCard {
return {
id: generateCardId(itemId, cellIndex),
regionId: '',
regionId: "",
position: [],
itemId,
cardData
cardData,
};
}
function createDeckRegions(): DeckRegions {
return {
drawPile: createRegion('drawPile', []),
hand: createRegion('hand', []),
discardPile: createRegion('discardPile', []),
exhaustPile: createRegion('exhaustPile', []),
drawPile: createRegion("drawPile", []),
hand: createRegion("hand", []),
discardPile: createRegion("discardPile", []),
exhaustPile: createRegion("exhaustPile", []),
};
}
function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): PlayerDeck {
function generateDeckFromInventory(inventory: IInventory): PlayerDeck {
const cards: Record<string, GameCard> = {};
const regions = createDeckRegions();
@@ -46,7 +48,7 @@ function generateDeckFromInventory(inventory: GridInventory<GameItemMeta>): Play
return {
cards,
regions
regions,
};
}
@@ -1,7 +1,2 @@
export type { GameCard, GameCardMeta, PlayerDeck, DeckRegions } from './types';
export {
generateDeckFromInventory,
createCard,
createPlayerDeck,
generateCardId,
} from './factory';
export * from "./factory";
export * from "./types";
@@ -1,6 +1,7 @@
import type { Part } from '@/core/part';
import {CardData} from "@/samples/slay-the-spire-like/system/types";
import type { Part } from "@/core/part";
import { CardData, ItemData } from "@/samples/slay-the-spire-like/system/types";
import { Region } from "@/core/region";
import { ParsedShape } from "../utils/parse-shape";
/**
* Metadata for a game card.
@@ -11,6 +12,17 @@ export interface GameCardMeta {
itemId: string;
}
export interface IInventory {
items: Map<
string,
{
id: string;
meta?: { itemData: ItemData };
shape: ParsedShape;
}
>;
}
/**
* A card instance in the game.
* Cards are generated from inventory items or created as status effects.
@@ -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 {
createGridInventory,
flipItem,
getAdjacentItems,
getItemAtCell,
getOccupiedCellSet,
moveItem,
placeItem,
removeItem,
rotateItem,
validatePlacement,
} from './transform';
export * from "./types";
export * from "./transform";
export * from "./factory";
@@ -1,5 +1,6 @@
import type { ParsedShape } from '../utils/parse-shape';
import type { Transform2D } from '../utils/shape-collision';
import { EffectData } from "../types";
import type { ParsedShape } from "../utils/parse-shape";
import type { Transform2D } from "../utils/shape-collision";
import {
checkBoardCollision,
checkBounds,
@@ -7,14 +8,24 @@ import {
flipYTransform,
rotateTransform,
transformShape,
} from '../utils/shape-collision';
import type { CellKey, GridInventory, InventoryItem, MutationResult, PlacementResult } from './types';
} from "../utils/shape-collision";
import type {
CellKey,
GameItemMeta,
GridInventory,
InventoryItem,
MutationResult,
PlacementResult,
} from "./types";
/**
* Creates a new empty grid inventory.
* Note: When used inside `.produce()`, call this before returning the draft.
*/
export function createGridInventory<TMeta = Record<string, unknown>>(width: number, height: number): GridInventory<TMeta> {
export function createGridInventory<TMeta = Record<string, unknown>>(
width: number,
height: number,
): GridInventory<TMeta> {
return {
width,
height,
@@ -26,9 +37,12 @@ export function createGridInventory<TMeta = Record<string, unknown>>(width: numb
/**
* Builds a Set of occupied cell keys from a shape and transform.
*/
function getShapeCellKeys(shape: ParsedShape, transform: Transform2D): Set<CellKey> {
function getShapeCellKeys(
shape: ParsedShape,
transform: Transform2D,
): Set<CellKey> {
const cells = transformShape(shape, transform);
return new Set(cells.map(c => `${c.x},${c.y}` as CellKey));
return new Set(cells.map((c) => `${c.x},${c.y}` as CellKey));
}
/**
@@ -37,15 +51,15 @@ function getShapeCellKeys(shape: ParsedShape, transform: Transform2D): Set<CellK
*/
export function validatePlacement<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
shape: InventoryItem<TMeta>['shape'],
transform: Transform2D
shape: InventoryItem<TMeta>["shape"],
transform: Transform2D,
): PlacementResult {
if (!checkBounds(shape, transform, inventory.width, inventory.height)) {
return { valid: false, reason: '超出边界' };
return { valid: false, reason: "超出边界" };
}
if (checkBoardCollision(shape, transform, inventory.occupiedCells)) {
return { valid: false, reason: '与已有物品重叠' };
return { valid: false, reason: "与已有物品重叠" };
}
return { valid: true };
@@ -56,7 +70,10 @@ export function validatePlacement<TMeta = Record<string, unknown>>(
* **Mutates directly** — call inside a `.produce()` callback.
* Does not validate; call `validatePlacement` first.
*/
export function placeItem<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>, item: InventoryItem<TMeta>): void {
export function placeItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
item: InventoryItem<TMeta>,
): void {
const cells = getShapeCellKeys(item.shape, item.transform);
for (const cellKey of cells) {
inventory.occupiedCells.add(cellKey);
@@ -68,7 +85,10 @@ export function placeItem<TMeta = Record<string, unknown>>(inventory: GridInvent
* Removes an item from the grid by its ID.
* **Mutates directly** — call inside a `.produce()` callback.
*/
export function removeItem<TMeta = Record<string, unknown>>(inventory: GridInventory<TMeta>, itemId: string): void {
export function removeItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
): void {
const item = inventory.items.get(itemId);
if (!item) return;
@@ -87,11 +107,11 @@ export function removeItem<TMeta = Record<string, unknown>>(inventory: GridInven
export function moveItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
newTransform: Transform2D
newTransform: Transform2D,
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: '物品不存在' };
return { success: false, reason: "物品不存在" };
}
// Temporarily remove item's cells for validation
@@ -130,11 +150,11 @@ export function moveItem<TMeta = Record<string, unknown>>(
export function rotateItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
degrees: number
degrees: number,
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: '物品不存在' };
return { success: false, reason: "物品不存在" };
}
const rotatedTransform = rotateTransform(item.transform, degrees);
@@ -149,14 +169,15 @@ export function rotateItem<TMeta = Record<string, unknown>>(
export function flipItem<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
itemId: string,
axis: 'x' | 'y'
axis: "x" | "y",
): MutationResult {
const item = inventory.items.get(itemId);
if (!item) {
return { success: false, reason: '物品不存在' };
return { success: false, reason: "物品不存在" };
}
const flippedTransform = axis === 'x'
const flippedTransform =
axis === "x"
? flipXTransform(item.transform)
: flipYTransform(item.transform);
@@ -166,7 +187,9 @@ export function flipItem<TMeta = Record<string, unknown>>(
/**
* 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>>(
inventory: GridInventory<TMeta>,
): Set<CellKey> {
return new Set(inventory.occupiedCells);
}
@@ -176,7 +199,7 @@ export function getOccupiedCellSet<TMeta = Record<string, unknown>>(inventory: G
export function getItemAtCell<TMeta = Record<string, unknown>>(
inventory: GridInventory<TMeta>,
x: number,
y: number
y: number,
): InventoryItem<TMeta> | undefined {
const cellKey = `${x},${y}` as CellKey;
if (!inventory.occupiedCells.has(cellKey)) {
@@ -199,7 +222,7 @@ export function getItemAtCell<TMeta = Record<string, unknown>>(
*/
export function getAdjacentItems<TMeta>(
inventory: GridInventory<TMeta>,
itemId: string
itemId: string,
): Map<string, InventoryItem<TMeta>> {
const item = inventory.items.get(itemId);
if (!item) {
@@ -210,7 +233,7 @@ export function getAdjacentItems<TMeta>(
const adjacent = new Map<string, InventoryItem<TMeta>>();
for (const cellKey of ownCells) {
const [cx, cy] = cellKey.split(',').map(Number);
const [cx, cy] = cellKey.split(",").map(Number);
const neighbors: CellKey[] = [
`${cx + 1},${cy}` as CellKey,
`${cx - 1},${cy}` as CellKey,
@@ -219,8 +242,11 @@ export function getAdjacentItems<TMeta>(
];
for (const neighborKey of neighbors) {
if (inventory.occupiedCells.has(neighborKey) && !ownCells.has(neighborKey)) {
const [nx, ny] = neighborKey.split(',').map(Number);
if (
inventory.occupiedCells.has(neighborKey) &&
!ownCells.has(neighborKey)
) {
const [nx, ny] = neighborKey.split(",").map(Number);
const neighborItem = getItemAtCell(inventory, nx, ny);
if (neighborItem) {
adjacent.set(neighborItem.id, neighborItem);
@@ -231,3 +257,21 @@ export function getAdjacentItems<TMeta>(
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 type { Transform2D } from '../utils/shape-collision';
import { EffectData, ItemData } from "../types";
import type { ParsedShape } from "../utils/parse-shape";
import type { Transform2D } from "../utils/shape-collision";
/**
* String key representing a grid cell in "x,y" format.
@@ -32,12 +33,16 @@ export interface InventoryItem<TMeta> {
/**
* 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).
*/
export type MutationResult = { success: true } | { success: false; reason: string };
export type MutationResult =
| { success: true }
| { success: false; reason: string };
/**
* Grid inventory state.
@@ -54,3 +59,12 @@ export interface GridInventory<TMeta> {
/** Set of occupied cells in "x,y" format for O(1) collision lookups */
occupiedCells: Set<CellKey>;
}
export interface GameItemMeta {
itemData: ItemData;
shape: ParsedShape;
consumedUses?: number;
startEffects?: Record<string, { data: EffectData; stacks: number }>;
tradePrice?: number;
}
export type GameItem = InventoryItem<GameItemMeta>;
@@ -1,5 +1,3 @@
export { MapNodeType, MapLayerType } from './types';
export type { MapNode, MapLayer, PointCrawlMap, MapGenerationConfig } from './types';
export { generatePointCrawlMap } from './generator';
export { getNode, getChildren, getParents, hasPath, findAllPaths } from './generator';
export * from "./generator";
export * from "./navigation";
export * from "./types";
@@ -0,0 +1,65 @@
import { getNode } from "./generator";
import { PointCrawlMap, PointCrawlMapNavigator } from "./types";
export function canMoveTo(
navigator: PointCrawlMapNavigator,
map: PointCrawlMap,
targetNodeId: string,
): boolean {
const currentNode = getNode(map, navigator.currentNodeId);
if (!currentNode) return false;
return currentNode.childIds.includes(targetNodeId);
}
export function moveToNode(
navigator: PointCrawlMapNavigator,
map: PointCrawlMap,
targetNodeId: string,
): boolean {
if (!canMoveTo(navigator, map, targetNodeId)) {
return false;
}
const targetNode = getNode(map, targetNodeId);
if (!targetNode) {
return false;
}
// Update current position
navigator.currentNodeId = targetNodeId;
navigator.visitedNodes.add(targetNodeId);
return true;
}
export function* getReachableChildren(
map: PointCrawlMap,
navigator: PointCrawlMapNavigator,
) {
const currentNode = getNode(map, navigator.currentNodeId);
if (!currentNode) return;
for (const id of currentNode.childIds) {
const node = getNode(map, id);
if (!node) continue;
yield node;
}
}
export function isAtStartNode(
map: PointCrawlMap,
navigator: PointCrawlMapNavigator,
): boolean {
return navigator.currentNodeId === map.layers[0]?.nodes[0]?.id;
}
/**
* Checks if the current encounter is the end node.
*/
export function isAtEndNode(
map: PointCrawlMap,
navigator: PointCrawlMapNavigator,
): boolean {
const endLayer = map.layers[map.layers.length - 1];
return endLayer?.nodes[0]?.id === navigator.currentNodeId;
}
@@ -4,22 +4,22 @@ import {EncounterData} from "@/samples/slay-the-spire-like/system/types";
* Types of nodes that can appear on the point crawl map.
*/
export enum MapNodeType {
Start = 'start',
End = 'end',
Minion = 'minion',
Elite = 'elite',
Event = 'event',
Camp = 'camp',
Shop = 'shop',
Curio = 'curio',
Start = "start",
End = "end",
Minion = "minion",
Elite = "elite",
Event = "event",
Camp = "camp",
Shop = "shop",
Curio = "curio",
}
/**
* Semantic type of a layer.
*/
export enum MapLayerType {
Wild = 'wild',
Settlement = 'settlement',
Wild = "wild",
Settlement = "settlement",
}
/**
@@ -47,7 +47,7 @@ export interface MapLayer {
/** Ordered IDs of nodes in this layer */
nodeIds: string[];
/** Semantic type of the layer */
layerType: MapLayerType | 'start' | 'end';
layerType: MapLayerType | "start" | "end";
/** Direct references to nodes in this layer (for performance) */
nodes: MapNode[];
}
@@ -64,6 +64,11 @@ export interface PointCrawlMap {
parentIndex?: Map<string, string[]>;
}
export interface PointCrawlMapNavigator {
currentNodeId: string;
visitedNodes: Set<string>;
}
/**
* Configuration for map generation.
*/
@@ -1,256 +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(runState: RunState): CombatState {
const encounter = getCurrentEncounterData(runState);
if (!encounter)
throw new Error(`No encounter found for node ${runState.currentNodeId}`);
const enemies = createEnemyEntities(encounter);
const deck = generateDeckFromInventory(runState.inventory);
const player = createPlayerEntity(runState, deck);
return {
enemies,
player,
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(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,91 +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**/
consumedUses?: number;
/** Effects applied to the item */
effects?: Record<string, 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 -86
View File
@@ -1,89 +1,25 @@
export type EffectData = {
readonly id: string;
readonly name: string;
readonly description: string;
readonly lifecycle: EffectLifecycle;
};
export type EffectLifecycle =
| "instant"
| "temporary"
| "lingering"
| "permanent"
| "posture"
| "item"
| "itemTemporary"
| "itemUntilPlay"
| "itemUntilDiscard"
| "itemPermanent";
import {
Card,
Effect,
Encounter,
EncounterType,
Enemy,
Intent,
Item,
} from "../data/desert";
export type EnemyData = {
readonly id: string;
readonly name: string;
readonly intents: readonly IntentData[];
readonly description: string;
};
export type CardData = Card;
export type ItemData = Item;
export type EffectData = Effect;
export type IntentData = Intent;
export type EnemyData = Enemy;
export type EncounterData<T extends EncounterType = EncounterType> =
Encounter & { type: T };
export type CardType = "item" | "status";
export type CardCostType = "energy" | "uses" | "none";
export type CardTargetType = "single" | "none";
export type EffectTarget = "self" | "player" | "team";
export {
CardTargetType,
CardEffectTarget,
IntentEffectTarget,
} from "../data/desert";
export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard";
export type CardEffectTarget = "self" | "target" | "all" | "random";
export type CardEffect = {
readonly id: string;
readonly trigger: CardEffectTrigger;
readonly target: CardEffectTarget;
readonly effects: readonly [EffectData, number][];
};
export type CardData = {
readonly id: string;
readonly name: string;
readonly desc: string;
readonly type: CardType;
readonly costType: CardCostType;
readonly costCount: number;
readonly targetType: CardTargetType;
readonly effects: readonly CardEffect[];
};
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;
};
export { ContentModule } from "../data";
@@ -0,0 +1,2 @@
export * from "./parse-shape";
export * from "./shape-collision";
@@ -1,4 +1,4 @@
import type { ParsedShape } from './parse-shape';
import type { ParsedShape } from "./parse-shape";
/**
* Represents a 2D point in grid coordinates.
@@ -54,7 +54,7 @@ export function transformPoint(
point: Point2D,
transform: Transform2D,
shapeWidth: number,
shapeHeight: number
shapeHeight: number,
): Point2D {
let { x, y } = point;
@@ -96,10 +96,13 @@ export function transformPoint(
/**
* Transforms a shape and returnss its occupied cells in world coordinates.
*/
export function transformShape(shape: ParsedShape, transform: Transform2D): Point2D[] {
export function transformShape(
shape: ParsedShape,
transform: Transform2D,
): Point2D[] {
const cells = getOccupiedCells(shape);
return cells.map(cell =>
transformPoint(cell, transform, shape.width, shape.height)
return cells.map((cell) =>
transformPoint(cell, transform, shape.width, shape.height),
);
}
@@ -110,12 +113,12 @@ export function checkCollision(
shapeA: ParsedShape,
transformA: Transform2D,
shapeB: ParsedShape,
transformB: Transform2D
transformB: Transform2D,
): boolean {
const cellsA = transformShape(shapeA, transformA);
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) {
if (setA.has(`${cell.x},${cell.y}`)) {
@@ -135,7 +138,7 @@ export function checkCollision(
export function checkBoardCollision(
shape: ParsedShape,
transform: Transform2D,
occupiedCells: Set<string>
occupiedCells: Set<string>,
): boolean {
const cells = transformShape(shape, transform);
@@ -159,12 +162,17 @@ export function checkBounds(
shape: ParsedShape,
transform: Transform2D,
boardWidth: number,
boardHeight: number
boardHeight: number,
): boolean {
const cells = transformShape(shape, transform);
for (const cell of cells) {
if (cell.x < 0 || cell.x >= boardWidth || cell.y < 0 || cell.y >= boardHeight) {
if (
cell.x < 0 ||
cell.x >= boardWidth ||
cell.y < 0 ||
cell.y >= boardHeight
) {
return false;
}
}
@@ -176,19 +184,19 @@ export function checkBounds(
* Validates that a placement is both in bounds and collision-free.
* @returns Object with `valid` flag and optional `reason` string
*/
export function validatePlacement(
export function validateShapePlacement(
shape: ParsedShape,
transform: Transform2D,
boardWidth: number,
boardHeight: number,
occupiedCells: Set<string>
occupiedCells: Set<string>,
): { valid: true } | { valid: false; reason: string } {
if (!checkBounds(shape, transform, boardWidth, boardHeight)) {
return { valid: false, reason: '超出边界' };
return { valid: false, reason: "超出边界" };
}
if (checkBoardCollision(shape, transform, occupiedCells)) {
return { valid: false, reason: '与已有形状重叠' };
return { valid: false, reason: "与已有形状重叠" };
}
return { valid: true };
@@ -199,10 +207,13 @@ export function validatePlacement(
* @param current The current transform
* @param degrees Degrees to rotate (typically 90, 180, or 270)
*/
export function rotateTransform(current: Transform2D, degrees: number): Transform2D {
export function rotateTransform(
current: Transform2D,
degrees: number,
): Transform2D {
return {
...current,
rotation: ((current.rotation + degrees) % 360 + 360) % 360,
rotation: (((current.rotation + degrees) % 360) + 360) % 360,
};
}
+143 -45
View File
@@ -1,43 +1,77 @@
import {Part} from "boardgame-core";
import {createRegion} from "boardgame-core";
import {createGameCommandRegistry, createPromptDef, IGameContext} from "boardgame-core";
import { Part } from "@/core/part";
import { createRegion, Region } from "@/core/region";
import { createPromptDef, IGameContext } from "@/core/game";
import { BaseGameClient } from "@/core/game-client";
import { createMiddlewareChain } from "@/utils/middleware";
const BOARD_SIZE = 3;
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
const WINNING_LINES: number[][][] = [
[[0, 0], [0, 1], [0, 2]],
[[1, 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]],
[
[0, 0],
[0, 1],
[0, 2],
],
[
[1, 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 WinnerType = PlayerType | 'draw' | null;
export type PlayerType = "X" | "O";
export type WinnerType = PlayerType | "draw" | null;
export type TicTacToePart = Part<{ player: PlayerType }>;
export function createInitialState() {
return {
board: createRegion('board', [
{ name: 'x', min: 0, max: BOARD_SIZE - 1 },
{ name: 'y', min: 0, max: BOARD_SIZE - 1 },
board: createRegion("board", [
{ name: "x", min: 0, max: BOARD_SIZE - 1 },
{ name: "y", min: 0, max: BOARD_SIZE - 1 },
]),
parts: {} as Record<string, TicTacToePart>,
currentPlayer: 'X' as PlayerType,
currentPlayer: "X" as PlayerType,
winner: null as WinnerType,
turn: 0,
};
}
export type TicTacToeState = ReturnType<typeof createInitialState>;
export type TicTacToeGame = IGameContext<TicTacToeState>;
export const registry = createGameCommandRegistry<TicTacToeState>();
export const prompts = {
play: createPromptDef<[PlayerType, number, number]>(
'play <player> <row:number> <col:number>')
}
"play <player> <row:number> <col:number>",
),
};
export async function start(game: TicTacToeGame) {
while (true) {
@@ -45,10 +79,10 @@ export async function start(game: TicTacToeGame) {
const turnNumber = game.value.turn + 1;
const turnOutput = await turn(game, currentPlayer, turnNumber);
game.produce(state => {
await game.produceAsync((state) => {
state.winner = turnOutput.winner;
if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'X' ? 'O' : 'X';
state.currentPlayer = state.currentPlayer === "X" ? "O" : "X";
state.turn = turnNumber;
}
});
@@ -58,9 +92,11 @@ export async function start(game: TicTacToeGame) {
return game.value;
}
const turn = registry.register({
schema: 'turn <player> <turnNumber:number>',
async run(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) {
async function turn(
game: TicTacToeGame,
turnPlayer: PlayerType,
turnNumber: number,
) {
const { player, row, col } = await game.prompt(
prompts.play,
(player, row, col) => {
@@ -74,32 +110,40 @@ const turn = registry.register({
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);
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 };
}
});
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(
host: TicTacToeGame,
row: number,
col: number,
): boolean {
return !!host.value.board.partMap[`${row},${col}`];
}
export function hasWinningLine(positions: number[][]): boolean {
return WINNING_LINES.some(line =>
line.every(([r, c]) =>
positions.some(([pr, pc]) => pr === r && pc === c)
)
return WINNING_LINES.some((line) =>
line.every(([r, c]) => positions.some(([pr, pc]) => pr === r && pc === c)),
);
}
@@ -107,26 +151,80 @@ export function checkWinner(host: TicTacToeGame): WinnerType {
const parts = host.value.parts;
const partsArray = Object.values(parts);
const xPositions = partsArray.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);
const xPositions = partsArray
.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(oPositions)) return 'O';
if (partsArray.length >= MAX_TURNS) return 'draw';
if (hasWinningLine(xPositions)) return "X";
if (hasWinningLine(oPositions)) return "O";
if (partsArray.length >= MAX_TURNS) return "draw";
return null;
}
export function placePiece(host: TicTacToeGame, row: number, col: number, player: PlayerType) {
export async function placePiece(
host: TicTacToeGame,
row: number,
col: number,
player: PlayerType,
) {
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}`
}
host.produce(state => {
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;
}
return game.value;
}
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,
};
}
+26 -7
View File
@@ -1,6 +1,10 @@
export { parseCommand } from './command-parse';
export { parseCommandSchema } from './schema-parse';
export { validateCommand, parseCommandWithSchema, applyCommandSchema } from './command-validate';
export { parseCommand } from "./command-parse";
export { parseCommandSchema } from "./schema-parse";
export {
validateCommand,
parseCommandWithSchema,
applyCommandSchema,
} from "./command-validate";
export {
createCommandRegistry,
registerCommand,
@@ -10,13 +14,28 @@ export {
runCommand,
runCommandParsed,
createCommandRunnerContext,
} from './command-registry';
} from "./command-registry";
export type {
Command,
CommandParamSchema,
CommandOptionSchema,
CommandFlagSchema,
CommandSchema,
} from './types';
export type { CommandRunner, CommandDef, CommandResult, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './command-runner';
export type { CommandRegistry, CommandRunnerContextExport } from './command-registry';
} from "./types";
export type {
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> = (
context: TContext,
next: () => Promise<TReturn>
next: () => Promise<TReturn>,
) => Promise<TReturn>;
export type MiddlewareChain<TContext, TReturn> = {
use: (middleware: Middleware<TContext, TReturn>) => void;
use: (middleware: Middleware<TContext, TReturn>) => () => void;
execute: (context: TContext) => Promise<TReturn>;
};
export function createMiddlewareChain<TContext extends object, TReturn=TContext>(
fallback?: (context: TContext) => Promise<TReturn>
export function createMiddlewareChain<
TContext extends object,
TReturn = TContext,
>(
fallback?: (context: TContext) => Promise<TReturn>,
): MiddlewareChain<TContext, TReturn> {
const middlewares: Middleware<TContext, TReturn>[] = [];
return {
use(middleware: Middleware<TContext, TReturn>) {
middlewares.push(middleware);
return function () {
const index = middlewares.indexOf(middleware);
if (index !== -1) {
middlewares.splice(index, 1);
}
};
},
async execute(context: TContext) {
let index = 0;
async function dispatch(ctx: TContext): Promise<TReturn> {
if (index >= middlewares.length) {
return fallback ? fallback(ctx) : ctx as unknown as TReturn;
return fallback ? fallback(ctx) : (ctx as unknown as TReturn);
}
const current = middlewares[index++];
return current(ctx, () => dispatch(ctx));
+17 -7
View File
@@ -1,13 +1,20 @@
import {Signal, SignalOptions} from '@preact/signals-core';
import {create} from 'mutative';
import { Signal, SignalOptions } from "@preact/signals-core";
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>[] = [];
public constructor(t?: T, options?: SignalOptions<T>) {
super(t, options);
}
produce(fn: (draft: T) => void) {
produce(fn: (draft: T) => undefined) {
this.value = create(this.value, fn);
}
@@ -30,13 +37,16 @@ export class MutableSignal<T> extends Signal<T> {
* 异步版本的 produce。会先等待所有通过 `addInterruption` 添加的 Promise 完成,
* 然后再更新状态。适用于需要在状态更新前播放动画的场景。
*/
async produceAsync(fn: (draft: T) => void): Promise<void> {
async produceAsync(fn: (draft: T) => undefined): Promise<void> {
await Promise.allSettled(this._interruptions);
this._interruptions = [];
this.produce(fn);
}
}
export function mutableSignal<T>(initial?: T, options?: SignalOptions<T>): MutableSignal<T> {
return new MutableSignal<T>(initial, options);
export function mutableSignal<T>(
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);
});
}
+176 -159
View File
@@ -1,95 +1,84 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from "vitest";
import {
registry,
createInitialState,
start,
TicTacToeState,
WinnerType,
PlayerType
} from '@/samples/tic-tac-toe';
import { createGameHost, GameHost } from '@/core/game-host';
import type { PromptEvent } from '@/utils/command';
import { MutableSignal } from '@/utils/mutable-signal';
import {IGameContext} from "../../src";
type TestGameHost = GameHost<TicTacToeState> & {
_context: IGameContext<TicTacToeState>;
context: IGameContext<TicTacToeState>;
}
} from "@/samples/tic-tac-toe";
import { createGameHost, GameHost } from "@/core/game-host";
import type { PromptEvent } from "@/utils/command";
function createTestHost() {
const host: TestGameHost = createGameHost(
{ registry, createInitialState, start }
);
host.context = host._context;
const host = createGameHost({ registry, createInitialState, start });
return { host };
}
function waitForPromptEvent(host: GameHost<any>): Promise<PromptEvent> {
return new Promise(resolve => {
host.context._commands.on('prompt', resolve);
return new Promise((resolve) => {
// @ts-ignore - accessing private _context for testing
host._context._commands.on("prompt", resolve);
});
}
describe('GameHost', () => {
describe('creation', () => {
it('should create host with initial state', () => {
describe("GameHost", () => {
describe("creation", () => {
it("should create host with initial state", () => {
const { host } = createTestHost();
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.winner).toBeNull();
expect(host.context._state.value.turn).toBe(0);
expect(Object.keys(host.context._state.value.parts).length).toBe(0);
expect(host.state.value.currentPlayer).toBe("X");
expect(host.state.value.winner).toBeNull();
expect(host.state.value.turn).toBe(0);
expect(Object.keys(host.state.value.parts).length).toBe(0);
});
it('should have status "created" by default', () => {
const { host } = createTestHost();
expect(host.status.value).toBe('created');
expect(host.status.value).toBe("created");
});
it('should have null activePromptSchema initially', () => {
it("should have null activePromptSchema initially", () => {
const { host } = createTestHost();
expect(host.activePromptSchema.value).toBeNull();
});
});
describe('tryInput', () => {
describe("tryInput", () => {
it('should return "No active prompt" when no prompt is active', () => {
const { host } = createTestHost();
const result = host.tryInput('play X 1 1');
expect(result).toBe('No active prompt');
const result = host.tryInput("play X 1 1");
expect(result).toBe("No active prompt");
});
it('should accept valid input when prompt is active', async () => {
it("should accept valid input when prompt is active", async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
expect(promptEvent.schema.name).toBe('play');
expect(host.activePromptSchema.value?.name).toBe('play');
expect(promptEvent.schema.name).toBe("play");
expect(host.activePromptSchema.value?.name).toBe("play");
const error = host.tryInput('play X 1 1');
const error = host.tryInput("play X 1 1");
expect(error).toBeNull();
// Cancel to end the game since start runs until game over
const nextPromptPromise = waitForPromptEvent(host);
const nextPrompt = await nextPromptPromise;
nextPrompt.cancel('test cleanup');
nextPrompt.cancel("test cleanup");
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
expect(error.message).toBe("test cleanup");
}
});
it('should reject invalid input', async () => {
it("should reject invalid input", async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
@@ -97,29 +86,29 @@ describe('GameHost', () => {
const promptEvent = await promptPromise;
const error = host.tryInput('invalid command');
const error = host.tryInput("invalid command");
expect(error).not.toBeNull();
promptEvent.cancel('test cleanup');
promptEvent.cancel("test cleanup");
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
expect(error.message).toBe("test cleanup");
}
});
it('should return error when disposed', () => {
it("should return error when disposed", () => {
const { host } = createTestHost();
host.dispose();
const result = host.tryInput('play X 1 1');
expect(result).toBe('GameHost is disposed');
const result = host.tryInput("play X 1 1");
expect(result).toBe("GameHost is disposed");
});
});
describe('getActivePromptSchema', () => {
it('should return schema when prompt is active', async () => {
describe("getActivePromptSchema", () => {
it("should return schema when prompt is active", async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
@@ -129,26 +118,26 @@ describe('GameHost', () => {
const schema = host.activePromptSchema.value;
expect(schema).not.toBeNull();
expect(schema?.name).toBe('play');
expect(schema?.name).toBe("play");
promptEvent.cancel('test cleanup');
promptEvent.cancel("test cleanup");
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
expect(error.message).toBe("test cleanup");
}
});
it('should return null when no prompt is active', () => {
it("should return null when no prompt is active", () => {
const { host } = createTestHost();
expect(host.activePromptSchema.value).toBeNull();
});
});
describe('start', () => {
it('should reset state and run start command', async () => {
describe("start", () => {
it("should reset state and run start command", async () => {
const { host } = createTestHost();
// First setup - make one move
@@ -157,20 +146,25 @@ describe('GameHost', () => {
let promptEvent = await promptPromise;
// Make a move
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
promptEvent.tryCommit({
name: "play",
params: ["X", 1, 1],
options: {},
flags: {},
});
// Wait for next prompt (next turn) and cancel
promptPromise = waitForPromptEvent(host);
promptEvent = await promptPromise;
promptEvent.cancel('test end');
promptEvent.cancel("test end");
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test end');
expect(error.message).toBe("test end");
}
expect(Object.keys(host.context._state.value.parts).length).toBe(1);
expect(Object.keys(host.state.value.parts).length).toBe(1);
// Setup listener before calling start
const newPromptPromise = waitForPromptEvent(host);
@@ -179,15 +173,15 @@ describe('GameHost', () => {
const newRunPromise = host.start();
// State should be back to initial
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.winner).toBeNull();
expect(host.context._state.value.turn).toBe(0);
expect(Object.keys(host.context._state.value.parts).length).toBe(0);
expect(host.state.value.currentPlayer).toBe("X");
expect(host.state.value.winner).toBeNull();
expect(host.state.value.turn).toBe(0);
expect(Object.keys(host.state.value.parts).length).toBe(0);
// New game should be running and prompting
const newPrompt = await newPromptPromise;
expect(newPrompt.schema.name).toBe('play');
newPrompt.cancel('test end');
expect(newPrompt.schema.name).toBe("play");
newPrompt.cancel("test end");
try {
await newRunPromise;
@@ -196,7 +190,7 @@ describe('GameHost', () => {
}
});
it('should cancel active prompt during start', async () => {
it("should cancel active prompt during start", async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
@@ -212,31 +206,31 @@ describe('GameHost', () => {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toContain('Cancelled');
expect(error.message).toContain("Cancelled");
}
// State should be reset
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.turn).toBe(0);
expect(host.state.value.currentPlayer).toBe("X");
expect(host.state.value.turn).toBe(0);
});
it('should throw error when disposed', () => {
it("should throw error when disposed", () => {
const { host } = createTestHost();
host.dispose();
expect(() => host.start()).toThrow('GameHost is disposed');
expect(() => host.start()).toThrow("GameHost is disposed");
});
});
describe('dispose', () => {
it('should change status to disposed', () => {
describe("dispose", () => {
it("should change status to disposed", () => {
const { host } = createTestHost();
host.dispose();
expect(host.status.value).toBe('disposed');
expect(host.status.value).toBe("disposed");
});
it('should cancel active prompt on dispose', async () => {
it("should cancel active prompt on dispose", async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
@@ -251,25 +245,25 @@ describe('GameHost', () => {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toContain('Cancelled');
expect(error.message).toContain("Cancelled");
}
});
it('should be idempotent', () => {
it("should be idempotent", () => {
const { host } = createTestHost();
host.dispose();
host.dispose(); // Should not throw
expect(host.status.value).toBe('disposed');
expect(host.status.value).toBe("disposed");
});
});
describe('events', () => {
it('should emit start event', async () => {
describe("events", () => {
it("should emit start event", async () => {
const { host } = createTestHost();
let setupCount = 0;
host.on('start', () => {
host.on("start", () => {
setupCount++;
});
@@ -281,11 +275,11 @@ describe('GameHost', () => {
expect(setupCount).toBe(1);
// State should be running
expect(host.status.value).toBe('running');
expect(host.status.value).toBe("running");
// Cancel the background setup command
const prompt = await promptPromise;
prompt.cancel('test end');
prompt.cancel("test end");
try {
await runPromise;
@@ -294,11 +288,11 @@ describe('GameHost', () => {
}
});
it('should emit dispose event', () => {
it("should emit dispose event", () => {
const { host } = createTestHost();
let disposeCount = 0;
host.on('dispose', () => {
host.on("dispose", () => {
disposeCount++;
});
@@ -306,11 +300,11 @@ describe('GameHost', () => {
expect(disposeCount).toBe(1);
});
it('should allow unsubscribing from events', () => {
it("should allow unsubscribing from events", () => {
const { host } = createTestHost();
let setupCount = 0;
const unsubscribe = host.on('start', () => {
const unsubscribe = host.on("start", () => {
setupCount++;
});
@@ -318,43 +312,48 @@ describe('GameHost', () => {
// No event should be emitted
// (we can't easily test this without triggering setup, but we verify unsubscribe works)
expect(typeof unsubscribe).toBe('function');
expect(typeof unsubscribe).toBe("function");
});
});
describe('reactive state', () => {
it('should have state that reflects game progress', async () => {
describe("reactive state", () => {
it("should have state that reflects game progress", async () => {
const { host } = createTestHost();
// Initial state
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.turn).toBe(0);
expect(host.state.value.currentPlayer).toBe("X");
expect(host.state.value.turn).toBe(0);
// Make a move
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
promptEvent.tryCommit({
name: "play",
params: ["X", 1, 1],
options: {},
flags: {},
});
// Wait for next prompt and cancel
const nextPromptPromise = waitForPromptEvent(host);
const nextPrompt = await nextPromptPromise;
nextPrompt.cancel('test end');
nextPrompt.cancel("test end");
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test end');
expect(error.message).toBe("test end");
}
expect(host.context._state.value.currentPlayer).toBe('O');
expect(host.context._state.value.turn).toBe(1);
expect(Object.keys(host.context._state.value.parts).length).toBe(1);
expect(host.state.value.currentPlayer).toBe("O");
expect(host.state.value.turn).toBe(1);
expect(Object.keys(host.state.value.parts).length).toBe(1);
});
it('should update activePromptSchema reactively', async () => {
it("should update activePromptSchema reactively", async () => {
const { host } = createTestHost();
// Initially null
@@ -368,11 +367,11 @@ describe('GameHost', () => {
// Now schema should be set
expect(host.activePromptSchema.value).not.toBeNull();
expect(host.activePromptSchema.value?.name).toBe('play');
expect(host.activePromptSchema.value?.name).toBe("play");
// Cancel and wait
const cancelEvent = host.activePromptSchema.value;
host.context._commands._cancel();
// @ts-ignore - accessing private _context for testing
host._context._commands._cancel();
try {
await runPromise;
} catch {
@@ -384,29 +383,30 @@ describe('GameHost', () => {
});
});
describe('full game', () => {
it('should run a complete game of tic-tac-toe with X winning diagonally', async () => {
describe("full game", () => {
it("should run a complete game of tic-tac-toe with X winning diagonally", async () => {
const { host } = createTestHost();
// Initial state
expect(host.context._state.value.currentPlayer).toBe('X');
expect(host.context._state.value.winner).toBeNull();
expect(host.context._state.value.turn).toBe(0);
expect(Object.keys(host.context._state.value.parts).length).toBe(0);
expect(host.state.value.currentPlayer).toBe("X");
expect(host.state.value.winner).toBeNull();
expect(host.state.value.turn).toBe(0);
expect(Object.keys(host.state.value.parts).length).toBe(0);
// X wins diagonally: (0,0), (1,1), (2,2)
// O plays: (0,1), (2,1)
const moves = [
'play X 0 0', // turn 1: X
'play O 0 1', // turn 2: O
'play X 1 1', // turn 3: X
'play O 2 1', // turn 4: O
'play X 2 2', // turn 5: X wins!
"play X 0 0", // turn 1: X
"play O 0 1", // turn 2: O
"play X 1 1", // turn 3: X
"play O 2 1", // turn 4: O
"play X 2 2", // turn 5: X wins!
];
// Track prompt events in a queue
const promptEvents: PromptEvent[] = [];
host.context._commands.on('prompt', (e) => {
// @ts-ignore - accessing private _context for testing
host._context._commands.on("prompt", (e) => {
promptEvents.push(e);
});
@@ -416,71 +416,83 @@ describe('GameHost', () => {
for (let i = 0; i < moves.length; i++) {
// Wait until the next prompt event arrives
while (i >= promptEvents.length) {
await new Promise(r => setTimeout(r, 10));
await new Promise((r) => setTimeout(r, 10));
}
const promptEvent = promptEvents[i];
expect(promptEvent.schema.name).toBe('play');
expect(promptEvent.schema.name).toBe("play");
// Submit the move
const error = host.tryInput(moves[i]);
expect(error).toBeNull();
// Wait for the command to complete before submitting next move
await new Promise(resolve => setImmediate(resolve));
await new Promise((resolve) => setImmediate(resolve));
}
// Wait for setup to complete (game ended with winner)
try {
const finalState = await setupPromise;
expect(finalState.winner).toBe('X');
expect(finalState.winner).toBe("X");
// Final state checks
expect(host.context._state.value.winner).toBe('X');
expect(host.context._state.value.currentPlayer).toBe('X');
expect(Object.keys(host.context._state.value.parts).length).toBe(5);
expect(host.state.value.winner).toBe("X");
expect(host.state.value.currentPlayer).toBe("X");
expect(Object.keys(host.state.value.parts).length).toBe(5);
// Verify winning diagonal
const parts = Object.values(host.context._state.value.parts);
const xPieces = parts.filter(p => p.player === 'X');
const parts = Object.values(host.state.value.parts);
const xPieces = parts.filter((p: any) => p.player === "X");
expect(xPieces).toHaveLength(3);
expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([0, 0]))).toBe(true);
expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([1, 1]))).toBe(true);
expect(xPieces.some(p => JSON.stringify(p.position) === JSON.stringify([2, 2]))).toBe(true);
expect(
xPieces.some(
(p: any) => JSON.stringify(p.position) === JSON.stringify([0, 0]),
),
).toBe(true);
expect(
xPieces.some(
(p: any) => JSON.stringify(p.position) === JSON.stringify([1, 1]),
),
).toBe(true);
expect(
xPieces.some(
(p: any) => JSON.stringify(p.position) === JSON.stringify([2, 2]),
),
).toBe(true);
} catch (e) {
// If setup fails due to cancellation, check state directly
const error = e as Error;
if (!error.message.includes('Cancelled')) {
if (!error.message.includes("Cancelled")) {
throw e;
}
}
host.dispose();
expect(host.status.value).toBe('disposed');
expect(host.status.value).toBe("disposed");
});
});
describe('currentPlayer in prompt', () => {
it('should have currentPlayer in PromptEvent', async () => {
describe("currentPlayer in prompt", () => {
it("should have currentPlayer in PromptEvent", async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
expect(promptEvent.currentPlayer).toBe('X');
expect(host.activePromptPlayer.value).toBe('X');
expect(promptEvent.currentPlayer).toBe("X");
expect(host.activePromptPlayer.value).toBe("X");
promptEvent.cancel('test cleanup');
promptEvent.cancel("test cleanup");
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
expect(error.message).toBe("test cleanup");
}
});
it('should update activePromptPlayer reactively', async () => {
it("should update activePromptPlayer reactively", async () => {
const { host } = createTestHost();
// Initially null
@@ -490,25 +502,30 @@ describe('GameHost', () => {
let promptPromise = waitForPromptEvent(host);
let runPromise = host.start();
let promptEvent = await promptPromise;
expect(promptEvent.currentPlayer).toBe('X');
expect(host.activePromptPlayer.value).toBe('X');
expect(promptEvent.currentPlayer).toBe("X");
expect(host.activePromptPlayer.value).toBe("X");
// Make a move
promptEvent.tryCommit({ name: 'play', params: ['X', 1, 1], options: {}, flags: {} });
promptEvent.tryCommit({
name: "play",
params: ["X", 1, 1],
options: {},
flags: {},
});
// Second prompt - O's turn
promptPromise = waitForPromptEvent(host);
promptEvent = await promptPromise;
expect(promptEvent.currentPlayer).toBe('O');
expect(host.activePromptPlayer.value).toBe('O');
expect(promptEvent.currentPlayer).toBe("O");
expect(host.activePromptPlayer.value).toBe("O");
// Cancel
promptEvent.cancel('test cleanup');
promptEvent.cancel("test cleanup");
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
expect(error.message).toBe("test cleanup");
}
// After prompt ends, player should be null
@@ -516,35 +533,35 @@ describe('GameHost', () => {
});
});
describe('tryAnswerPrompt', () => {
it('should answer prompt with valid arguments', async () => {
describe("tryAnswerPrompt", () => {
it("should answer prompt with valid arguments", async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
const runPromise = host.start();
const promptEvent = await promptPromise;
expect(promptEvent.schema.name).toBe('play');
expect(promptEvent.schema.name).toBe("play");
// Use tryAnswerPrompt with the prompt def
const { prompts } = await import('@/samples/tic-tac-toe');
const error = host.tryAnswerPrompt(prompts.play, 'X', 1, 1);
const { prompts } = await import("@/samples/tic-tac-toe");
const error = host.tryAnswerPrompt(prompts.play, "X", 1, 1);
expect(error).toBeNull();
// Wait for next prompt and cancel
const nextPromptPromise = waitForPromptEvent(host);
const nextPrompt = await nextPromptPromise;
nextPrompt.cancel('test cleanup');
nextPrompt.cancel("test cleanup");
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
expect(error.message).toBe("test cleanup");
}
});
it('should reject invalid arguments', async () => {
it("should reject invalid arguments", async () => {
const { host } = createTestHost();
const promptPromise = waitForPromptEvent(host);
@@ -553,26 +570,26 @@ describe('GameHost', () => {
const promptEvent = await promptPromise;
// Use tryAnswerPrompt with invalid position
const { prompts } = await import('@/samples/tic-tac-toe');
const error = host.tryAnswerPrompt(prompts.play, 'X', 5, 5);
const { prompts } = await import("@/samples/tic-tac-toe");
const error = host.tryAnswerPrompt(prompts.play, "X", 5, 5);
expect(error).not.toBeNull();
promptEvent.cancel('test cleanup');
promptEvent.cancel("test cleanup");
try {
await runPromise;
} catch (e) {
const error = e as Error;
expect(error.message).toBe('test cleanup');
expect(error.message).toBe("test cleanup");
}
});
});
describe('addInterruption and clearInterruptions', () => {
it('should add interruption promise to state', async () => {
describe("addInterruption and clearInterruptions", () => {
it("should add interruption promise to state", async () => {
const { host } = createTestHost();
let resolveInterruption: () => void;
const interruptionPromise = new Promise<void>(resolve => {
const interruptionPromise = new Promise<void>((resolve) => {
resolveInterruption = resolve;
});
@@ -589,7 +606,7 @@ describe('GameHost', () => {
resolveInterruption!();
// Cancel and cleanup
promptEvent.cancel('test cleanup');
promptEvent.cancel("test cleanup");
try {
await runPromise;
} catch {
@@ -597,15 +614,15 @@ describe('GameHost', () => {
}
});
it('should clear all pending interruptions', async () => {
it("should clear all pending interruptions", async () => {
const { host } = createTestHost();
let resolveInterruption1: () => void;
let resolveInterruption2: () => void;
const interruptionPromise1 = new Promise<void>(resolve => {
const interruptionPromise1 = new Promise<void>((resolve) => {
resolveInterruption1 = resolve;
});
const interruptionPromise2 = new Promise<void>(resolve => {
const interruptionPromise2 = new Promise<void>((resolve) => {
resolveInterruption2 = resolve;
});
@@ -621,7 +638,7 @@ describe('GameHost', () => {
const runPromise = host.start();
const promptEvent = await promptPromise;
promptEvent.cancel('test cleanup');
promptEvent.cancel("test cleanup");
try {
await runPromise;
+78 -56
View File
@@ -1,14 +1,24 @@
import { describe, it, expect } from 'vitest';
import { createGameContext, createGameCommandRegistry, createPromptDef, IGameContext, PromptDef } from '@/core/game';
import type { PromptEvent, Command } from '@/utils/command';
import { describe, it, expect } from "vitest";
import {
createGameContext,
createGameCommandRegistry,
createPromptDef,
IGameContext,
PromptDef,
} from "@/core/game";
import type {
PromptEvent,
Command,
CommandRunnerContext,
} from "@/utils/command";
type MyState = {
score: number;
round: number;
};
describe('createGameContext', () => {
it('should create a game context with state', () => {
describe("createGameContext", () => {
it("should create a game context with state", () => {
const registry = createGameCommandRegistry();
const ctx = createGameContext(registry);
@@ -16,7 +26,7 @@ describe('createGameContext', () => {
expect(ctx._state.value).toBeDefined();
});
it('should wire commands to the context', () => {
it("should wire commands to the context", () => {
const registry = createGameCommandRegistry();
const ctx = createGameContext(registry);
@@ -24,7 +34,7 @@ describe('createGameContext', () => {
expect(ctx._commands.registry).toBe(registry);
});
it('should accept initial state as an object', () => {
it("should accept initial state as an object", () => {
const registry = createGameCommandRegistry<MyState>();
const ctx = createGameContext<MyState>(registry, {
score: 0,
@@ -35,7 +45,7 @@ describe('createGameContext', () => {
expect(ctx._state.value.round).toBe(1);
});
it('should accept initial state as a factory function', () => {
it("should accept initial state as a factory function", () => {
const registry = createGameCommandRegistry<MyState>();
const ctx = createGameContext<MyState>(registry, () => ({
score: 10,
@@ -46,24 +56,32 @@ describe('createGameContext', () => {
expect(ctx._state.value.round).toBe(3);
});
it('should forward prompt events via listener', async () => {
it("should forward prompt events via listener", async () => {
const registry = createGameCommandRegistry();
const ctx = createGameContext(registry);
registry.register('test <value>', async function (_ctx, value) {
return this.prompt({schema: 'prompt <answer>'}, () => 'ok');
registry.register("test <value>", async function (_ctx, value) {
return _ctx.prompt(
createPromptDef("prompt <answer>"),
(answer) => answer,
);
});
const promptPromise = new Promise<PromptEvent>(resolve => {
ctx._commands.on('prompt', resolve);
const promptPromise = new Promise<PromptEvent>((resolve) => {
ctx._commands.on("prompt", resolve);
});
const runPromise = ctx.run('test hello');
const runPromise = ctx.run("test hello");
const promptEvent = await promptPromise;
expect(promptEvent).not.toBeNull();
expect(promptEvent.schema.name).toBe('prompt');
expect(promptEvent.schema.name).toBe("prompt");
const error = promptEvent.tryCommit({ name: 'prompt', params: ['yes'], options: {}, flags: {} });
const error = promptEvent.tryCommit({
name: "prompt",
params: ["yes"],
options: {},
flags: {},
});
expect(error).toBeNull();
const result = await runPromise;
@@ -71,41 +89,41 @@ describe('createGameContext', () => {
});
});
describe('createGameCommand', () => {
it('should run a command with access to game context', async () => {
describe("createGameCommand", () => {
it("should run a command with access to game context", async () => {
const registry = createGameCommandRegistry<{ marker: string }>();
registry.register('set-marker <id>', async function (ctx, id) {
ctx.produce(state => {
registry.register("set-marker <id>", async function (ctx, id) {
ctx.produce((state) => {
state.marker = id;
});
return id;
});
const ctx = createGameContext(registry, { marker: '' });
const ctx = createGameContext(registry, { marker: "" });
const result = await ctx.run('set-marker board');
const result = await ctx.run("set-marker board");
if (!result.success) {
console.error('Error:', result.error);
console.error("Error:", result.error);
}
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('board');
expect(result.result).toBe("board");
}
expect(ctx._state.value.marker).toBe('board');
expect(ctx._state.value.marker).toBe("board");
});
it('should run a typed command with extended context', async () => {
it("should run a typed command with extended context", async () => {
const registry = createGameCommandRegistry<MyState>();
registry.register(
'add-score <amount:number>',
"add-score <amount:number>",
async function (ctx, amount) {
ctx.produce(state => {
ctx.produce((state) => {
state.score += amount;
});
return ctx.value.score;
}
},
);
const ctx = createGameContext<MyState>(registry, () => ({
@@ -113,7 +131,7 @@ describe('createGameCommand', () => {
round: 1,
}));
const result = await ctx.run('add-score 5');
const result = await ctx.run("add-score 5");
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe(5);
@@ -121,67 +139,71 @@ describe('createGameCommand', () => {
expect(ctx._state.value.score).toBe(5);
});
it('should return error for unknown command', async () => {
it("should return error for unknown command", async () => {
const registry = createGameCommandRegistry();
const ctx = createGameContext(registry);
const result = await ctx.run('nonexistent');
const result = await ctx.run("nonexistent");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('nonexistent');
expect(result.error).toContain("nonexistent");
}
});
});
describe('createPromptDef', () => {
it('should create a PromptDef with string schema', () => {
const promptDef = createPromptDef<[string, number]>('play <player> <score:number>');
describe("createPromptDef", () => {
it("should create a PromptDef with string schema", () => {
const promptDef = createPromptDef<[string, number]>(
"play <player> <score:number>",
);
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[0].name).toBe('player');
expect(promptDef.schema.params[1].name).toBe('score');
expect(promptDef.schema.params[0].name).toBe("player");
expect(promptDef.schema.params[1].name).toBe("score");
});
it('should create a PromptDef with CommandSchema object', () => {
it("should create a PromptDef with CommandSchema object", () => {
const schemaObj = {
name: 'test',
name: "test",
params: [],
options: {},
flags: {}
flags: {},
};
const promptDef = createPromptDef<[]>(schemaObj);
expect(promptDef.schema).toEqual(schemaObj);
});
it('should be usable with game.prompt', async () => {
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';
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 promptPromise = new Promise<PromptEvent>(resolve => {
ctx._commands.on('prompt', resolve);
const promptPromise = new Promise<PromptEvent>((resolve) => {
ctx._commands.on("prompt", resolve);
});
const runPromise = ctx.run('test-prompt');
const runPromise = ctx.run("test-prompt");
const promptEvent = await promptPromise;
expect(promptEvent.schema.name).toBe('input');
expect(promptEvent.schema.name).toBe("input");
const error = promptEvent.tryCommit({ name: 'input', params: [42], options: {}, flags: {} });
const error = promptEvent.tryCommit({
name: "input",
params: [42],
options: {},
flags: {},
});
expect(error).toBeNull();
const result = await runPromise;
+355 -231
View File
@@ -1,66 +1,81 @@
import { describe, it, expect } from 'vitest';
import { registry, createInitialState, BoopState } from '@/samples/boop';
import { createGameContext } from '@/core/game';
import type { PromptEvent } from '@/utils/command';
import { describe, it, expect } from "vitest";
import { registry, createInitialState, BoopState } from "@/samples/boop";
import { createGameContext } from "@/core/game";
import type { PromptEvent } from "@/utils/command";
function createTestContext() {
const ctx = createGameContext(registry, createInitialState());
return { registry, ctx };
}
function waitForPrompt(ctx: ReturnType<typeof createTestContext>['ctx']): Promise<PromptEvent> {
return new Promise(resolve => {
ctx._commands.on('prompt', resolve);
function waitForPrompt(
ctx: ReturnType<typeof createTestContext>["ctx"],
): Promise<PromptEvent> {
return new Promise((resolve) => {
ctx._commands.on("prompt", resolve);
});
}
describe('Boop Game', () => {
describe('Setup', () => {
it('should create initial state correctly', () => {
describe("Boop Game", () => {
describe("Setup", () => {
it("should create initial state correctly", () => {
const state = createInitialState();
expect(state.currentPlayer).toBe('white');
expect(state.currentPlayer).toBe("white");
expect(state.winner).toBeNull();
expect(state.regions.board).toBeDefined();
expect(state.regions.white).toBeDefined();
expect(state.regions.black).toBeDefined();
// 8 kittens per player
const whiteKittens = Object.values(state.pieces).filter(p => p.player === 'white' && p.type === 'kitten');
const blackKittens = Object.values(state.pieces).filter(p => p.player === 'black' && p.type === 'kitten');
const whiteKittens = Object.values(state.pieces).filter(
(p) => p.player === "white" && p.type === "kitten",
);
const blackKittens = Object.values(state.pieces).filter(
(p) => p.player === "black" && p.type === "kitten",
);
expect(whiteKittens.length).toBe(8);
expect(blackKittens.length).toBe(8);
// 8 cats per player (initially in box)
const whiteCats = Object.values(state.pieces).filter(p => p.player === 'white' && p.type === 'cat');
const blackCats = Object.values(state.pieces).filter(p => p.player === 'black' && p.type === 'cat');
const whiteCats = Object.values(state.pieces).filter(
(p) => p.player === "white" && p.type === "cat",
);
const blackCats = Object.values(state.pieces).filter(
(p) => p.player === "black" && p.type === "cat",
);
expect(whiteCats.length).toBe(8);
expect(blackCats.length).toBe(8);
// All cats should be in box (regionId = '')
whiteCats.forEach(cat => expect(cat.regionId).toBe(''));
blackCats.forEach(cat => expect(cat.regionId).toBe(''));
whiteCats.forEach((cat) => expect(cat.regionId).toBe(""));
blackCats.forEach((cat) => expect(cat.regionId).toBe(""));
// Kittens should be in player supplies
whiteKittens.forEach(k => expect(k.regionId).toBe('white'));
blackKittens.forEach(k => expect(k.regionId).toBe('black'));
whiteKittens.forEach((k) => expect(k.regionId).toBe("white"));
blackKittens.forEach((k) => expect(k.regionId).toBe("black"));
});
});
describe('Place and Boop Commands', () => {
it('should place a kitten via play command', async () => {
describe("Place and Boop Commands", () => {
it("should place a kitten via play command", async () => {
const { ctx } = createTestContext();
// Use turn command instead of setup which runs indefinitely
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.run('turn white');
const runPromise = ctx.run("turn white");
const promptEvent = await promptPromise;
expect(promptEvent).not.toBeNull();
expect(promptEvent.schema.name).toBe('play');
expect(promptEvent.schema.name).toBe("play");
// Place a kitten at position 2,2
const error = promptEvent.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} });
const error = promptEvent.tryCommit({
name: "play",
params: ["white", 2, 2, "kitten"],
options: {},
flags: {},
});
expect(error).toBeNull();
const result = await runPromise;
@@ -72,29 +87,41 @@ describe('Boop Game', () => {
expect(boardPieces.length).toBeGreaterThan(0);
// Should have one less kitten in supply
const whiteSupply = state.regions.white.childIds.filter(id => state.pieces[id].type === 'kitten');
const whiteSupply = state.regions.white.childIds.filter(
(id) => state.pieces[id].type === "kitten",
);
expect(whiteSupply.length).toBe(7);
});
});
describe('Boop Mechanics', () => {
it('should boop adjacent pieces away from placement', async () => {
describe("Boop Mechanics", () => {
it("should boop adjacent pieces away from placement", async () => {
const { ctx } = createTestContext();
// White places at 2,2
let promptPromise = waitForPrompt(ctx);
let runPromise = ctx.run('turn white');
let runPromise = ctx.run("turn white");
let promptEvent = await promptPromise;
let error = promptEvent.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} });
let error = promptEvent.tryCommit({
name: "play",
params: ["white", 2, 2, "kitten"],
options: {},
flags: {},
});
expect(error).toBeNull();
let result = await runPromise;
expect(result.success).toBe(true);
// Black places at 2,3, which will boop white's piece
promptPromise = waitForPrompt(ctx);
runPromise = ctx.run('turn black');
runPromise = ctx.run("turn black");
promptEvent = await promptPromise;
error = promptEvent.tryCommit({ name: 'play', params: ['black', 2, 3, 'kitten'], options: {}, flags: {} });
error = promptEvent.tryCommit({
name: "play",
params: ["black", 2, 3, "kitten"],
options: {},
flags: {},
});
expect(error).toBeNull();
result = await runPromise;
expect(result.success).toBe(true);
@@ -105,152 +132,186 @@ describe('Boop Game', () => {
expect(boardPieceCount).toBeGreaterThanOrEqual(1);
});
it('should handle pieces being booped off the board', async () => {
it("should handle pieces being booped off the board", async () => {
const { ctx } = createTestContext();
// White places at corner
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.run('turn white');
const runPromise = ctx.run("turn white");
const promptEvent = await promptPromise;
const error = promptEvent.tryCommit({ name: 'play', params: ['white', 0, 0, 'kitten'], options: {}, flags: {} });
const error = promptEvent.tryCommit({
name: "play",
params: ["white", 0, 0, "kitten"],
options: {},
flags: {},
});
expect(error).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
const state = ctx.value;
// Verify placement
expect(state.regions.board.partMap['0,0']).toBeDefined();
expect(state.regions.board.partMap["0,0"]).toBeDefined();
});
});
describe('Full Game Flow', () => {
it('should play a turn and switch players', async () => {
describe("Full Game Flow", () => {
it("should play a turn and switch players", async () => {
const { ctx } = createTestContext();
// White's turn - place at 2,2
let promptPromise = waitForPrompt(ctx);
let runPromise = ctx.run('turn white');
let runPromise = ctx.run("turn white");
let prompt = await promptPromise;
const error1 = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} });
const error1 = prompt.tryCommit({
name: "play",
params: ["white", 2, 2, "kitten"],
options: {},
flags: {},
});
expect(error1).toBeNull();
let result = await runPromise;
expect(result.success).toBe(true);
const stateAfterWhite = ctx.value;
// Should have placed a piece
expect(stateAfterWhite.regions.board.partMap['2,2']).toBeDefined();
expect(stateAfterWhite.regions.board.partMap["2,2"]).toBeDefined();
});
});
describe('Kitten vs Cat Hierarchy', () => {
it('should not boop cats when placing a kitten', async () => {
describe("Kitten vs Cat Hierarchy", () => {
it("should not boop cats when placing a kitten", async () => {
const { ctx } = createTestContext();
// White places a kitten at 2,2
let promptPromise = waitForPrompt(ctx);
let runPromise = ctx.run('turn white');
let runPromise = ctx.run("turn white");
let prompt = await promptPromise;
let error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} });
let error = prompt.tryCommit({
name: "play",
params: ["white", 2, 2, "kitten"],
options: {},
flags: {},
});
expect(error).toBeNull();
let result = await runPromise;
expect(result.success).toBe(true);
// Manually move white's kitten to box and replace with a cat (for testing)
ctx.produce(state => {
const whiteKitten = state.pieces['white-kitten-1'];
if (whiteKitten && whiteKitten.regionId === 'board') {
whiteKitten.type = 'cat';
ctx.produce((state) => {
const whiteKitten = state.pieces["white-kitten-1"];
if (whiteKitten && whiteKitten.regionId === "board") {
(whiteKitten as { type: string }).type = "cat";
}
});
// Black places a kitten at 2,3 (adjacent to the cat)
promptPromise = waitForPrompt(ctx);
runPromise = ctx.run('turn black');
runPromise = ctx.run("turn black");
prompt = await promptPromise;
error = prompt.tryCommit({ name: 'play', params: ['black', 2, 3, 'kitten'], options: {}, flags: {} });
error = prompt.tryCommit({
name: "play",
params: ["black", 2, 3, "kitten"],
options: {},
flags: {},
});
expect(error).toBeNull();
result = await runPromise;
expect(result.success).toBe(true);
const state = ctx.value;
// White's cat should still be at 2,2 (not booped)
expect(state.regions.board.partMap['2,2']).toBe('white-kitten-1');
expect(state.regions.board.partMap["2,2"]).toBe("white-kitten-1");
// Black's kitten should be at 2,3
expect(state.regions.board.partMap['2,3']).toBe('black-kitten-1');
expect(state.regions.board.partMap["2,3"]).toBe("black-kitten-1");
});
it('should boop both kittens and cats when placing a cat', async () => {
it("should boop both kittens and cats when placing a cat", async () => {
const { ctx } = createTestContext();
// Manually set up: white cat at 2,3, black cat at 3,2
// First move cats to white and black supplies
ctx.produce(state => {
const whiteCat = state.pieces['white-cat-1'];
const blackCat = state.pieces['black-cat-1'];
if (whiteCat && whiteCat.regionId === '') {
whiteCat.regionId = 'white';
ctx.produce((state) => {
const whiteCat = state.pieces["white-cat-1"];
const blackCat = state.pieces["black-cat-1"];
if (whiteCat && whiteCat.regionId === "") {
whiteCat.regionId = "white";
state.regions.white.childIds.push(whiteCat.id);
}
if (blackCat && blackCat.regionId === '') {
blackCat.regionId = 'black';
if (blackCat && blackCat.regionId === "") {
blackCat.regionId = "black";
state.regions.black.childIds.push(blackCat.id);
}
});
// Now move them to the board
ctx.produce(state => {
const whiteCat = state.pieces['white-cat-1'];
const blackCat = state.pieces['black-cat-1'];
if (whiteCat && whiteCat.regionId === 'white') {
whiteCat.regionId = 'board';
ctx.produce((state) => {
const whiteCat = state.pieces["white-cat-1"];
const blackCat = state.pieces["black-cat-1"];
if (whiteCat && whiteCat.regionId === "white") {
whiteCat.regionId = "board";
whiteCat.position = [2, 3];
state.regions.board.partMap['2,3'] = whiteCat.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== whiteCat.id);
state.regions.board.partMap["2,3"] = whiteCat.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== whiteCat.id,
);
}
if (blackCat && blackCat.regionId === 'black') {
blackCat.regionId = 'board';
if (blackCat && blackCat.regionId === "black") {
blackCat.regionId = "board";
blackCat.position = [3, 2];
state.regions.board.partMap['3,2'] = blackCat.id;
state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== blackCat.id);
state.regions.board.partMap["3,2"] = blackCat.id;
state.regions.black.childIds = state.regions.black.childIds.filter(
(id) => id !== blackCat.id,
);
}
});
// Give white another cat for placement
ctx.produce(state => {
const whiteCat2 = state.pieces['white-cat-2'];
if (whiteCat2 && whiteCat2.regionId === '') {
whiteCat2.regionId = 'white';
ctx.produce((state) => {
const whiteCat2 = state.pieces["white-cat-2"];
if (whiteCat2 && whiteCat2.regionId === "") {
whiteCat2.regionId = "white";
state.regions.white.childIds.push(whiteCat2.id);
}
});
// White places a cat at 2,2 (should boop black's cat at 3,2 to 4,2)
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.run('turn white');
const runPromise = ctx.run("turn white");
const prompt = await promptPromise;
const error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'cat'], options: {}, flags: {} });
const error = prompt.tryCommit({
name: "play",
params: ["white", 2, 2, "cat"],
options: {},
flags: {},
});
expect(error).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
const state = ctx.value;
// Black's cat should have been booped to 4,2
expect(state.regions.board.partMap['4,2']).toBeDefined();
const pieceAt42 = state.pieces[state.regions.board.partMap['4,2']];
expect(pieceAt42?.player).toBe('black');
expect(pieceAt42?.type).toBe('cat');
expect(state.regions.board.partMap["4,2"]).toBeDefined();
const pieceAt42 = state.pieces[state.regions.board.partMap["4,2"]];
expect(pieceAt42?.player).toBe("black");
expect(pieceAt42?.type).toBe("cat");
});
});
describe('Boop Obstructions', () => {
it('should boop pieces to empty positions', async () => {
describe("Boop Obstructions", () => {
it("should boop pieces to empty positions", async () => {
const { ctx } = createTestContext();
// White places at 2,2
let promptPromise = waitForPrompt(ctx);
let runPromise = ctx.run('turn white');
let runPromise = ctx.run("turn white");
let prompt = await promptPromise;
let error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} });
let error = prompt.tryCommit({
name: "play",
params: ["white", 2, 2, "kitten"],
options: {},
flags: {},
});
expect(error).toBeNull();
let result = await runPromise;
expect(result.success).toBe(true);
@@ -261,9 +322,14 @@ describe('Boop Game', () => {
// Black places at 3,3
promptPromise = waitForPrompt(ctx);
runPromise = ctx.run('turn black');
runPromise = ctx.run("turn black");
prompt = await promptPromise;
error = prompt.tryCommit({ name: 'play', params: ['black', 3, 3, 'kitten'], options: {}, flags: {} });
error = prompt.tryCommit({
name: "play",
params: ["black", 3, 3, "kitten"],
options: {},
flags: {},
});
expect(error).toBeNull();
result = await runPromise;
expect(result.success).toBe(true);
@@ -276,294 +342,334 @@ describe('Boop Game', () => {
expect(boardPieces.length).toBe(2);
// Find black's piece
const blackPiece = boardPieces.find(([pos, id]) => state.pieces[id]?.player === 'black');
const blackPiece = boardPieces.find(
([pos, id]) => state.pieces[id]?.player === "black",
);
expect(blackPiece).toBeDefined();
});
it('should keep both pieces in place when boop is blocked', async () => {
it("should keep both pieces in place when boop is blocked", async () => {
const { ctx } = createTestContext();
// Setup: place white at 2,2 and 4,4, black at 3,3
await ctx._commands.run('place 2 2 white kitten');
await ctx._commands.run('place 3 3 black kitten');
await ctx._commands.run('place 4 4 white kitten');
await ctx._commands.run("place 2 2 white kitten");
await ctx._commands.run("place 3 3 black kitten");
await ctx._commands.run("place 4 4 white kitten");
const stateBefore = ctx.value;
// Verify setup - 3 pieces on board
const boardPiecesBefore = Object.keys(stateBefore.regions.board.partMap);
expect(boardPiecesBefore.length).toBe(3);
expect(stateBefore.regions.board.partMap['2,2']).toBeDefined();
expect(stateBefore.regions.board.partMap['3,3']).toBeDefined();
expect(stateBefore.regions.board.partMap['4,4']).toBeDefined();
expect(stateBefore.regions.board.partMap["2,2"]).toBeDefined();
expect(stateBefore.regions.board.partMap["3,3"]).toBeDefined();
expect(stateBefore.regions.board.partMap["4,4"]).toBeDefined();
// Black places at 2,3 - should try to boop piece at 3,3 to 4,4
// but 4,4 is occupied, so both should stay
await ctx._commands.run('place 2 3 black kitten');
await ctx._commands.run("place 2 3 black kitten");
const state = ctx.value;
// Should now have 4 pieces on board
const boardPiecesAfter = Object.keys(state.regions.board.partMap);
expect(boardPiecesAfter.length).toBe(4);
// 3,3 should still have the same piece (not booped)
expect(state.regions.board.partMap['3,3']).toBeDefined();
expect(state.regions.board.partMap["3,3"]).toBeDefined();
// 4,4 should still be occupied
expect(state.regions.board.partMap['4,4']).toBeDefined();
expect(state.regions.board.partMap["4,4"]).toBeDefined();
// 2,3 should have black's new piece
expect(state.regions.board.partMap['2,3']).toBeDefined();
expect(state.regions.board.partMap["2,3"]).toBeDefined();
});
});
describe('Graduation Mechanic', () => {
it('should graduate three kittens in a row to cats', async () => {
describe("Graduation Mechanic", () => {
it("should graduate three kittens in a row to cats", async () => {
const { ctx } = createTestContext();
// Manually place three white kittens in a row
ctx.produce(state => {
const k1 = state.pieces['white-kitten-1'];
const k2 = state.pieces['white-kitten-2'];
const k3 = state.pieces['white-kitten-3'];
ctx.produce((state) => {
const k1 = state.pieces["white-kitten-1"];
const k2 = state.pieces["white-kitten-2"];
const k3 = state.pieces["white-kitten-3"];
if (k1) {
k1.regionId = 'board';
k1.regionId = "board";
k1.position = [0, 0];
state.regions.board.partMap['0,0'] = k1.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k1.id);
state.regions.board.partMap["0,0"] = k1.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== k1.id,
);
}
if (k2) {
k2.regionId = 'board';
k2.regionId = "board";
k2.position = [0, 1];
state.regions.board.partMap['0,1'] = k2.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k2.id);
state.regions.board.partMap["0,1"] = k2.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== k2.id,
);
}
if (k3) {
k3.regionId = 'board';
k3.regionId = "board";
k3.position = [0, 2];
state.regions.board.partMap['0,2'] = k3.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k3.id);
state.regions.board.partMap["0,2"] = k3.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== k3.id,
);
}
});
const stateBefore = ctx.value;
// Verify three kittens on board
expect(stateBefore.regions.board.partMap['0,0']).toBeDefined();
expect(stateBefore.regions.board.partMap['0,1']).toBeDefined();
expect(stateBefore.regions.board.partMap['0,2']).toBeDefined();
expect(stateBefore.regions.board.partMap["0,0"]).toBeDefined();
expect(stateBefore.regions.board.partMap["0,1"]).toBeDefined();
expect(stateBefore.regions.board.partMap["0,2"]).toBeDefined();
// Count cats in white supply before graduation
const catsInWhiteSupplyBefore = stateBefore.regions.white.childIds.filter(
id => stateBefore.pieces[id].type === 'cat'
(id) => stateBefore.pieces[id].type === "cat",
);
expect(catsInWhiteSupplyBefore.length).toBe(0);
// Run check-graduates command
const result = await ctx._commands.run('check-graduates');
const result = await ctx._commands.run("check-graduates");
expect(result.success).toBe(true);
const state = ctx.value;
// The three positions on board should now be empty (kittens removed)
expect(state.regions.board.partMap['0,0']).toBeUndefined();
expect(state.regions.board.partMap['0,1']).toBeUndefined();
expect(state.regions.board.partMap['0,2']).toBeUndefined();
expect(state.regions.board.partMap["0,0"]).toBeUndefined();
expect(state.regions.board.partMap["0,1"]).toBeUndefined();
expect(state.regions.board.partMap["0,2"]).toBeUndefined();
// White's supply should now have 3 cats (graduated)
const catsInWhiteSupply = state.regions.white.childIds.filter(
id => state.pieces[id].type === 'cat'
(id) => state.pieces[id].type === "cat",
);
expect(catsInWhiteSupply.length).toBe(3);
// White's supply should have 5 kittens left (8 - 3 graduated)
const kittensInWhiteSupply = state.regions.white.childIds.filter(
id => state.pieces[id].type === 'kitten'
(id) => state.pieces[id].type === "kitten",
);
expect(kittensInWhiteSupply.length).toBe(5);
});
});
describe('Win Detection', () => {
it('should detect horizontal win with three cats', async () => {
describe("Win Detection", () => {
it("should detect horizontal win with three cats", async () => {
const { ctx } = createTestContext();
// Manually set up a winning scenario for white
ctx.produce(state => {
const k1 = state.pieces['white-kitten-1'];
const k2 = state.pieces['white-kitten-2'];
const k3 = state.pieces['white-kitten-3'];
ctx.produce((state) => {
const k1 = state.pieces["white-kitten-1"];
const k2 = state.pieces["white-kitten-2"];
const k3 = state.pieces["white-kitten-3"];
if (k1) {
k1.type = 'cat';
k1.regionId = 'board';
(k1 as { type: string }).type = "cat";
k1.regionId = "board";
k1.position = [0, 0];
state.regions.board.partMap['0,0'] = k1.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k1.id);
state.regions.board.partMap["0,0"] = k1.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== k1.id,
);
}
if (k2) {
k2.type = 'cat';
k2.regionId = 'board';
(k2 as { type: string }).type = "cat";
k2.regionId = "board";
k2.position = [0, 1];
state.regions.board.partMap['0,1'] = k2.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k2.id);
state.regions.board.partMap["0,1"] = k2.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== k2.id,
);
}
if (k3) {
k3.type = 'cat';
k3.regionId = 'board';
(k3 as { type: string }).type = "cat";
k3.regionId = "board";
k3.position = [0, 2];
state.regions.board.partMap['0,2'] = k3.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k3.id);
state.regions.board.partMap["0,2"] = k3.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== k3.id,
);
}
});
// Run check-win command
const result = await ctx._commands.run('check-win');
const result = await ctx._commands.run("check-win");
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('white');
expect(result.result).toBe("white");
}
});
it('should detect vertical win with three cats', async () => {
it("should detect vertical win with three cats", async () => {
const { ctx } = createTestContext();
// Manually set up a vertical winning scenario for black
ctx.produce(state => {
const k1 = state.pieces['black-kitten-1'];
const k2 = state.pieces['black-kitten-2'];
const k3 = state.pieces['black-kitten-3'];
ctx.produce((state) => {
const k1 = state.pieces["black-kitten-1"];
const k2 = state.pieces["black-kitten-2"];
const k3 = state.pieces["black-kitten-3"];
if (k1) {
k1.type = 'cat';
k1.regionId = 'board';
(k1 as { type: string }).type = "cat";
k1.regionId = "board";
k1.position = [0, 0];
state.regions.board.partMap['0,0'] = k1.id;
state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== k1.id);
state.regions.board.partMap["0,0"] = k1.id;
state.regions.black.childIds = state.regions.black.childIds.filter(
(id) => id !== k1.id,
);
}
if (k2) {
k2.type = 'cat';
k2.regionId = 'board';
(k2 as { type: string }).type = "cat";
k2.regionId = "board";
k2.position = [1, 0];
state.regions.board.partMap['1,0'] = k2.id;
state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== k2.id);
state.regions.board.partMap["1,0"] = k2.id;
state.regions.black.childIds = state.regions.black.childIds.filter(
(id) => id !== k2.id,
);
}
if (k3) {
k3.type = 'cat';
k3.regionId = 'board';
(k3 as { type: string }).type = "cat";
k3.regionId = "board";
k3.position = [2, 0];
state.regions.board.partMap['2,0'] = k3.id;
state.regions.black.childIds = state.regions.black.childIds.filter(id => id !== k3.id);
state.regions.board.partMap["2,0"] = k3.id;
state.regions.black.childIds = state.regions.black.childIds.filter(
(id) => id !== k3.id,
);
}
});
// Run check-win command
const result = await ctx._commands.run('check-win');
const result = await ctx._commands.run("check-win");
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('black');
expect(result.result).toBe("black");
}
});
it('should detect diagonal win with three cats', async () => {
it("should detect diagonal win with three cats", async () => {
const { ctx } = createTestContext();
// Manually set up a diagonal winning scenario for white
ctx.produce(state => {
const k1 = state.pieces['white-kitten-1'];
const k2 = state.pieces['white-kitten-2'];
const k3 = state.pieces['white-kitten-3'];
ctx.produce((state) => {
const k1 = state.pieces["white-kitten-1"];
const k2 = state.pieces["white-kitten-2"];
const k3 = state.pieces["white-kitten-3"];
if (k1) {
k1.type = 'cat';
k1.regionId = 'board';
(k1 as { type: string }).type = "cat";
k1.regionId = "board";
k1.position = [0, 0];
state.regions.board.partMap['0,0'] = k1.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k1.id);
state.regions.board.partMap["0,0"] = k1.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== k1.id,
);
}
if (k2) {
k2.type = 'cat';
k2.regionId = 'board';
(k2 as { type: string }).type = "cat";
k2.regionId = "board";
k2.position = [1, 1];
state.regions.board.partMap['1,1'] = k2.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k2.id);
state.regions.board.partMap["1,1"] = k2.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== k2.id,
);
}
if (k3) {
k3.type = 'cat';
k3.regionId = 'board';
(k3 as { type: string }).type = "cat";
k3.regionId = "board";
k3.position = [2, 2];
state.regions.board.partMap['2,2'] = k3.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== k3.id);
state.regions.board.partMap["2,2"] = k3.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== k3.id,
);
}
});
// Run check-win command
const result = await ctx._commands.run('check-win');
const result = await ctx._commands.run("check-win");
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('white');
expect(result.result).toBe("white");
}
});
});
describe('Placing Cats', () => {
it('should allow placing a cat from supply', async () => {
describe("Placing Cats", () => {
it("should allow placing a cat from supply", async () => {
const { ctx } = createTestContext();
// Manually give a cat to white's supply
ctx.produce(state => {
const cat = state.pieces['white-cat-1'];
if (cat && cat.regionId === '') {
cat.regionId = 'white';
ctx.produce((state) => {
const cat = state.pieces["white-cat-1"];
if (cat && cat.regionId === "") {
cat.regionId = "white";
state.regions.white.childIds.push(cat.id);
}
});
// White places a cat at 2,2
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.run('turn white');
const runPromise = ctx.run("turn white");
const prompt = await promptPromise;
const error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'cat'], options: {}, flags: {} });
const error = prompt.tryCommit({
name: "play",
params: ["white", 2, 2, "cat"],
options: {},
flags: {},
});
expect(error).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
const state = ctx.value;
// Cat should be on the board
expect(state.regions.board.partMap['2,2']).toBe('white-cat-1');
expect(state.regions.board.partMap["2,2"]).toBe("white-cat-1");
// Cat should no longer be in supply
const whiteCatsInSupply = state.regions.white.childIds.filter(id => state.pieces[id].type === 'cat');
const whiteCatsInSupply = state.regions.white.childIds.filter(
(id) => state.pieces[id].type === "cat",
);
expect(whiteCatsInSupply.length).toBe(0);
});
});
describe('Check Full Board', () => {
it('should not trigger when player has fewer than 8 pieces on board', async () => {
describe("Check Full Board", () => {
it("should not trigger when player has fewer than 8 pieces on board", async () => {
const { ctx } = createTestContext();
// White places a single kitten
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx.run('turn white');
const runPromise = ctx.run("turn white");
const prompt = await promptPromise;
const error = prompt.tryCommit({ name: 'play', params: ['white', 2, 2, 'kitten'], options: {}, flags: {} });
const error = prompt.tryCommit({
name: "play",
params: ["white", 2, 2, "kitten"],
options: {},
flags: {},
});
expect(error).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
// check-full-board should return without prompting
const fullBoardResult = await ctx._commands.run('check-full-board white');
const fullBoardResult = await ctx._commands.run("check-full-board white");
expect(fullBoardResult.success).toBe(true);
});
it('should force graduation when all 8 pieces are on board', async () => {
it("should force graduation when all 8 pieces are on board", async () => {
const { ctx } = createTestContext();
// Manually place all 8 white pieces on the board
ctx.produce(state => {
ctx.produce((state) => {
for (let i = 1; i <= 8; i++) {
const piece = state.pieces[`white-kitten-${i}`];
if (piece) {
const row = Math.floor((i - 1) / 4);
const col = (i - 1) % 4;
piece.regionId = 'board';
piece.regionId = "board";
piece.position = [row, col];
state.regions.board.partMap[`${row},${col}`] = piece.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== piece.id);
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== piece.id,
);
}
}
});
@@ -574,12 +680,17 @@ describe('Boop Game', () => {
// Run check-full-board - should prompt for piece to graduate
const promptPromise = waitForPrompt(ctx);
const runPromise = ctx._commands.run('check-full-board white');
const runPromise = ctx._commands.run("check-full-board white");
const prompt = await promptPromise;
expect(prompt.schema.name).toBe('choose');
expect(prompt.schema.name).toBe("choose");
// Select a piece to graduate
const error = prompt.tryCommit({ name: 'choose', params: ['white', 0, 0], options: {}, flags: {} });
const error = prompt.tryCommit({
name: "choose",
params: ["white", 0, 0],
options: {},
flags: {},
});
expect(error).toBeNull();
const result = await runPromise;
@@ -587,65 +698,73 @@ describe('Boop Game', () => {
const state = ctx.value;
// Position 0,0 should be empty (piece moved to box)
expect(state.regions.board.partMap['0,0']).toBeUndefined();
expect(state.regions.board.partMap["0,0"]).toBeUndefined();
});
it('should not trigger when player has a winner', async () => {
it("should not trigger when player has a winner", async () => {
const { ctx } = createTestContext();
// Set up a winning state for white
ctx.produce(state => {
state.winner = 'white';
ctx.produce((state) => {
state.winner = "white";
for (let i = 1; i <= 8; i++) {
const piece = state.pieces[`white-kitten-${i}`];
if (piece) {
const row = Math.floor((i - 1) / 4);
const col = (i - 1) % 4;
piece.regionId = 'board';
piece.regionId = "board";
piece.position = [row, col];
state.regions.board.partMap[`${row},${col}`] = piece.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== piece.id);
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== piece.id,
);
}
}
});
// check-full-board should return without prompting
const fullBoardResult = await ctx._commands.run('check-full-board white');
const fullBoardResult = await ctx._commands.run("check-full-board white");
expect(fullBoardResult.success).toBe(true);
});
});
describe('Start Command', () => {
it('should run a complete game until there is a winner', async () => {
describe("Start Command", () => {
it("should run a complete game until there is a winner", async () => {
const { ctx } = createTestContext();
// Set up a quick win scenario
ctx.produce(state => {
ctx.produce((state) => {
// Place three white cats in a row
const c1 = state.pieces['white-cat-1'];
const c2 = state.pieces['white-cat-2'];
const c3 = state.pieces['white-cat-3'];
const c1 = state.pieces["white-cat-1"];
const c2 = state.pieces["white-cat-2"];
const c3 = state.pieces["white-cat-3"];
if (c1) {
c1.type = 'cat';
c1.regionId = 'board';
(c1 as { type: string }).type = "cat";
c1.regionId = "board";
c1.position = [0, 0];
state.regions.board.partMap['0,0'] = c1.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== c1.id);
state.regions.board.partMap["0,0"] = c1.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== c1.id,
);
}
if (c2) {
c2.type = 'cat';
c2.regionId = 'board';
(c2 as { type: string }).type = "cat";
c2.regionId = "board";
c2.position = [0, 1];
state.regions.board.partMap['0,1'] = c2.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== c2.id);
state.regions.board.partMap["0,1"] = c2.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== c2.id,
);
}
if (c3) {
c3.type = 'cat';
c3.regionId = 'board';
(c3 as { type: string }).type = "cat";
c3.regionId = "board";
c3.position = [0, 2];
state.regions.board.partMap['0,2'] = c3.id;
state.regions.white.childIds = state.regions.white.childIds.filter(id => id !== c3.id);
state.regions.board.partMap["0,2"] = c3.id;
state.regions.white.childIds = state.regions.white.childIds.filter(
(id) => id !== c3.id,
);
}
});
@@ -653,11 +772,16 @@ describe('Boop Game', () => {
// Note: start() runs indefinitely until there's a winner
// Since we already set up a win, it should complete after one turn
const promptPromise = waitForPrompt(ctx);
const startPromise = ctx.run('turn white');
const startPromise = ctx.run("turn white");
const prompt = await promptPromise;
// Complete the turn
const error = prompt.tryCommit({ name: 'play', params: ['white', 3, 3, 'kitten'], options: {}, flags: {} });
const error = prompt.tryCommit({
name: "play",
params: ["white", 3, 3, "kitten"],
options: {},
flags: {},
});
expect(error).toBeNull();
const result = await startPromise;
+106 -86
View File
@@ -1,6 +1,7 @@
import {createGameContext} from '@/core/game';
import {registry} from '@/samples/regicide/commands';
import {createInitialState} from '@/samples/regicide/state';
import { describe, it, expect } from "vitest";
import { createGameContext } from "@/core/game";
import { registry } from "@/samples/regicide/commands";
import { createInitialState } from "@/samples/regicide/state";
import {
buildEnemyDeck,
buildTavernDeck,
@@ -8,50 +9,55 @@ import {
createCard,
createEnemy,
getCardValue,
isEnemyDefeated
} from '@/samples/regicide/utils';
import {Mulberry32RNG} from '@/utils/rng';
import {CARD_VALUES, ENEMY_COUNT, FACE_CARDS, INITIAL_HAND_SIZE} from '@/samples/regicide/constants';
import {PlayerType} from '@/samples/regicide/types';
isEnemyDefeated,
} from "@/samples/regicide/utils";
import { Mulberry32RNG } from "@/utils/rng";
import {
CARD_VALUES,
ENEMY_COUNT,
FACE_CARDS,
INITIAL_HAND_SIZE,
} from "@/samples/regicide/constants";
import { PlayerType } from "@/samples/regicide/types";
describe('Regicide - Utils', () => {
describe('getCardValue', () => {
it('should return correct value for number cards', () => {
expect(getCardValue('A')).toBe(1);
expect(getCardValue('5')).toBe(5);
expect(getCardValue('10')).toBe(10);
describe("Regicide - Utils", () => {
describe("getCardValue", () => {
it("should return correct value for number cards", () => {
expect(getCardValue("A")).toBe(1);
expect(getCardValue("5")).toBe(5);
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);
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 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');
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');
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);
});
it('should create enemy with different values for different ranks', () => {
const jEnemy = createEnemy('enemy_0', 'J', 'spades');
const qEnemy = createEnemy('enemy_1', 'Q', 'hearts');
const kEnemy = createEnemy('enemy_2', 'K', 'diamonds');
it("should create enemy with different values for different ranks", () => {
const jEnemy = createEnemy("enemy_0", "J", "spades");
const qEnemy = createEnemy("enemy_1", "Q", "hearts");
const kEnemy = createEnemy("enemy_2", "K", "diamonds");
expect(jEnemy.value).toBe(10);
expect(qEnemy.value).toBe(15);
@@ -63,16 +69,30 @@ describe('Regicide - Utils', () => {
});
});
describe('createAllCards', () => {
it('should create 52 cards', () => {
describe("createAllCards", () => {
it("should create 52 cards", () => {
const cards = createAllCards();
expect(Object.keys(cards).length).toBe(52);
});
it('should have all suits and ranks', () => {
it("should have all suits and ranks", () => {
const cards = createAllCards();
const suits = ['spades', 'hearts', 'diamonds', 'clubs'];
const ranks = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
const suits = ["spades", "hearts", "diamonds", "clubs"];
const ranks = [
"A",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"10",
"J",
"Q",
"K",
];
for (const suit of suits) {
for (const rank of ranks) {
@@ -85,39 +105,39 @@ describe('Regicide - Utils', () => {
});
});
describe('buildEnemyDeck', () => {
it('should create 12 enemies (J/Q/K)', () => {
describe("buildEnemyDeck", () => {
it("should create 12 enemies (J/Q/K)", () => {
const rng = new Mulberry32RNG(12345);
const deck = buildEnemyDeck(rng);
expect(deck.length).toBe(12);
});
it('should have J at top, Q in middle, K at bottom', () => {
it("should have J at top, Q in middle, K at bottom", () => {
const rng = new Mulberry32RNG(12345);
const deck = buildEnemyDeck(rng);
for (let i = 0; i < 4; i++) {
expect(deck[i].rank).toBe('J');
expect(deck[i].rank).toBe("J");
}
for (let i = 4; i < 8; i++) {
expect(deck[i].rank).toBe('Q');
expect(deck[i].rank).toBe("Q");
}
for (let i = 8; i < 12; i++) {
expect(deck[i].rank).toBe('K');
expect(deck[i].rank).toBe("K");
}
});
});
describe('buildTavernDeck', () => {
it('should create 40 cards (A-10)', () => {
describe("buildTavernDeck", () => {
it("should create 40 cards (A-10)", () => {
const rng = new Mulberry32RNG(12345);
const deck = buildTavernDeck(rng);
expect(deck.length).toBe(40);
});
it('should not contain face cards', () => {
it("should not contain face cards", () => {
const rng = new Mulberry32RNG(12345);
const deck = buildTavernDeck(rng);
@@ -127,9 +147,9 @@ describe('Regicide - Utils', () => {
});
});
describe('isEnemyDefeated', () => {
it('should return true when enemy HP <= 0', () => {
const enemy = createEnemy('enemy_0', 'J', 'spades');
describe("isEnemyDefeated", () => {
it("should return true when enemy HP <= 0", () => {
const enemy = createEnemy("enemy_0", "J", "spades");
expect(isEnemyDefeated(enemy)).toBe(false);
enemy.hp = 0;
@@ -139,13 +159,13 @@ describe('Regicide - Utils', () => {
expect(isEnemyDefeated(enemy)).toBe(true);
});
it('should return false for null enemy', () => {
it("should return false for null enemy", () => {
expect(isEnemyDefeated(null)).toBe(false);
});
});
});
describe('Regicide - Commands', () => {
describe("Regicide - Commands", () => {
function createTestContext() {
const initialState = createInitialState();
return createGameContext(registry, initialState);
@@ -157,7 +177,7 @@ describe('Regicide - Commands', () => {
const enemyDeck = buildEnemyDeck(rng);
const tavernDeck = buildTavernDeck(rng);
game.produce(state => {
game.produce((state) => {
state.cards = cards;
state.playerCount = 2;
state.currentPlayerIndex = 0;
@@ -171,8 +191,8 @@ describe('Regicide - Commands', () => {
for (let i = 0; i < 6; i++) {
const card1 = tavernDeck[i];
const card2 = tavernDeck[i + 6];
card1.regionId = 'hand_player1';
card2.regionId = 'hand_player2';
card1.regionId = "hand_player1";
card2.regionId = "hand_player2";
state.playerHands.player1.push(card1.id);
state.playerHands.player2.push(card2.id);
state.regions.hand_player1.childIds.push(card1.id);
@@ -181,8 +201,8 @@ describe('Regicide - Commands', () => {
});
}
describe('play command', () => {
it('should deal damage to current enemy', async () => {
describe("play command", () => {
it("should deal damage to current enemy", async () => {
const game = createTestContext();
setupTestGame(game);
@@ -195,17 +215,17 @@ describe('Regicide - Commands', () => {
expect(game.value.currentEnemy!.hp).toBe(enemyHpBefore - card.value);
});
it('should double damage for clubs suit', async () => {
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');
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 clubsCardId = "clubs_5";
const enemyHpBefore = game.value.currentEnemy!.hp;
const card = game.value.cards[clubsCardId];
@@ -215,79 +235,79 @@ describe('Regicide - Commands', () => {
});
});
describe('pass command', () => {
it('should allow player to pass', async () => {
describe("pass command", () => {
it("should allow player to pass", async () => {
const game = createTestContext();
setupTestGame(game);
const result = await game.run('pass player1');
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 () => {
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 => {
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).not.toBe(firstEnemy);
});
it('should not defeat enemy if HP > 0', async () => {
it("should not defeat enemy if HP > 0", async () => {
const game = createTestContext();
setupTestGame(game);
const currentEnemyId = game.value.currentEnemy!.id;
await game.run('check-enemy');
await game.run("check-enemy");
expect(game.value.currentEnemy!.id).toBe(currentEnemyId);
});
});
describe('next-turn command', () => {
it('should switch to next player', async () => {
describe("next-turn command", () => {
it("should switch to next player", async () => {
const game = createTestContext();
setupTestGame(game);
expect(game.value.currentPlayerIndex).toBe(0);
await game.run('next-turn');
await game.run("next-turn");
expect(game.value.currentPlayerIndex).toBe(1);
});
it('should wrap around to first player', async () => {
it("should wrap around to first player", async () => {
const game = createTestContext();
setupTestGame(game);
game.produce(state => {
game.produce((state) => {
state.currentPlayerIndex = 1;
});
await game.run('next-turn');
await game.run("next-turn");
expect(game.value.currentPlayerIndex).toBe(0);
});
});
});
describe('Regicide - Game Flow', () => {
describe("Regicide - Game Flow", () => {
function createTestContext() {
const initialState = createInitialState();
return createGameContext(registry, initialState);
}
it('should complete a full turn cycle', async () => {
it("should complete a full turn cycle", async () => {
const game = createTestContext();
const cards = createAllCards();
@@ -295,7 +315,7 @@ describe('Regicide - Game Flow', () => {
const enemyDeck = buildEnemyDeck(rng);
const tavernDeck = buildTavernDeck(rng);
game.produce(state => {
game.produce((state) => {
state.cards = cards;
state.playerCount = 1;
state.currentPlayerIndex = 0;
@@ -308,7 +328,7 @@ describe('Regicide - Game Flow', () => {
for (let i = 0; i < 6; i++) {
const card = tavernDeck[i];
card.regionId = 'hand_player1';
card.regionId = "hand_player1";
state.playerHands.player1.push(card.id);
state.regions.hand_player1.childIds.push(card.id);
}
@@ -323,14 +343,14 @@ describe('Regicide - Game Flow', () => {
expect(game.value.currentEnemy!.hp).toBeLessThan(enemyHpBefore);
});
it('should win game when all enemies defeated', async () => {
it("should win game when all enemies defeated", async () => {
const game = createTestContext();
const cards = createAllCards();
const rng = new Mulberry32RNG(12345);
const tavernDeck = buildTavernDeck(rng);
game.produce(state => {
game.produce((state) => {
state.cards = cards;
state.playerCount = 1;
state.currentPlayerIndex = 0;
@@ -342,12 +362,12 @@ describe('Regicide - Game Flow', () => {
}
});
game.produce(state => {
state.phase = 'victory';
game.produce((state) => {
state.phase = "victory";
state.winner = true;
});
expect(game.value.phase).toBe('victory');
expect(game.value.phase).toBe("victory");
expect(game.value.winner).toBe(true);
});
});
@@ -30,9 +30,10 @@ import type {
GridInventory,
InventoryItem,
} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress/types";
import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import type { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
import type { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
import { CardEffect } from "@/samples/slay-the-spire-like/data/desert";
function createRunContext(
items: Map<string, InventoryItem<GameItemMeta>>,
@@ -42,7 +43,7 @@ function createRunContext(
const item = items.get(id);
return item?.meta?.itemData ?? null;
},
getNeighborItems(_id: string): Iterable<string> {
getAdjacentItems(_id: string): Iterable<string> {
return [];
},
getConsumedUses(id: string): number {
@@ -63,7 +64,7 @@ function createEffect(
id: string,
lifecycle: EffectData["lifecycle"],
): EffectData {
return { id, name: id, description: "", lifecycle };
return { id, name: id, description: "", lifecycle, emoji: "" };
}
function createCard(
@@ -78,8 +79,8 @@ function createCard(
type: "item" as const,
costType,
costCount,
targetType: "none" as const,
effects: [] as const,
targetType: "player" as const,
effects: [] as CardEffect[],
};
}
@@ -6,7 +6,6 @@ import {
} from "@/core/game";
import { createRegion } from "@/core/region";
import {
createStartWith,
createTriggers,
Triggers,
} from "@/samples/slay-the-spire-like/system/combat/triggers";
@@ -26,20 +25,15 @@ import {
GridInventory,
InventoryItem,
} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress/types";
import { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
import { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import {
CardEffect,
getCards,
getEffects,
getEncounters,
getEnemies,
getItems,
} from "@/samples/slay-the-spire-like/data/desert";
const cards = getCards();
const effects = getEffects();
const encounters = getEncounters();
const items = getItems();
const enemies = getEnemies();
function createEffect(
@@ -48,7 +42,7 @@ function createEffect(
): EffectData {
const found = effects.find((e) => e.id === id);
if (found) return found;
return { id, name: id, description: "", lifecycle };
return { id, name: id, description: "", lifecycle, emoji: "" };
}
function createDeckRegions(): DeckRegions {
@@ -73,8 +67,8 @@ function createCard(
type: "item" as const,
costType,
costCount,
targetType: "none" as const,
effects: [],
targetType: "player" as const,
effects: [] as CardEffect[],
};
return {
id,
@@ -162,7 +156,7 @@ function getTriggers(): Triggers {
getItemData() {
return null;
},
*getNeighborItems() {},
*getAdjacentItems() {},
async setConsumedUsesAsync() {},
};
const triggers = createTriggers(run);
@@ -211,9 +205,9 @@ describe("desert triggers", () => {
await triggers.onEffectApplied.execute(ctx, {
effect: attackEffect,
entityKey: "player",
entityId: "player",
stacks: 5,
sourceEntityKey: "enemy-0",
sourceEntityId: "enemy-0",
});
expect(ctx.value.player.hp).toBe(25);
@@ -229,7 +223,7 @@ describe("desert triggers", () => {
await triggers.onEffectApplied.execute(ctx, {
effect: drawEffect,
entityKey: "player",
entityId: "player",
stacks: 2,
});
@@ -245,7 +239,7 @@ describe("desert triggers", () => {
const initialEnergy = ctx.value.player.energy;
await triggers.onEffectApplied.execute(ctx, {
effect: gainEnergyEffect,
entityKey: "player",
entityId: "player",
stacks: 2,
});
@@ -267,7 +261,7 @@ describe("desert triggers", () => {
await triggers.onEffectApplied.execute(ctx, {
effect: removeWoundEffect,
entityKey: "player",
entityId: "player",
stacks: 2,
});
@@ -285,20 +279,20 @@ describe("desert triggers", () => {
}),
);
const triggers = getTriggers();
const defendEffect = createEffect("defend", "posture");
const defendEffect = createEffect("defend", "temporary");
ctx._state.produce((draft) => {
draft.player.effects.defend = { data: defendEffect, stacks: 5 };
});
await triggers.onDamage.execute(ctx, {
entityKey: "player",
entityId: "player",
amount: 8,
sourceEntityKey: "enemy-0",
sourceEntityId: "enemy-0",
});
expect(ctx.value.player.hp).toBe(27);
expect(ctx.value.player.effects.defend?.stacks).toBe(2);
expect(ctx.value.player.effects.defend?.stacks).toBe(undefined);
});
it("should reduce damage with damageReduce", async () => {
@@ -318,9 +312,9 @@ describe("desert triggers", () => {
});
await triggers.onDamage.execute(ctx, {
entityKey: "player",
entityId: "player",
amount: 8,
sourceEntityKey: "enemy-0",
sourceEntityId: "enemy-0",
});
expect(ctx.value.player.hp).toBe(25);
@@ -340,9 +334,9 @@ describe("desert triggers", () => {
});
await triggers.onDamage.execute(ctx, {
entityKey: "player",
entityId: "player",
amount: 5,
sourceEntityKey: "enemy-0",
sourceEntityId: "enemy-0",
});
expect(ctx.value.player.hp).toBe(23);
@@ -358,16 +352,18 @@ describe("desert triggers", () => {
);
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.onDamage.execute(ctx, {
entityKey: "仙人掌怪-0",
amount: 5,
sourceEntityKey: "player",
await triggers.onEffectApplied.execute(ctx, {
effect: attackEffect,
entityId: "仙人掌怪-0",
stacks: 5,
sourceEntityId: "player",
});
expect(ctx.value.player.hp).toBe(27);
@@ -382,12 +378,6 @@ describe("desert triggers", () => {
}),
);
const triggers = getTriggers();
const stormEffect = createEffect("storm", "permanent");
ctx._state.produce((draft) => {
const enemy = draft.enemies[0];
enemy.effects.storm = { data: stormEffect, stacks: 2 };
});
await triggers.onEnemyIntent.execute(ctx, { enemyId: "风暴之灵-0" });
@@ -414,9 +404,9 @@ describe("desert triggers", () => {
});
await triggers.onDamage.execute(ctx, {
entityKey: "幼沙虫-0",
entityId: "幼沙虫-0",
amount: 5,
sourceEntityKey: "player",
sourceEntityId: "player",
});
expect(ctx.value.player.energy).toBe(2);
@@ -441,9 +431,9 @@ describe("desert triggers", () => {
let threw = false;
try {
await triggers.onDamage.execute(ctx, {
entityKey: "蜥蜴-0",
entityId: "蜥蜴-0",
amount: 1,
sourceEntityKey: "player",
sourceEntityId: "player",
});
} catch (e) {
threw = true;
@@ -469,7 +459,7 @@ describe("desert triggers", () => {
draft.player.effects.discard = { data: discardEffect, stacks: 1 };
});
await triggers.onTurnStart.execute(ctx, { entityKey: "player" });
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);
@@ -486,7 +476,7 @@ describe("desert triggers", () => {
draft.player.effects.defendNext = { data: defendNextEffect, stacks: 5 };
});
await triggers.onTurnStart.execute(ctx, { entityKey: "player" });
await triggers.onTurnStart.execute(ctx, { entityId: "player" });
expect(ctx.value.player.effects.defend?.stacks).toBe(5);
expect(ctx.value.player.effects.defendNext).toBeUndefined();
@@ -501,7 +491,7 @@ describe("desert triggers", () => {
draft.player.effects.energyNext = { data: energyNextEffect, stacks: 2 };
});
await triggers.onTurnStart.execute(ctx, { entityKey: "player" });
await triggers.onTurnStart.execute(ctx, { entityId: "player" });
expect(ctx.value.player.energy).toBe(5);
expect(ctx.value.player.effects.energyNext).toBeUndefined();
@@ -520,7 +510,7 @@ describe("desert triggers", () => {
draft.player.effects.drawNext = { data: drawNextEffect, stacks: 2 };
});
await triggers.onTurnStart.execute(ctx, { entityKey: "player" });
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();
@@ -542,9 +532,9 @@ describe("desert triggers", () => {
});
await triggers.onDamage.execute(ctx, {
entityKey: "仙人掌怪-0",
entityId: "仙人掌怪-0",
amount: 5,
sourceEntityKey: "player",
sourceEntityId: "player",
});
expect(ctx.value.enemies[0].hp).toBe(2);
@@ -564,9 +554,9 @@ describe("desert triggers", () => {
});
await triggers.onDamage.execute(ctx, {
entityKey: "仙人掌怪-0",
entityId: "仙人掌怪-0",
amount: 5,
sourceEntityKey: "player",
sourceEntityId: "player",
});
expect(ctx.value.enemies[0].hp).toBe(74);
@@ -588,9 +578,9 @@ describe("desert triggers", () => {
});
await triggers.onDamage.execute(ctx, {
entityKey: "player",
entityId: "player",
amount: 5,
sourceEntityKey: "沙蝎-0",
sourceEntityId: "沙蝎-0",
});
expect(ctx.value.player.hp).toBe(23);
@@ -611,9 +601,9 @@ describe("desert triggers", () => {
});
await triggers.onDamage.execute(ctx, {
entityKey: "player",
entityId: "player",
amount: 5,
sourceEntityKey: "骑马枪手-0",
sourceEntityId: "骑马枪手-0",
});
expect(ctx.value.player.hp).toBe(20);
@@ -635,10 +625,10 @@ describe("desert triggers", () => {
await triggers.onEffectApplied.execute(ctx, {
effect: crossbowEffect,
entityKey: "player",
entityId: "player",
stacks: 0,
cardId: "crossbow-1",
sourceEntityKey: "player",
sourceEntityId: "player",
targetId: "仙人掌怪-0",
});
@@ -659,7 +649,7 @@ describe("desert triggers", () => {
await triggers.onCardDiscarded.execute(ctx, {
cardId: "fatigue-1",
sourceEntityKey: "player",
sourceEntityId: "player",
});
expect(ctx.value.enemies[0].hp).toBe(40);
@@ -676,9 +666,9 @@ describe("desert triggers", () => {
const triggers = getTriggers();
await triggers.onDamage.execute(ctx, {
entityKey: "player",
entityId: "player",
amount: 5,
sourceEntityKey: "秃鹫-0",
sourceEntityId: "秃鹫-0",
});
const vultureEyeCards = Object.values(ctx.value.player.deck.cards).filter(
@@ -1,17 +1,26 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from "vitest";
import {
generateDeckFromInventory,
createCard,
createDeckRegions,
createPlayerDeck,
generateCardId,
} from '@/samples/slay-the-spire-like/system/deck/factory';
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/system/grid-inventory';
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory';
import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/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';
} from "@/samples/slay-the-spire-like/system/deck/factory";
import {
createGridInventory,
placeItem,
} from "@/samples/slay-the-spire-like/system/grid-inventory";
import type {
GridInventory,
InventoryItem,
} from "@/samples/slay-the-spire-like/system/grid-inventory";
import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import { IDENTITY_TRANSFORM } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
import { parseShapeString } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
import type {
CardData,
ItemData,
} from "@/samples/slay-the-spire-like/system/types";
/**
* Helper: create a minimal CardData for testing.
@@ -21,10 +30,10 @@ function createTestCardData(id: string, name: string, desc: string): CardData {
id,
name,
desc,
type: 'item',
costType: 'energy',
type: "item",
costType: "energy",
costCount: 1,
targetType: 'single',
targetType: "enemy",
effects: [],
};
}
@@ -32,10 +41,15 @@ function createTestCardData(id: string, name: string, desc: string): CardData {
/**
* Helper: create a minimal ItemData for testing.
*/
function createTestItemData(id: string, name: string, shapeStr: string, desc: string): ItemData {
function createTestItemData(
id: string,
name: string,
shapeStr: string,
desc: string,
): ItemData {
return {
id,
type: 'weapon',
type: "weapon",
name,
shape: shapeStr,
card: createTestCardData(id, name, desc),
@@ -47,7 +61,11 @@ function createTestItemData(id: string, name: string, shapeStr: string, desc: st
/**
* Helper: create a minimal GameItemMeta for testing.
*/
function createTestMeta(name: string, desc: string, shapeStr: string): GameItemMeta {
function createTestMeta(
name: string,
desc: string,
shapeStr: string,
): GameItemMeta {
const shape = parseShapeString(shapeStr);
const itemData = createTestItemData(name.toLowerCase(), name, shapeStr, desc);
return {
@@ -63,9 +81,9 @@ function createTestInventory(): GridInventory<GameItemMeta> {
const inv = createGridInventory<GameItemMeta>(6, 4);
// Item "短刀" with shape "oe" (2 cells)
const meta1 = createTestMeta('短刀', '【攻击3】【攻击3】', 'oe');
const meta1 = createTestMeta("短刀", "【攻击3】【攻击3】", "oe");
const item1: InventoryItem<GameItemMeta> = {
id: 'dagger-1',
id: "dagger-1",
shape: meta1.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
meta: meta1,
@@ -73,9 +91,9 @@ function createTestInventory(): GridInventory<GameItemMeta> {
placeItem(inv, item1);
// Item "盾" with shape "oesw" (4 cells)
const meta2 = createTestMeta('盾', '【防御3】', 'oesw');
const meta2 = createTestMeta("盾", "【防御3】", "oesw");
const item2: InventoryItem<GameItemMeta> = {
id: 'shield-1',
id: "shield-1",
shape: meta2.shape,
transform: { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 0 } },
meta: meta2,
@@ -85,30 +103,30 @@ function createTestInventory(): GridInventory<GameItemMeta> {
return inv;
}
describe('deck/factory', () => {
describe('generateCardId', () => {
it('should generate deterministic unique IDs', () => {
expect(generateCardId('item-1', 0)).toBe('card-item-1-0');
expect(generateCardId('item-1', 1)).toBe('card-item-1-1');
expect(generateCardId('item-2', 0)).toBe('card-item-2-0');
describe("deck/factory", () => {
describe("generateCardId", () => {
it("should generate deterministic unique IDs", () => {
expect(generateCardId("item-1", 0)).toBe("card-item-1-0");
expect(generateCardId("item-1", 1)).toBe("card-item-1-1");
expect(generateCardId("item-2", 0)).toBe("card-item-2-0");
});
});
describe('createCard', () => {
it('should create a card with itemId and cardData', () => {
const cardData = createTestCardData('wound', '伤口', '无法被弃牌');
const card = createCard('wound-1', cardData, 0);
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.id).toBe("card-wound-1-0");
expect(card.itemId).toBe("wound-1");
expect(card.cardData).toBe(cardData);
expect(card.regionId).toBe('');
expect(card.regionId).toBe("");
expect(card.position).toEqual([]);
});
});
describe('generateDeckFromInventory', () => {
it('should generate correct number of cards based on shape cell counts', () => {
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
@@ -121,26 +139,26 @@ describe('deck/factory', () => {
expect(deck.regions.exhaustPile.childIds).toEqual([]);
});
it('should link cards to their source items', () => {
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'
(c) => c.itemId === "dagger-1",
);
const shieldCards = Object.values(deck.cards).filter(
c => c.itemId === 'shield-1'
(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('盾');
expect(daggerCards[0].cardData.name).toBe("短刀");
expect(shieldCards[0].cardData.name).toBe("盾");
});
it('should set displayName and description from item data', () => {
it("should set displayName and description from item data", () => {
const inv = createTestInventory();
const deck = generateDeckFromInventory(inv);
@@ -150,26 +168,26 @@ describe('deck/factory', () => {
}
const daggerCard = Object.values(deck.cards).find(
c => c.cardData.name === '短刀'
(c) => c.cardData.name === "短刀",
);
expect(daggerCard?.cardData.name).toBe('短刀');
expect(daggerCard?.cardData.desc).toBe('【攻击3】【攻击3】');
expect(daggerCard?.cardData.name).toBe("短刀");
expect(daggerCard?.cardData.desc).toBe("【攻击3】【攻击3】");
});
it('should assign unique IDs to each card from same item', () => {
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'
(c) => c.itemId === "dagger-1",
);
const ids = daggerCards.map(c => c.id);
const ids = daggerCards.map((c) => c.id);
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
it('should handle empty inventory', () => {
it("should handle empty inventory", () => {
const inv = createGridInventory<GameItemMeta>(6, 4);
const deck = generateDeckFromInventory(inv);
@@ -177,7 +195,7 @@ describe('deck/factory', () => {
expect(deck.regions.drawPile.childIds).toEqual([]);
});
it('should place all cards in draw pile initially', () => {
it("should place all cards in draw pile initially", () => {
const inv = createTestInventory();
const deck = generateDeckFromInventory(inv);
@@ -186,21 +204,23 @@ describe('deck/factory', () => {
}
// All cards are in draw pile
expect(new Set(deck.regions.drawPile.childIds).size).toBe(Object.keys(deck.cards).length);
expect(new Set(deck.regions.drawPile.childIds).size).toBe(
Object.keys(deck.cards).length,
);
});
});
describe('createDeckRegions', () => {
it('should create regions for all deck zones', () => {
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');
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', () => {
it("should have empty childIds initially", () => {
const regions = createDeckRegions();
expect(regions.drawPile.childIds).toEqual([]);
@@ -210,8 +230,8 @@ describe('deck/factory', () => {
});
});
describe('createPlayerDeck', () => {
it('should create an empty deck structure', () => {
describe("createPlayerDeck", () => {
it("should create an empty deck structure", () => {
const deck = createPlayerDeck();
expect(deck.cards).toEqual({});
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { parseShapeString } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
import { describe, it, expect } from "vitest";
import { parseShapeString } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
import { IDENTITY_TRANSFORM } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
import {
createGridInventory,
placeItem,
@@ -12,14 +12,62 @@ import {
getItemAtCell,
getAdjacentItems,
validatePlacement,
type GridInventory,
type InventoryItem,
} from '@/samples/slay-the-spire-like/system/grid-inventory';
} from "@/samples/slay-the-spire-like/system/grid-inventory";
import { createItemIn } from "@/samples/slay-the-spire-like/system/grid-inventory/factory";
import type {
GameItem,
GameItemMeta,
} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import type {
CardData,
ItemData,
} from "@/samples/slay-the-spire-like/system/types";
/**
* Helper: create a minimal CardData for testing.
*/
function createTestCardData(id: string, name: string, desc: string): CardData {
return {
id,
name,
desc,
type: "item",
costType: "energy",
costCount: 1,
targetType: "enemy",
effects: [],
};
}
/**
* Helper: create a minimal ItemData for testing.
*/
function createTestItemData(
id: string,
name: string,
shapeStr: string,
desc: string,
): ItemData {
return {
id,
type: "weapon",
name,
shape: shapeStr,
card: createTestCardData(id, name, desc),
price: 10,
description: desc,
};
}
/**
* Helper: create a test inventory item.
*/
function createTestItem(id: string, shapeStr: string, transform = IDENTITY_TRANSFORM): InventoryItem {
function createTestItem(
id: string,
shapeStr: string,
transform = IDENTITY_TRANSFORM,
): InventoryItem<Record<string, never>> {
const shape = parseShapeString(shapeStr);
return {
id,
@@ -28,9 +76,9 @@ function createTestItem(id: string, shapeStr: string, transform = IDENTITY_TRANS
};
}
describe('grid-inventory', () => {
describe('createGridInventory', () => {
it('should create an empty inventory with correct dimensions', () => {
describe("grid-inventory", () => {
describe("createGridInventory", () => {
it("should create an empty inventory with correct dimensions", () => {
const inv = createGridInventory(6, 4);
expect(inv.width).toBe(6);
expect(inv.height).toBe(4);
@@ -39,111 +87,117 @@ describe('grid-inventory', () => {
});
});
describe('placeItem', () => {
it('should place a single-cell item', () => {
describe("placeItem", () => {
it("should place a single-cell item", () => {
const inv = createGridInventory(6, 4);
const item = createTestItem('sword', 'o');
const item = createTestItem("sword", "o");
placeItem(inv, item);
expect(inv.items.size).toBe(1);
expect(inv.items.has('sword')).toBe(true);
expect(inv.occupiedCells.has('0,0')).toBe(true);
expect(inv.items.has("sword")).toBe(true);
expect(inv.occupiedCells.has("0,0")).toBe(true);
});
it('should place a multi-cell item', () => {
it("should place a multi-cell item", () => {
const inv = createGridInventory(6, 4);
const item = createTestItem('axe', 'oee');
const item = createTestItem("axe", "oee");
placeItem(inv, item);
expect(inv.items.size).toBe(1);
expect(inv.occupiedCells.size).toBe(3);
expect(inv.occupiedCells.has('0,0')).toBe(true);
expect(inv.occupiedCells.has('1,0')).toBe(true);
expect(inv.occupiedCells.has('2,0')).toBe(true);
expect(inv.occupiedCells.has("0,0")).toBe(true);
expect(inv.occupiedCells.has("1,0")).toBe(true);
expect(inv.occupiedCells.has("2,0")).toBe(true);
});
it('should place multiple items', () => {
it("should place multiple items", () => {
const inv = createGridInventory(6, 4);
const itemA = createTestItem('a', 'o');
const itemB = createTestItem('b', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 0 } });
const itemA = createTestItem("a", "o");
const itemB = createTestItem("b", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 3, y: 0 },
});
placeItem(inv, itemA);
placeItem(inv, itemB);
expect(inv.items.size).toBe(2);
expect(inv.occupiedCells.size).toBe(2);
expect(inv.occupiedCells.has('0,0')).toBe(true);
expect(inv.occupiedCells.has('3,0')).toBe(true);
expect(inv.occupiedCells.has("0,0")).toBe(true);
expect(inv.occupiedCells.has("3,0")).toBe(true);
});
});
describe('removeItem', () => {
it('should remove an item and free its cells', () => {
describe("removeItem", () => {
it("should remove an item and free its cells", () => {
const inv = createGridInventory(6, 4);
const item = createTestItem('sword', 'oee');
const item = createTestItem("sword", "oee");
placeItem(inv, item);
removeItem(inv, 'sword');
removeItem(inv, "sword");
expect(inv.items.size).toBe(0);
expect(inv.occupiedCells.size).toBe(0);
});
it('should only free the removed item\'s cells', () => {
it("should only free the removed item's cells", () => {
const inv = createGridInventory(6, 4);
const itemA = createTestItem('a', 'o');
const itemB = createTestItem('b', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } });
const itemA = createTestItem("a", "o");
const itemB = createTestItem("b", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 2, y: 0 },
});
placeItem(inv, itemA);
placeItem(inv, itemB);
removeItem(inv, 'a');
removeItem(inv, "a");
expect(inv.items.size).toBe(1);
expect(inv.occupiedCells.size).toBe(1);
expect(inv.occupiedCells.has('0,0')).toBe(false);
expect(inv.occupiedCells.has('2,0')).toBe(true);
expect(inv.occupiedCells.has("0,0")).toBe(false);
expect(inv.occupiedCells.has("2,0")).toBe(true);
});
it('should do nothing for non-existent item', () => {
it("should do nothing for non-existent item", () => {
const inv = createGridInventory(6, 4);
removeItem(inv, 'nonexistent');
removeItem(inv, "nonexistent");
expect(inv.items.size).toBe(0);
});
});
describe('validatePlacement', () => {
it('should return valid for empty board', () => {
describe("validatePlacement", () => {
it("should return valid for empty board", () => {
const inv = createGridInventory(6, 4);
const shape = parseShapeString('o');
const shape = parseShapeString("o");
const result = validatePlacement(inv, shape, IDENTITY_TRANSFORM);
expect(result).toEqual({ valid: true });
});
it('should return invalid for out of bounds', () => {
it("should return invalid for out of bounds", () => {
const inv = createGridInventory(6, 4);
const shape = parseShapeString('o');
const shape = parseShapeString("o");
const result = validatePlacement(inv, shape, {
...IDENTITY_TRANSFORM,
offset: { x: 6, y: 0 },
});
expect(result).toEqual({ valid: false, reason: '超出边界' });
expect(result).toEqual({ valid: false, reason: "超出边界" });
});
it('should return invalid for collision with existing item', () => {
it("should return invalid for collision with existing item", () => {
const inv = createGridInventory(6, 4);
const existing = createTestItem('a', 'oee');
const existing = createTestItem("a", "oee");
placeItem(inv, existing);
const shape = parseShapeString('o');
const shape = parseShapeString("o");
const result = validatePlacement(inv, shape, IDENTITY_TRANSFORM);
expect(result).toEqual({ valid: false, reason: '与已有物品重叠' });
expect(result).toEqual({ valid: false, reason: "与已有物品重叠" });
});
it('should return valid when there is room nearby', () => {
it("should return valid when there is room nearby", () => {
const inv = createGridInventory(6, 4);
const existing = createTestItem('a', 'o');
const existing = createTestItem("a", "o");
placeItem(inv, existing);
const shape = parseShapeString('o');
const shape = parseShapeString("o");
const result = validatePlacement(inv, shape, {
...IDENTITY_TRANSFORM,
offset: { x: 1, y: 0 },
@@ -152,160 +206,169 @@ describe('grid-inventory', () => {
});
});
describe('moveItem', () => {
it('should move item to a new position', () => {
describe("moveItem", () => {
it("should move item to a new position", () => {
const inv = createGridInventory(6, 4);
const item = createTestItem('sword', 'o');
const item = createTestItem("sword", "o");
placeItem(inv, item);
const result = moveItem(inv, 'sword', {
const result = moveItem(inv, "sword", {
...IDENTITY_TRANSFORM,
offset: { x: 5, y: 3 },
});
expect(result).toEqual({ success: true });
expect(inv.occupiedCells.has('0,0')).toBe(false);
expect(inv.occupiedCells.has('5,3')).toBe(true);
expect(inv.occupiedCells.has("0,0")).toBe(false);
expect(inv.occupiedCells.has("5,3")).toBe(true);
expect(item.transform.offset).toEqual({ x: 5, y: 3 });
});
it('should reject move that goes out of bounds', () => {
it("should reject move that goes out of bounds", () => {
const inv = createGridInventory(6, 4);
const item = createTestItem('sword', 'o');
const item = createTestItem("sword", "o");
placeItem(inv, item);
const result = moveItem(inv, 'sword', {
const result = moveItem(inv, "sword", {
...IDENTITY_TRANSFORM,
offset: { x: 6, y: 0 },
});
expect(result).toEqual({ success: false, reason: '超出边界' });
expect(inv.occupiedCells.has('0,0')).toBe(true);
expect(result).toEqual({ success: false, reason: "超出边界" });
expect(inv.occupiedCells.has("0,0")).toBe(true);
expect(item.transform.offset).toEqual({ x: 0, y: 0 });
});
it('should reject move that collides with another item', () => {
it("should reject move that collides with another item", () => {
const inv = createGridInventory(6, 4);
const itemA = createTestItem('a', 'o');
const itemB = createTestItem('b', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } });
const itemA = createTestItem("a", "o");
const itemB = createTestItem("b", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 2, y: 0 },
});
placeItem(inv, itemA);
placeItem(inv, itemB);
const result = moveItem(inv, 'b', {
const result = moveItem(inv, "b", {
...IDENTITY_TRANSFORM,
offset: { x: 0, y: 0 },
});
expect(result).toEqual({ success: false, reason: '与已有物品重叠' });
expect(inv.occupiedCells.has('2,0')).toBe(true);
expect(result).toEqual({ success: false, reason: "与已有物品重叠" });
expect(inv.occupiedCells.has("2,0")).toBe(true);
});
it('should return error for non-existent item', () => {
it("should return error for non-existent item", () => {
const inv = createGridInventory(6, 4);
const result = moveItem(inv, 'ghost', IDENTITY_TRANSFORM);
expect(result).toEqual({ success: false, reason: '物品不存在' });
const result = moveItem(inv, "ghost", IDENTITY_TRANSFORM);
expect(result).toEqual({ success: false, reason: "物品不存在" });
});
it('should move multi-cell item correctly', () => {
it("should move multi-cell item correctly", () => {
const inv = createGridInventory(6, 4);
// oes: cells at (0,0), (1,0), (1,1)
const item = createTestItem('axe', 'oes');
const item = createTestItem("axe", "oes");
placeItem(inv, item);
const newTransform = { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 1 } };
moveItem(inv, 'axe', newTransform);
moveItem(inv, "axe", newTransform);
// Old cells should be freed
expect(inv.occupiedCells.has('0,0')).toBe(false);
expect(inv.occupiedCells.has('1,0')).toBe(false);
expect(inv.occupiedCells.has('1,1')).toBe(false);
expect(inv.occupiedCells.has("0,0")).toBe(false);
expect(inv.occupiedCells.has("1,0")).toBe(false);
expect(inv.occupiedCells.has("1,1")).toBe(false);
// New cells: (0,0)+offset(3,1)=(3,1), (1,0)+(3,1)=(4,1), (1,1)+(3,1)=(4,2)
expect(inv.occupiedCells.has('3,1')).toBe(true);
expect(inv.occupiedCells.has('4,1')).toBe(true);
expect(inv.occupiedCells.has('4,2')).toBe(true);
expect(inv.occupiedCells.has("3,1")).toBe(true);
expect(inv.occupiedCells.has("4,1")).toBe(true);
expect(inv.occupiedCells.has("4,2")).toBe(true);
});
});
describe('rotateItem', () => {
it('should rotate item by 90 degrees', () => {
describe("rotateItem", () => {
it("should rotate item by 90 degrees", () => {
const inv = createGridInventory(6, 4);
// Horizontal line: (0,0), (1,0)
const item = createTestItem('bar', 'oe', {
const item = createTestItem("bar", "oe", {
...IDENTITY_TRANSFORM,
offset: { x: 0, y: 1 }, // Place away from edge so rotation stays in bounds
});
placeItem(inv, item);
const result = rotateItem(inv, 'bar', 90);
const result = rotateItem(inv, "bar", 90);
expect(result).toEqual({ success: true });
expect(item.transform.rotation).toBe(90);
});
it('should reject rotation that goes out of bounds', () => {
it("should reject rotation that goes out of bounds", () => {
const inv = createGridInventory(3, 3);
// Item at the edge: place a 2-wide item at x=1
const item = createTestItem('bar', 'oe', {
const item = createTestItem("bar", "oe", {
...IDENTITY_TRANSFORM,
offset: { x: 1, y: 0 },
});
placeItem(inv, item);
// Rotating 90° would make it vertical starting at (1,0), going to (1,-1) -> out of bounds
const result = rotateItem(inv, 'bar', 90);
const result = rotateItem(inv, "bar", 90);
expect(result).toEqual({ success: false, reason: '超出边界' });
expect(result).toEqual({ success: false, reason: "超出边界" });
});
it('should reject rotation that collides', () => {
it("should reject rotation that collides", () => {
const inv = createGridInventory(4, 4);
const itemA = createTestItem('a', 'o');
const itemB = createTestItem('b', 'oe', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 0 } });
const itemA = createTestItem("a", "o");
const itemB = createTestItem("b", "oe", {
...IDENTITY_TRANSFORM,
offset: { x: 2, y: 0 },
});
placeItem(inv, itemA);
placeItem(inv, itemB);
// Rotating b 90° would place cells at (2,0) and (2,-1) -> (2,-1) is out of bounds
// Let's try a different scenario: rotate b 270° -> (2,0) and (2,1) which is fine
// But rotating to collide with a at (0,0)... need item close to a
const itemC = createTestItem('c', 'os', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: 0 } });
const itemC = createTestItem("c", "os", {
...IDENTITY_TRANSFORM,
offset: { x: 1, y: 0 },
});
placeItem(inv, itemC);
// Rotating c 90° would give cells at (1,0) and (0,0) -> collision with a
const result = rotateItem(inv, 'c', 90);
expect(result).toEqual({ success: false, reason: '与已有物品重叠' });
const result = rotateItem(inv, "c", 90);
expect(result).toEqual({ success: false, reason: "与已有物品重叠" });
});
it('should return error for non-existent item', () => {
it("should return error for non-existent item", () => {
const inv = createGridInventory(6, 4);
const result = rotateItem(inv, 'ghost', 90);
expect(result).toEqual({ success: false, reason: '物品不存在' });
const result = rotateItem(inv, "ghost", 90);
expect(result).toEqual({ success: false, reason: "物品不存在" });
});
});
describe('flipItem', () => {
it('should flip item horizontally', () => {
describe("flipItem", () => {
it("should flip item horizontally", () => {
const inv = createGridInventory(6, 4);
const item = createTestItem('bar', 'oe');
const item = createTestItem("bar", "oe");
placeItem(inv, item);
const result = flipItem(inv, 'bar', 'x');
const result = flipItem(inv, "bar", "x");
expect(result).toEqual({ success: true });
expect(item.transform.flipX).toBe(true);
});
it('should flip item vertically', () => {
it("should flip item vertically", () => {
const inv = createGridInventory(6, 4);
const item = createTestItem('bar', 'os');
const item = createTestItem("bar", "os");
placeItem(inv, item);
const result = flipItem(inv, 'bar', 'y');
const result = flipItem(inv, "bar", "y");
expect(result).toEqual({ success: true });
expect(item.transform.flipY).toBe(true);
});
it('should reject flip that causes collision', () => {
it("should reject flip that causes collision", () => {
// oes local cells: (0,0),(1,0),(1,1). flipY: (0,1),(1,1),(1,0).
// Place flipper at offset (0,2): world cells (0,2),(1,2),(1,3).
// flipY gives local (0,1),(1,1),(1,0) + offset(0,2) = (0,3),(1,3),(1,2) — same cells rearranged.
@@ -313,7 +376,10 @@ describe('grid-inventory', () => {
// Use oes at offset (0,0): cells (0,0),(1,0),(1,1). flipY: (0,1),(1,1),(1,0).
// Place blocker at (0,1) — which is NOT occupied by oes initially.
const inv = createGridInventory(4, 4);
const blocker = createTestItem('blocker', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 1 } });
const blocker = createTestItem("blocker", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 0, y: 1 },
});
// oes at (0,1): cells (0,1),(1,1),(1,2). This overlaps blocker at (0,1)!
// Let me try: blocker at (1,0), flipper at offset (0,2).
// flipper oes at (0,2): (0,2),(1,2),(1,3). blocker at (1,0) — no overlap.
@@ -326,30 +392,33 @@ describe('grid-inventory', () => {
// Place flipper at (0,0): cells (0,0),(1,0),(1,1). Place blocker at (0,1) — but (0,1) is not occupied.
// flipY: (0,1),(1,1),(1,0). (0,1) hits blocker!
const inv2 = createGridInventory(4, 4);
const blocker2 = createTestItem('blocker', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 1 } });
const flipper2 = createTestItem('flipper', 'oes'); // at (0,0): (0,0),(1,0),(1,1)
const blocker2 = createTestItem("blocker", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 0, y: 1 },
});
const flipper2 = createTestItem("flipper", "oes"); // at (0,0): (0,0),(1,0),(1,1)
placeItem(inv2, blocker2);
placeItem(inv2, flipper2);
const result = flipItem(inv2, 'flipper', 'y');
expect(result).toEqual({ success: false, reason: '与已有物品重叠' });
const result = flipItem(inv2, "flipper", "y");
expect(result).toEqual({ success: false, reason: "与已有物品重叠" });
});
it('should return error for non-existent item', () => {
it("should return error for non-existent item", () => {
const inv = createGridInventory(6, 4);
const result = flipItem(inv, 'ghost', 'x');
expect(result).toEqual({ success: false, reason: '物品不存在' });
const result = flipItem(inv, "ghost", "x");
expect(result).toEqual({ success: false, reason: "物品不存在" });
});
});
describe('getOccupiedCellSet', () => {
it('should return a copy of occupied cells', () => {
describe("getOccupiedCellSet", () => {
it("should return a copy of occupied cells", () => {
const inv = createGridInventory(6, 4);
const item = createTestItem('a', 'oe');
const item = createTestItem("a", "oe");
placeItem(inv, item);
const cells = getOccupiedCellSet(inv);
expect(cells).toEqual(new Set(['0,0', '1,0']));
expect(cells).toEqual(new Set(["0,0", "1,0"]));
// Mutating the copy should not affect the original
cells.clear();
@@ -357,50 +426,68 @@ describe('grid-inventory', () => {
});
});
describe('getItemAtCell', () => {
it('should return item at occupied cell', () => {
describe("getItemAtCell", () => {
it("should return item at occupied cell", () => {
const inv = createGridInventory(6, 4);
const item = createTestItem('sword', 'oee');
const item = createTestItem("sword", "oee");
placeItem(inv, item);
const found = getItemAtCell(inv, 1, 0);
expect(found).toBeDefined();
expect(found!.id).toBe('sword');
expect(found!.id).toBe("sword");
});
it('should return undefined for empty cell', () => {
it("should return undefined for empty cell", () => {
const inv = createGridInventory(6, 4);
const item = createTestItem('sword', 'o');
const item = createTestItem("sword", "o");
placeItem(inv, item);
const found = getItemAtCell(inv, 5, 5);
expect(found).toBeUndefined();
});
it('should return correct item when multiple items exist', () => {
it("should return correct item when multiple items exist", () => {
const inv = createGridInventory(6, 4);
const itemA = createTestItem('a', 'o');
const itemB = createTestItem('b', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 2 } });
const itemA = createTestItem("a", "o");
const itemB = createTestItem("b", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 3, y: 2 },
});
placeItem(inv, itemA);
placeItem(inv, itemB);
expect(getItemAtCell(inv, 0, 0)!.id).toBe('a');
expect(getItemAtCell(inv, 3, 2)!.id).toBe('b');
expect(getItemAtCell(inv, 0, 0)!.id).toBe("a");
expect(getItemAtCell(inv, 3, 2)!.id).toBe("b");
});
});
describe('getAdjacentItems', () => {
it('should return orthogonally adjacent items', () => {
describe("getAdjacentItems", () => {
it("should return orthogonally adjacent items", () => {
const inv = createGridInventory(6, 4);
const center = createTestItem('center', 'o', {
const center = createTestItem("center", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 2, y: 2 },
});
const top = createTestItem('top', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 1 } });
const left = createTestItem('left', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: 2 } });
const right = createTestItem('right', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 3, y: 2 } });
const bottom = createTestItem('bottom', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 2, y: 3 } });
const diagonal = createTestItem('diagonal', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: 1 } });
const top = createTestItem("top", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 2, y: 1 },
});
const left = createTestItem("left", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 1, y: 2 },
});
const right = createTestItem("right", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 3, y: 2 },
});
const bottom = createTestItem("bottom", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 2, y: 3 },
});
const diagonal = createTestItem("diagonal", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 1, y: 1 },
});
placeItem(inv, center);
placeItem(inv, top);
@@ -409,77 +496,167 @@ describe('grid-inventory', () => {
placeItem(inv, bottom);
placeItem(inv, diagonal);
const adj = getAdjacentItems(inv, 'center');
const adj = getAdjacentItems(inv, "center");
expect(adj.size).toBe(4);
expect(adj.has('top')).toBe(true);
expect(adj.has('left')).toBe(true);
expect(adj.has('right')).toBe(true);
expect(adj.has('bottom')).toBe(true);
expect(adj.has('diagonal')).toBe(false);
expect(adj.has("top")).toBe(true);
expect(adj.has("left")).toBe(true);
expect(adj.has("right")).toBe(true);
expect(adj.has("bottom")).toBe(true);
expect(adj.has("diagonal")).toBe(false);
});
it('should return empty for item with no neighbors', () => {
it("should return empty for item with no neighbors", () => {
const inv = createGridInventory(6, 4);
const item = createTestItem('alone', 'o');
const item = createTestItem("alone", "o");
placeItem(inv, item);
const adj = getAdjacentItems(inv, 'alone');
const adj = getAdjacentItems(inv, "alone");
expect(adj.size).toBe(0);
});
it('should return empty for non-existent item', () => {
it("should return empty for non-existent item", () => {
const inv = createGridInventory(6, 4);
const adj = getAdjacentItems(inv, 'ghost');
const adj = getAdjacentItems(inv, "ghost");
expect(adj.size).toBe(0);
});
it('should handle multi-cell items with multiple adjacencies', () => {
it("should handle multi-cell items with multiple adjacencies", () => {
const inv = createGridInventory(6, 4);
// Horizontal bar at (0,0)-(1,0)
const bar = createTestItem('bar', 'oe');
const bar = createTestItem("bar", "oe");
// Item above left cell
const topA = createTestItem('topA', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 0, y: -1 } });
const topA = createTestItem("topA", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 0, y: -1 },
});
// Item above right cell
const topB = createTestItem('topB', 'o', { ...IDENTITY_TRANSFORM, offset: { x: 1, y: -1 } });
const topB = createTestItem("topB", "o", {
...IDENTITY_TRANSFORM,
offset: { x: 1, y: -1 },
});
placeItem(inv, bar);
placeItem(inv, topA);
placeItem(inv, topB);
const adj = getAdjacentItems(inv, 'bar');
const adj = getAdjacentItems(inv, "bar");
expect(adj.size).toBe(2);
expect(adj.has('topA')).toBe(true);
expect(adj.has('topB')).toBe(true);
expect(adj.has("topA")).toBe(true);
expect(adj.has("topB")).toBe(true);
});
});
describe('integration: fill a 4x6 backpack', () => {
it('should place items fitting a slay-the-spire-like backpack', () => {
describe("integration: fill a 4x6 backpack", () => {
it("should place items fitting a slay-the-spire-like backpack", () => {
const inv = createGridInventory(4, 6);
// Sword: 1x3 horizontal at (0,0)
const sword = createTestItem('sword', 'oee');
const sword = createTestItem("sword", "oee");
// Shield: 2x2 at (0,1)
const shield = createTestItem('shield', 'oes', {
const shield = createTestItem("shield", "oes", {
...IDENTITY_TRANSFORM,
offset: { x: 0, y: 1 },
});
expect(validatePlacement(inv, sword.shape, sword.transform)).toEqual({ valid: true });
expect(validatePlacement(inv, sword.shape, sword.transform)).toEqual({
valid: true,
});
placeItem(inv, sword);
expect(validatePlacement(inv, shield.shape, shield.transform)).toEqual({ valid: true });
expect(validatePlacement(inv, shield.shape, shield.transform)).toEqual({
valid: true,
});
placeItem(inv, shield);
expect(inv.items.size).toBe(2);
expect(inv.occupiedCells.size).toBe(6); // sword(3) + shield(3)
// Adjacent items should detect each other
const adjSword = getAdjacentItems(inv, 'sword');
expect(adjSword.has('shield')).toBe(true);
const adjSword = getAdjacentItems(inv, "sword");
expect(adjSword.has("shield")).toBe(true);
const adjShield = getAdjacentItems(inv, 'shield');
expect(adjShield.has('sword')).toBe(true);
const adjShield = getAdjacentItems(inv, "shield");
expect(adjShield.has("sword")).toBe(true);
});
});
describe("createItemIn", () => {
it("should place item at first valid position", () => {
const inv = createGridInventory<GameItemMeta>(6, 4);
const itemData = createTestItemData("sword", "长剑", "oee", "攻击");
const result = createItemIn(inv, "sword-1", itemData);
expect(result).toEqual({ success: true });
expect(inv.items.size).toBe(1);
expect(inv.occupiedCells.size).toBe(3); // shape "oee" has 3 cells
expect(inv.items.get("sword-1")?.meta?.itemData.id).toBe("sword");
});
it("should skip occupied cells and find next valid position", () => {
const inv = createGridInventory<GameItemMeta>(6, 4);
// Place first item manually at origin
const firstItem = createTestItem(
"existing",
"oee",
) as unknown as GameItem;
placeItem(inv, firstItem);
const itemData = createTestItemData("shield", "盾牌", "o", "防御");
const result = createItemIn(inv, "shield-1", itemData);
expect(result).toEqual({ success: true });
// Shield should be at x=3, y=0 (first available spot after "oee" at x=0-2)
const placedItem = inv.items.get("shield-1");
expect(placedItem?.transform.offset).toEqual({ x: 3, y: 0 });
});
it("should return error when no valid placement exists", () => {
const inv = createGridInventory<GameItemMeta>(3, 3);
// Fill the grid completely with 1x1 items
for (let i = 0; i < 9; i++) {
const itemData = createTestItemData(`item${i}`, `物品${i}`, "o", "");
createItemIn(inv, `item-${i}`, itemData);
}
// Try to place one more item
const itemData = createTestItemData("overflow", "溢出", "oee", "");
const result = createItemIn(inv, "overflow-1", itemData);
expect(result).toEqual({ success: false, reason: "无可用位置" });
});
it("should handle multi-cell items correctly", () => {
const inv = createGridInventory<GameItemMeta>(4, 4);
const itemData = createTestItemData("lshape", "L形", "oes", "特殊");
const result = createItemIn(inv, "lshape-1", itemData);
expect(result).toEqual({ success: true });
expect(inv.items.size).toBe(1);
// Shape "oes" has 3 cells
expect(inv.occupiedCells.size).toBe(3);
});
it("should not place item when inventory is completely full", () => {
const inv = createGridInventory<GameItemMeta>(2, 2);
// Fill with 1x1 items
const itemData = createTestItemData("small", "小物品", "o", "");
createItemIn(inv, "small-1", itemData);
createItemIn(inv, "small-2", itemData);
createItemIn(inv, "small-3", itemData);
createItemIn(inv, "small-4", itemData);
// Grid is now full
const overflowItem = createTestItemData("overflow", "溢出", "o", "");
const result = createItemIn(inv, "overflow-5", overflowItem);
expect(result).toEqual({ success: false, reason: "无可用位置" });
expect(inv.items.size).toBe(4);
});
});
});
@@ -1,18 +1,18 @@
import { describe, it, expect } from 'vitest';
import { parseShapeString } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
import { describe, it, expect } from "vitest";
import { parseShapeString } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
import {
checkCollision,
checkBoardCollision,
checkBounds,
validatePlacement,
validateShapePlacement,
transformShape,
getOccupiedCells,
IDENTITY_TRANSFORM,
} from '@/samples/slay-the-spire-like/system/utils/shape-collision';
} from "@/samples/slay-the-spire-like/system/utils/shape-collision";
describe('parseShapeString', () => {
it('should parse a single cell with o', () => {
const result = parseShapeString('o');
describe("parseShapeString", () => {
it("should parse a single cell with o", () => {
const result = parseShapeString("o");
expect(result.grid).toEqual([[true]]);
expect(result.width).toBe(1);
expect(result.height).toBe(1);
@@ -21,8 +21,8 @@ describe('parseShapeString', () => {
expect(result.originY).toBe(0);
});
it('should parse a horizontal line', () => {
const result = parseShapeString('oee');
it("should parse a horizontal line", () => {
const result = parseShapeString("oee");
expect(result.width).toBe(3);
expect(result.height).toBe(1);
expect(result.count).toBe(3);
@@ -31,8 +31,8 @@ describe('parseShapeString', () => {
expect(result.originY).toBe(0);
});
it('should parse a vertical line', () => {
const result = parseShapeString('oss');
it("should parse a vertical line", () => {
const result = parseShapeString("oss");
expect(result.width).toBe(1);
expect(result.height).toBe(3);
expect(result.count).toBe(3);
@@ -41,8 +41,8 @@ describe('parseShapeString', () => {
expect(result.originY).toBe(0);
});
it('should parse an L shape', () => {
const result = parseShapeString('oes');
it("should parse an L shape", () => {
const result = parseShapeString("oes");
expect(result.width).toBe(2);
expect(result.height).toBe(2);
expect(result.count).toBe(3);
@@ -52,47 +52,47 @@ describe('parseShapeString', () => {
]);
});
it('should handle return command', () => {
const result = parseShapeString('oeerww');
it("should handle return command", () => {
const result = parseShapeString("oeerww");
expect(result.width).toBe(4);
expect(result.height).toBe(1);
expect(result.count).toBe(4);
expect(result.grid).toEqual([[true, true, true, true]]);
});
it('should handle case insensitivity', () => {
const resultLower = parseShapeString('oes');
const resultUpper = parseShapeString('OES');
it("should handle case insensitivity", () => {
const resultLower = parseShapeString("oes");
const resultUpper = parseShapeString("OES");
expect(resultLower.grid).toEqual(resultUpper.grid);
expect(resultLower.count).toBe(resultUpper.count);
});
it('should return empty grid for empty input', () => {
const result = parseShapeString('');
it("should return empty grid for empty input", () => {
const result = parseShapeString("");
expect(result.grid).toEqual([[]]);
expect(result.width).toBe(0);
expect(result.height).toBe(1);
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)
// 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.originY).toBe(1);
});
it('should track origin at first o only', () => {
const result = parseShapeString('oes');
it("should track origin at first o only", () => {
const result = parseShapeString("oes");
expect(result.originX).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)
// Filled: (0,0), (1,0), (0,1) - 3 cells
const result = parseShapeString('oewers');
const result = parseShapeString("oewers");
expect(result.width).toBe(2);
expect(result.height).toBe(2);
expect(result.count).toBe(3);
@@ -103,16 +103,16 @@ describe('parseShapeString', () => {
});
});
describe('shape-collision', () => {
describe('getOccupiedCells', () => {
it('should return cells for a single cell shape', () => {
const shape = parseShapeString('o');
describe("shape-collision", () => {
describe("getOccupiedCells", () => {
it("should return cells for a single cell shape", () => {
const shape = parseShapeString("o");
const cells = getOccupiedCells(shape);
expect(cells).toEqual([{ x: 0, y: 0 }]);
});
it('should return cells for a horizontal line', () => {
const shape = parseShapeString('oe');
it("should return cells for a horizontal line", () => {
const shape = parseShapeString("oe");
const cells = getOccupiedCells(shape);
expect(cells).toEqual([
{ x: 0, y: 0 },
@@ -120,8 +120,8 @@ describe('shape-collision', () => {
]);
});
it('should return cells for an L shape', () => {
const shape = parseShapeString('oes');
it("should return cells for an L shape", () => {
const shape = parseShapeString("oes");
const cells = getOccupiedCells(shape);
expect(cells).toEqual([
{ x: 0, y: 0 },
@@ -131,49 +131,49 @@ describe('shape-collision', () => {
});
});
describe('checkCollision', () => {
it('should detect collision between overlapping shapes', () => {
const shapeA = parseShapeString('o');
const shapeB = parseShapeString('o');
describe("checkCollision", () => {
it("should detect collision between 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: 0, y: 0 } }
{ ...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');
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 } }
{ ...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');
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 } }
{ ...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');
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)
@@ -182,130 +182,142 @@ describe('shape-collision', () => {
shapeA,
{ ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
shapeB,
{ ...IDENTITY_TRANSFORM, rotation: 90, offset: { x: 0, y: 0 } }
{ ...IDENTITY_TRANSFORM, rotation: 90, offset: { x: 0, y: 0 } },
);
expect(result).toBe(true);
});
});
describe('checkBoardCollision', () => {
it('should detect collision with occupied cells', () => {
const shape = parseShapeString('oe');
const occupied = new Set(['0,0', '1,0']);
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');
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']);
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
occupied,
);
expect(result).toBe(true);
});
});
describe('checkBounds', () => {
it('should return true for shape within bounds', () => {
const shape = parseShapeString('oe');
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');
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
10,
);
expect(result).toBe(false);
});
it('should return false for negative coordinates', () => {
const shape = parseShapeString('oe');
it("should return false for negative coordinates", () => {
const shape = parseShapeString("oe");
const result = checkBounds(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: -1, y: 0 } },
10,
10
10,
);
expect(result).toBe(false);
});
it('should return true for shape at boundary edge', () => {
const shape = parseShapeString('o');
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
10,
);
expect(result).toBe(true);
});
});
describe('validatePlacement', () => {
it('should return valid for good placement', () => {
const shape = parseShapeString('oe');
describe("validatePlacement", () => {
it("should return valid for good placement", () => {
const shape = parseShapeString("oe");
const occupied = new Set<string>();
const result = validatePlacement(shape, IDENTITY_TRANSFORM, 10, 10, occupied);
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');
it("should return invalid for out of bounds", () => {
const shape = parseShapeString("oe");
const occupied = new Set<string>();
const result = validatePlacement(
const result = validateShapePlacement(
shape,
{ ...IDENTITY_TRANSFORM, offset: { x: 9, y: 0 } },
10,
10,
occupied
occupied,
);
expect(result).toEqual({ valid: false, reason: '超出边界' });
expect(result).toEqual({ valid: false, reason: "超出边界" });
});
it('should return invalid for collision', () => {
const shape = parseShapeString('oe');
const occupied = new Set(['0,0', '1,0']);
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: '与已有形状重叠' });
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');
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');
it("should apply 90° rotation correctly", () => {
const shape = parseShapeString("oe");
const transform = { ...IDENTITY_TRANSFORM, rotation: 90 };
const cells = transformShape(shape, transform);
@@ -315,8 +327,8 @@ describe('shape-collision', () => {
]);
});
it('should apply horizontal flip correctly', () => {
const shape = parseShapeString('oe');
it("should apply horizontal flip correctly", () => {
const shape = parseShapeString("oe");
const transform = { ...IDENTITY_TRANSFORM, flipX: true };
const cells = transformShape(shape, transform);
@@ -326,8 +338,8 @@ describe('shape-collision', () => {
]);
});
it('should apply vertical flip correctly', () => {
const shape = parseShapeString('os');
it("should apply vertical flip correctly", () => {
const shape = parseShapeString("os");
const transform = { ...IDENTITY_TRANSFORM, flipY: true };
const cells = transformShape(shape, transform);
@@ -337,8 +349,8 @@ describe('shape-collision', () => {
]);
});
it('should combine rotation and translation', () => {
const shape = parseShapeString('os');
it("should combine rotation and translation", () => {
const shape = parseShapeString("os");
const transform = {
...IDENTITY_TRANSFORM,
rotation: 90,
+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" });
});
});
});
+177 -118
View File
@@ -1,5 +1,5 @@
import { describe, it, expect } from 'vitest';
import { parseCommandSchema } from '@/utils/command/schema-parse';
import { describe, it, expect } from "vitest";
import { parseCommandSchema } from "@/utils/command/schema-parse";
import {
createCommandRegistry,
registerCommand,
@@ -10,109 +10,120 @@ import {
createCommandRunnerContext,
type CommandRegistry,
type CommandRunnerContextExport,
} from '@/utils/command/command-registry';
import type { CommandRunner, PromptEvent } from '@/utils/command/command-runner';
} from "@/utils/command/command-registry";
import type {
CommandRunner,
PromptEvent,
} from "@/utils/command/command-runner";
type TestContext = {
counter: number;
log: string[];
};
describe('CommandRegistry', () => {
it('should create an empty registry', () => {
describe("CommandRegistry", () => {
it("should create an empty registry", () => {
const registry = createCommandRegistry<TestContext>();
expect(registry.size).toBe(0);
});
it('should register a command', () => {
it("should register a command", () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('add <a> <b>'),
schema: parseCommandSchema("add <a> <b>"),
run: async function (cmd) {
return Number(cmd.params[0]) + Number(cmd.params[1]);
},
};
registerCommand(registry, runner);
expect(registry.size).toBe(1);
expect(hasCommand(registry, 'add')).toBe(true);
expect(hasCommand(registry, "add")).toBe(true);
});
it('should unregister a command', () => {
it("should unregister a command", () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext> = {
schema: parseCommandSchema('remove'),
schema: parseCommandSchema("remove"),
run: async () => {},
};
registerCommand(registry, runner);
expect(hasCommand(registry, 'remove')).toBe(true);
unregisterCommand(registry, 'remove');
expect(hasCommand(registry, 'remove')).toBe(false);
expect(hasCommand(registry, "remove")).toBe(true);
unregisterCommand(registry, "remove");
expect(hasCommand(registry, "remove")).toBe(false);
});
it('should get a command runner', () => {
it("should get a command runner", () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext> = {
schema: parseCommandSchema('get'),
schema: parseCommandSchema("get"),
run: async () => {},
};
registerCommand(registry, runner);
const retrieved = getCommand(registry, 'get');
const retrieved = getCommand(registry, "get");
expect(retrieved).toBe(runner);
});
it('should return undefined for unknown command', () => {
it("should return undefined for unknown command", () => {
const registry = createCommandRegistry<TestContext>();
const retrieved = getCommand(registry, 'unknown');
const retrieved = getCommand(registry, "unknown");
expect(retrieved).toBeUndefined();
});
});
describe('runCommand', () => {
it('should run a command successfully', async () => {
describe("runCommand", () => {
it("should run a command successfully", async () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('add <a> <b>'),
schema: parseCommandSchema("add <a> <b>"),
run: async function (cmd) {
return Number(cmd.params[0]) + Number(cmd.params[1]);
},
};
registerCommand(registry, runner);
const result = await runCommand(registry, { counter: 0, log: [] }, 'add 1 2');
const result = await runCommand(
registry,
{ counter: 0, log: [] },
"add 1 2",
);
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe(3);
}
});
it('should fail for unknown command', async () => {
it("should fail for unknown command", async () => {
const registry = createCommandRegistry<TestContext>();
const result = await runCommand(registry, { counter: 0, log: [] }, 'unknown');
const result = await runCommand(
registry,
{ counter: 0, log: [] },
"unknown",
);
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('Unknown command');
expect(result.error).toContain("Unknown command");
}
});
it('should fail for invalid command params', async () => {
it("should fail for invalid command params", async () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext> = {
schema: parseCommandSchema('add <a> <b>'),
schema: parseCommandSchema("add <a> <b>"),
run: async () => {},
};
registerCommand(registry, runner);
const result = await runCommand(registry, { counter: 0, log: [] }, 'add 1');
const result = await runCommand(registry, { counter: 0, log: [] }, "add 1");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toContain('参数不足');
expect(result.error).toContain("参数不足");
}
});
it('should access context via this.context', async () => {
it("should access context via this.context", async () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('increment'),
schema: parseCommandSchema("increment"),
run: async function () {
this.context.counter++;
return this.context.counter;
@@ -121,30 +132,30 @@ describe('runCommand', () => {
registerCommand(registry, runner);
const ctx = { counter: 0, log: [] };
await runCommand(registry, ctx, 'increment');
await runCommand(registry, ctx, "increment");
expect(ctx.counter).toBe(1);
});
it('should handle async errors', async () => {
it("should handle async errors", async () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext> = {
schema: parseCommandSchema('fail'),
schema: parseCommandSchema("fail"),
run: async () => {
throw new Error('Something went wrong');
throw new Error("Something went wrong");
},
};
registerCommand(registry, runner);
const result = await runCommand(registry, { counter: 0, log: [] }, 'fail');
const result = await runCommand(registry, { counter: 0, log: [] }, "fail");
expect(result.success).toBe(false);
if (!result.success) {
expect(result.error).toBe('Something went wrong');
expect(result.error).toBe("Something went wrong");
}
});
});
describe('CommandRunnerContext', () => {
it('should create a runner context', () => {
describe("CommandRunnerContext", () => {
it("should create a runner context", () => {
const registry = createCommandRegistry<TestContext>();
const ctx = { counter: 0, log: [] };
const runnerCtx = createCommandRunnerContext(registry, ctx);
@@ -152,10 +163,10 @@ describe('CommandRunnerContext', () => {
expect(runnerCtx.context).toBe(ctx);
});
it('should run commands via runner context', async () => {
it("should run commands via runner context", async () => {
const registry = createCommandRegistry<TestContext>();
const runner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('greet <name>'),
schema: parseCommandSchema("greet <name>"),
run: async function (cmd) {
this.context.log.push(`Hello, ${cmd.params[0]}!`);
return `Hello, ${cmd.params[0]}!`;
@@ -165,19 +176,19 @@ describe('CommandRunnerContext', () => {
const ctx = { counter: 0, log: [] };
const runnerCtx = createCommandRunnerContext(registry, ctx);
const result = await runnerCtx.run('greet World');
const result = await runnerCtx.run("greet World");
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('Hello, World!');
expect(result.result).toBe("Hello, World!");
}
expect(ctx.log).toEqual(['Hello, World!']);
expect(ctx.log).toEqual(["Hello, World!"]);
});
it('should allow commands to call other commands via this.run', async () => {
it("should allow commands to call other commands via this.run", async () => {
const registry = createCommandRegistry<TestContext>();
const addRunner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('add <a> <b>'),
schema: parseCommandSchema("add <a> <b>"),
run: async function (cmd) {
return Number(cmd.params[0]) + Number(cmd.params[1]);
},
@@ -186,12 +197,12 @@ describe('CommandRunnerContext', () => {
registerCommand(registry, addRunner);
const multiplyRunner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('multiply <a> <b>'),
schema: parseCommandSchema("multiply <a> <b>"),
run: async function (cmd) {
const a = Number(cmd.params[0]);
const b = Number(cmd.params[1]);
const addResult = await this.run(`add ${a} ${a}`);
if (!addResult.success) throw new Error('add failed');
if (!addResult.success) throw new Error("add failed");
return (addResult.result as number) * b;
},
};
@@ -199,18 +210,18 @@ describe('CommandRunnerContext', () => {
registerCommand(registry, multiplyRunner);
const ctx = { counter: 0, log: [] };
const result = await runCommand(registry, ctx, 'multiply 3 4');
const result = await runCommand(registry, ctx, "multiply 3 4");
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe(24);
}
});
it('should allow commands to call other commands via this.runParsed', async () => {
it("should allow commands to call other commands via this.runParsed", async () => {
const registry = createCommandRegistry<TestContext>();
const doubleRunner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('double <n>'),
schema: parseCommandSchema("double <n>"),
run: async function (cmd) {
return Number(cmd.params[0]) * 2;
},
@@ -219,11 +230,16 @@ describe('CommandRunnerContext', () => {
registerCommand(registry, doubleRunner);
const quadrupleRunner: CommandRunner<TestContext, number> = {
schema: parseCommandSchema('quadruple <n>'),
schema: parseCommandSchema("quadruple <n>"),
run: async function (cmd) {
const n = Number(cmd.params[0]);
const doubleResult = await this.runParsed({ name: 'double', params: [String(n)], options: {}, flags: {} });
if (!doubleResult.success) throw new Error('double failed');
const doubleResult = await this.runParsed({
name: "double",
params: [String(n)],
options: {},
flags: {},
});
if (!doubleResult.success) throw new Error("double failed");
return (doubleResult.result as number) * 2;
},
};
@@ -231,7 +247,7 @@ describe('CommandRunnerContext', () => {
registerCommand(registry, quadrupleRunner);
const ctx = { counter: 0, log: [] };
const result = await runCommand(registry, ctx, 'quadruple 5');
const result = await runCommand(registry, ctx, "quadruple 5");
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe(20);
@@ -239,14 +255,17 @@ describe('CommandRunnerContext', () => {
});
});
describe('prompt', () => {
it('should dispatch prompt event with string schema', async () => {
describe("prompt", () => {
it("should dispatch prompt event with string schema", async () => {
const registry = createCommandRegistry<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('choose'),
schema: parseCommandSchema("choose"),
run: async function () {
const result = await this.prompt('select <card>', (cmd) => cmd.params[0] as string);
const result = await this.prompt(
parseCommandSchema("select <card>"),
(cmd) => cmd.params[0] as string,
);
return result;
},
};
@@ -257,27 +276,30 @@ describe('prompt', () => {
let promptEvent: PromptEvent | null = null;
const runnerCtx = createCommandRunnerContext(registry, ctx);
runnerCtx.on('prompt', (e) => {
runnerCtx.on("prompt", (e) => {
promptEvent = e;
});
const runPromise = runnerCtx.run('choose');
const runPromise = runnerCtx.run("choose");
await new Promise((r) => setTimeout(r, 0));
expect(promptEvent).not.toBeNull();
expect(promptEvent!.schema.name).toBe('select');
expect(promptEvent!.schema.name).toBe("select");
promptEvent!.cancel('test cleanup');
promptEvent!.cancel("test cleanup");
await runPromise;
});
it('should resolve prompt with valid input', async () => {
it("should resolve prompt with valid input", async () => {
const registry = createCommandRegistry<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('choose'),
schema: parseCommandSchema("choose"),
run: async function () {
const result = await this.prompt('select <card>', (card) => card as string);
const result = await this.prompt(
parseCommandSchema("select <card>"),
(card) => card as string,
);
this.context.log.push(`selected ${result}`);
return result;
},
@@ -289,36 +311,39 @@ describe('prompt', () => {
let promptEvent: PromptEvent | null = null;
const runnerCtx = createCommandRunnerContext(registry, ctx);
runnerCtx.on('prompt', (e) => {
runnerCtx.on("prompt", (e) => {
promptEvent = e;
});
const runPromise = runnerCtx.run('choose');
const runPromise = runnerCtx.run("choose");
await new Promise((r) => setTimeout(r, 0));
expect(promptEvent).not.toBeNull();
const parsed = { name: 'select', params: ['Ace'], options: {}, flags: {} };
const parsed = { name: "select", params: ["Ace"], options: {}, flags: {} };
const error = promptEvent!.tryCommit(parsed);
expect(error).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('Ace');
expect(result.result).toBe("Ace");
}
expect(ctx.log).toEqual(['selected Ace']);
expect(ctx.log).toEqual(["selected Ace"]);
});
it('should reject prompt with invalid input', async () => {
it("should reject prompt with invalid input", async () => {
const registry = createCommandRegistry<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('choose'),
schema: parseCommandSchema("choose"),
run: async function () {
try {
await this.prompt('select <card>', (card) => card as string);
return 'unexpected success';
await this.prompt(
parseCommandSchema("select <card>"),
(card) => card as string,
);
return "unexpected success";
} catch (e) {
return (e as Error).message;
}
@@ -331,30 +356,30 @@ describe('prompt', () => {
let promptEvent: PromptEvent | null = null;
const runnerCtx = createCommandRunnerContext(registry, ctx);
runnerCtx.on('prompt', (e) => {
runnerCtx.on("prompt", (e) => {
promptEvent = e;
});
const runPromise = runnerCtx.run('choose');
const runPromise = runnerCtx.run("choose");
await new Promise((r) => setTimeout(r, 0));
expect(promptEvent).not.toBeNull();
promptEvent!.cancel('user cancelled');
promptEvent!.cancel("user cancelled");
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('user cancelled');
expect(result.result).toBe("user cancelled");
}
});
it('should accept CommandSchema object in prompt', async () => {
it("should accept CommandSchema object in prompt", async () => {
const registry = createCommandRegistry<TestContext>();
const schema = parseCommandSchema('pick <item>');
const schema = parseCommandSchema("pick <item>");
const pickRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('pick'),
schema: parseCommandSchema("pick"),
run: async function () {
const result = await this.prompt(schema, (item) => item as string);
return result;
@@ -367,34 +392,45 @@ describe('prompt', () => {
let promptEvent: PromptEvent | null = null;
const runnerCtx = createCommandRunnerContext(registry, ctx);
runnerCtx.on('prompt', (e) => {
runnerCtx.on("prompt", (e) => {
promptEvent = e;
});
const runPromise = runnerCtx.run('pick');
const runPromise = runnerCtx.run("pick");
await new Promise((r) => setTimeout(r, 0));
expect(promptEvent).not.toBeNull();
expect(promptEvent!.schema.name).toBe('pick');
expect(promptEvent!.schema.name).toBe("pick");
const error = promptEvent!.tryCommit({ name: 'pick', params: ['sword'], options: {}, flags: {} });
const error = promptEvent!.tryCommit({
name: "pick",
params: ["sword"],
options: {},
flags: {},
});
expect(error).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('sword');
expect(result.result).toBe("sword");
}
});
it('should allow multiple sequential prompts', async () => {
it("should allow multiple sequential prompts", async () => {
const registry = createCommandRegistry<TestContext>();
const multiPromptRunner: CommandRunner<TestContext, string[]> = {
schema: parseCommandSchema('multi'),
schema: parseCommandSchema("multi"),
run: async function () {
const first = await this.prompt('first <a>', (a) => a as string);
const second = await this.prompt('second <b>', (b) => b as string);
const first = await this.prompt(
parseCommandSchema("first <a>"),
(a) => a as string,
);
const second = await this.prompt(
parseCommandSchema("second <b>"),
(b) => b as string,
);
return [first, second];
},
};
@@ -405,48 +441,58 @@ describe('prompt', () => {
const promptEvents: PromptEvent[] = [];
const runnerCtx = createCommandRunnerContext(registry, ctx);
runnerCtx.on('prompt', (e) => {
runnerCtx.on("prompt", (e) => {
promptEvents.push(e);
});
const runPromise = runnerCtx.run('multi');
const runPromise = runnerCtx.run("multi");
await new Promise((r) => setTimeout(r, 0));
expect(promptEvents.length).toBe(1);
expect(promptEvents[0].schema.name).toBe('first');
expect(promptEvents[0].schema.name).toBe("first");
const error1 = promptEvents[0].tryCommit({ name: 'first', params: ['one'], options: {}, flags: {} });
const error1 = promptEvents[0].tryCommit({
name: "first",
params: ["one"],
options: {},
flags: {},
});
expect(error1).toBeNull();
await new Promise((r) => setTimeout(r, 0));
expect(promptEvents.length).toBe(2);
expect(promptEvents[1].schema.name).toBe('second');
expect(promptEvents[1].schema.name).toBe("second");
const error2 = promptEvents[1].tryCommit({ name: 'second', params: ['two'], options: {}, flags: {} });
const error2 = promptEvents[1].tryCommit({
name: "second",
params: ["two"],
options: {},
flags: {},
});
expect(error2).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toEqual(['one', 'two']);
expect(result.result).toEqual(["one", "two"]);
}
});
it('should validate input with validator function', async () => {
it("should validate input with validator function", async () => {
const registry = createCommandRegistry<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('choose'),
schema: parseCommandSchema("choose"),
run: async function () {
const result = await this.prompt(
'select <card>',
parseCommandSchema("select <card>"),
(card) => {
const cardStr = card as string;
if (!['Ace', 'King', 'Queen'].includes(cardStr)) {
if (!["Ace", "King", "Queen"].includes(cardStr)) {
throw `Invalid card: ${cardStr}. Must be Ace, King, or Queen.`;
}
return cardStr;
}
},
);
return result;
},
@@ -458,39 +504,52 @@ describe('prompt', () => {
let promptEvent: PromptEvent | null = null;
const runnerCtx = createCommandRunnerContext(registry, ctx);
runnerCtx.on('prompt', (e) => {
runnerCtx.on("prompt", (e) => {
promptEvent = e;
});
const runPromise = runnerCtx.run('choose');
const runPromise = runnerCtx.run("choose");
await new Promise((r) => setTimeout(r, 0));
expect(promptEvent).not.toBeNull();
// Try invalid input
const invalidError = promptEvent!.tryCommit({ name: 'select', params: ['Jack'], options: {}, flags: {} });
expect(invalidError).toContain('Invalid card: Jack');
const invalidError = promptEvent!.tryCommit({
name: "select",
params: ["Jack"],
options: {},
flags: {},
});
expect(invalidError).toContain("Invalid card: Jack");
// Try valid input
const validError = promptEvent!.tryCommit({ name: 'select', params: ['Ace'], options: {}, flags: {} });
const validError = promptEvent!.tryCommit({
name: "select",
params: ["Ace"],
options: {},
flags: {},
});
expect(validError).toBeNull();
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('Ace');
expect(result.result).toBe("Ace");
}
});
it('should allow cancel with custom reason', async () => {
it("should allow cancel with custom reason", async () => {
const registry = createCommandRegistry<TestContext>();
const chooseRunner: CommandRunner<TestContext, string> = {
schema: parseCommandSchema('choose'),
schema: parseCommandSchema("choose"),
run: async function () {
try {
await this.prompt('select <card>', (cmd) => cmd.params[0] as string);
return 'unexpected success';
await this.prompt(
parseCommandSchema("select <card>"),
(cmd) => cmd.params[0] as string,
);
return "unexpected success";
} catch (e) {
return (e as Error).message;
}
@@ -503,21 +562,21 @@ describe('prompt', () => {
let promptEvent: PromptEvent | null = null;
const runnerCtx = createCommandRunnerContext(registry, ctx);
runnerCtx.on('prompt', (e) => {
runnerCtx.on("prompt", (e) => {
promptEvent = e;
});
const runPromise = runnerCtx.run('choose');
const runPromise = runnerCtx.run("choose");
await new Promise((r) => setTimeout(r, 0));
expect(promptEvent).not.toBeNull();
promptEvent!.cancel('custom cancellation reason');
promptEvent!.cancel("custom cancellation reason");
const result = await runPromise;
expect(result.success).toBe(true);
if (result.success) {
expect(result.result).toBe('custom cancellation reason');
expect(result.result).toBe("custom cancellation reason");
}
});
});
+75 -73
View File
@@ -1,174 +1,176 @@
import { describe, it, expect } from 'vitest';
import { parseCommand, type Command } from '@/utils/command';
import { describe, it, expect } from "vitest";
import { parseCommand, type Command } from "@/utils/command";
describe('parseCommand', () => {
it('should parse empty string', () => {
const result = parseCommand('');
describe("parseCommand", () => {
it("should parse empty string", () => {
const result = parseCommand("");
expect(result).toEqual({
name: '',
name: "",
flags: {},
options: {},
params: []
params: [],
});
});
it('should parse command name only', () => {
const result = parseCommand('move');
it("should parse command name only", () => {
const result = parseCommand("move");
expect(result).toEqual({
name: 'move',
name: "move",
flags: {},
options: {},
params: []
params: [],
});
});
it('should parse command with params', () => {
const result = parseCommand('move meeple1 region1');
it("should parse command with params", () => {
const result = parseCommand("move meeple1 region1");
expect(result).toEqual({
name: 'move',
name: "move",
flags: {},
options: {},
params: ['meeple1', 'region1']
params: ["meeple1", "region1"],
});
});
it('should parse command with long flags', () => {
const result = parseCommand('move meeple1 --force --quiet');
it("should parse command with long flags", () => {
const result = parseCommand("move meeple1 --force --quiet");
expect(result).toEqual({
name: 'move',
name: "move",
flags: { force: true, quiet: true },
options: {},
params: ['meeple1']
params: ["meeple1"],
});
});
it('should parse command with short flags', () => {
const result = parseCommand('move meeple1 -f -q');
it("should parse command with short flags", () => {
const result = parseCommand("move meeple1 -f -q");
expect(result).toEqual({
name: 'move',
name: "move",
flags: { f: true, q: true },
options: {},
params: ['meeple1']
params: ["meeple1"],
});
});
it('should parse command with long options', () => {
const result = parseCommand('move meeple1 --x 10 --y 20');
it("should parse command with long options", () => {
const result = parseCommand("move meeple1 --x 10 --y 20");
expect(result).toEqual({
name: 'move',
name: "move",
flags: {},
options: { x: '10', y: '20' },
params: ['meeple1']
options: { x: "10", y: "20" },
params: ["meeple1"],
});
});
it('should parse command with short options', () => {
const result = parseCommand('move meeple1 -x 10 -y 20');
it("should parse command with short options", () => {
const result = parseCommand("move meeple1 -x 10 -y 20");
expect(result).toEqual({
name: 'move',
name: "move",
flags: {},
options: { x: '10', y: '20' },
params: ['meeple1']
options: { x: "10", y: "20" },
params: ["meeple1"],
});
});
it('should parse command with mixed flags和选项', () => {
const result = parseCommand('move meeple1 region1 --force -x 10 -q');
it("should parse command with mixed flags和选项", () => {
const result = parseCommand("move meeple1 region1 --force -x 10 -q");
expect(result).toEqual({
name: 'move',
name: "move",
flags: { force: true, q: true },
options: { x: '10' },
params: ['meeple1', 'region1']
options: { x: "10" },
params: ["meeple1", "region1"],
});
});
it('should handle extra whitespace', () => {
const result = parseCommand(' move meeple1 --force ');
it("should handle extra whitespace", () => {
const result = parseCommand(" move meeple1 --force ");
expect(result).toEqual({
name: 'move',
name: "move",
flags: { force: true },
options: {},
params: ['meeple1']
params: ["meeple1"],
});
});
it('should parse complex command', () => {
const result = parseCommand('place meeple1 board --x 5 --y 3 --rotate 90 --force');
it("should parse complex command", () => {
const result = parseCommand(
"place meeple1 board --x 5 --y 3 --rotate 90 --force",
);
expect(result).toEqual({
name: 'place',
name: "place",
flags: { force: true },
options: { x: '5', y: '3', rotate: '90' },
params: ['meeple1', 'board']
options: { x: "5", y: "3", rotate: "90" },
params: ["meeple1", "board"],
});
});
it('should treat negative number as option value', () => {
const result = parseCommand('set --value -10');
it("should treat negative number as option value", () => {
const result = parseCommand("set --value -10");
expect(result).toEqual({
name: 'set',
name: "set",
flags: {},
options: { value: '-10' },
params: []
options: { value: "-10" },
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');
expect(result).toEqual({
name: 'place',
name: "place",
flags: {},
options: { x: '5' },
params: ['tile', 'large castle']
options: { x: "5" },
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");
expect(result).toEqual({
name: 'place',
name: "place",
flags: {},
options: { x: '5' },
params: ['tile', 'large castle']
options: { x: "5" },
params: ["tile", "large castle"],
});
});
it('should handle escaped quotes', () => {
it("should handle escaped quotes", () => {
const result = parseCommand('say "hello \\"world\\""');
expect(result).toEqual({
name: 'say',
name: "say",
flags: {},
options: {},
params: ['hello "world"']
params: ['hello "world"'],
});
});
it('should handle escaped backslash', () => {
it("should handle escaped backslash", () => {
const result = parseCommand('set path "C:\\\\Users"');
expect(result).toEqual({
name: 'set',
name: "set",
flags: {},
options: {},
params: ['path', 'C:\\Users']
params: ["path", "C:\\Users"],
});
});
it('should handle mixed quotes', () => {
const result = parseCommand('cmd "hello world" \'foo bar\' --flag');
it("should handle mixed quotes", () => {
const result = parseCommand("cmd \"hello world\" 'foo bar' --flag");
expect(result).toEqual({
name: 'cmd',
name: "cmd",
flags: { flag: true },
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"');
expect(result).toEqual({
name: 'cmd',
name: "cmd",
flags: {},
options: {},
params: ["hello's world"]
params: ["hello's world"],
});
});
});
+82 -73
View File
@@ -1,27 +1,30 @@
import { describe, it, expect } from 'vitest';
import { createMiddlewareChain, type MiddlewareChain } from '@/utils/middleware';
import { describe, it, expect } from "vitest";
import {
createMiddlewareChain,
type MiddlewareChain,
} from "@/utils/middleware";
describe('createMiddlewareChain', () => {
describe('basic execution', () => {
it('should return context when no middlewares and no fallback', async () => {
describe("createMiddlewareChain", () => {
describe("basic execution", () => {
it("should return context when no middlewares and no fallback", async () => {
const chain = createMiddlewareChain<{ value: number }>();
const result = await chain.execute({ value: 42 });
expect(result).toEqual({ value: 42 });
});
it('should call fallback when no middlewares', async () => {
it("should call fallback when no middlewares", async () => {
const chain = createMiddlewareChain<{ value: number }, string>(
async ctx => `value is ${ctx.value}`
async (ctx) => `value is ${ctx.value}`,
);
const result = await chain.execute({ value: 42 });
expect(result).toBe('value is 42');
expect(result).toBe("value is 42");
});
it('should pass context to fallback', async () => {
it("should pass context to fallback", async () => {
const chain = createMiddlewareChain<{ a: number; b: number }, number>(
async ctx => ctx.a + ctx.b
async (ctx) => ctx.a + ctx.b,
);
const result = await chain.execute({ a: 3, b: 7 });
@@ -29,8 +32,8 @@ describe('createMiddlewareChain', () => {
});
});
describe('single middleware', () => {
it('should execute a single middleware', async () => {
describe("single middleware", () => {
it("should execute a single middleware", async () => {
const chain = createMiddlewareChain<{ count: number }>();
chain.use(async (ctx, next) => {
ctx.count *= 2;
@@ -42,9 +45,9 @@ describe('createMiddlewareChain', () => {
expect(result.count).toBe(10);
});
it('should allow middleware to modify return value', async () => {
it("should allow middleware to modify return value", async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => ctx.value
async (ctx) => ctx.value,
);
chain.use(async (ctx, next) => {
const result = await next();
@@ -56,7 +59,7 @@ describe('createMiddlewareChain', () => {
expect(result).toBe(42);
});
it('should allow middleware to short-circuit without calling next', async () => {
it("should allow middleware to short-circuit without calling next", async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, _next) => {
return { value: 999 };
@@ -68,8 +71,8 @@ describe('createMiddlewareChain', () => {
});
});
describe('multiple middlewares', () => {
it('should execute middlewares in order', async () => {
describe("multiple middlewares", () => {
it("should execute middlewares in order", async () => {
const order: number[] = [];
const chain = createMiddlewareChain<{ value: number }>();
@@ -91,7 +94,7 @@ describe('createMiddlewareChain', () => {
expect(order).toEqual([1, 2, 3, 4]);
});
it('should accumulate modifications through the chain', async () => {
it("should accumulate modifications through the chain", async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (ctx, next) => {
@@ -112,9 +115,9 @@ describe('createMiddlewareChain', () => {
expect(result.value).toBe(5);
});
it('should allow middleware to modify result on the way back', async () => {
it("should allow middleware to modify result on the way back", async () => {
const chain = createMiddlewareChain<{ base: number }, number>(
async ctx => ctx.base
async (ctx) => ctx.base,
);
chain.use(async (_ctx, next) => {
@@ -131,7 +134,7 @@ describe('createMiddlewareChain', () => {
expect(result).toBe(20);
});
it('should allow middleware to short-circuit in the middle', async () => {
it("should allow middleware to short-circuit in the middle", async () => {
const executed: number[] = [];
const chain = createMiddlewareChain<{ value: number }>();
@@ -155,13 +158,14 @@ describe('createMiddlewareChain', () => {
});
});
describe('nested next calls', () => {
it('should advance index on each next call, skipping remaining middlewares', async () => {
describe("nested next calls", () => {
it("should advance index on each next call, skipping remaining middlewares", async () => {
const chain = createMiddlewareChain<{ counter: number }>();
chain.use(async (_ctx, next) => {
await next();
await next();
return undefined as unknown as { counter: number };
});
const result = await chain.execute({ counter: 0 });
@@ -169,12 +173,12 @@ describe('createMiddlewareChain', () => {
expect(result).toBeUndefined();
});
it('should allow middleware to call next conditionally', async () => {
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 { skip: ctx.skip, value: -1 };
}
return next();
});
@@ -190,33 +194,33 @@ describe('createMiddlewareChain', () => {
expect(resultB.value).toBe(10);
});
it('should handle middleware that awaits next multiple times with a fallback', async () => {
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
async (_ctx) => log,
);
chain.use(async (_ctx, next) => {
log.push('before');
log.push("before");
await next();
log.push('after-first');
log.push("after-first");
await next();
log.push('after-second');
log.push("after-second");
return log;
});
chain.use(async (_ctx, next) => {
log.push('mw2');
log.push("mw2");
return next();
});
const result = await chain.execute({ value: 0 });
expect(result).toEqual(['before', 'mw2', 'after-first', 'after-second']);
expect(result).toEqual(["before", "mw2", "after-first", "after-second"]);
});
it('should return fallback result on second next call when no more middlewares remain', async () => {
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
async (ctx) => ctx.value * 10,
);
chain.use(async (_ctx, next) => {
@@ -232,9 +236,9 @@ describe('createMiddlewareChain', () => {
expect(result).toBe(50);
});
it('should return fallback result on second next call when no more middlewares remain', async () => {
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
async (ctx) => ctx.value * 10,
);
chain.use(async (_ctx, next) => {
@@ -251,17 +255,17 @@ describe('createMiddlewareChain', () => {
});
});
describe('async behavior', () => {
it('should handle async middlewares', async () => {
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));
await new Promise((resolve) => setTimeout(resolve, 10));
ctx.value += 1;
return next();
});
chain.use(async (ctx, next) => {
await new Promise(resolve => setTimeout(resolve, 10));
await new Promise((resolve) => setTimeout(resolve, 10));
ctx.value += 2;
return next();
});
@@ -271,12 +275,12 @@ describe('createMiddlewareChain', () => {
expect(result.value).toBe(3);
});
it('should handle async fallback', async () => {
it("should handle async fallback", async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async ctx => {
await new Promise(resolve => setTimeout(resolve, 10));
async (ctx) => {
await new Promise((resolve) => setTimeout(resolve, 10));
return ctx.value * 10;
}
},
);
const result = await chain.execute({ value: 5 });
@@ -285,28 +289,32 @@ describe('createMiddlewareChain', () => {
});
});
describe('error handling', () => {
it('should propagate errors from middleware', async () => {
describe("error handling", () => {
it("should propagate errors from middleware", async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async () => {
throw new Error('middleware error');
throw new Error("middleware error");
});
await expect(chain.execute({ value: 1 })).rejects.toThrow('middleware error');
await expect(chain.execute({ value: 1 })).rejects.toThrow(
"middleware error",
);
});
it('should propagate errors from fallback', async () => {
it("should propagate errors from fallback", async () => {
const chain = createMiddlewareChain<{ value: number }, number>(
async () => {
throw new Error('fallback error');
}
throw new Error("fallback error");
},
);
await expect(chain.execute({ value: 1 })).rejects.toThrow('fallback error');
await expect(chain.execute({ value: 1 })).rejects.toThrow(
"fallback error",
);
});
it('should allow middleware to catch errors from downstream', async () => {
it("should allow middleware to catch errors from downstream", async () => {
const chain = createMiddlewareChain<{ value: number }>();
chain.use(async (_ctx, next) => {
@@ -317,7 +325,7 @@ describe('createMiddlewareChain', () => {
}
});
chain.use(async () => {
throw new Error('downstream error');
throw new Error("downstream error");
});
const result = await chain.execute({ value: 1 });
@@ -326,20 +334,20 @@ describe('createMiddlewareChain', () => {
});
});
describe('return type override', () => {
it('should support different TReturn type than TContext', async () => {
describe("return type override", () => {
it("should support different TReturn type than TContext", async () => {
const chain = createMiddlewareChain<{ name: string }, string>(
async ctx => `Hello, ${ctx.name}!`
async (ctx) => `Hello, ${ctx.name}!`,
);
const result = await chain.execute({ name: 'World' });
const result = await chain.execute({ name: "World" });
expect(result).toBe('Hello, World!');
expect(result).toBe("Hello, World!");
});
it('should allow middleware to transform return type', async () => {
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)
async (ctx) => ctx.items.reduce((a, b) => a + b, 0),
);
chain.use(async (_ctx, next) => {
@@ -353,8 +361,8 @@ describe('createMiddlewareChain', () => {
});
});
describe('reusability', () => {
it('should reset index on each execute call', async () => {
describe("reusability", () => {
it("should reset index on each execute call", async () => {
const chain = createMiddlewareChain<{ count: number }>();
chain.use(async (ctx, next) => {
@@ -369,11 +377,11 @@ describe('createMiddlewareChain', () => {
expect(resultB.count).toBe(1);
});
it('should share middlewares across execute calls', async () => {
it("should share middlewares across execute calls", async () => {
const chain = createMiddlewareChain<{ log: string[] }>();
chain.use(async (ctx, next) => {
ctx.log.push('always');
ctx.log.push("always");
return next();
});
@@ -384,17 +392,17 @@ describe('createMiddlewareChain', () => {
});
});
describe('edge cases', () => {
it('should handle empty context object', async () => {
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 () => {
it("should handle middleware that returns a completely different object", async () => {
const chain = createMiddlewareChain<{ x: number }, { y: string }>(
async () => ({ y: 'default' })
async () => ({ y: "default" }),
);
chain.use(async (_ctx, next) => {
@@ -403,12 +411,12 @@ describe('createMiddlewareChain', () => {
const result = await chain.execute({ x: 42 });
expect(result).toEqual({ y: 'default' });
expect(result).toEqual({ y: "default" });
});
it('should handle middleware that mutates context without returning', async () => {
it("should handle middleware that mutates context without returning", async () => {
const chain = createMiddlewareChain<{ value: number }>(
async ctx => ctx
async (ctx) => ctx,
);
chain.use(async (ctx, next) => {
@@ -421,11 +429,12 @@ describe('createMiddlewareChain', () => {
expect(result.value).toBe(100);
});
it('should return undefined when middleware does not call next or return', async () => {
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 });