Compare commits

..
36 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
45 changed files with 2538 additions and 2084 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
"scripts": { "scripts": {
"build": "tsup", "build": "tsup",
"build:samples": "tsup --config tsup.samples.config.ts", "build:samples": "tsup --config tsup.samples.config.ts",
"prepare": "npm run build", "prepare": "npm run build && npm run build:samples",
"test": "vitest", "test": "vitest",
"test:run": "vitest run", "test:run": "vitest run",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
+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 { ReadonlySignal, Signal } from "@preact/signals-core";
import { CommandSchema, CommandRegistry, PromptEvent } from "@/utils/command"; import { createGameContext, IGameContext } from "./game";
import {
createGameCommandRegistry,
createGameContext,
IGameContext,
PromptDef,
} from "./game";
export type GameHostStatus = "created" | "running" | "disposed"; export type GameHostStatus = "created" | "running" | "disposed";
export class GameHost< export class GameHost<
TState extends Record<string, unknown>, TState extends Record<string, unknown>,
TResult = unknown, TResult = unknown,
TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
> { > {
readonly state: ReadonlySignal<TState>; readonly state: ReadonlySignal<TState>;
readonly status: ReadonlySignal<GameHostStatus>; readonly status: ReadonlySignal<GameHostStatus>;
readonly activePromptSchema: ReadonlySignal<CommandSchema | null>; readonly prompts: PromptContext;
readonly activePromptPlayer: ReadonlySignal<string | null>;
readonly activePromptHint: ReadonlySignal<string | null>;
private _context: IGameContext<TState>; private _context: IGameContext<TState>;
private _start: (ctx: IGameContext<TState>) => Promise<TResult>; private _start: (ctx: IGameContext<TState>) => Promise<TResult>;
private _status: Signal<GameHostStatus>; private _status: Signal<GameHostStatus>;
private _activePromptSchema: Signal<CommandSchema | null>;
private _activePromptPlayer: Signal<string | null>;
private _activePromptHint: Signal<string | null>;
private _createInitialState: () => TState; private _createInitialState: () => TState;
private _eventListeners: Map<"start" | "dispose", Set<() => void>>; private _eventListeners: Map<"start" | "dispose", Set<() => void>>;
private _isDisposed = false; private _isDisposed = false;
constructor( constructor(public readonly gameModule: TModule) {
registry: CommandRegistry<IGameContext<TState>>, const { createInitialState, start } = gameModule;
createInitialState: () => TState,
start: (ctx: IGameContext<TState>) => Promise<TResult>,
) {
this._createInitialState = createInitialState; this._createInitialState = createInitialState;
this._eventListeners = new Map(); this._eventListeners = new Map();
this.prompts = createPromptContext();
const initialState = createInitialState(); const initialState = createInitialState();
this._context = createGameContext(registry, initialState); this._context = createGameContext(this.prompts, initialState);
this._start = start; this._start = start;
this.state = this._context._state; this.state = this._context._state;
this._status = new Signal<GameHostStatus>("created"); this._status = new Signal<GameHostStatus>("created");
this.status = this._status; this.status = this._status;
this._activePromptSchema = new Signal<CommandSchema | null>(null);
this.activePromptSchema = this._activePromptSchema;
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"); throw new Error("GameHost is disposed");
} }
this._context._commands._cancel(); this.prompts.reset();
const initialState = this._createInitialState(); const initialState = this._createInitialState();
this._context._state.value = initialState as any; this._context._state.value = initialState as any;
@@ -138,7 +78,7 @@ export class GameHost<
} }
this._isDisposed = true; this._isDisposed = true;
this._context._commands._cancel(); this.prompts.reset();
this._status.value = "disposed"; this._status.value = "disposed";
// Emit dispose event BEFORE clearing listeners // Emit dispose event BEFORE clearing listeners
@@ -168,21 +108,17 @@ export class GameHost<
} }
export type GameModule< export type GameModule<
TState extends Record<string, unknown>, TState extends Record<string, unknown> = Record<string, unknown>,
TResult = unknown, TResult = unknown,
> = { > = {
registry?: CommandRegistry<IGameContext<TState>>;
createInitialState: () => TState; createInitialState: () => TState;
start: (ctx: IGameContext<TState>) => Promise<TResult>; start: (ctx: IGameContext<TState>) => Promise<TResult>;
}; };
export function createGameHost< export function createGameHost<
TState extends Record<string, unknown>, TState extends Record<string, unknown> = Record<string, unknown>,
TResult = unknown, TResult = unknown,
>(gameModule: GameModule<TState, TResult>): GameHost<TState, TResult> { TModule extends GameModule<TState, TResult> = GameModule<TState, TResult>,
return new GameHost( >(gameModule: TModule): GameHost<TState, TResult, TModule> {
gameModule.registry || createGameCommandRegistry(), return new GameHost(gameModule);
gameModule.createInitialState,
gameModule.start,
);
} }
+10 -50
View File
@@ -1,40 +1,29 @@
import { MutableSignal, mutableSignal } from "@/utils/mutable-signal"; import { MutableSignal, mutableSignal } from "@/utils/mutable-signal";
import { import { CommandSchema, parseCommandSchema, PromptDef } from "@/utils/command";
Command,
CommandRegistry,
CommandResult,
CommandRunnerContextExport,
CommandSchema,
createCommandRegistry,
createCommandRunnerContext,
parseCommandSchema,
} from "@/utils/command";
import { PromptValidator } from "@/utils/command/command-runner"; import { PromptValidator } from "@/utils/command/command-runner";
import { Mulberry32RNG, ReadonlyRNG, RNG } from "@/utils/rng"; import { Mulberry32RNG, ReadonlyRNG, RNG } from "@/utils/rng";
import { PromptContext } from "@/utils/command/command-prompt";
export interface IGameContext<TState extends Record<string, unknown> = {}> { export interface IGameContext<TState extends Record<string, unknown> = {}> {
get value(): TState; get value(): TState;
get rng(): ReadonlyRNG; get rng(): ReadonlyRNG;
produce(fn: (draft: TState) => void): void; produce(fn: (draft: TState) => undefined): void;
produceAsync(fn: (draft: TState) => void): Promise<void>; produceAsync(fn: (draft: TState) => undefined): Promise<void>;
run<T>(input: string): Promise<CommandResult<T>>;
runParsed<T>(command: Command): Promise<CommandResult<T>>;
prompt: <TResult, TArgs extends any[] = any[]>( prompt: <TResult, TArgs extends any[] = any[]>(
def: PromptDef<TArgs>, def: PromptDef<TArgs>,
validator: PromptValidator<TResult, TArgs>, validator: PromptValidator<TResult, TArgs>,
currentPlayer?: string | null, player?: string,
) => Promise<TResult>; ) => Promise<TResult>;
// test only // test only
_state: MutableSignal<TState>; _state: MutableSignal<TState>;
_commands: CommandRunnerContextExport<IGameContext<TState>>;
_rng: RNG; _rng: RNG;
} }
export type IGameContextExport<TState extends Record<string, unknown> = {}> = export type IGameContextExport<TState extends Record<string, unknown> = {}> =
Omit<IGameContext<TState>, "_state" | "_commands" | "_rng">; Omit<IGameContext<TState>, "_state" | "_commands" | "_rng">;
export function createGameContext<TState extends Record<string, unknown> = {}>( export function createGameContext<TState extends Record<string, unknown> = {}>(
commandRegistry: CommandRegistry<IGameContext<TState>>, promptContext: PromptContext,
initialState?: TState | (() => TState), initialState?: TState | (() => TState),
): IGameContext<TState> { ): IGameContext<TState> {
const stateValue = const stateValue =
@@ -42,7 +31,7 @@ export function createGameContext<TState extends Record<string, unknown> = {}>(
? initialState() ? initialState()
: (initialState ?? ({} as TState)); : (initialState ?? ({} as TState));
const state = mutableSignal(stateValue); const state = mutableSignal(stateValue);
let commands: CommandRunnerContextExport<IGameContext<TState>> = null as any; const { prompt } = promptContext;
const context: IGameContext<TState> = { const context: IGameContext<TState> = {
get value(): TState { get value(): TState {
@@ -51,44 +40,21 @@ export function createGameContext<TState extends Record<string, unknown> = {}>(
get rng() { get rng() {
return this._rng; return this._rng;
}, },
produce(fn) { produce(fn: (draft: TState) => undefined) {
return state.produce(fn); return state.produce(fn);
}, },
produceAsync(fn) { produceAsync(fn: (draft: TState) => undefined) {
return state.produceAsync(fn); return state.produceAsync(fn);
}, },
run<T>(input: string) { prompt,
return commands.run<T>(input);
},
runParsed<T>(command: Command) {
return commands.runParsed<T>(command);
},
prompt(def, validator, currentPlayer) {
return commands.prompt(
def.schema,
validator,
def.hintText,
currentPlayer,
);
},
_state: state, _state: state,
_commands: commands,
_rng: new Mulberry32RNG(), _rng: new Mulberry32RNG(),
}; };
context._commands = commands = createCommandRunnerContext(
commandRegistry,
context,
);
return context; return context;
} }
export type PromptDef<TArgs extends any[] = any[]> = {
schema: CommandSchema;
hintText?: string;
};
export function createPromptDef<TArgs extends any[] = any[]>( export function createPromptDef<TArgs extends any[] = any[]>(
schema: CommandSchema | string, schema: CommandSchema | string,
hintText?: string, hintText?: string,
@@ -96,9 +62,3 @@ export function createPromptDef<TArgs extends any[] = any[]>(
schema = typeof schema === "string" ? parseCommandSchema(schema) : schema; schema = typeof schema === "string" ? parseCommandSchema(schema) : schema;
return { schema, hintText }; return { schema, hintText };
} }
export function createGameCommandRegistry<
TState extends Record<string, unknown> = {},
>() {
return createCommandRegistry<IGameContext<TState>>();
}
+5 -1
View File
@@ -5,7 +5,7 @@
// Core types // Core types
export type { IGameContext } from "./core/game"; export type { IGameContext } from "./core/game";
export { createGameCommandRegistry, createPromptDef } from "./core/game"; export { createPromptDef } from "./core/game";
export type { GameHost, GameHostStatus, GameModule } from "./core/game-host"; export type { GameHost, GameHostStatus, GameModule } from "./core/game-host";
export { createGameHost } from "./core/game-host"; export { createGameHost } from "./core/game-host";
@@ -24,6 +24,8 @@ export {
moveToRegion, moveToRegion,
} from "./core/region"; } from "./core/region";
export * from "./core/game-client";
// Utils // Utils
export type { export type {
Command, Command,
@@ -71,4 +73,6 @@ export { createRNG, Mulberry32RNG } from "./utils/rng";
export type { MiddlewareChain } from "./utils/middleware"; export type { MiddlewareChain } from "./utils/middleware";
export { createMiddlewareChain } from "./utils/middleware"; export { createMiddlewareChain } from "./utils/middleware";
export * from "./utils/spawner";
export * from "@preact/signals-core"; export * from "@preact/signals-core";
+22 -92
View File
@@ -1,101 +1,31 @@
import {BoopGame} from "@/samples/boop/types-extensions"; import { BoopGame } from "@/samples/boop/types-extensions";
import {PlayerType} from "@/samples/boop/types"; import { PlayerType } from "@/samples/boop/types";
import {turn} from "@/samples/boop/commands/turn"; import { turn } from "@/samples/boop/commands/turn";
import {createGameCommandRegistry} from "@/core/game"; import { boop } from "./boop";
import { checkFullBoard } from "./full-board";
export const registry = createGameCommandRegistry<BoopGame['value']>(); import { checkGraduates } from "./graduate";
import { place } from "./place";
/** import { checkWin } from "./win";
* 放置棋子到棋盘
*/
const placeCmd = registry.register({
schema: 'place <row:number> <col:number> <player> <type>',
run: async (game, row, col, player, type) => {
const {place} = await import('./place');
return place(game, row, col, player, type);
}
});
/**
* 执行boop - 推动周围棋子
*/
const boopCmd = registry.register({
schema: 'boop <row:number> <col:number> <type>',
run: async (game, row, col, type) => {
const {boop} = await import('./boop');
return boop(game, row, col, type);
}
});
/**
* 检查是否有玩家获胜(三个猫连线)
*/
const checkWinCmd = registry.register({
schema: 'check-win',
run: async (game) => {
const {checkWin} = await import('./win');
return checkWin(game);
}
});
/**
* 检查并执行小猫升级(三个小猫连线变成猫)
*/
const checkGraduatesCmd = registry.register({
schema: 'check-graduates',
run: async (game) => {
const {checkGraduates} = await import('./graduate');
return checkGraduates(game);
}
});
/**
* 检查并执行 8-piece 规则
*/
const checkFullBoardCmd = registry.register({
schema: 'check-full-board <player:string>',
run: async (game, player) => {
const {checkFullBoard} = await import('./full-board');
return checkFullBoard(game, player);
}
});
/**
* 处理一个回合
*/
const turnCmd = registry.register({
schema: 'turn <player:string>',
run: async (game, player) => {
const {turn} = await import('./turn');
return turn(game, player);
}
});
/** /**
* 启动游戏主循环 * 启动游戏主循环
*/ */
export async function start(game: BoopGame) { export async function start(game: BoopGame) {
while (true) { while (true) {
const currentPlayer = game.value.currentPlayer; const currentPlayer = game.value.currentPlayer;
const turnOutput = await turn(game, currentPlayer); const turnOutput = await turn(game, currentPlayer);
await game.produceAsync(state => { await game.produceAsync((state) => {
state.winner = turnOutput.winner; state.winner = turnOutput.winner;
if (!state.winner) { if (!state.winner) {
state.currentPlayer = state.currentPlayer === 'white' ? 'black' : 'white'; state.currentPlayer =
} state.currentPlayer === "white" ? "black" : "white";
}); }
if (game.value.winner) break; });
} if (game.value.winner) break;
}
return game.value; return game.value;
} }
export { export { place, boop, checkWin, checkGraduates, checkFullBoard, turn };
placeCmd as place,
boopCmd as boop,
checkWinCmd as checkWin,
checkGraduatesCmd as checkGraduates,
checkFullBoardCmd as checkFullBoard,
turnCmd as turn
};
+26 -34
View File
@@ -1,49 +1,41 @@
// Types // Types
export type { export type {
PlayerType, PlayerType,
PieceType, PieceType,
WinnerType, WinnerType,
RegionType, RegionType,
BoopPartMeta, BoopPartMeta,
BoopPart BoopPart,
} from './types'; } from "./types";
export type {BoopGame} from './types-extensions'; export type { BoopGame } from "./types-extensions";
// Constants // Constants
export { export { BOARD_SIZE, MAX_PIECES_PER_PLAYER, WIN_LENGTH } from "./constants";
BOARD_SIZE,
MAX_PIECES_PER_PLAYER,
WIN_LENGTH
} from './constants';
// State // State
export { export { createInitialState, type BoopState } from "./state";
createInitialState,
type BoopState
} from './state';
// Prompts // Prompts
export {prompts} from './prompts'; export { prompts } from "./prompts";
// Commands // Commands
export { export {
registry, start,
start, place as placeCmd,
place as placeCmd, boop as boopCmd,
boop as boopCmd, checkWin as checkWinCmd,
checkWin as checkWinCmd, checkGraduates as checkGraduatesCmd,
checkGraduates as checkGraduatesCmd, checkFullBoard as checkFullBoardCmd,
checkFullBoard as checkFullBoardCmd, turn as turnCmd,
turn as turnCmd } from "./commands";
} from './commands';
// Utils // Utils
export { export {
getLineCandidates, getLineCandidates,
isInBounds, isInBounds,
isCellOccupied, isCellOccupied,
getNeighborPositions, getNeighborPositions,
findPartInRegion, findPartInRegion,
findPartAtPosition findPartAtPosition,
} from './utils'; } from "./utils";
+311 -281
View File
@@ -1,379 +1,409 @@
import { import {
OnitamaGame, OnitamaGame,
OnitamaState, OnitamaState,
PlayerType, PlayerType,
prompts, prompts,
initializeCards initializeCards,
} from "./types"; } from "./types";
import {createGameCommandRegistry} from "@/core/game"; import { moveToRegion } from "@/core/region";
import {moveToRegion} from "@/core/region";
export const registry = createGameCommandRegistry<OnitamaState>();
/** /**
* 检查位置是否在棋盘范围内 * 检查位置是否在棋盘范围内
*/ */
function isInBounds(x: number, y: number): boolean { function isInBounds(x: number, y: number): boolean {
return x >= 0 && x < 5 && y >= 0 && y < 5; return x >= 0 && x < 5 && y >= 0 && y < 5;
} }
/** /**
* 检查位置是否有棋子 * 检查位置是否有棋子
*/ */
function getPawnAtPosition(state: OnitamaState, x: number, y: number) { function getPawnAtPosition(state: OnitamaState, x: number, y: number) {
const key = `${x},${y}`; const key = `${x},${y}`;
const pawnId = state.regions.board.partMap[key]; const pawnId = state.regions.board.partMap[key];
return pawnId ? state.pawns[pawnId] : null; return pawnId ? state.pawns[pawnId] : null;
} }
/** /**
* 检查玩家是否拥有某张卡牌 * 检查玩家是否拥有某张卡牌
*/ */
function playerHasCard(state: OnitamaState, player: PlayerType, cardName: string): boolean { function playerHasCard(
const cardList = player === 'red' ? state.redCards : state.blackCards; state: OnitamaState,
return cardList.includes(cardName); player: PlayerType,
cardName: string,
): boolean {
const cardList = player === "red" ? state.redCards : state.blackCards;
return cardList.includes(cardName);
} }
/** /**
* 获取卡牌的移动候选项,根据玩家视角进行旋转 * 获取卡牌的移动候选项,根据玩家视角进行旋转
* 黑方需要将卡牌旋转180度 * 黑方需要将卡牌旋转180度
*/ */
export function getCardMoveCandidates(state: OnitamaState, cardName: string, player: PlayerType) { export function getCardMoveCandidates(
const card = state.cards[cardName]; state: OnitamaState,
const candidates = card.moveCandidates; cardName: string,
player: PlayerType,
) {
const card = state.cards[cardName];
const candidates = card.moveCandidates;
// 黑方需要将卡牌旋转180度 // 黑方需要将卡牌旋转180度
if (player === 'black') { if (player === "black") {
return candidates.map(m => ({ dx: -m.dx, dy: -m.dy })); return candidates.map((m) => ({ dx: -m.dx, dy: -m.dy }));
} }
return candidates; return candidates;
} }
/** /**
* 检查移动是否合法 * 检查移动是否合法
*/ */
export function isValidMove(state: OnitamaState, cardName: string, fromX: number, fromY: number, toX: number, toY: number, player: PlayerType): string | null { export function isValidMove(
// 检查玩家是否拥有该卡牌 state: OnitamaState,
if (!playerHasCard(state, player, cardName)) { cardName: string,
return `玩家 ${player} 不拥有卡牌 ${cardName}`; fromX: number,
} fromY: number,
toX: number,
toY: number,
player: PlayerType,
): string | null {
// 检查玩家是否拥有该卡牌
if (!playerHasCard(state, player, cardName)) {
return `玩家 ${player} 不拥有卡牌 ${cardName}`;
}
// 检查起始位置是否有玩家的棋子 // 检查起始位置是否有玩家的棋子
const fromPawn = getPawnAtPosition(state, fromX, fromY); const fromPawn = getPawnAtPosition(state, fromX, fromY);
if (!fromPawn) { if (!fromPawn) {
return `位置 (${fromX}, ${fromY}) 没有棋子`; return `位置 (${fromX}, ${fromY}) 没有棋子`;
} }
if (fromPawn.owner !== player) { if (fromPawn.owner !== player) {
return `位置 (${fromX}, ${fromY}) 的棋子不属于玩家 ${player}`; return `位置 (${fromX}, ${fromY}) 的棋子不属于玩家 ${player}`;
} }
// 检查卡牌是否存在 // 检查卡牌是否存在
const card = state.cards[cardName]; const card = state.cards[cardName];
if (!card) { if (!card) {
return `卡牌 ${cardName} 不存在`; return `卡牌 ${cardName} 不存在`;
} }
// 计算移动偏移量 // 计算移动偏移量
const dx = toX - fromX; const dx = toX - fromX;
const dy = toY - fromY; const dy = toY - fromY;
// 检查移动是否在卡牌的移动候选项中(黑方需要旋转180度) // 检查移动是否在卡牌的移动候选项中(黑方需要旋转180度)
const candidates = getCardMoveCandidates(state, cardName, player); const candidates = getCardMoveCandidates(state, cardName, player);
const isValid = candidates.some(m => m.dx === dx && m.dy === dy); const isValid = candidates.some((m) => m.dx === dx && m.dy === dy);
if (!isValid) { if (!isValid) {
return `卡牌 ${cardName} 不支持移动 (${dx}, ${dy})`; return `卡牌 ${cardName} 不支持移动 (${dx}, ${dy})`;
} }
// 检查目标位置是否在棋盘内 // 检查目标位置是否在棋盘内
if (!isInBounds(toX, toY)) { if (!isInBounds(toX, toY)) {
return `目标位置 (${toX}, ${toY}) 超出棋盘范围`; return `目标位置 (${toX}, ${toY}) 超出棋盘范围`;
} }
// 检查目标位置是否有己方棋子 // 检查目标位置是否有己方棋子
const toPawn = getPawnAtPosition(state, toX, toY); const toPawn = getPawnAtPosition(state, toX, toY);
if (toPawn && toPawn.owner === player) { if (toPawn && toPawn.owner === player) {
return `目标位置 (${toX}, ${toY}) 已有己方棋子`; return `目标位置 (${toX}, ${toY}) 已有己方棋子`;
} }
return null; return null;
} }
/** /**
* 执行移动 * 执行移动
*/ */
async function handleMove(game: OnitamaGame, player: PlayerType, cardName: string, fromX: number, fromY: number, toX: number, toY: number) { async function move(
const state = game.value; game: OnitamaGame,
player: PlayerType,
// 验证移动 cardName: string,
const error = isValidMove(state, cardName, fromX, fromY, toX, toY, player); fromX: number,
if (error) { fromY: number,
throw new Error(error); toX: number,
} toY: number,
) {
const capturedPawnId = getPawnAtPosition(state, toX, toY)?.id || null; const state = game.value;
await game.produceAsync(state => {
const pawn = state.pawns[getPawnAtPosition(state, fromX, fromY)!.id];
// 如果目标位置有敌方棋子,将其移除(吃掉)
if (capturedPawnId) {
const capturedPawn = state.pawns[capturedPawnId];
moveToRegion(capturedPawn, state.regions.board, null);
}
// 移动棋子到目标位置
moveToRegion(pawn, state.regions.board, state.regions.board, [toX, toY]);
});
// 交换卡牌
await handleSwapCard(game, player, cardName);
return {
from: { x: fromX, y: fromY },
to: { x: toX, y: toY },
card: cardName,
captured: capturedPawnId
};
}
const move = registry.register({ // 验证移动
schema: 'move <player> <card:string> <fromX:number> <fromY:number> <toX:number> <toY:number>', const error = isValidMove(state, cardName, fromX, fromY, toX, toY, player);
run: handleMove if (error) {
}); throw new Error(error);
}
const capturedPawnId = getPawnAtPosition(state, toX, toY)?.id || null;
await game.produceAsync((state) => {
const pawn = state.pawns[getPawnAtPosition(state, fromX, fromY)!.id];
// 如果目标位置有敌方棋子,将其移除(吃掉)
if (capturedPawnId) {
const capturedPawn = state.pawns[capturedPawnId];
moveToRegion(capturedPawn, state.regions.board, null);
}
// 移动棋子到目标位置
moveToRegion(pawn, state.regions.board, state.regions.board, [toX, toY]);
});
// 交换卡牌
await swapCard(game, player, cardName);
return {
from: { x: fromX, y: fromY },
to: { x: toX, y: toY },
card: cardName,
captured: capturedPawnId,
};
}
/** /**
* 交换卡牌:将使用的卡牌与备用卡牌交换 * 交换卡牌:将使用的卡牌与备用卡牌交换
*/ */
async function handleSwapCard(game: OnitamaGame, player: PlayerType, usedCard: string) { async function swapCard(
await game.produceAsync(state => { game: OnitamaGame,
const spareCard = state.spareCard; player: PlayerType,
const usedCardData = state.cards[usedCard]; usedCard: string,
const spareCardData = state.cards[spareCard]; ) {
await game.produceAsync((state) => {
// 从玩家手牌中移除使用的卡牌 const spareCard = state.spareCard;
if (player === 'red') { const usedCardData = state.cards[usedCard];
state.redCards = state.redCards.filter(c => c !== usedCard); const spareCardData = state.cards[spareCard];
state.redCards.push(spareCard);
} else {
state.blackCards = state.blackCards.filter(c => c !== usedCard);
state.blackCards.push(spareCard);
}
// 更新卡牌区域
usedCardData.regionId = 'spare';
spareCardData.regionId = player;
// 更新备用卡牌
state.spareCard = usedCard;
});
}
const swapCard = registry.register({ // 从玩家手牌中移除使用的卡牌
schema: 'swap-card <player> <card:string>', if (player === "red") {
run: handleSwapCard state.redCards = state.redCards.filter((c) => c !== usedCard);
}); state.redCards.push(spareCard);
} else {
state.blackCards = state.blackCards.filter((c) => c !== usedCard);
state.blackCards.push(spareCard);
}
// 更新卡牌区域
usedCardData.regionId = "spare";
spareCardData.regionId = player;
// 更新备用卡牌
state.spareCard = usedCard;
});
}
/** /**
* 检查占领胜利条件:玩家的师父棋子到达对手的初始位置 * 检查占领胜利条件:玩家的师父棋子到达对手的初始位置
* 红色师父需要到达 (2, 4) - 黑色师父的初始位置 * 红色师父需要到达 (2, 4) - 黑色师父的初始位置
* 黑色师父需要到达 (2, 0) - 红色师父的初始位置 * 黑色师父需要到达 (2, 0) - 红色师父的初始位置
*/ */
async function handleCheckConquestWin(game: OnitamaGame): Promise<PlayerType | null> { async function checkConquestWin(game: OnitamaGame): Promise<PlayerType | null> {
const state = game.value; const state = game.value;
// 红色师父到达 (2, 4)(黑色师父的初始位置) // 红色师父到达 (2, 4)(黑色师父的初始位置)
const redMaster = state.pawns['red-master']; const redMaster = state.pawns["red-master"];
if (redMaster && redMaster.regionId === 'board' && redMaster.position[0] === 2 && redMaster.position[1] === 4) { if (
return 'red'; redMaster &&
} redMaster.regionId === "board" &&
redMaster.position[0] === 2 &&
redMaster.position[1] === 4
) {
return "red";
}
// 黑色师父到达 (2, 0)(红色师父的初始位置) // 黑色师父到达 (2, 0)(红色师父的初始位置)
const blackMaster = state.pawns['black-master']; const blackMaster = state.pawns["black-master"];
if (blackMaster && blackMaster.regionId === 'board' && blackMaster.position[0] === 2 && blackMaster.position[1] === 0) { if (
return 'black'; blackMaster &&
} blackMaster.regionId === "board" &&
blackMaster.position[0] === 2 &&
blackMaster.position[1] === 0
) {
return "black";
}
return null; return null;
} }
const checkConquestWin = registry.register({
schema: 'check-conquest-win',
run: handleCheckConquestWin
});
/** /**
* 检查吃掉胜利条件:对手的师父棋子被吃掉(不在棋盘上) * 检查吃掉胜利条件:对手的师父棋子被吃掉(不在棋盘上)
*/ */
async function handleCheckCaptureWin(game: OnitamaGame): Promise<PlayerType | null> { async function checkCaptureWin(game: OnitamaGame): Promise<PlayerType | null> {
const state = game.value; const state = game.value;
// 红色师父不在棋盘上,黑色获胜
const redMaster = state.pawns['red-master'];
if (!redMaster || redMaster.regionId !== 'board') {
return 'black';
}
// 黑色师父不在棋盘上,红色获胜
const blackMaster = state.pawns['black-master'];
if (!blackMaster || blackMaster.regionId !== 'board') {
return 'red';
}
return null;
}
const checkCaptureWin = registry.register({ // 红色师父不在棋盘上,黑色获胜
schema: 'check-capture-win', const redMaster = state.pawns["red-master"];
run: handleCheckCaptureWin if (!redMaster || redMaster.regionId !== "board") {
}); return "black";
}
// 黑色师父不在棋盘上,红色获胜
const blackMaster = state.pawns["black-master"];
if (!blackMaster || blackMaster.regionId !== "board") {
return "red";
}
return null;
}
/** /**
* 综合胜利检测 * 综合胜利检测
*/ */
async function handleCheckWin(game: OnitamaGame): Promise<PlayerType | null> { async function checkWin(game: OnitamaGame): Promise<PlayerType | null> {
const conquestWinner = await handleCheckConquestWin(game); const conquestWinner = await checkConquestWin(game);
if (conquestWinner) { if (conquestWinner) {
return conquestWinner; return conquestWinner;
} }
const captureWinner = await handleCheckCaptureWin(game);
if (captureWinner) {
return captureWinner;
}
return null;
}
const checkWin = registry.register({ const captureWinner = await checkCaptureWin(game);
schema: 'check-win', if (captureWinner) {
run: handleCheckWin return captureWinner;
}); }
return null;
}
/** /**
* 获取玩家可用的移动 * 获取玩家可用的移动
*/ */
export function getAvailableMoves(state: OnitamaState, player: PlayerType): Array<{card: string, fromX: number, fromY: number, toX: number, toY: number}> { export function getAvailableMoves(
const moves: Array<{card: string, fromX: number, fromY: number, toX: number, toY: number}> = []; state: OnitamaState,
player: PlayerType,
): Array<{
card: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}> {
const moves: Array<{
card: string;
fromX: number;
fromY: number;
toX: number;
toY: number;
}> = [];
// 获取玩家的所有卡牌 // 获取玩家的所有卡牌
const cardNames = player === 'red' ? state.redCards : state.blackCards; const cardNames = player === "red" ? state.redCards : state.blackCards;
// 获取玩家的所有棋子 // 获取玩家的所有棋子
const playerPawns = Object.values(state.pawns).filter(p => p.owner === player && p.regionId === 'board'); const playerPawns = Object.values(state.pawns).filter(
(p) => p.owner === player && p.regionId === "board",
);
// 对于每张卡牌 // 对于每张卡牌
for (const cardName of cardNames) { for (const cardName of cardNames) {
// 获取旋转后的移动候选项(黑方需要旋转180度) // 获取旋转后的移动候选项(黑方需要旋转180度)
const candidates = getCardMoveCandidates(state, cardName, player); const candidates = getCardMoveCandidates(state, cardName, player);
// 对于每个棋子 // 对于每个棋子
for (const pawn of playerPawns) { for (const pawn of playerPawns) {
const [fromX, fromY] = pawn.position; const [fromX, fromY] = pawn.position;
// 对于卡牌的每个移动 // 对于卡牌的每个移动
for (const move of candidates) { for (const move of candidates) {
const toX = fromX + move.dx; const toX = fromX + move.dx;
const toY = fromY + move.dy; const toY = fromY + move.dy;
// 检查移动是否合法 // 检查移动是否合法
if (isInBounds(toX, toY)) { if (isInBounds(toX, toY)) {
const targetPawn = getPawnAtPosition(state, toX, toY); const targetPawn = getPawnAtPosition(state, toX, toY);
// 目标位置为空或有敌方棋子 // 目标位置为空或有敌方棋子
if (!targetPawn || targetPawn.owner !== player) { if (!targetPawn || targetPawn.owner !== player) {
moves.push({ card: cardName, fromX, fromY, toX, toY }); moves.push({ card: cardName, fromX, fromY, toX, toY });
} }
}
}
} }
}
} }
}
return moves; return moves;
} }
/** /**
* 处理回合 * 处理回合
*/ */
async function handleTurn(game: OnitamaGame, turnPlayer: PlayerType) { async function turn(game: OnitamaGame, turnPlayer: PlayerType) {
const state = game.value; const state = game.value;
const availableMoves = getAvailableMoves(state, turnPlayer); const availableMoves = getAvailableMoves(state, turnPlayer);
let moveOutput;
if (availableMoves.length === 0) {
// 没有可用移动,玩家必须交换一张卡牌
const cardToSwap = await game.prompt(
prompts.move,
(player, card, _fromX, _fromY, _toX, _toY) => {
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (!playerHasCard(state, player, card)) {
throw `Player ${player} does not have card ${card}.`;
}
return card;
},
turnPlayer
);
await swapCard(game, turnPlayer, cardToSwap);
moveOutput = { swappedCard: cardToSwap, noMoves: true };
} else {
// 有可用移动,提示玩家选择
moveOutput = await game.prompt(
prompts.move,
(player, card, fromX, fromY, toX, toY) => {
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
const error = isValidMove(state, card, fromX, fromY, toX, toY, player);
if (error) {
throw error;
}
return { player, card, fromX, fromY, toX, toY };
},
turnPlayer
);
await move(game, moveOutput.player, moveOutput.card, moveOutput.fromX, moveOutput.fromY, moveOutput.toX, moveOutput.toY);
}
// 检查胜利
const winner = await checkWin(game);
await game.produceAsync(state => {
state.winner = winner;
if (!winner) {
state.currentPlayer = state.currentPlayer === 'red' ? 'black' : 'red';
state.turn++;
}
});
return { winner, move: moveOutput };
}
const turn = registry.register({ let moveOutput;
schema: 'turn <player>',
run: handleTurn if (availableMoves.length === 0) {
}); // 没有可用移动,玩家必须交换一张卡牌
const cardToSwap = await game.prompt(
prompts.move,
(player, card, _fromX, _fromY, _toX, _toY) => {
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
if (!playerHasCard(state, player, card)) {
throw `Player ${player} does not have card ${card}.`;
}
return card;
},
turnPlayer,
);
await swapCard(game, turnPlayer, cardToSwap);
moveOutput = { swappedCard: cardToSwap, noMoves: true };
} else {
// 有可用移动,提示玩家选择
moveOutput = await game.prompt(
prompts.move,
(player, card, fromX, fromY, toX, toY) => {
if (player !== turnPlayer) {
throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
}
const error = isValidMove(state, card, fromX, fromY, toX, toY, player);
if (error) {
throw error;
}
return { player, card, fromX, fromY, toX, toY };
},
turnPlayer,
);
await move(
game,
moveOutput.player,
moveOutput.card,
moveOutput.fromX,
moveOutput.fromY,
moveOutput.toX,
moveOutput.toY,
);
}
// 检查胜利
const winner = await checkWin(game);
await game.produceAsync((state) => {
state.winner = winner;
if (!winner) {
state.currentPlayer = state.currentPlayer === "red" ? "black" : "red";
state.turn++;
}
});
return { winner, move: moveOutput };
}
/** /**
* 开始游戏主循环 * 开始游戏主循环
*/ */
export async function start(game: OnitamaGame) { export async function start(game: OnitamaGame) {
// Initialize cards with RNG at game start // Initialize cards with RNG at game start
initializeCards(game); initializeCards(game);
while (true) { while (true) {
const currentPlayer = game.value.currentPlayer; const currentPlayer = game.value.currentPlayer;
const turnOutput = await turn(game, currentPlayer); const turnOutput = await turn(game, currentPlayer);
if (turnOutput.winner) { if (turnOutput.winner) {
break; break;
}
} }
}
return game.value; return game.value;
} }
+342 -346
View File
@@ -1,421 +1,417 @@
import {IGameContext} from "@/core/game"; import { IGameContext } from "@/core/game";
import {RegicideState} from "@/samples/regicide/state"; import { RegicideState } from "@/samples/regicide/state";
import {createGameCommandRegistry} from "@/core/game"; import { PlayerType, RegicideCard } from "@/samples/regicide/types";
import {PlayerType, RegicideCard} from "@/samples/regicide/types"; import { CARD_VALUES, FACE_CARDS } from "@/samples/regicide/constants";
import {CARD_VALUES, FACE_CARDS} from "@/samples/regicide/constants"; import { isEnemyDefeated } from "@/samples/regicide/utils";
import {isEnemyDefeated} from "@/samples/regicide/utils";
export type RegicideGame = IGameContext<RegicideState>; export type RegicideGame = IGameContext<RegicideState>;
export const registry = createGameCommandRegistry<RegicideState>();
/** /**
* 打出一张牌(对当前敌人造成伤害) * 打出一张牌(对当前敌人造成伤害)
*/ */
const playCmd = registry.register({ async function playCmd(game: RegicideGame, player: string, cardId: string) {
schema: 'play <player:string> <cardId:string>', const state = game.value;
run: async (game: RegicideGame, player: string, cardId: string) => { const card = state.cards[cardId];
const state = game.value;
const card = state.cards[cardId];
if (!card) { if (!card) {
return {success: false, error: `卡牌 ${cardId} 不存在`}; return { success: false, error: `卡牌 ${cardId} 不存在` };
} }
// 检查卡牌是否在玩家手牌中 // 检查卡牌是否在玩家手牌中
const playerKey = player as PlayerType; const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey]; const playerHand = state.playerHands[playerKey];
if (!playerHand || !playerHand.includes(cardId)) { if (!playerHand || !playerHand.includes(cardId)) {
return {success: false, error: `卡牌 ${cardId} 不在玩家 ${player} 的手牌中`}; return {
} success: false,
error: `卡牌 ${cardId} 不在玩家 ${player} 的手牌中`,
};
}
// 检查是否有当前敌人 // 检查是否有当前敌人
if (!state.currentEnemy) { if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'}; return { success: false, error: "没有活跃的敌人" };
} }
// 计算伤害(基础伤害为卡牌面值) // 计算伤害(基础伤害为卡牌面值)
let damage = card.value; let damage = card.value;
let attackReduction = 0; let attackReduction = 0;
// 梅花双倍伤害 // 梅花双倍伤害
if (card.suit === 'clubs') { if (card.suit === "clubs") {
damage *= 2; damage *= 2;
} }
// 黑桃降低敌人攻击力 // 黑桃降低敌人攻击力
if (card.suit === 'spades') { if (card.suit === "spades") {
attackReduction = card.value; attackReduction = card.value;
} }
const enemyHpBefore = state.currentEnemy.hp; const enemyHpBefore = state.currentEnemy.hp;
await game.produce(state => { await game.produceAsync((state) => {
// 对敌人造成伤害 // 对敌人造成伤害
state.currentEnemy!.hp -= damage; state.currentEnemy!.hp -= damage;
// 记录黑桃的攻击力降低 // 记录黑桃的攻击力降低
if (attackReduction > 0) { if (attackReduction > 0) {
state.currentEnemy!.value = Math.max(0, state.currentEnemy!.value - attackReduction); state.currentEnemy!.value = Math.max(
} 0,
state.currentEnemy!.value - attackReduction,
// 从手牌移除卡牌 );
const hand = state.playerHands[playerKey];
const cardIndex = hand.indexOf(cardId);
if (cardIndex !== -1) {
hand.splice(cardIndex, 1);
}
// 将卡牌移到弃牌堆
state.cards[cardId].regionId = 'discardPile';
// 红心能力:将弃牌堆洗回酒馆牌堆
if (card.suit === 'hearts') {
const discardIds = state.regions.discardPile.childIds.filter(id => id !== state.currentEnemy!.id);
if (discardIds.length > 0) {
// 将弃牌堆(除当前敌人外)移回酒馆牌堆
for (const discardId of discardIds) {
state.cards[discardId].regionId = 'tavernDeck';
}
state.regions.tavernDeck.childIds.push(...discardIds);
state.regions.discardPile.childIds = [state.currentEnemy!.id];
}
}
// 方块能力:从酒馆牌堆抓牌
if (card.suit === 'diamonds') {
const tavernDeckCount = state.regions.tavernDeck.childIds.length;
if (tavernDeckCount > 0) {
const drawCardId = state.regions.tavernDeck.childIds.shift()!;
state.cards[drawCardId].regionId = `hand_${player}`;
hand.push(drawCardId);
}
}
});
// 检查敌人是否被击败
const enemyDefeated = isEnemyDefeated(game.value.currentEnemy);
return {
success: true,
result: {
damage,
attackReduction,
enemyHpBefore,
enemyHpAfter: game.value.currentEnemy!.hp,
enemyDefeated,
suitAbility: card.suit
}
};
} }
});
// 从手牌移除卡牌
const hand = state.playerHands[playerKey];
const cardIndex = hand.indexOf(cardId);
if (cardIndex !== -1) {
hand.splice(cardIndex, 1);
}
// 将卡牌移到弃牌堆
state.cards[cardId].regionId = "discardPile";
// 红心能力:将弃牌堆洗回酒馆牌堆
if (card.suit === "hearts") {
const discardIds = state.regions.discardPile.childIds.filter(
(id) => id !== state.currentEnemy!.id,
);
if (discardIds.length > 0) {
// 将弃牌堆(除当前敌人外)移回酒馆牌堆
for (const discardId of discardIds) {
state.cards[discardId].regionId = "tavernDeck";
}
state.regions.tavernDeck.childIds.push(...discardIds);
state.regions.discardPile.childIds = [state.currentEnemy!.id];
}
}
// 方块能力:从酒馆牌堆抓牌
if (card.suit === "diamonds") {
const tavernDeckCount = state.regions.tavernDeck.childIds.length;
if (tavernDeckCount > 0) {
const drawCardId = state.regions.tavernDeck.childIds.shift()!;
state.cards[drawCardId].regionId = `hand_${player}`;
hand.push(drawCardId);
}
}
});
// 检查敌人是否被击败
const enemyDefeated = isEnemyDefeated(game.value.currentEnemy);
return {
success: true,
result: {
damage,
attackReduction,
enemyHpBefore,
enemyHpAfter: game.value.currentEnemy!.hp,
enemyDefeated,
suitAbility: card.suit,
},
};
}
/** /**
* 打出A配合另一张牌 * 打出A配合另一张牌
*/ */
const playWithACmd = registry.register({ async function playWithACmd(
schema: 'play-with-a <player:string> <aceCardId:string> <otherCardId:string>', game: RegicideGame,
run: async (game: RegicideGame, player: string, aceCardId: string, otherCardId: string) => { player: string,
const state = game.value; aceCardId: string,
const aceCard = state.cards[aceCardId]; otherCardId: string,
const otherCard = state.cards[otherCardId]; ) {
const state = game.value;
const aceCard = state.cards[aceCardId];
const otherCard = state.cards[otherCardId];
if (!aceCard || !otherCard) { if (!aceCard || !otherCard) {
return {success: false, error: '卡牌不存在'}; return { success: false, error: "卡牌不存在" };
} }
// 检查是否是A牌 // 检查是否是A牌
if (aceCard.rank !== 'A') { if (aceCard.rank !== "A") {
return {success: false, error: `第一张牌必须是A`}; return { success: false, error: `第一张牌必须是A` };
} }
const playerKey = player as PlayerType; const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey]; const playerHand = state.playerHands[playerKey];
// 检查两张牌都在手牌中 // 检查两张牌都在手牌中
if (!playerHand.includes(aceCardId) || !playerHand.includes(otherCardId)) { if (!playerHand.includes(aceCardId) || !playerHand.includes(otherCardId)) {
return {success: false, error: '卡牌不在手牌中'}; return { success: false, error: "卡牌不在手牌中" };
} }
if (!state.currentEnemy) { if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'}; return { success: false, error: "没有活跃的敌人" };
} }
// 计算两张牌的总伤害 // 计算两张牌的总伤害
let totalDamage = aceCard.value + otherCard.value; let totalDamage = aceCard.value + otherCard.value;
// 如果另一张牌是梅花,双倍伤害 // 如果另一张牌是梅花,双倍伤害
if (otherCard.suit === 'clubs') { if (otherCard.suit === "clubs") {
totalDamage *= 2; totalDamage *= 2;
} }
let attackReduction = 0; let attackReduction = 0;
if (aceCard.suit === 'spades') { if (aceCard.suit === "spades") {
attackReduction += aceCard.value; attackReduction += aceCard.value;
} }
if (otherCard.suit === 'spades') { if (otherCard.suit === "spades") {
attackReduction += otherCard.value; attackReduction += otherCard.value;
} }
await game.produce(state => { await game.produceAsync((state) => {
// 对敌人造成伤害 // 对敌人造成伤害
state.currentEnemy!.hp -= totalDamage; state.currentEnemy!.hp -= totalDamage;
// 记录黑桃的攻击力降低 // 记录黑桃的攻击力降低
if (attackReduction > 0) { if (attackReduction > 0) {
state.currentEnemy!.value = Math.max(0, state.currentEnemy!.value - attackReduction); state.currentEnemy!.value = Math.max(
} 0,
state.currentEnemy!.value - attackReduction,
// 从手牌移除两张牌 );
const hand = state.playerHands[playerKey];
const aceIndex = hand.indexOf(aceCardId);
const otherIndex = hand.indexOf(otherCardId);
if (aceIndex !== -1) hand.splice(aceIndex, 1);
if (otherIndex !== -1) hand.splice(otherIndex, 1);
// 将卡牌移到弃牌堆
state.cards[aceCardId].regionId = 'discardPile';
state.cards[otherCardId].regionId = 'discardPile';
});
const enemyDefeated = isEnemyDefeated(state.currentEnemy);
return {
success: true,
result: {
damage: totalDamage,
attackReduction,
enemyHp: state.currentEnemy!.hp,
enemyDefeated
}
};
} }
});
// 从手牌移除两张牌
const hand = state.playerHands[playerKey];
const aceIndex = hand.indexOf(aceCardId);
const otherIndex = hand.indexOf(otherCardId);
if (aceIndex !== -1) hand.splice(aceIndex, 1);
if (otherIndex !== -1) hand.splice(otherIndex, 1);
// 将卡牌移到弃牌堆
state.cards[aceCardId].regionId = "discardPile";
state.cards[otherCardId].regionId = "discardPile";
});
const enemyDefeated = isEnemyDefeated(state.currentEnemy);
return {
success: true,
result: {
damage: totalDamage,
attackReduction,
enemyHp: state.currentEnemy!.hp,
enemyDefeated,
},
};
}
/** /**
* 让过(不出牌) * 让过(不出牌)
*/ */
const passCmd = registry.register({ async function passCmd(game: RegicideGame, player: string) {
schema: 'pass <player:string>', // 即使让过,也会受到敌人反击(在回合结束时处理)
run: async (game: RegicideGame, player: string) => { return { success: true, result: { message: `${player} 让过` } };
// 即使让过,也会受到敌人反击(在回合结束时处理) }
return {success: true, result: {message: `${player} 让过`}};
}
});
/** /**
* 敌人反击 - 玩家必须弃掉点数和 >= 敌人攻击力的牌 * 敌人反击 - 玩家必须弃掉点数和 >= 敌人攻击力的牌
*/ */
const enemyCounterattackCmd = registry.register({ async function enemyCounterattackCmd(
schema: 'counterattack <player:string> <discardCards:string[]>', game: RegicideGame,
run: async (game: RegicideGame, player: string, discardCards: string[]) => { player: string,
const state = game.value; discardCards: string[],
) {
const state = game.value;
if (!state.currentEnemy) { if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'}; return { success: false, error: "没有活跃的敌人" };
} }
const playerKey = player as PlayerType; const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey]; const playerHand = state.playerHands[playerKey];
// 检查要弃的牌都在手牌中 // 检查要弃的牌都在手牌中
for (const cardId of discardCards) { for (const cardId of discardCards) {
if (!playerHand.includes(cardId)) { if (!playerHand.includes(cardId)) {
return {success: false, error: `卡牌 ${cardId} 不在手牌中`}; return { success: false, error: `卡牌 ${cardId} 不在手牌中` };
}
}
// 计算弃牌的点数和
let totalValue = 0;
for (const cardId of discardCards) {
const card = state.cards[cardId];
if (card) {
totalValue += card.value;
}
}
const enemyAttack = state.currentEnemy.value;
// 检查点数和是否 >= 敌人攻击力
if (totalValue < enemyAttack) {
return {
success: false,
error: `弃牌点数和 (${totalValue}) 小于敌人攻击力 (${enemyAttack}),游戏失败`
};
}
// 执行弃牌
await game.produce(state => {
const hand = state.playerHands[playerKey];
for (const cardId of discardCards) {
const index = hand.indexOf(cardId);
if (index !== -1) {
hand.splice(index, 1);
}
state.cards[cardId].regionId = 'discardPile';
}
});
return {
success: true,
result: {
discardedCards: discardCards,
totalValue,
enemyAttack
}
};
} }
}); }
// 计算弃牌的点数和
let totalValue = 0;
for (const cardId of discardCards) {
const card = state.cards[cardId];
if (card) {
totalValue += card.value;
}
}
const enemyAttack = state.currentEnemy.value;
// 检查点数和是否 >= 敌人攻击力
if (totalValue < enemyAttack) {
return {
success: false,
error: `弃牌点数和 (${totalValue}) 小于敌人攻击力 (${enemyAttack}),游戏失败`,
};
}
// 执行弃牌
await game.produceAsync((state) => {
const hand = state.playerHands[playerKey];
for (const cardId of discardCards) {
const index = hand.indexOf(cardId);
if (index !== -1) {
hand.splice(index, 1);
}
state.cards[cardId].regionId = "discardPile";
}
});
return {
success: true,
result: {
discardedCards: discardCards,
totalValue,
enemyAttack,
},
};
}
/** /**
* 检查敌人是否被击败,如果击败则翻开下一个敌人 * 检查敌人是否被击败,如果击败则翻开下一个敌人
*/ */
const checkEnemyDefeatedCmd = registry.register({ async function checkEnemyDefeatedCmd(game: RegicideGame) {
schema: 'check-enemy', const state = game.value;
run: async (game: RegicideGame) => {
const state = game.value;
if (!state.currentEnemy) { if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'}; return { success: false as const, error: "没有活跃的敌人" };
} }
const defeated = state.currentEnemy.hp <= 0; const defeated = state.currentEnemy.hp <= 0;
if (defeated) { if (defeated) {
const defeatedEnemy = {...state.currentEnemy}; const defeatedEnemy = { ...state.currentEnemy };
await game.produce(state => { await game.produceAsync((state) => {
// 将当前敌人移到弃牌堆 // 将当前敌人移到弃牌堆
state.regions.discardPile.childIds.push(state.currentEnemy!.id); state.regions.discardPile.childIds.push(state.currentEnemy!.id);
// 翻开下一个敌人 // 翻开下一个敌人
if (state.enemyDeck.length > 0) { if (state.enemyDeck.length > 0) {
const nextEnemy = state.enemyDeck.shift()!; const nextEnemy = state.enemyDeck.shift()!;
state.currentEnemy = nextEnemy; state.currentEnemy = nextEnemy;
} else { } else {
// 没有更多敌人了 // 没有更多敌人了
state.currentEnemy = null; state.currentEnemy = null;
} }
}); });
// 检查是否胜利(没有更多敌人) // 检查是否胜利(没有更多敌人)
if (!game.value.currentEnemy) { if (!game.value.currentEnemy) {
await game.produce(state => { await game.produceAsync((state) => {
state.phase = 'victory'; state.phase = "victory";
state.winner = true; state.winner = true;
}); });
}
return {
success: true,
result: {
defeated: true,
defeatedEnemy,
nextEnemy: game.value.currentEnemy,
enemiesRemaining: game.value.enemyDeck.length
}
};
}
return {
success: true,
result: {
defeated: false,
currentEnemy: {...state.currentEnemy},
enemiesRemaining: state.enemyDeck.length
}
};
} }
});
return {
success: true as const,
result: {
defeated: true,
defeatedEnemy,
nextEnemy: game.value.currentEnemy,
enemiesRemaining: game.value.enemyDeck.length,
},
};
}
return {
success: true,
result: {
defeated: false,
currentEnemy: { ...state.currentEnemy },
enemiesRemaining: state.enemyDeck.length,
},
};
}
/** /**
* 检查玩家是否有可出的牌 * 检查玩家是否有可出的牌
*/ */
const checkCanPlayCmd = registry.register({ async function checkCanPlayCmd(game: RegicideGame, player: string) {
schema: 'check-can-play <player:string>', const state = game.value;
run: async (game: RegicideGame, player: string) => { const playerKey = player as PlayerType;
const state = game.value; const playerHand = state.playerHands[playerKey];
const playerKey = player as PlayerType;
const playerHand = state.playerHands[playerKey];
const canPlay = playerHand.length > 0; const canPlay = playerHand.length > 0;
const canPlayWithA = playerHand.some(cardId => { const canPlayWithA = playerHand.some((cardId) => {
const card = state.cards[cardId]; const card = state.cards[cardId];
return card && card.rank === 'A' && playerHand.length > 1; return card && card.rank === "A" && playerHand.length > 1;
}); });
return { return {
success: true, success: true,
result: { result: {
canPlay, canPlay,
canPlayWithA, canPlayWithA,
handSize: playerHand.length handSize: playerHand.length,
} },
}; };
} }
});
/** /**
* 检查酒馆牌堆是否为空 * 检查酒馆牌堆是否为空
*/ */
const checkTavernDeckCmd = registry.register({ async function checkTavernDeckCmd(game: RegicideGame) {
schema: 'check-tavern-deck', const state = game.value;
run: async (game: RegicideGame) => { const isEmpty = state.regions.tavernDeck.childIds.length === 0;
const state = game.value;
const isEmpty = state.regions.tavernDeck.childIds.length === 0;
// 如果酒馆牌堆为空且所有玩家手牌也为空,则游戏失败 // 如果酒馆牌堆为空且所有玩家手牌也为空,则游戏失败
if (isEmpty) { if (isEmpty) {
const allHandsEmpty = Object.values(state.playerHands).every(hand => hand.length === 0); const allHandsEmpty = Object.values(state.playerHands).every(
if (allHandsEmpty) { (hand) => hand.length === 0,
await game.produce(state => { );
state.phase = 'defeat'; if (allHandsEmpty) {
state.winner = false; await game.produceAsync((state) => {
}); state.phase = "defeat";
} state.winner = false;
} });
return {
success: true,
result: {
isEmpty,
cardsRemaining: state.regions.tavernDeck.childIds.length
}
};
} }
}); }
return {
success: true,
result: {
isEmpty,
cardsRemaining: state.regions.tavernDeck.childIds.length,
},
};
}
/** /**
* 下一个玩家回合 * 下一个玩家回合
*/ */
const nextTurnCmd = registry.register({ async function nextTurnCmd(game: RegicideGame) {
schema: 'next-turn', const state = game.value;
run: async (game: RegicideGame) => { await game.produce((state) => {
const state = game.value; state.currentPlayerIndex =
await game.produce(state => { (state.currentPlayerIndex + 1) % state.playerCount;
state.currentPlayerIndex = (state.currentPlayerIndex + 1) % state.playerCount; });
});
const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4']; const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
const currentPlayer = players[game.value.currentPlayerIndex]; const currentPlayer = players[game.value.currentPlayerIndex];
return { return {
success: true, success: true,
result: { result: {
currentPlayer, currentPlayer,
currentPlayerIndex: game.value.currentPlayerIndex currentPlayerIndex: game.value.currentPlayerIndex,
} },
}; };
} }
});
export { export {
playCmd as play, playCmd as play,
playWithACmd as playWithA, playWithACmd as playWithA,
passCmd as pass, passCmd as pass,
enemyCounterattackCmd as enemyCounterattack, enemyCounterattackCmd as enemyCounterattack,
checkEnemyDefeatedCmd as checkEnemy, checkEnemyDefeatedCmd as checkEnemy,
checkCanPlayCmd as checkCanPlay, checkCanPlayCmd as checkCanPlay,
checkTavernDeckCmd as checkTavernDeck, checkTavernDeckCmd as checkTavernDeck,
nextTurnCmd as nextTurn, nextTurnCmd as nextTurn,
}; };
+219 -185
View File
@@ -1,8 +1,15 @@
import {IGameContext} from "@/core/game"; import { IGameContext } from "@/core/game";
import {RegicideState} from "@/samples/regicide/state"; import { RegicideState } from "@/samples/regicide/state";
import {buildEnemyDeck, buildTavernDeck, createAllCards, getPlayerHandRegionId} from "@/samples/regicide/utils"; import {
import {INITIAL_HAND_SIZE} from "@/samples/regicide/constants"; buildEnemyDeck,
import {Enemy, PlayerType, RegicideCard} from "@/samples/regicide/types"; buildTavernDeck,
createAllCards,
getPlayerHandRegionId,
} from "@/samples/regicide/utils";
import { INITIAL_HAND_SIZE } from "@/samples/regicide/constants";
import { Enemy, PlayerType, RegicideCard } from "@/samples/regicide/types";
import { checkEnemy, nextTurn, pass, playWithA } from "./commands";
import { enemyCounterattackCmd, playCmd } from ".";
export type RegicideGame = IGameContext<RegicideState>; export type RegicideGame = IGameContext<RegicideState>;
@@ -12,228 +19,255 @@ export type RegicideGame = IGameContext<RegicideState>;
* @param playerCount 玩家数量(1-4) * @param playerCount 玩家数量(1-4)
* @param seed 随机种子(可选) * @param seed 随机种子(可选)
*/ */
export async function setupGame(game: RegicideGame, playerCount: number, seed?: number) { export async function setupGame(
if (playerCount < 1 || playerCount > 4) { game: RegicideGame,
throw new Error('玩家数量必须为 1-4 人'); playerCount: number,
seed?: number,
) {
if (playerCount < 1 || playerCount > 4) {
throw new Error("玩家数量必须为 1-4 人");
}
if (seed) {
// RNG seeding handled by game context
}
// 创建所有卡牌
const allCards = createAllCards();
// 构建敌人牌堆(J/Q/K)
const enemyDeck = buildEnemyDeck(game._rng);
// 构建酒馆牌堆(A-10)
const tavernDeck = buildTavernDeck(game._rng);
// 初始化游戏状态
await game.produceAsync((state) => {
state.cards = allCards;
state.playerCount = playerCount;
state.currentPlayerIndex = 0;
state.enemyDeck = enemyDeck;
// 设置酒馆牌堆区域
for (const card of tavernDeck) {
card.regionId = "tavernDeck";
state.regions.tavernDeck.childIds.push(card.id);
} }
if (seed) { // 设置敌人牌堆区域(只存储ID,敌人是独立对象)
// RNG seeding handled by game context state.regions.enemyDeck.childIds = enemyDeck.map((e) => e.id);
// 给每个玩家发牌
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
for (let i = 0; i < playerCount; i++) {
const player = players[i];
const regionId = getPlayerHandRegionId(player);
for (let j = 0; j < INITIAL_HAND_SIZE; j++) {
if (tavernDeck.length === 0) break;
const card = tavernDeck.shift()!;
card.regionId = regionId;
state.playerHands[player].push(card.id);
const region = state.regions[regionId as keyof typeof state.regions];
region.childIds.push(card.id);
}
} }
// 创建所有卡牌 // 翻开第一个敌人
const allCards = createAllCards(); if (enemyDeck.length > 0) {
const firstEnemy = enemyDeck.shift()!;
// 构建敌人牌堆(J/Q/K) state.currentEnemy = firstEnemy;
const enemyDeck = buildEnemyDeck(game._rng); }
});
// 构建酒馆牌堆(A-10)
const tavernDeck = buildTavernDeck(game._rng);
// 初始化游戏状态
await game.produceAsync(state => {
state.cards = allCards;
state.playerCount = playerCount;
state.currentPlayerIndex = 0;
state.enemyDeck = enemyDeck;
// 设置酒馆牌堆区域
for (const card of tavernDeck) {
card.regionId = 'tavernDeck';
state.regions.tavernDeck.childIds.push(card.id);
}
// 设置敌人牌堆区域(只存储ID,敌人是独立对象)
state.regions.enemyDeck.childIds = enemyDeck.map(e => e.id);
// 给每个玩家发牌
const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4'];
for (let i = 0; i < playerCount; i++) {
const player = players[i];
const regionId = getPlayerHandRegionId(player);
for (let j = 0; j < INITIAL_HAND_SIZE; j++) {
if (tavernDeck.length === 0) break;
const card = tavernDeck.shift()!;
card.regionId = regionId;
state.playerHands[player].push(card.id);
const region = state.regions[regionId as keyof typeof state.regions];
region.childIds.push(card.id);
}
}
// 翻开第一个敌人
if (enemyDeck.length > 0) {
const firstEnemy = enemyDeck.shift()!;
state.currentEnemy = firstEnemy;
}
});
} }
/** /**
* 启动游戏主循环 * 启动游戏主循环
*/ */
export async function start(game: RegicideGame) { export async function start(game: RegicideGame) {
const state = game.value; const state = game.value;
// 检查游戏是否已设置 // 检查游戏是否已设置
if (!state.currentEnemy) { if (!state.currentEnemy) {
throw new Error('请先调用 setupGame 初始化游戏'); throw new Error("请先调用 setupGame 初始化游戏");
}
const players: PlayerType[] = ["player1", "player2", "player3", "player4"];
// 主游戏循环
while (state.phase === "playing") {
const currentPlayerIndex = state.currentPlayerIndex;
const currentPlayer = players[currentPlayerIndex];
// 检查当前玩家是否有手牌
const currentHand = state.playerHands[currentPlayer];
if (currentHand.length === 0) {
// 玩家没有手牌,跳过回合
await game.produceAsync((state) => {
state.currentPlayerIndex =
(state.currentPlayerIndex + 1) % state.playerCount;
});
continue;
} }
const players: PlayerType[] = ['player1', 'player2', 'player3', 'player4']; // 等待玩家输入(出牌或让过)
// 这里需要外部通过 prompt 系统获取输入
// 实际使用时由 UI 或测试代码提供输入
// 主游戏循环 // 循环会在外部调用 play/pass 命令后继续
while (state.phase === 'playing') { // 当 phase 变为 'victory' 或 'defeat' 时退出
const currentPlayerIndex = state.currentPlayerIndex; break;
const currentPlayer = players[currentPlayerIndex]; }
// 检查当前玩家是否有手牌 return game.value;
const currentHand = state.playerHands[currentPlayer];
if (currentHand.length === 0) {
// 玩家没有手牌,跳过回合
await game.produceAsync(state => {
state.currentPlayerIndex = (state.currentPlayerIndex + 1) % state.playerCount;
});
continue;
}
// 等待玩家输入(出牌或让过)
// 这里需要外部通过 prompt 系统获取输入
// 实际使用时由 UI 或测试代码提供输入
// 循环会在外部调用 play/pass 命令后继续
// 当 phase 变为 'victory' 或 'defeat' 时退出
break;
}
return game.value;
} }
/** /**
* 处理完整的玩家回合 * 处理完整的玩家回合
*/ */
export async function playTurn(game: RegicideGame, player: PlayerType, action: 'play' | 'pass', cardId?: string, secondCardId?: string) { export async function playTurn(
const state = game.value; game: RegicideGame,
player: PlayerType,
action: "play" | "pass",
cardId?: string,
secondCardId?: string,
) {
const state = game.value;
if (state.phase !== 'playing') { if (state.phase !== "playing") {
return {success: false, error: '游戏已结束'}; return { success: false, error: "游戏已结束" };
} }
if (!state.currentEnemy) { if (!state.currentEnemy) {
return {success: false, error: '没有活跃的敌人'}; return { success: false, error: "没有活跃的敌人" };
} }
let playResult: any; let playResult: any;
// 执行玩家动作 // 执行玩家动作
if (action === 'play' && cardId) { if (action === "play" && cardId) {
// 检查是否是A配合另一张牌 // 检查是否是A配合另一张牌
const card = state.cards[cardId]; const card = state.cards[cardId];
if (card.rank === 'A' && secondCardId) { if (card.rank === "A" && secondCardId) {
playResult = await game.run(`play-with-a ${player} ${cardId} ${secondCardId}`); playResult = await playWithA(game, player, cardId, secondCardId);
} else {
playResult = await game.run(`play ${player} ${cardId}`);
}
} else { } else {
// 让过 playResult = await playCmd(game, player, cardId);
playResult = await game.run(`pass ${player}`);
} }
} else {
// 让过
playResult = await pass(game, player);
}
if (!playResult.success) { if (!playResult.success) {
return playResult; return playResult;
} }
// 检查敌人是否被击败 // 检查敌人是否被击败
const checkResult = await game.run<{defeated: boolean; currentEnemy?: any; nextEnemy?: any; defeatedEnemy?: any; enemiesRemaining?: number}>('check-enemy'); const checkResult = await checkEnemy(game);
if (!checkResult.success) { if (!checkResult.success) {
return checkResult; return checkResult;
} }
// 如果敌人未被击败,处理反击
if (!checkResult.result.defeated) {
// 反击逻辑需要玩家选择弃牌,这里返回状态让外部处理
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: false,
needsDiscard: true,
enemyAttack: state.currentEnemy.value,
playerHand: state.playerHands[player]
}
};
}
// 敌人被击败,检查是否还有更多敌人
if (state.enemyDeck.length === 0 && state.currentEnemy && state.currentEnemy.hp <= 0) {
await game.produceAsync(state => {
state.phase = 'victory';
state.winner = true;
});
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: true,
gameWon: true
}
};
}
// 切换到下一个玩家
await game.run('next-turn');
// 如果敌人未被击败,处理反击
if (!checkResult.result.defeated) {
// 反击逻辑需要玩家选择弃牌,这里返回状态让外部处理
return { return {
success: true, success: true,
result: { result: {
playResult: playResult.result, playResult: playResult.result,
enemyDefeated: true, enemyDefeated: false,
nextEnemy: state.currentEnemy needsDiscard: true,
} enemyAttack: state.currentEnemy.value,
playerHand: state.playerHands[player],
},
}; };
}
// 敌人被击败,检查是否还有更多敌人
if (
state.enemyDeck.length === 0 &&
state.currentEnemy &&
state.currentEnemy.hp <= 0
) {
await game.produceAsync((state) => {
state.phase = "victory";
state.winner = true;
});
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: true,
gameWon: true,
},
};
}
// 切换到下一个玩家
await nextTurn(game);
return {
success: true,
result: {
playResult: playResult.result,
enemyDefeated: true,
nextEnemy: state.currentEnemy,
},
};
} }
/** /**
* 处理反击阶段的弃牌 * 处理反击阶段的弃牌
*/ */
export async function handleCounterattack(game: RegicideGame, player: PlayerType, discardCardIds: string[]) { export async function handleCounterattack(
const result = await game.run(`counterattack ${player} ${JSON.stringify(discardCardIds)}`); game: RegicideGame,
player: PlayerType,
if (!result.success) { discardCardIds: string[],
// 弃牌失败(点数和不足),游戏失败 ) {
await game.produceAsync(state => { const result = await enemyCounterattackCmd(game, player, discardCardIds);
state.phase = 'defeat'; if (!result.success) {
state.winner = false; // 弃牌失败(点数和不足),游戏失败
}); await game.produceAsync((state) => {
return result; state.phase = "defeat";
} state.winner = false;
});
// 弃牌成功,切换到下一个玩家
await game.run('next-turn');
return result; return result;
}
// 弃牌成功,切换到下一个玩家
await nextTurn(game);
return result;
} }
/** /**
* 获取当前游戏状态摘要 * 获取当前游戏状态摘要
*/ */
export function getGameStatus(game: RegicideGame) { export function getGameStatus(game: RegicideGame) {
const state = game.value; const state = game.value;
return { return {
phase: state.phase, phase: state.phase,
currentPlayer: ['player1', 'player2', 'player3', 'player4'][state.currentPlayerIndex], currentPlayer: ["player1", "player2", "player3", "player4"][
currentEnemy: state.currentEnemy ? { state.currentPlayerIndex
...state.currentEnemy, ],
hpPercent: Math.round((state.currentEnemy.hp / state.currentEnemy.maxHp) * 100) currentEnemy: state.currentEnemy
} : null, ? {
enemiesRemaining: state.enemyDeck.length, ...state.currentEnemy,
tavernDeckCount: state.regions.tavernDeck.childIds.length, hpPercent: Math.round(
discardPileCount: state.regions.discardPile.childIds.length, (state.currentEnemy.hp / state.currentEnemy.maxHp) * 100,
playerHands: Object.fromEntries( ),
Object.entries(state.playerHands).map(([player, hand]) => [player, hand.length]) }
), : null,
winner: state.winner enemiesRemaining: state.enemyDeck.length,
}; tavernDeckCount: state.regions.tavernDeck.childIds.length,
discardPileCount: state.regions.discardPile.childIds.length,
playerHands: Object.fromEntries(
Object.entries(state.playerHands).map(([player, hand]) => [
player,
hand.length,
]),
),
winner: state.winner,
};
} }
+45 -49
View File
@@ -1,67 +1,63 @@
// Types // Types
export type { export type {
SuitType, SuitType,
CardRank, CardRank,
PlayerType, PlayerType,
RegionType, RegionType,
RegicideCardMeta, RegicideCardMeta,
RegicideCard, RegicideCard,
Enemy, Enemy,
GamePhase GamePhase,
} from './types'; } from "./types";
// Constants // Constants
export { export {
ENEMY_COUNT, ENEMY_COUNT,
INITIAL_HAND_SIZE, INITIAL_HAND_SIZE,
CARD_VALUES, CARD_VALUES,
ALL_SUITS, ALL_SUITS,
ALL_RANKS, ALL_RANKS,
FACE_CARDS, FACE_CARDS,
NUMBER_CARDS NUMBER_CARDS,
} from './constants'; } from "./constants";
// State // State
export { export { createInitialState, type RegicideState } from "./state";
createInitialState,
type RegicideState
} from './state';
// Prompts // Prompts
export {prompts} from './prompts'; export { prompts } from "./prompts";
// Commands // Commands
export { export {
registry, play as playCmd,
play as playCmd, playWithA as playWithACmd,
playWithA as playWithACmd, pass as passCmd,
pass as passCmd, enemyCounterattack as enemyCounterattackCmd,
enemyCounterattack as enemyCounterattackCmd, checkEnemy as checkEnemyCmd,
checkEnemy as checkEnemyCmd, checkCanPlay as checkCanPlayCmd,
checkCanPlay as checkCanPlayCmd, checkTavernDeck as checkTavernDeckCmd,
checkTavernDeck as checkTavernDeckCmd, nextTurn as nextTurnCmd,
nextTurn as nextTurnCmd, } from "./commands";
} from './commands';
// Game // Game
export { export {
setupGame, setupGame,
start, start,
playTurn, playTurn,
handleCounterattack, handleCounterattack,
getGameStatus, getGameStatus,
type RegicideGame type RegicideGame,
} from './game'; } from "./game";
// Utils // Utils
export { export {
getCardValue, getCardValue,
createCard, createCard,
createEnemy, createEnemy,
createAllCards, createAllCards,
buildEnemyDeck, buildEnemyDeck,
buildTavernDeck, buildTavernDeck,
drawFromDeck, drawFromDeck,
isEnemyDefeated, isEnemyDefeated,
getPlayerHandRegionId getPlayerHandRegionId,
} from './utils'; } from "./utils";
@@ -2,37 +2,42 @@
# type: 'item' = inventory item card, 'status' = status effect card # type: 'item' = inventory item card, 'status' = status effect card
# costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free # costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
# targetType: 'single' = target one enemy, 'none' = no target # targetType: 'single' = target one enemy, 'none' = no target
# type CardType = 'item' | 'status'
# type CardCostType = 'energy' | 'uses' | 'none'
# type CardTargetType = 'player' | 'enemy' | 'enemies'
# inject effects = ~cardEffect(card) # inject effects = ~cardEffect(card)
id,name,desc,type,costType,costCount,targetType id,name,desc,type,costType,costCount,targetType
string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none' string,string,string,CardType,CardCostType,int,CardTargetType
sword,剑,【攻击2】【攻击2】,item,energy,1,single sword,剑,【攻击2】【攻击2】,item,energy,1,enemy
greataxe,长斧,对全体【攻击5】,item,energy,2,none greataxe,长斧,对全体【攻击5】,item,energy,2,enemies
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,enemy
dagger,短刀,【攻击3】【攻击3】,item,energy,1,single dagger,短刀,【攻击3】【攻击3】,item,energy,1,enemy
dart,飞镖,【攻击1】抓一张牌,item,energy,0,single dart,飞镖,【攻击1】抓一张牌,item,energy,0,enemy
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,enemy
shield,盾,【防御3】,item,energy,1,none shield,盾,【防御3】,item,energy,1,player
hat,斗笠,【防御8】,item,energy,2,none hat,斗笠,【防御8】,item,energy,2,player
cape,披风,【防御2】下回合【防御2】,item,energy,1,none cape,披风,【防御2】下回合【防御2】,item,energy,1,player
bracer,护腕,【防御1】抓1张牌,item,energy,0,none bracer,护腕,【防御1】抓1张牌,item,energy,0,player
greatshield,大盾,【防御5】,item,energy,1,none greatshield,大盾,【防御5】,item,energy,1,player
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none chainmail,锁子甲,本回合受到伤害-3,item,energy,1,player
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,player
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,player
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,player
vitalityPotion,活力药剂,获得1点能量,item,uses,3,none vitalityPotion,活力药剂,获得1点能量,item,uses,3,player
focusPotion,集中药剂,抓2张牌,item,uses,3,none focusPotion,集中药剂,抓2张牌,item,uses,3,player
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,player
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,player
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,player
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,player
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,player
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,player
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,player
wound,伤口,无效果占用手牌和牌堆,status,none,0,none wound,伤口,无效果占用手牌和牌堆,status,none,0,player
venom,蛇毒,弃掉时受到3点伤害,status,none,0,none venom,蛇毒,弃掉时受到3点伤害,status,none,0,player
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,player
static,静电,在手里时受电击伤害+1,status,none,0,none static,静电,在手里时受电击伤害+1,status,none,0,player
fatigue,疲劳,占用手牌,status,none,0,none fatigue,疲劳,占用手牌,status,none,0,player
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none 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 # 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 # inject effects = ~cardEffect(card) 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'; import type { CardEffect } from './cardEffect.csv';
export type CardType = 'item' | 'status';
export type CardCostType = 'energy' | 'uses' | 'none';
export type CardTargetType = 'player' | 'enemy' | 'enemies';
type CardTable = readonly { type CardTable = readonly {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly desc: string; readonly desc: string;
readonly type: "item" | "status"; readonly type: CardType;
readonly costType: "energy" | "uses" | "none"; readonly costType: CardCostType;
readonly costCount: number; readonly costCount: number;
readonly targetType: "single" | "none"; readonly targetType: CardTargetType;
readonly effects: CardEffect[]; 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 id,card,trigger,target,effects
string,@card,'onPlay'|'onDraw'|'onDiscard','self'|'target'|'all'|'random',[@effect;number][] string,@card,CardEffectTrigger,CardEffectTarget,CardEffectList
sword,sword,onPlay,target,[attack;2];[attack;2] sword,sword,onPlay,eachTarget,[attack;2];[attack;2]
greataxe,greataxe,onPlay,all,[attack;5] greataxe,greataxe,onPlay,eachTarget,[attack;5]
spear,spear,onPlay,target,[attack;2];[attack;2];[attack;2] spear,spear,onPlay,eachTarget,[attack;2];[attack;2];[attack;2]
dagger,dagger,onPlay,target,[attack;3];[attack;3] dagger,dagger,onPlay,eachTarget,[attack;3];[attack;3]
dart,dart,onPlay,target,[attack;1] dart,dart,onPlay,eachTarget,[attack;1]
dart-draw,dart,onPlay,self,[draw;1] dart-draw,dart,onPlay,source,[draw;1]
crossbow,crossbow,onPlay,target,[attack;6] crossbow,crossbow,onPlay,eachTarget,[attack;6]
crossbow-combo,crossbow,onPlay,self,[crossbow;0] crossbow-combo,crossbow,onPlay,source,[crossbow;0]
shield,shield,onPlay,self,[defend;3] shield,shield,onPlay,source,[defend;3]
hat,hat,onPlay,self,[defend;8] hat,hat,onPlay,source,[defend;8]
cape,cape,onPlay,self,[defend;2];[defendNext;2] cape,cape,onPlay,source,[defend;2];[defendNext;2]
bracer,bracer,onPlay,self,[defend;1];[draw;1] bracer,bracer,onPlay,source,[defend;1];[draw;1]
greatshield,greatshield,onPlay,self,[defend;5] greatshield,greatshield,onPlay,source,[defend;5]
chainmail,chainmail,onPlay,self,[damageReduce;3] chainmail,chainmail,onPlay,source,[damageReduce;3]
bandage,bandage,onPlay,self,[removeWound;1] bandage,bandage,onPlay,source,[removeWound;1]
poisonPotion,poisonPotion,onPlay,self,[attackBuff;2] poisonPotion,poisonPotion,onPlay,source,[attackBuff;2]
fortifyPotion,fortifyPotion,onPlay,self,[defendBuff;2] fortifyPotion,fortifyPotion,onPlay,source,[defendBuff;2]
vitalityPotion,vitalityPotion,onPlay,self,[gainEnergy;1] vitalityPotion,vitalityPotion,onPlay,source,[gainEnergy;1]
focusPotion,focusPotion,onPlay,self,[draw;2] focusPotion,focusPotion,onPlay,source,[draw;2]
healingPotion,healingPotion,onPlay,self,[removeWound;3] healingPotion,healingPotion,onPlay,source,[removeWound;3]
waterBag,waterBag,onPlay,self,[energyNext;1];[drawNext;2] waterBag,waterBag,onPlay,source,[energyNext;1];[drawNext;2]
rope,rope,onPlay,self,[defendBuffUntilPlay;2] rope,rope,onPlay,source,[defendBuffUntilPlay;2]
belt,belt,onPlay,self,[drawChoice;1] belt,belt,onPlay,source,[drawChoice;1]
torch,torch,onPlay,self,[burnForEnergy;1] torch,torch,onPlay,source,[burnForEnergy;1]
whetstone,whetstone,onPlay,self,[attackBuffUntilPlay;3] whetstone,whetstone,onPlay,source,[attackBuffUntilPlay;3]
blacksmithHammer,blacksmithHammer,onPlay,self,[transformRandom;1] blacksmithHammer,blacksmithHammer,onPlay,source,[transformRandom;1]
venom,venom,onDiscard,self,[attack;3] venom,venom,onDiscard,source,[attack;3]
curse,curse,onDraw,self,[curse;1] curse,curse,onDraw,source,[curse;1]
static,static,onDraw,self,[static;1] static,static,onDraw,source,[static;1]
vultureEye,vultureEye,onDraw,self,[expose;3] 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 { Card } from './card.csv';
import type { Effect } from './effect.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 { type CardEffectTable = readonly {
readonly id: string; readonly id: string;
readonly card: Card; readonly card: Card;
readonly trigger: "onPlay" | "onDraw" | "onDiscard"; readonly trigger: CardEffectTrigger;
readonly target: "self" | "target" | "all" | "random"; readonly target: CardEffectTarget;
readonly effects: [Effect, number][]; readonly effects: CardEffectList;
}[]; }[];
export type CardEffect = CardEffectTable[number]; export type CardEffect = CardEffectTable[number];
@@ -11,40 +11,42 @@
# itemUntilDiscard: 施加buff到周围物品,物品被弃掉后失效 # itemUntilDiscard: 施加buff到周围物品,物品被弃掉后失效
# itemPermanent: 施加buff到周围物品,持续整场冒险 # itemPermanent: 施加buff到周围物品,持续整场冒险
id, name, description, lifecycle # type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent'
string, string, string, 'instant'|'temporary'|'lingering'|'permanent'|'posture'|'item'|'itemTemporary'|'itemUntilPlay'|'itemUntilDiscard'|'itemPermanent'
attack, 攻击, 对对手造成伤害, instant id, name, description, lifecycle, emoji
defend, 防御, 抵消下次行动前受到的伤害, posture string, string, string, EffectLifecycle, string
spike, 尖刺, 对攻击者造成X点伤害, permanent attack, 攻击, 对对手造成伤害, instant, ⚔️
venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, instant defend, 防御, 抵消下次行动前受到的伤害, temporary, 🛡️
curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, lingering spike, 尖刺, 对攻击者造成X点伤害, permanent, 🌵
aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture venom, 蛇毒, 同名状态牌/1费:打出时移除此牌。弃掉时受到3点伤害, instant, 🧪
roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, lingering, 💀
rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant aim, 瞄准, 造成双倍伤害,受伤时失去等量瞄准, posture, 🎯
vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), instant roll, 滚动, 攻击时每消耗10点滚动造成等量伤害, posture, 🎲
tailSting, 尾刺, 攻击时,伤害提升X, posture rollDamage, 滚动攻击, 消耗滚动层数造成的伤害, instant, 💥
energyDrain, 能量吸取, 受伤时,玩家失去X点能量, lingering vultureEye, 秃鹫之眼, 抓到时获得3层暴露(临时debuff,受到的伤害+1/每层), instant, 👁️
molt, 脱皮, 若脱皮达到生命上限则怪物逃跑, posture tailSting, 尾刺, 攻击时,伤害提升X, posture, 🦂
discard, 劫掠, 回合开始时随机弃掉一张手牌, lingering energyDrain, 能量吸取, 受伤时,玩家失去X点能量, lingering, 🔋
storm, 风暴, 攻击时给玩家塞入1张静电, permanent molt, 脱皮, 若脱皮达到生命上限则怪物逃跑, posture, 🐚
static, 静电, 在手里时受电击伤害+1, permanent discard, 劫掠, 回合开始时随机弃掉一张手牌, lingering, 🗑️
charge, 冲锋, 受到或造成的伤害翻倍并消耗等量冲锋, lingering storm, 风暴, 攻击时给玩家塞入1张静电, permanent, ⚡
summonMummy, 召唤木乃伊, 召唤1个木乃伊, instant static, 静电, 在手里时受电击伤害+1, permanent, ⚡
summonSandwormLarva, 召唤幼沙虫, 召唤1个幼沙虫, instant charge, 冲锋, 受到或造成的伤害翻倍并消耗等量冲锋, lingering, 🐎
reviveMummy, 复活木乃伊, 复活1个已死亡的木乃伊, instant summonMummy, 召唤木乃伊, 召唤1个木乃伊, instant, 🧟
draw, 抓牌, 抓X张牌, instant summonSandwormLarva, 召唤幼沙虫, 召唤1个幼沙虫, instant, 🐛
crossbow, 十字弩连击, 对同一目标打出其他十字弩, instant reviveMummy, 复活木乃伊, 复活1个已死亡的木乃伊, instant, 🌅
defendNext, 下回合防御, 下回合开始时获得防御, temporary draw, 抓牌, 抓X张牌, instant, 🃏
damageReduce, 减伤, 本回合受到的伤害减少X, temporary crossbow, 十字弩连击, 对同一目标打出其他十字弩, instant, 🏹
removeWound, 移除伤口, 从牌堆或弃牌堆移除X张伤口, instant defendNext, 下回合防御, 下回合开始时获得防御, temporary, 🛡️
attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlay damageReduce, 减伤, 本回合受到的伤害减少X, temporary, 📉
defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlay removeWound, 移除伤口, 从牌堆或弃牌堆移除X张伤口, instant, 🩹
gainEnergy, 获得能量, 获得X点能量, instant attackBuff, 攻击增益, 周围物品的攻击+X, itemUntilPlay, ⬆️
energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary defendBuff, 防御增益, 周围物品的防御+X, itemUntilPlay, ⬆️
drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary gainEnergy, 获得能量, 获得X点能量, instant, ⚡
defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlay energyNext, 下回合获能量, 下回合开始时获得X点能量, temporary, ⚡
drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant drawNext, 下回合抓牌, 下回合开始时抓X张牌, temporary, 🃏
burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlay defendBuffUntilPlay, 防御增益直到打出, 周围物品的牌防御+X直到打出, itemUntilPlay, 🛡️
attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlay drawChoice, 选择抓牌, 从牌堆周围物品的牌中选择一张加入手牌, instant, 🔍
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant burnForEnergy, 消耗获能量, 打出周围物品的牌时消耗并获得X能量, itemUntilPlay, 🔥
expose, 暴露, 受到的伤害+1/每层, temporary attackBuffUntilPlay, 攻击增益直到打出, 周围物品的牌攻击+X直到打出, itemUntilPlay, ⚔️
transformRandom, 随机变牌, 选择一张牌随机变为周围物品的牌, instant, 🌀
expose, 暴露, 受到的伤害+1/每层, temporary, 👁️
1 # instant: 不施加buff,瞬间生效
11 id # type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent'
12 string id
13 attack string
14 defend attack
15 spike defend
16 venom spike
17 curse venom
18 aim curse
19 roll aim
20 rollDamage roll
21 vultureEye rollDamage
22 tailSting vultureEye
23 energyDrain tailSting
24 molt energyDrain
25 discard molt
26 storm discard
27 static storm
28 charge static
29 summonMummy charge
30 summonSandwormLarva summonMummy
31 reviveMummy summonSandwormLarva
32 draw reviveMummy
33 crossbow draw
34 defendNext crossbow
35 damageReduce defendNext
36 removeWound damageReduce
37 attackBuff removeWound
38 defendBuff attackBuff
39 gainEnergy defendBuff
40 energyNext gainEnergy
41 drawNext energyNext
42 defendBuffUntilPlay drawNext
43 drawChoice defendBuffUntilPlay
44 burnForEnergy drawChoice
45 attackBuffUntilPlay burnForEnergy
46 transformRandom attackBuffUntilPlay
47 expose transformRandom
48 expose
49
50
51
52
@@ -1,8 +1,11 @@
export type EffectLifecycle = 'instant' | 'temporary' | 'lingering' | 'permanent' | 'posture' | 'item' | 'itemTemporary' | 'itemUntilPlay' | 'itemUntilDiscard' | 'itemPermanent';
type EffectTable = readonly { type EffectTable = readonly {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly description: string; readonly description: string;
readonly lifecycle: "instant" | "temporary" | "lingering" | "permanent" | "posture" | "item" | "itemTemporary" | "itemUntilPlay" | "itemUntilDiscard" | "itemPermanent"; readonly lifecycle: EffectLifecycle;
readonly emoji: string;
}[]; }[];
export type Effect = EffectTable[number]; export type Effect = EffectTable[number];
@@ -6,8 +6,11 @@
# curio (8): random pickup of treasure or resources # curio (8): random pickup of treasure or resources
# enemies: array of [enemyId; initialHp; buffs[]] # enemies: array of [enemyId; initialHp; buffs[]]
# type EncounterType = 'minion' | 'elite' | 'event' | 'shop' | 'camp' | 'curio'
# type EnemyList = [data: @enemy; hp: int; effects: [effect: @effect; stacks: int][]][]
id,type,name,description,enemies,dialogue id,type,name,description,enemies,dialogue
string,'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[data: @enemy; hp: int; effects: [effect: @effect;stacks: int][]][],string string,EncounterType,string,string,EnemyList,string
cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;12;[]];[仙人掌怪;12;[]], cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;12;[]];[仙人掌怪;12;[]],
snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[]], snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[]],
mummy_cactus,minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;14;[]];[仙人掌怪;12;[]], mummy_cactus,minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;14;[]];[仙人掌怪;12;[]],
1 # minion (10): minor enemies
6 # curio (8): random pickup of treasure or resources
7 # enemies: array of [enemyId; initialHp; buffs[]]
8 id,type,name,description,enemies,dialogue # type EncounterType = 'minion' | 'elite' | 'event' | 'shop' | 'camp' | 'curio'
9 # type EnemyList = [data: @enemy; hp: int; effects: [effect: @effect; stacks: int][]][]
10 id
11 string
12 string,'minion'|'elite'|'event'|'shop'|'camp'|'curio',string,string,[data: @enemy; hp: int; effects: [effect: @effect;stacks: int][]][],string cactus_pair
13 cactus_pair,minion,仙人掌怪,概念:防+强化。【尖刺X】:对攻击者造成X点伤害。,[仙人掌怪;12;[]];[仙人掌怪;12;[]], snake_pair
14 snake_pair,minion,蛇,概念:攻+强化。给玩家塞入蛇毒牌(1费:打出时移除此牌。弃掉时受到3点伤害)。,[蛇;10;[]], mummy_cactus
15 mummy_cactus,minion,木乃伊,概念:攻+防。【诅咒】:受攻击时物品【攻击】-1,直到弃掉一张该物品的牌。,[木乃伊;14;[]];[仙人掌怪;12;[]], gunslinger
16 gunslinger,minion,枪手,概念:单回高攻。【瞄准X】:造成双倍伤害。受伤时失去等量【瞄准】,[枪手;12;[]], tumbleweed_pair
@@ -1,12 +1,15 @@
import type { Enemy } from './enemy.csv'; import type { Enemy } from './enemy.csv';
import type { Effect } from './effect.csv'; import type { Effect } from './effect.csv';
export type EncounterType = 'minion' | 'elite' | 'event' | 'shop' | 'camp' | 'curio';
export type EnemyList = [data: Enemy, hp: int, effects: [effect: Effect, stacks: int][]][];
type EncounterTable = readonly { type EncounterTable = readonly {
readonly id: string; readonly id: string;
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio"; readonly type: EncounterType;
readonly name: string; readonly name: string;
readonly description: string; readonly description: string;
readonly enemies: [data: Enemy, hp: number, effects: [effect: Effect, stacks: number][]][]; readonly enemies: EnemyList;
readonly dialogue: string; readonly dialogue: string;
}[]; }[];
@@ -1,5 +1,13 @@
import getItems, { Item } from "./item.csv"; 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 getCards } from "./card.csv";
export { default as getEffects } from "./effect.csv"; export { default as getEffects } from "./effect.csv";
export { default as getEncounters } from "./encounter.csv"; export { default as getEncounters } from "./encounter.csv";
@@ -6,46 +6,50 @@
# initBuffs: initial buffs for this intent (applied when intent becomes active) # initBuffs: initial buffs for this intent (applied when intent becomes active)
# effects: effects executed when this intent is active # effects: effects executed when this intent is active
# type IntentEffectTarget = 'source' | 'eachEnemy' | 'randomEnemy' | 'player'
# type IntentEffect = [IntentEffectTarget;@effect;number]
# type IntentEffectList = IntentEffect[]
id,enemy,initialIntent,nextIntents,brokenIntent,effects id,enemy,initialIntent,nextIntents,brokenIntent,effects
string,@enemy,boolean,@intent[],@intent[],['self'|'player'|'team';@effect;number][] string,@enemy,boolean,@intent[],@intent[],IntentEffectList
仙人掌怪-boost,仙人掌怪,true,仙人掌怪-boost;仙人掌怪-defend,,[self;spike;1];[self;defend;4] 仙人掌怪-boost,仙人掌怪,true,仙人掌怪-boost;仙人掌怪-defend,,[source;spike;1];[source;defend;4]
仙人掌怪-defend,仙人掌怪,false,仙人掌怪-attack,,[self;defend;8] 仙人掌怪-defend,仙人掌怪,false,仙人掌怪-attack,,[source;defend;8]
仙人掌怪-attack,仙人掌怪,false,仙人掌怪-boost,,[player;attack;5] 仙人掌怪-attack,仙人掌怪,false,仙人掌怪-boost,,[player;attack;5]
蛇-poison,蛇,true,蛇-attack;蛇-attack,,[player;venom;1];[player;attack;4] 蛇-poison,蛇,true,蛇-attack;蛇-attack,,[player;venom;1];[player;attack;4]
蛇-attack,蛇,false,蛇-poison;蛇-boost,,[player;attack;6] 蛇-attack,蛇,false,蛇-poison;蛇-boost,,[player;attack;6]
蛇-boost,蛇,false,蛇-poison;蛇-attack,,[self;defend;3];[player;venom;1] 蛇-boost,蛇,false,蛇-poison;蛇-attack,,[source;defend;3];[player;venom;1]
木乃伊-attack,木乃伊,true,木乃伊-defend;木乃伊-curse,,[player;attack;6] 木乃伊-attack,木乃伊,true,木乃伊-defend;木乃伊-curse,,[player;attack;6]
木乃伊-defend,木乃伊,false,木乃伊-attack,,[self;defend;6] 木乃伊-defend,木乃伊,false,木乃伊-attack,,[source;defend;6]
木乃伊-curse,木乃伊,false,木乃伊-defend;木乃伊-attack,木乃伊-attack,[player;curse;1] 木乃伊-curse,木乃伊,false,木乃伊-defend;木乃伊-attack,木乃伊-attack,[player;curse;1]
枪手-aim,枪手,true,枪手-attack,,[self;aim;2] 枪手-aim,枪手,true,枪手-attack,,[source;aim;2]
枪手-attack,枪手,false,枪手-aim;枪手-defend,枪手-aim,[player;attack;8] 枪手-attack,枪手,false,枪手-aim;枪手-defend,枪手-aim,[player;attack;8]
枪手-defend,枪手,false,枪手-aim,枪手-aim,[self;defend;5] 枪手-defend,枪手,false,枪手-aim,枪手-aim,[source;defend;5]
风卷草-boost,风卷草,true,风卷草-defend;风卷草-defend;风卷草-boost,,[self;roll;5];[self;defend;4] 风卷草-boost,风卷草,true,风卷草-defend;风卷草-defend;风卷草-boost,,[source;roll;5];[source;defend;4]
风卷草-defend,风卷草,false,风卷草-boost;风卷草-attack,,[self;defend;8] 风卷草-defend,风卷草,false,风卷草-boost;风卷草-attack,,[source;defend;8]
风卷草-attack,风卷草,false,风卷草-boost,,[player;rollDamage;0] 风卷草-attack,风卷草,false,风卷草-boost,,[player;rollDamage;0]
秃鹫-attack,秃鹫,true,秃鹫-defend;秃鹫-defend,,[player;attack;6];[player;vultureEye;1] 秃鹫-attack,秃鹫,true,秃鹫-defend;秃鹫-defend,,[player;attack;6];[player;vultureEye;1]
秃鹫-defend,秃鹫,false,秃鹫-attack;秃鹫-attack,,[self;defend;5] 秃鹫-defend,秃鹫,false,秃鹫-attack;秃鹫-attack,,[source;defend;5]
沙蝎-boost,沙蝎,true,沙蝎-attack;沙蝎-attack,,[self;tailSting;2] 沙蝎-boost,沙蝎,true,沙蝎-attack;沙蝎-attack,,[source;tailSting;2]
沙蝎-attack,沙蝎,false,沙蝎-boost;沙蝎-attack,,[player;attack;6] 沙蝎-attack,沙蝎,false,沙蝎-boost;沙蝎-attack,,[player;attack;6]
幼沙虫-defend,幼沙虫,true,幼沙虫-defend;幼沙虫-boost,,[self;defend;6] 幼沙虫-defend,幼沙虫,true,幼沙虫-defend;幼沙虫-boost,,[source;defend;6]
幼沙虫-boost,幼沙虫,false,幼沙虫-attack;幼沙虫-defend,,[self;energyDrain;1];[self;defend;4] 幼沙虫-boost,幼沙虫,false,幼沙虫-attack;幼沙虫-defend,,[source;energyDrain;1];[source;defend;4]
幼沙虫-attack,幼沙虫,false,幼沙虫-defend;幼沙虫-defend,,[player;attack;5] 幼沙虫-attack,幼沙虫,false,幼沙虫-defend;幼沙虫-defend,,[player;attack;5]
蜥蜴-attack,蜥蜴,true,蜥蜴-defend;蜥蜴-molt,,[player;attack;5] 蜥蜴-attack,蜥蜴,true,蜥蜴-defend;蜥蜴-molt,,[player;attack;5]
蜥蜴-defend,蜥蜴,false,蜥蜴-attack;蜥蜴-attack,,[self;defend;6] 蜥蜴-defend,蜥蜴,false,蜥蜴-attack;蜥蜴-attack,,[source;defend;6]
蜥蜴-molt,蜥蜴,false,蜥蜴-defend;蜥蜴-attack,,[self;molt;3] 蜥蜴-molt,蜥蜴,false,蜥蜴-defend;蜥蜴-attack,,[source;molt;3]
沙匪-attack,沙匪,true,沙匪-attack;沙匪-heavyAttack,,[player;attack;6] 沙匪-attack,沙匪,true,沙匪-attack;沙匪-heavyAttack,,[player;attack;6]
沙匪-heavyAttack,沙匪,false,沙匪-attack;沙匪-attack;沙匪-debuff,,[player;attack;10] 沙匪-heavyAttack,沙匪,false,沙匪-attack;沙匪-attack;沙匪-debuff,,[player;attack;10]
沙匪-debuff,沙匪,false,沙匪-attack;沙匪-attack,,[player;discard;1] 沙匪-debuff,沙匪,false,沙匪-attack;沙匪-attack,,[player;discard;1]
风暴之灵-storm,风暴之灵,true,风暴之灵-attack;风暴之灵-storm,,[self;storm;2];[self;defend;3] 风暴之灵-storm,风暴之灵,true,风暴之灵-attack;风暴之灵-storm,,[source;storm;2];[source;defend;3]
风暴之灵-attack,风暴之灵,false,风暴之灵-storm;风暴之灵-defend,,[player;attack;8];[player;static;1] 风暴之灵-attack,风暴之灵,false,风暴之灵-storm;风暴之灵-defend,,[player;attack;8];[player;static;1]
风暴之灵-defend,风暴之灵,false,风暴之灵-storm;风暴之灵-attack,,[self;defend;8] 风暴之灵-defend,风暴之灵,false,风暴之灵-storm;风暴之灵-attack,,[source;defend;8]
骑马枪手-charge,骑马枪手,true,骑马枪手-attack,,[self;charge;2] 骑马枪手-charge,骑马枪手,true,骑马枪手-attack,,[source;charge;2]
骑马枪手-attack,骑马枪手,false,骑马枪手-charge;骑马枪手-defend,骑马枪手-charge,[player;attack;6] 骑马枪手-attack,骑马枪手,false,骑马枪手-charge;骑马枪手-defend,骑马枪手-charge,[player;attack;6]
骑马枪手-defend,骑马枪手,false,骑马枪手-charge;骑马枪手-attack,骑马枪手-charge,[self;defend;5] 骑马枪手-defend,骑马枪手,false,骑马枪手-charge;骑马枪手-attack,骑马枪手-charge,[source;defend;5]
沙虫王-summon,沙虫王,true,沙虫王-attack;沙虫王-defend,,[self;summonSandwormLarva;18] 沙虫王-summon,沙虫王,true,沙虫王-attack;沙虫王-defend,,[source;summonSandwormLarva;18]
沙虫王-attack,沙虫王,false,沙虫王-summon;沙虫王-defend,,[player;attack;9] 沙虫王-attack,沙虫王,false,沙虫王-summon;沙虫王-defend,,[player;attack;9]
沙虫王-defend,沙虫王,false,沙虫王-attack;沙虫王-summon,,[self;defend;6] 沙虫王-defend,沙虫王,false,沙虫王-attack;沙虫王-summon,,[source;defend;6]
沙漠守卫-summon,沙漠守卫,true,沙漠守卫-attack;沙漠守卫-defend,,[self;summonMummy;14] 沙漠守卫-summon,沙漠守卫,true,沙漠守卫-attack;沙漠守卫-defend,,[source;summonMummy;14]
沙漠守卫-attack,沙漠守卫,false,沙漠守卫-defend;沙漠守卫-summon,,[player;attack;8] 沙漠守卫-attack,沙漠守卫,false,沙漠守卫-defend;沙漠守卫-summon,,[player;attack;8]
沙漠守卫-defend,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-revive,,[self;defend;8] 沙漠守卫-defend,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-revive,,[source;defend;8]
沙漠守卫-revive,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-summon,,[self;reviveMummy;1] 沙漠守卫-revive,沙漠守卫,false,沙漠守卫-attack;沙漠守卫-summon,,[source;reviveMummy;1]
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 { Enemy } from './enemy.csv';
import type { Effect } from './effect.csv'; import type { Effect } from './effect.csv';
export type IntentEffectTarget = 'source' | 'eachEnemy' | 'randomEnemy' | 'player';
export type IntentEffect = [IntentEffectTarget, Effect, number];
export type IntentEffectList = IntentEffect[];
type IntentTable = readonly { type IntentTable = readonly {
readonly id: string; readonly id: string;
readonly enemy: Enemy; readonly enemy: Enemy;
readonly initialIntent: boolean; readonly initialIntent: boolean;
readonly nextIntents: Intent[]; readonly nextIntents: Intent[];
readonly brokenIntent: Intent[]; readonly brokenIntent: Intent[];
readonly effects: ["self" | "player" | "team", Effect, number][]; readonly effects: IntentEffectList;
}[]; }[];
export type Intent = IntentTable[number]; export type Intent = IntentTable[number];
@@ -30,9 +30,9 @@ export function addCardEventTriggers(triggers: Triggers, run: IRunContext) {
for (let i = 0; i < storm; i++) { for (let i = 0; i < storm; i++) {
await triggers.onEffectApplied.execute(ctx.game, { await triggers.onEffectApplied.execute(ctx.game, {
effect: findEffect("static"), effect: findEffect("static"),
entityKey: "player", entityId: "player",
stacks: 1, 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, { await triggers.onCardPlayed.execute(ctx.game, {
cardId: id, cardId: id,
targetId: ctx.targetId, targetId: ctx.targetId,
sourceEntityKey: "player", sourceEntityId: "player",
}); });
} }
} }
@@ -113,17 +113,17 @@ export function addCardEventTriggers(triggers: Triggers, run: IRunContext) {
await next(); await next();
const dealt = ctx.amount - (ctx.prevented ?? 0); 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 !== "秃鹫") if (!attacker || !("enemy" in attacker) || attacker.enemy.id !== "秃鹫")
return; return;
await triggers.onEffectApplied.execute(ctx.game, { await triggers.onEffectApplied.execute(ctx.game, {
effect: findEffect("vultureEye"), effect: findEffect("vultureEye"),
entityKey: "player", entityId: "player",
stacks: 1, stacks: 1,
sourceEntityKey: ctx.sourceEntityKey, sourceEntityId: ctx.sourceEntityId,
}); });
}); });
} }
@@ -1,192 +1,194 @@
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers"; import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
import { import {
addEntityEffect, addEntityEffect,
getCombatEntity, getCombatEntity,
} from "@/samples/slay-the-spire-like/system/combat/effects"; } 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 { EffectData } from "@/samples/slay-the-spire-like/system/types";
import getEffects from "../effect.csv"; import getEffects from "../effect.csv";
export function addDamageTriggers(triggers: Triggers) { export function addDamageTriggers(triggers: Triggers) {
const effects = getEffects(); const effects = getEffects();
function findEffect(id: string): EffectData { function findEffect(id: string): EffectData {
const found = effects.find((e: EffectData) => e.id === id); const found = effects.find((e: EffectData) => e.id === id);
if (found) return found; 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.entityId);
if (!entity) return;
let preventable = ctx.amount - (ctx.prevented ?? 0);
// 1. apply damageReduce before block
const damageReduce = entity.effects.damageReduce?.stacks ?? 0;
if (damageReduce > 0) {
const reduced = Math.min(damageReduce, preventable);
ctx.prevented = (ctx.prevented ?? 0) + reduced;
preventable -= reduced;
} }
// block / damage prevention // 2. consume defend for damage prevented
triggers.onDamage.use(async (ctx, next) => { const blocks = entity.effects.defend?.stacks ?? 0;
const entity = getCombatEntity(ctx.game.value, ctx.entityKey); const blocked = Math.min(blocks, preventable);
if (!entity) return; 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);
});
}
let preventable = ctx.amount - (ctx.prevented ?? 0); const expose = entity.effects.expose?.stacks ?? 0;
if (expose > 0) {
ctx.amount += expose;
}
const blocks = entity.effects.defend?.stacks ?? 0; await next();
const blocked = Math.min(blocks, preventable); });
if (blocked) {
ctx.prevented = (ctx.prevented ?? 0) + blocked; // energyDrain: player loses energy when enemy takes damage
preventable -= blocked; triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
if (!entity) return;
const energyDrain = entity.effects.energyDrain?.stacks ?? 0;
if (energyDrain > 0 && ctx.entityId !== "player") {
const dealt = Math.min(
Math.max(0, entity.hp),
ctx.amount - (ctx.prevented ?? 0),
);
if (dealt > 0) {
await ctx.game.produceAsync((draft) => {
draft.player.energy = Math.max(0, draft.player.energy - energyDrain);
});
}
}
await next();
});
// molt: enemy flees if molt >= maxHp
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityId);
if (!entity || !entity.isAlive) {
await next();
return;
}
const molt = entity.effects.molt?.stacks ?? 0;
if (molt >= entity.maxHp) {
await ctx.game.produceAsync((draft) => {
const e = draft.enemies.find((en) => en.id === ctx.entityId);
if (e) {
e.isAlive = false;
e.hp = 0;
} }
draft.result = draft.enemies.every((en) => !en.isAlive)
? "victory"
: null;
});
if (ctx.game.value.result) throw ctx.game.value;
return;
}
const damageReduce = entity.effects.damageReduce?.stacks ?? 0; await next();
if (damageReduce > 0) { });
const reduced = Math.min(damageReduce, preventable);
ctx.prevented = (ctx.prevented ?? 0) + reduced; // aim: double damage, lose aim on damage
preventable -= reduced; triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityId === "player") {
const player = ctx.game.value.player;
const aim = player.effects.aim?.stacks ?? 0;
if (aim > 0) {
ctx.amount *= 2;
}
}
await next();
});
// roll: consume 10 roll per 10 damage
triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityId === "player") {
const player = ctx.game.value.player;
const roll = player.effects.roll?.stacks ?? 0;
if (roll >= 10) {
const rollDamage = Math.floor(roll / 10) * 10;
ctx.amount += rollDamage;
await ctx.game.produceAsync((draft) => {
addEntityEffect(draft.player, findEffect("roll"), -rollDamage);
});
}
}
await next();
});
// tailSting: bonus damage on attack
triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityId && ctx.sourceEntityId !== "player") {
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityId);
if (attacker) {
const tailSting = attacker.effects.tailSting?.stacks ?? 0;
if (tailSting > 0) {
ctx.amount += tailSting;
} }
}
}
await next();
});
const expose = entity.effects.expose?.stacks ?? 0; // charge: double damage dealt/received, consume equal charge
if (expose > 0) { triggers.onDamage.use(async (ctx, next) => {
ctx.amount += expose; const entity = getCombatEntity(ctx.game.value, ctx.entityId);
if (entity) {
const charge = entity.effects.charge?.stacks ?? 0;
if (charge > 0) {
const dealt = Math.min(
Math.max(0, entity.hp),
ctx.amount - (ctx.prevented ?? 0),
);
const consumed = Math.min(charge, dealt);
ctx.amount += dealt;
if (consumed > 0) {
await ctx.game.produceAsync((draft) => {
const e = getCombatEntity(draft, ctx.entityId);
if (e) addEntityEffect(e, findEffect("charge"), -consumed);
});
} }
}
}
await next(); if (ctx.sourceEntityId) {
}); const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityId);
if (attacker) {
// spike: damage attacker const charge = attacker.effects.charge?.stacks ?? 0;
triggers.onDamage.use(async (ctx, next) => { if (charge > 0) {
await next(); const baseAmount = ctx.amount;
const targetEntity = getCombatEntity(ctx.game.value, ctx.entityId);
if (ctx.amount - (ctx.prevented ?? 0) <= 0) return; const dealt = Math.min(
Math.max(0, targetEntity?.hp ?? 0),
const entity = getCombatEntity(ctx.game.value, ctx.entityKey); baseAmount - (ctx.prevented ?? 0),
if (!entity || !entity.isAlive) return; );
const consumed = Math.min(charge, dealt);
const spike = entity.effects.spike?.stacks ?? 0; ctx.amount += dealt;
if (spike > 0 && ctx.sourceEntityKey) { if (consumed > 0) {
await triggers.onDamage.execute(ctx.game, { await ctx.game.produceAsync((draft) => {
entityKey: ctx.sourceEntityKey, const a = getCombatEntity(draft, ctx.sourceEntityId!);
amount: spike, if (a) addEntityEffect(a, findEffect("charge"), -consumed);
sourceEntityKey: ctx.entityKey,
}); });
}
} }
}); }
}
// energyDrain: player loses energy when enemy takes damage await next();
triggers.onDamage.use(async (ctx, next) => { });
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
if (!entity) return;
const energyDrain = entity.effects.energyDrain?.stacks ?? 0;
if (energyDrain > 0 && ctx.entityKey !== "player") {
const dealt = Math.min(Math.max(0, entity.hp), ctx.amount - (ctx.prevented ?? 0));
if (dealt > 0) {
await ctx.game.produceAsync(draft => {
draft.player.energy = Math.max(0, draft.player.energy - energyDrain);
});
}
}
await next();
});
// molt: enemy flees if molt >= maxHp
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
if (!entity || !entity.isAlive) {
await next();
return;
}
const molt = entity.effects.molt?.stacks ?? 0;
if (molt >= entity.maxHp) {
await ctx.game.produceAsync(draft => {
const e = draft.enemies.find(en => en.id === ctx.entityKey);
if (e) {
e.isAlive = false;
e.hp = 0;
}
draft.result = draft.enemies.every(en => !en.isAlive) ? "victory" : null;
});
if (ctx.game.value.result) throw ctx.game.value;
return;
}
await next();
});
// aim: double damage, lose aim on damage
triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityKey === "player") {
const player = ctx.game.value.player;
const aim = player.effects.aim?.stacks ?? 0;
if (aim > 0) {
ctx.amount *= 2;
}
}
await next();
});
// roll: consume 10 roll per 10 damage
triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityKey === "player") {
const player = ctx.game.value.player;
const roll = player.effects.roll?.stacks ?? 0;
if (roll >= 10) {
const rollDamage = Math.floor(roll / 10) * 10;
ctx.amount += rollDamage;
await ctx.game.produceAsync(draft => {
addEntityEffect(draft.player, findEffect("roll"), -rollDamage);
});
}
}
await next();
});
// tailSting: bonus damage on attack
triggers.onDamage.use(async (ctx, next) => {
if (ctx.sourceEntityKey && ctx.sourceEntityKey !== "player") {
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityKey);
if (attacker) {
const tailSting = attacker.effects.tailSting?.stacks ?? 0;
if (tailSting > 0) {
ctx.amount += tailSting;
}
}
}
await next();
});
// charge: double damage dealt/received, consume equal charge
triggers.onDamage.use(async (ctx, next) => {
const entity = getCombatEntity(ctx.game.value, ctx.entityKey);
if (entity) {
const charge = entity.effects.charge?.stacks ?? 0;
if (charge > 0) {
const dealt = Math.min(Math.max(0, entity.hp), ctx.amount - (ctx.prevented ?? 0));
const consumed = Math.min(charge, dealt);
ctx.amount += dealt;
if (consumed > 0) {
await ctx.game.produceAsync(draft => {
const e = getCombatEntity(draft, ctx.entityKey);
if (e) addEntityEffect(e, findEffect("charge"), -consumed);
});
}
}
}
if (ctx.sourceEntityKey) {
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityKey);
if (attacker) {
const charge = attacker.effects.charge?.stacks ?? 0;
if (charge > 0) {
const baseAmount = ctx.amount;
const targetEntity = getCombatEntity(ctx.game.value, ctx.entityKey);
const dealt = Math.min(Math.max(0, targetEntity?.hp ?? 0), baseAmount - (ctx.prevented ?? 0));
const consumed = Math.min(charge, dealt);
ctx.amount += dealt;
if (consumed > 0) {
await ctx.game.produceAsync(draft => {
const a = getCombatEntity(draft, ctx.sourceEntityKey!);
if (a) addEntityEffect(a, findEffect("charge"), -consumed);
});
}
}
}
}
await next();
});
} }
@@ -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,158 +1,178 @@
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers"; 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 { moveToRegion } from "@/core/region";
import { CombatGameContext } from "@/samples/slay-the-spire-like/system/combat/types"; 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 { GameCard } from "@/samples/slay-the-spire-like/system/deck";
import getEffects from "../effect.csv";
import getCards from "../card.csv"; import getCards from "../card.csv";
export function addInstantEffectTriggers(triggers: Triggers) { export function addInstantEffectTriggers(triggers: Triggers) {
const effects = getEffects(); const cards = getCards();
const cards = getCards();
function findEffect(id: string): EffectData { function findCard(id: string) {
const found = effects.find((e: EffectData) => e.id === id); return cards.find((c) => c.id === id);
if (found) return found; }
return { id, name: id, description: "", lifecycle: "instant" } as EffectData;
function createStatusCard(
draft: CombatGameContext["value"],
cardId: string,
): void {
const cardData = findCard(cardId);
if (!cardData) return;
const instanceId = `${cardId}-${draft.player.deck.regions.drawPile.childIds.length}-${draft.player.deck.regions.discardPile.childIds.length}`;
const card: GameCard = {
id: instanceId,
regionId: "",
position: [],
itemId: cardId,
cardData: {
id: cardData.id,
name: cardData.name,
desc: cardData.desc,
type: cardData.type,
costType: cardData.costType,
costCount: cardData.costCount,
targetType: cardData.targetType,
effects: cardData.effects,
},
};
draft.player.deck.cards[instanceId] = card;
moveToRegion(card, null, draft.player.deck.regions.drawPile);
}
function getAllEnemyDataFromState(state: CombatGameContext["value"]) {
const seen = new Set<string>();
const result: (typeof state.enemies)[number]["enemy"][] = [];
for (const enemy of state.enemies) {
if (!seen.has(enemy.enemy.id)) {
seen.add(enemy.enemy.id);
result.push(enemy.enemy);
}
} }
return result;
}
function findCard(id: string) { function summonEnemy(
return cards.find(c => c.id === id); draft: CombatGameContext["value"],
} enemyId: string,
hp: number,
function createStatusCard(draft: CombatGameContext["value"], cardId: string): void { ) {
const cardData = findCard(cardId); for (const enemyData of getAllEnemyDataFromState(draft)) {
if (!cardData) return; if (enemyData.id === enemyId) {
const existing = draft.enemies.find(
const instanceId = `${cardId}-${draft.player.deck.regions.drawPile.childIds.length}-${draft.player.deck.regions.discardPile.childIds.length}`; (e) => e.enemy.id === enemyId && !e.isAlive,
const card: GameCard = { );
id: instanceId, if (existing) {
regionId: "", existing.isAlive = true;
position: [], existing.hp = existing.maxHp;
itemId: cardId, return;
cardData: {
id: cardData.id,
name: cardData.name,
desc: cardData.desc,
type: cardData.type,
costType: cardData.costType,
costCount: cardData.costCount,
targetType: cardData.targetType,
effects: cardData.effects,
},
};
draft.player.deck.cards[instanceId] = card;
moveToRegion(card, null, draft.player.deck.regions.drawPile);
}
function getAllEnemyDataFromState(state: CombatGameContext["value"]) {
const seen = new Set<string>();
const result: typeof state.enemies[number]["enemy"][] = [];
for (const enemy of state.enemies) {
if (!seen.has(enemy.enemy.id)) {
seen.add(enemy.enemy.id);
result.push(enemy.enemy);
}
} }
return result; const intent =
} enemyData.intents.find((i) => i.initialIntent) ??
enemyData.intents[0];
function summonEnemy(draft: CombatGameContext["value"], enemyId: string, hp: number) { const instanceId = `${enemyData.id}-${draft.enemies.length}`;
for (const enemyData of getAllEnemyDataFromState(draft)) { const intents: Record<string, typeof intent> = {};
if (enemyData.id === enemyId) { for (const i of enemyData.intents) {
const existing = draft.enemies.find(e => e.enemy.id === enemyId && !e.isAlive); intents[i.id] = i;
if (existing) {
existing.isAlive = true;
existing.hp = existing.maxHp;
return;
}
const intent = enemyData.intents.find(i => i.initialIntent) ?? enemyData.intents[0];
const instanceId = `${enemyData.id}-${draft.enemies.length}`;
const intents: Record<string, typeof intent> = {};
for (const i of enemyData.intents) {
intents[i.id] = i;
}
draft.enemies.push({
id: instanceId,
enemy: enemyData,
hp,
maxHp: hp,
isAlive: true,
effects: {},
intents,
currentIntent: intent,
});
break;
}
} }
draft.enemies.push({
id: instanceId,
enemy: enemyData,
hp,
maxHp: hp,
isAlive: true,
effects: {},
intents,
currentIntent: intent,
});
break;
}
} }
}
triggers.onEffectApplied.use(async (ctx, next) => { triggers.onEffectApplied.use(async (ctx, next) => {
if (ctx.effect.id === "attack") { if (ctx.effect.id === "attack") {
await triggers.onDamage.execute(ctx.game, { await triggers.onDamage.execute(ctx.game, {
entityKey: ctx.entityKey, entityId: ctx.entityId,
amount: ctx.stacks, amount: ctx.stacks,
sourceEntityKey: ctx.sourceEntityKey ?? ctx.entityKey === "player" ? undefined : "player", sourceEntityId: ctx.sourceEntityId,
}); });
} else if (ctx.effect.id === "draw") {
await triggers.onDraw.execute(ctx.game, { count: ctx.stacks }); const entity = getCombatEntity(ctx.game.value, ctx.entityId);
} else if (ctx.effect.id === "gainEnergy") { const spike = entity?.effects.spike?.stacks ?? 0;
await ctx.game.produceAsync(draft => { if (spike > 0 && ctx.sourceEntityId) {
draft.player.energy += ctx.stacks; await triggers.onDamage.execute(ctx.game, {
}); entityId: ctx.sourceEntityId,
} else if (ctx.effect.id === "removeWound") { amount: spike,
await ctx.game.produceAsync(draft => { sourceEntityId: ctx.entityId,
const { cards, regions } = draft.player.deck; });
let removed = 0; }
const allPileIds = [ } else if (ctx.effect.id === "draw") {
...regions.drawPile.childIds, await triggers.onDraw.execute(ctx.game, { count: ctx.stacks });
...regions.discardPile.childIds, } else if (ctx.effect.id === "gainEnergy") {
]; await ctx.game.produceAsync((draft) => {
for (const cardId of allPileIds) { draft.player.energy += ctx.stacks;
if (removed >= ctx.stacks) break; });
const card = cards[cardId]; } else if (ctx.effect.id === "removeWound") {
if (card && card.cardData.id === "wound") { await ctx.game.produceAsync((draft) => {
const sourceRegion = card.regionId === "drawPile" ? regions.drawPile : regions.discardPile; const { cards, regions } = draft.player.deck;
moveToRegion(card, sourceRegion, null); let removed = 0;
delete cards[cardId]; const allPileIds = [
removed++; ...regions.drawPile.childIds,
} ...regions.discardPile.childIds,
} ];
}); for (const cardId of allPileIds) {
} else if (ctx.effect.id === "venom") { if (removed >= ctx.stacks) break;
await ctx.game.produceAsync(draft => { const card = cards[cardId];
createStatusCard(draft, "venom"); if (card && card.cardData.id === "wound") {
}); const sourceRegion =
} else if (ctx.effect.id === "vultureEye") { card.regionId === "drawPile"
await ctx.game.produceAsync(draft => { ? regions.drawPile
createStatusCard(draft, "vultureEye"); : regions.discardPile;
}); moveToRegion(card, sourceRegion, null);
} else if (ctx.effect.id === "static") { delete cards[cardId];
await ctx.game.produceAsync(draft => { removed++;
createStatusCard(draft, "static"); }
});
} else if (ctx.effect.id === "summonMummy") {
await ctx.game.produceAsync(draft => {
summonEnemy(draft, "木乃伊", ctx.stacks);
});
} else if (ctx.effect.id === "summonSandwormLarva") {
await ctx.game.produceAsync(draft => {
summonEnemy(draft, "幼沙虫", ctx.stacks);
});
} else if (ctx.effect.id === "reviveMummy") {
await ctx.game.produceAsync(draft => {
const deadMummy = draft.enemies.find(e => e.enemy.id === "木乃伊" && !e.isAlive);
if (deadMummy) {
deadMummy.isAlive = true;
deadMummy.hp = deadMummy.maxHp;
}
});
} else if (ctx.effect.id === "curse") {
await ctx.game.produceAsync(draft => {
addEntityEffect(draft.player, ctx.effect, ctx.stacks);
});
} }
await next(); });
}); } else if (ctx.effect.id === "venom") {
await ctx.game.produceAsync((draft) => {
createStatusCard(draft, "venom");
});
} else if (ctx.effect.id === "vultureEye") {
await ctx.game.produceAsync((draft) => {
createStatusCard(draft, "vultureEye");
});
} else if (ctx.effect.id === "static") {
await ctx.game.produceAsync((draft) => {
createStatusCard(draft, "static");
});
} else if (ctx.effect.id === "summonMummy") {
await ctx.game.produceAsync((draft) => {
summonEnemy(draft, "木乃伊", ctx.stacks);
});
} else if (ctx.effect.id === "summonSandwormLarva") {
await ctx.game.produceAsync((draft) => {
summonEnemy(draft, "幼沙虫", ctx.stacks);
});
} else if (ctx.effect.id === "reviveMummy") {
await ctx.game.produceAsync((draft) => {
const deadMummy = draft.enemies.find(
(e) => e.enemy.id === "木乃伊" && !e.isAlive,
);
if (deadMummy) {
deadMummy.isAlive = true;
deadMummy.hp = deadMummy.maxHp;
}
});
} else if (ctx.effect.id === "curse") {
await ctx.game.produceAsync((draft) => {
addEntityEffect(draft.player, ctx.effect, ctx.stacks);
});
}
await next();
});
} }
@@ -4,85 +4,89 @@ import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import getEffects from "../effect.csv"; import getEffects from "../effect.csv";
export function addTurnStartTriggers(triggers: Triggers) { export function addTurnStartTriggers(triggers: Triggers) {
const effects = getEffects(); const effects = getEffects();
function findEffect(id: string): EffectData { 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; 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.entityId !== "player") {
await next();
return;
} }
// discard: random discard at turn start const discard = ctx.game.value.player.effects.discard;
triggers.onTurnStart.use(async (ctx, next) => { if (discard && discard.stacks > 0) {
if (ctx.entityKey !== "player") { const handIds = [...ctx.game.value.player.deck.regions.hand.childIds];
await next(); if (handIds.length > 0) {
return; const randomIndex = ctx.game.rng.nextInt(handIds.length);
} const randomCardId = handIds[randomIndex];
await triggers.onCardDiscarded.execute(ctx.game, {
cardId: randomCardId,
});
}
}
const discard = ctx.game.value.player.effects.discard; await next();
if (discard && discard.stacks > 0) { });
const handIds = [...ctx.game.value.player.deck.regions.hand.childIds];
if (handIds.length > 0) {
const randomIndex = ctx.game.rng.nextInt(handIds.length);
const randomCardId = handIds[randomIndex];
await triggers.onCardDiscarded.execute(ctx.game, { cardId: randomCardId });
}
}
await next(); // defendNext: gain block next turn
// record how many defendNext there is, before buff update
// then apply after buff update
triggers.onTurnStart.use(async (ctx, next) => {
const defendNext = ctx.game.value.player.effects.defendNext;
if (!defendNext || defendNext.stacks <= 0) {
await next();
return;
}
await next();
await ctx.game.produceAsync((draft) => {
addEntityEffect(draft.player, findEffect("defend"), defendNext.stacks);
}); });
});
// defendNext: gain block next turn // energyNext: gain energy next turn
triggers.onTurnStart.use(async (ctx, next) => { triggers.onTurnStart.use(async (ctx, next) => {
if (ctx.entityKey !== "player") { if (ctx.entityId !== "player") {
await next(); await next();
return; return;
} }
const defendNext = ctx.game.value.player.effects.defendNext; const energyNext = ctx.game.value.player.effects.energyNext;
if (defendNext && defendNext.stacks > 0) { if (energyNext && energyNext.stacks > 0) {
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
addEntityEffect(draft.player, findEffect("defend"), defendNext.stacks); draft.player.energy += energyNext.stacks;
addEntityEffect(draft.player, defendNext.data, -defendNext.stacks); addEntityEffect(draft.player, energyNext.data, -energyNext.stacks);
}); });
} }
await next(); await next();
}); });
// energyNext: gain energy next turn // drawNext: draw extra cards next turn
triggers.onTurnStart.use(async (ctx, next) => { triggers.onTurnStart.use(async (ctx, next) => {
if (ctx.entityKey !== "player") { if (ctx.entityId !== "player") {
await next(); await next();
return; return;
} }
const energyNext = ctx.game.value.player.effects.energyNext; const drawNext = ctx.game.value.player.effects.drawNext;
if (energyNext && energyNext.stacks > 0) { if (drawNext && drawNext.stacks > 0) {
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
draft.player.energy += energyNext.stacks; addEntityEffect(draft.player, drawNext.data, -drawNext.stacks);
addEntityEffect(draft.player, energyNext.data, -energyNext.stacks); });
}); await triggers.onDraw.execute(ctx.game, { count: drawNext.stacks });
} }
await next(); await next();
}); });
// drawNext: draw extra cards next turn
triggers.onTurnStart.use(async (ctx, next) => {
if (ctx.entityKey !== "player") {
await next();
return;
}
const drawNext = ctx.game.value.player.effects.drawNext;
if (drawNext && drawNext.stacks > 0) {
await ctx.game.produceAsync(draft => {
addEntityEffect(draft.player, drawNext.data, -drawNext.stacks);
});
await triggers.onDraw.execute(ctx.game, { count: drawNext.stacks });
}
await next();
});
} }
+13 -18
View File
@@ -1,23 +1,18 @@
import { LoadResult as YarnDialogues } from "yarn-spinner-loader"; import { LoadResult as YarnDialogues } from "yarn-spinner-loader";
import {
CardData,
EffectData,
EncounterData,
EnemyData,
IntentData,
ItemData,
} from "../system/types";
import { Triggers } from "../system/combat/triggers"; import { Triggers } from "../system/combat/triggers";
export type ContentModule = { import type * as desert from "./desert";
getCards: () => CardData[]; import { IRunContext } from "../system/combat";
getEffects: () => EffectData[];
getEncounters: () => EncounterData[];
getEnemies: () => EnemyData[];
getIntents: () => IntentData[];
getItems: () => ItemData[];
getStartingItems: () => ItemData[];
dialogues: YarnDialogues; export type ContentModule = {
addTriggers: (triggers: Triggers) => void; 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;
}; };
@@ -9,9 +9,8 @@ import {
import { import {
CardData, CardData,
CardEffectTarget, CardEffectTarget,
CardTargetType,
EffectData, EffectData,
EffectTarget, IntentEffectTarget,
} from "@/samples/slay-the-spire-like/system/types"; } from "@/samples/slay-the-spire-like/system/types";
export function addEffect( export function addEffect(
@@ -104,21 +103,25 @@ export function* getAliveEnemies(state: CombatState) {
} }
export function* getEffectTargets( export function* getEffectTargets(
target: CardEffectTarget | EffectTarget, target: CardEffectTarget | IntentEffectTarget,
game: CombatGameContext, game: CombatGameContext,
targetId?: string, targetId?: string,
sourceEntityKey: "player" | string = "player",
) { ) {
if (target === "all" || target === "team") { if (target === "eachEnemy") {
for (const enemy of getAliveEnemies(game.value)) { for (const enemy of getAliveEnemies(game.value)) {
yield enemy; 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; yield game.value.player;
} else if (target === "target") { } else if (target === "eachTarget") {
if (!targetId) return; if (!targetId) return;
const entity = getCombatEntity(game.value, targetId); const entity = getCombatEntity(game.value, targetId);
if (entity) yield entity; if (entity) yield entity;
} else if (target === "random") { } else if (target === "randomEnemy") {
const aliveEnemies = [...getAliveEnemies(game.value)]; const aliveEnemies = [...getAliveEnemies(game.value)];
if (aliveEnemies.length === 0) return; if (aliveEnemies.length === 0) return;
const index = game.rng.nextInt(aliveEnemies.length); const index = game.rng.nextInt(aliveEnemies.length);
@@ -38,12 +38,12 @@ export async function promptMainAction(
} }
const { targetType } = cardData; const { targetType } = cardData;
if (targetType === "single") { if (targetType === "enemy") {
if (!targetId) throw `请指定目标`; if (!targetId) throw `请指定目标`;
const target = game.value.enemies.find((e) => e.id === targetId); const target = game.value.enemies.find((e) => e.id === targetId);
if (!target) throw `目标"${targetId}"不存在`; if (!target) throw `目标"${targetId}"不存在`;
if (!target.isAlive) throw `目标"${targetId}"已死亡`; if (!target.isAlive) throw `目标"${targetId}"已死亡`;
} else if (targetType === "none") { } else if (targetType === "enemies" || targetType === "player") {
if (targetId) throw `目标"${targetId}"无效`; if (targetId) throw `目标"${targetId}"无效`;
} }
@@ -19,55 +19,58 @@ import { EffectData } from "@/samples/slay-the-spire-like/system/types";
type TriggerTypes = { type TriggerTypes = {
onCombatStart: {}; onCombatStart: {};
onTurnStart: { entityKey: "player" | string }; onTurnStart: { entityId: "player" | string };
onTurnEnd: { entityKey: "player" | string }; onTurnEnd: { entityId: "player" | string };
onShuffle: {}; onShuffle: {};
onCardPlayed: { onCardPlayed: {
cardId: string; cardId: string;
targetId?: string; targetId?: string;
sourceEntityKey?: "player" | string; sourceEntityId?: "player" | string;
}; };
onCardDiscarded: { cardId: string; sourceEntityKey?: "player" | string }; onCardDiscarded: { cardId: string; sourceEntityId?: "player" | string };
onCardDrawn: { cardId: string; sourceEntityKey?: "player" | string }; onCardDrawn: { cardId: string; sourceEntityId?: "player" | string };
onDraw: { count: number }; onDraw: { count: number };
onEffectApplied: { onEffectApplied: {
effect: EffectData; effect: EffectData;
entityKey: "player" | string; entityId: "player" | string;
stacks: number; stacks: number;
cardId?: string; cardId?: string;
sourceEntityKey?: "player" | string; sourceEntityId?: "player" | string;
targetId?: string; targetId?: string;
}; };
onHpChange: { entityKey: "player" | string; amount: number }; onHpChange: { entityId: "player" | string; amount: number };
onDamage: { onDamage: {
entityKey: "player" | string; entityId: "player" | string;
amount: number; amount: number;
prevented?: number; prevented?: number;
sourceEntityKey?: "player" | string; sourceEntityId?: "player" | string;
}; };
onEnemyIntent: { enemyId: string; sourceEntityKey?: "player" | string }; onEnemyIntent: { enemyId: string; sourceEntityId?: "player" | string };
onIntentUpdate: { enemyId: string }; onIntentUpdate: { enemyId: string };
}; };
export function createTriggers(run: IRunContext) { export function createTriggers(run: IRunContext) {
const triggers = { 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) => { onTurnStart: createTrigger("onTurnStart", async (ctx) => {
await ctx.game.produceAsync((draft) => { await ctx.game.produceAsync((draft) => {
const entity = getCombatEntity(draft, ctx.entityKey); const entity = getCombatEntity(draft, ctx.entityId);
if (entity) onEntityEffectUpkeep(entity); if (entity) onEntityEffectUpkeep(entity);
if (entity === draft.player) onPlayerItemEffectUpkeep(draft.player); if (entity === draft.player) onPlayerItemEffectUpkeep(draft.player);
}); });
}), }),
onTurnEnd: createTrigger("onTurnEnd", async (ctx) => { onTurnEnd: createTrigger("onTurnEnd", async (ctx) => {
if (ctx.entityKey !== "player") return; if (ctx.entityId !== "player") return;
const { regions } = ctx.game.value.player.deck; const { regions } = ctx.game.value.player.deck;
for (const cardId of Object.values(regions.hand.childIds)) { for (const cardId of Object.values(regions.hand.childIds)) {
await triggers.onCardDiscarded.execute(ctx.game, { cardId }); await triggers.onCardDiscarded.execute(ctx.game, { cardId });
} }
await ctx.game.produceAsync( await ctx.game.produceAsync((draft) => {
(draft) => (draft.player.energy = draft.player.maxEnergy), draft.player.energy = draft.player.maxEnergy;
); });
await triggers.onDraw.execute(ctx.game, { count: 5 }); await triggers.onDraw.execute(ctx.game, { count: 5 });
}), }),
onShuffle: createTrigger("onShuffle", async (ctx) => { onShuffle: createTrigger("onShuffle", async (ctx) => {
@@ -94,17 +97,22 @@ export function createTriggers(run: IRunContext) {
}); });
const { cards, regions } = ctx.game.value.player.deck; const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId]; const card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player"; const source = ctx.sourceEntityId ?? "player";
for (const { trigger, target, effects } of card.cardData.effects) { for (const { trigger, target, effects } of card.cardData.effects) {
if (trigger !== "onPlay") continue; if (trigger !== "onPlay") continue;
for (const [effect, stacks] of effects) 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, { await triggers.onEffectApplied.execute(ctx.game, {
effect, effect,
entityKey: entity.id, entityId: entity.id,
stacks, stacks,
cardId: ctx.cardId, cardId: ctx.cardId,
sourceEntityKey: source, sourceEntityId: source,
targetId: ctx.targetId, targetId: ctx.targetId,
}); });
} }
@@ -117,17 +125,22 @@ export function createTriggers(run: IRunContext) {
}); });
const { cards, regions } = ctx.game.value.player.deck; const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId]; const card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player"; const source = ctx.sourceEntityId ?? "player";
for (const { trigger, target, effects } of card.cardData.effects) { for (const { trigger, target, effects } of card.cardData.effects) {
if (trigger !== "onDiscard") continue; if (trigger !== "onDiscard") continue;
for (const [effect, stacks] of effects) 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, { await triggers.onEffectApplied.execute(ctx.game, {
effect, effect,
entityKey: entity.id, entityId: entity.id,
stacks, stacks,
cardId: ctx.cardId, cardId: ctx.cardId,
sourceEntityKey: source, sourceEntityId: source,
}); });
} }
}), }),
@@ -138,17 +151,22 @@ export function createTriggers(run: IRunContext) {
}); });
const { cards, regions } = ctx.game.value.player.deck; const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId]; const card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player"; const source = ctx.sourceEntityId ?? "player";
for (const { trigger, target, effects } of card.cardData.effects) { for (const { trigger, target, effects } of card.cardData.effects) {
if (trigger !== "onDraw") continue; if (trigger !== "onDraw") continue;
for (const [effect, stacks] of effects) 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, { await triggers.onEffectApplied.execute(ctx.game, {
effect, effect,
entityKey: entity.id, entityId: entity.id,
stacks, stacks,
cardId: ctx.cardId, cardId: ctx.cardId,
sourceEntityKey: source, sourceEntityId: source,
}); });
} }
}), }),
@@ -186,18 +204,18 @@ export function createTriggers(run: IRunContext) {
await ctx.game.produceAsync((draft) => { await ctx.game.produceAsync((draft) => {
const entity = const entity =
ctx.entityKey === "player" ctx.entityId === "player"
? draft.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); if (entity) addEntityEffect(entity, ctx.effect, ctx.stacks);
}); });
}), }),
onHpChange: createTrigger("onHpChange", async (ctx) => { onHpChange: createTrigger("onHpChange", async (ctx) => {
await ctx.game.produceAsync((draft) => { await ctx.game.produceAsync((draft) => {
const entity = const entity =
ctx.entityKey === "player" ctx.entityId === "player"
? draft.player ? draft.player
: draft.enemies.find((e) => e.id === ctx.entityKey); : draft.enemies.find((e) => e.id === ctx.entityId);
if (!entity) return; if (!entity) return;
entity.hp += ctx.amount; entity.hp += ctx.amount;
entity.isAlive = entity.hp > 0; entity.isAlive = entity.hp > 0;
@@ -211,9 +229,9 @@ export function createTriggers(run: IRunContext) {
}), }),
onDamage: createTrigger("onDamage", async (ctx) => { onDamage: createTrigger("onDamage", async (ctx) => {
const entity = const entity =
ctx.entityKey === "player" ctx.entityId === "player"
? ctx.game.value.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; if (!entity || !entity.isAlive) return;
const dealt = Math.min( const dealt = Math.min(
Math.max(0, entity.hp), Math.max(0, entity.hp),
@@ -223,7 +241,7 @@ export function createTriggers(run: IRunContext) {
onEntityPostureDamage(entity, dealt); onEntityPostureDamage(entity, dealt);
}); });
await triggers.onHpChange.execute(ctx.game, { await triggers.onHpChange.execute(ctx.game, {
entityKey: ctx.entityKey, entityId: ctx.entityId,
amount: -dealt, amount: -dealt,
}); });
}), }),
@@ -234,14 +252,19 @@ export function createTriggers(run: IRunContext) {
const intent = enemy.currentIntent; const intent = enemy.currentIntent;
if (!intent) return; 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 [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, { await triggers.onEffectApplied.execute(ctx.game, {
effect, effect,
entityKey: entity.id, entityId: entity.id,
stacks, stacks,
sourceEntityKey: source, sourceEntityId: source,
}); });
} }
}), }),
@@ -264,18 +287,13 @@ export function createTriggers(run: IRunContext) {
return triggers; return triggers;
} }
export type Triggers = ReturnType<typeof createTriggers>; export type Triggers = ReturnType<typeof createTriggers>;
export function createStartWith( export function createStart(triggers: Triggers, run: IRunContext) {
build: (triggers: Triggers, run: IRunContext) => void,
run: IRunContext,
) {
const triggers = createTriggers(run);
build(triggers, run);
return async function (game: CombatGameContext) { return async function (game: CombatGameContext) {
await triggers.onCombatStart.execute(game, {}); await triggers.onCombatStart.execute(game, {});
try { try {
while (true) { while (true) {
await triggers.onTurnStart.execute(game, { entityKey: "player" }); await triggers.onTurnStart.execute(game, { entityId: "player" });
while (true) { while (true) {
const action = await promptMainAction(game, run); const action = await promptMainAction(game, run);
if (action.action === "end-turn") break; if (action.action === "end-turn") break;
@@ -283,17 +301,17 @@ export function createStartWith(
await triggers.onCardPlayed.execute(game, action); 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)) { 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)) { for (const enemy of getAliveEnemies(game.value)) {
await triggers.onEnemyIntent.execute(game, { enemyId: enemy.id }); await triggers.onEnemyIntent.execute(game, { enemyId: enemy.id });
await triggers.onIntentUpdate.execute(game, { enemyId: enemy.id }); await triggers.onIntentUpdate.execute(game, { enemyId: enemy.id });
} }
for (const enemy of getAliveEnemies(game.value)) { 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) { } catch (e) {
+22 -86
View File
@@ -1,89 +1,25 @@
export type EffectData = { import {
readonly id: string; Card,
readonly name: string; Effect,
readonly description: string; Encounter,
readonly lifecycle: EffectLifecycle; EncounterType,
}; Enemy,
export type EffectLifecycle = Intent,
| "instant" Item,
| "temporary" } from "../data/desert";
| "lingering"
| "permanent"
| "posture"
| "item"
| "itemTemporary"
| "itemUntilPlay"
| "itemUntilDiscard"
| "itemPermanent";
export type EnemyData = { export type CardData = Card;
readonly id: string; export type ItemData = Item;
readonly name: string; export type EffectData = Effect;
readonly intents: readonly IntentData[]; export type IntentData = Intent;
readonly description: string; export type EnemyData = Enemy;
}; export type EncounterData<T extends EncounterType = EncounterType> =
Encounter & { type: T };
export type CardType = "item" | "status"; export {
export type CardCostType = "energy" | "uses" | "none"; CardTargetType,
export type CardTargetType = "single" | "none"; CardEffectTarget,
export type EffectTarget = "self" | "player" | "team"; IntentEffectTarget,
} from "../data/desert";
export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard"; export { ContentModule } from "../data";
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<T extends EncounterType = EncounterType> = {
readonly id: string;
readonly type: T;
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;
};
+76 -35
View File
@@ -1,10 +1,8 @@
import { Part } from "@/core/part"; import { Part } from "@/core/part";
import { createRegion } from "@/core/region"; import { createRegion, Region } from "@/core/region";
import { import { createPromptDef, IGameContext } from "@/core/game";
createGameCommandRegistry, import { BaseGameClient } from "@/core/game-client";
createPromptDef, import { createMiddlewareChain } from "@/utils/middleware";
IGameContext,
} from "@/core/game";
const BOARD_SIZE = 3; const BOARD_SIZE = 3;
const MAX_TURNS = BOARD_SIZE * BOARD_SIZE; const MAX_TURNS = BOARD_SIZE * BOARD_SIZE;
@@ -69,7 +67,6 @@ export function createInitialState() {
} }
export type TicTacToeState = ReturnType<typeof createInitialState>; export type TicTacToeState = ReturnType<typeof createInitialState>;
export type TicTacToeGame = IGameContext<TicTacToeState>; export type TicTacToeGame = IGameContext<TicTacToeState>;
export const registry = createGameCommandRegistry<TicTacToeState>();
export const prompts = { export const prompts = {
play: createPromptDef<[PlayerType, number, number]>( play: createPromptDef<[PlayerType, number, number]>(
"play <player> <row:number> <col:number>", "play <player> <row:number> <col:number>",
@@ -82,7 +79,7 @@ export async function start(game: TicTacToeGame) {
const turnNumber = game.value.turn + 1; const turnNumber = game.value.turn + 1;
const turnOutput = await turn(game, currentPlayer, turnNumber); const turnOutput = await turn(game, currentPlayer, turnNumber);
game.produce((state) => { await game.produceAsync((state) => {
state.winner = turnOutput.winner; state.winner = turnOutput.winner;
if (!state.winner) { if (!state.winner) {
state.currentPlayer = state.currentPlayer === "X" ? "O" : "X"; state.currentPlayer = state.currentPlayer === "X" ? "O" : "X";
@@ -95,34 +92,35 @@ export async function start(game: TicTacToeGame) {
return game.value; return game.value;
} }
const turn = registry.register({ async function turn(
schema: "turn <player> <turnNumber:number>", game: TicTacToeGame,
async run(game: TicTacToeGame, turnPlayer: PlayerType, turnNumber: number) { turnPlayer: PlayerType,
const { player, row, col } = await game.prompt( turnNumber: number,
prompts.play, ) {
(player, row, col) => { const { player, row, col } = await game.prompt(
if (player !== turnPlayer) { prompts.play,
throw `Invalid player: ${player}. Expected ${turnPlayer}.`; (player, row, col) => {
} else if (!isValidMove(row, col)) { if (player !== turnPlayer) {
throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`; throw `Invalid player: ${player}. Expected ${turnPlayer}.`;
} else if (isCellOccupied(game, row, col)) { } else if (!isValidMove(row, col)) {
throw `Cell (${row}, ${col}) is already occupied.`; throw `Invalid position: (${row}, ${col}). Must be between 0 and ${BOARD_SIZE - 1}.`;
} else { } else if (isCellOccupied(game, row, col)) {
return { player, row, col }; throw `Cell (${row}, ${col}) is already occupied.`;
} } else {
}, return { player, row, col };
game.value.currentPlayer, }
); },
game.value.currentPlayer,
);
placePiece(game, row, col, turnPlayer); await placePiece(game, row, col, turnPlayer);
const winner = checkWinner(game); const winner = checkWinner(game);
if (winner) return { winner }; if (winner) return { winner };
if (turnNumber >= MAX_TURNS) return { winner: "draw" as WinnerType }; if (turnNumber >= MAX_TURNS) return { winner: "draw" as WinnerType };
return { winner: null }; return { winner: null };
}, }
});
function isValidMove(row: number, col: number): boolean { function isValidMove(row: number, col: number): boolean {
return ( return (
@@ -167,7 +165,7 @@ export function checkWinner(host: TicTacToeGame): WinnerType {
return null; return null;
} }
export function placePiece( export async function placePiece(
host: TicTacToeGame, host: TicTacToeGame,
row: number, row: number,
col: number, col: number,
@@ -181,9 +179,52 @@ export function placePiece(
player, player,
id: `piece-${player}-${moveNumber}`, id: `piece-${player}-${moveNumber}`,
}; };
host.produce((state) => { await host.produceAsync((state) => {
state.parts[piece.id] = piece; state.parts[piece.id] = piece;
board.childIds.push(piece.id); board.childIds.push(piece.id);
board.partMap[`${row},${col}`] = 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,
};
}
+39 -20
View File
@@ -1,22 +1,41 @@
export { parseCommand } from './command-parse'; export { parseCommand } from "./command-parse";
export { parseCommandSchema } from './schema-parse'; export { parseCommandSchema } from "./schema-parse";
export { validateCommand, parseCommandWithSchema, applyCommandSchema } from './command-validate';
export { export {
createCommandRegistry, validateCommand,
registerCommand, parseCommandWithSchema,
unregisterCommand, applyCommandSchema,
hasCommand, } from "./command-validate";
getCommand, export {
runCommand, createCommandRegistry,
runCommandParsed, registerCommand,
createCommandRunnerContext, unregisterCommand,
} from './command-registry'; hasCommand,
getCommand,
runCommand,
runCommandParsed,
createCommandRunnerContext,
} from "./command-registry";
export type { export type {
Command, Command,
CommandParamSchema, CommandParamSchema,
CommandOptionSchema, CommandOptionSchema,
CommandFlagSchema, CommandFlagSchema,
CommandSchema, CommandSchema,
} from './types'; } from "./types";
export type { CommandRunner, CommandDef, CommandResult, CommandRunnerHandler, CommandRunnerContext, PromptEvent, CommandRunnerEvents } from './command-runner'; export type {
export type { CommandRegistry, CommandRunnerContextExport } from './command-registry'; CommandRunner,
CommandDef,
CommandResult,
CommandRunnerHandler,
CommandRunnerContext,
PromptEvent,
CommandRunnerEvents,
} from "./command-runner";
export type {
CommandRegistry,
CommandRunnerContextExport,
} from "./command-registry";
export type { PromptDef, PromptTryResult, PromptCall } from "./command-prompt";
export { createPromptContext } from "./command-prompt";
+15 -5
View File
@@ -1,28 +1,38 @@
type Middleware<TContext, TReturn> = ( type Middleware<TContext, TReturn> = (
context: TContext, context: TContext,
next: () => Promise<TReturn> next: () => Promise<TReturn>,
) => Promise<TReturn>; ) => Promise<TReturn>;
export type MiddlewareChain<TContext, TReturn> = { export type MiddlewareChain<TContext, TReturn> = {
use: (middleware: Middleware<TContext, TReturn>) => void; use: (middleware: Middleware<TContext, TReturn>) => () => void;
execute: (context: TContext) => Promise<TReturn>; execute: (context: TContext) => Promise<TReturn>;
}; };
export function createMiddlewareChain<TContext extends object, TReturn=TContext>( export function createMiddlewareChain<
fallback?: (context: TContext) => Promise<TReturn> TContext extends object,
TReturn = TContext,
>(
fallback?: (context: TContext) => Promise<TReturn>,
): MiddlewareChain<TContext, TReturn> { ): MiddlewareChain<TContext, TReturn> {
const middlewares: Middleware<TContext, TReturn>[] = []; const middlewares: Middleware<TContext, TReturn>[] = [];
return { return {
use(middleware: Middleware<TContext, TReturn>) { use(middleware: Middleware<TContext, TReturn>) {
middlewares.push(middleware); middlewares.push(middleware);
return function () {
const index = middlewares.indexOf(middleware);
if (index !== -1) {
middlewares.splice(index, 1);
}
};
}, },
async execute(context: TContext) { async execute(context: TContext) {
let index = 0; let index = 0;
async function dispatch(ctx: TContext): Promise<TReturn> { async function dispatch(ctx: TContext): Promise<TReturn> {
if (index >= middlewares.length) { if (index >= middlewares.length) {
return fallback ? fallback(ctx) : ctx as unknown as TReturn; return fallback ? fallback(ctx) : (ctx as unknown as TReturn);
} }
const current = middlewares[index++]; const current = middlewares[index++];
return current(ctx, () => dispatch(ctx)); return current(ctx, () => dispatch(ctx));
+17 -7
View File
@@ -1,13 +1,20 @@
import {Signal, SignalOptions} from '@preact/signals-core'; import { Signal, SignalOptions } from "@preact/signals-core";
import {create} from 'mutative'; import { create } from "mutative";
export class MutableSignal<T> extends Signal<T> { export interface MutableSignal<T> extends Signal<T> {
produce(fn: (draft: T) => undefined): void;
addInterruption(promise: Promise<void>): void;
clearInterruptions(): void;
produceAsync(fn: (draft: T) => undefined): Promise<void>;
}
class MutableSignalImpl<T> extends Signal<T> {
private _interruptions: Promise<void>[] = []; private _interruptions: Promise<void>[] = [];
public constructor(t?: T, options?: SignalOptions<T>) { public constructor(t?: T, options?: SignalOptions<T>) {
super(t, options); super(t, options);
} }
produce(fn: (draft: T) => void) { produce(fn: (draft: T) => undefined) {
this.value = create(this.value, fn); this.value = create(this.value, fn);
} }
@@ -30,13 +37,16 @@ export class MutableSignal<T> extends Signal<T> {
* 异步版本的 produce。会先等待所有通过 `addInterruption` 添加的 Promise 完成, * 异步版本的 produce。会先等待所有通过 `addInterruption` 添加的 Promise 完成,
* 然后再更新状态。适用于需要在状态更新前播放动画的场景。 * 然后再更新状态。适用于需要在状态更新前播放动画的场景。
*/ */
async produceAsync(fn: (draft: T) => void): Promise<void> { async produceAsync(fn: (draft: T) => undefined): Promise<void> {
await Promise.allSettled(this._interruptions); await Promise.allSettled(this._interruptions);
this._interruptions = []; this._interruptions = [];
this.produce(fn); this.produce(fn);
} }
} }
export function mutableSignal<T>(initial?: T, options?: SignalOptions<T>): MutableSignal<T> { export function mutableSignal<T>(
return new MutableSignal<T>(initial, options); initial?: T,
options?: SignalOptions<T>,
): MutableSignal<T> {
return new MutableSignalImpl<T>(initial, options);
} }
+39
View File
@@ -0,0 +1,39 @@
import { effect } from "@preact/signals-core";
export type SpawnerFunction = (id: string) => () => void;
export function createSpawner(spawner: SpawnerFunction) {
const entries = new Map<string, () => void>();
function update(set: Set<string>) {
for (const key of set) {
if (!entries.has(key)) {
entries.set(key, spawner(key));
}
}
for (const [key, entry] of entries) {
if (!set.has(key)) {
entry();
entries.delete(key);
}
}
}
return update;
}
export function spawnerEffect(
iterable: () => Iterable<string>,
spawner: SpawnerFunction,
) {
const update = createSpawner(spawner);
return effect(() => {
const set = new Set<string>();
for (const each of iterable()) {
set.add(each);
}
update(set);
});
}
@@ -33,6 +33,7 @@ import type {
import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types"; import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import type { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape"; import type { 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 type { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
import { CardEffect } from "@/samples/slay-the-spire-like/data/desert";
function createRunContext( function createRunContext(
items: Map<string, InventoryItem<GameItemMeta>>, items: Map<string, InventoryItem<GameItemMeta>>,
@@ -63,7 +64,7 @@ function createEffect(
id: string, id: string,
lifecycle: EffectData["lifecycle"], lifecycle: EffectData["lifecycle"],
): EffectData { ): EffectData {
return { id, name: id, description: "", lifecycle }; return { id, name: id, description: "", lifecycle, emoji: "" };
} }
function createCard( function createCard(
@@ -78,8 +79,8 @@ function createCard(
type: "item" as const, type: "item" as const,
costType, costType,
costCount, costCount,
targetType: "none" as const, targetType: "player" as const,
effects: [] as const, effects: [] as CardEffect[],
}; };
} }
@@ -6,7 +6,6 @@ import {
} from "@/core/game"; } from "@/core/game";
import { createRegion } from "@/core/region"; import { createRegion } from "@/core/region";
import { import {
createStartWith,
createTriggers, createTriggers,
Triggers, Triggers,
} from "@/samples/slay-the-spire-like/system/combat/triggers"; } from "@/samples/slay-the-spire-like/system/combat/triggers";
@@ -27,19 +26,14 @@ import {
InventoryItem, InventoryItem,
} from "@/samples/slay-the-spire-like/system/grid-inventory/types"; } from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types"; import { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/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 { import {
CardEffect,
getCards, getCards,
getEffects, getEffects,
getEncounters,
getEnemies, getEnemies,
getItems,
} from "@/samples/slay-the-spire-like/data/desert"; } from "@/samples/slay-the-spire-like/data/desert";
const cards = getCards(); const cards = getCards();
const effects = getEffects(); const effects = getEffects();
const encounters = getEncounters();
const items = getItems();
const enemies = getEnemies(); const enemies = getEnemies();
function createEffect( function createEffect(
@@ -48,7 +42,7 @@ function createEffect(
): EffectData { ): EffectData {
const found = effects.find((e) => e.id === id); const found = effects.find((e) => e.id === id);
if (found) return found; if (found) return found;
return { id, name: id, description: "", lifecycle }; return { id, name: id, description: "", lifecycle, emoji: "" };
} }
function createDeckRegions(): DeckRegions { function createDeckRegions(): DeckRegions {
@@ -73,8 +67,8 @@ function createCard(
type: "item" as const, type: "item" as const,
costType, costType,
costCount, costCount,
targetType: "none" as const, targetType: "player" as const,
effects: [], effects: [] as CardEffect[],
}; };
return { return {
id, id,
@@ -211,9 +205,9 @@ describe("desert triggers", () => {
await triggers.onEffectApplied.execute(ctx, { await triggers.onEffectApplied.execute(ctx, {
effect: attackEffect, effect: attackEffect,
entityKey: "player", entityId: "player",
stacks: 5, stacks: 5,
sourceEntityKey: "enemy-0", sourceEntityId: "enemy-0",
}); });
expect(ctx.value.player.hp).toBe(25); expect(ctx.value.player.hp).toBe(25);
@@ -229,7 +223,7 @@ describe("desert triggers", () => {
await triggers.onEffectApplied.execute(ctx, { await triggers.onEffectApplied.execute(ctx, {
effect: drawEffect, effect: drawEffect,
entityKey: "player", entityId: "player",
stacks: 2, stacks: 2,
}); });
@@ -245,7 +239,7 @@ describe("desert triggers", () => {
const initialEnergy = ctx.value.player.energy; const initialEnergy = ctx.value.player.energy;
await triggers.onEffectApplied.execute(ctx, { await triggers.onEffectApplied.execute(ctx, {
effect: gainEnergyEffect, effect: gainEnergyEffect,
entityKey: "player", entityId: "player",
stacks: 2, stacks: 2,
}); });
@@ -267,7 +261,7 @@ describe("desert triggers", () => {
await triggers.onEffectApplied.execute(ctx, { await triggers.onEffectApplied.execute(ctx, {
effect: removeWoundEffect, effect: removeWoundEffect,
entityKey: "player", entityId: "player",
stacks: 2, stacks: 2,
}); });
@@ -285,20 +279,20 @@ describe("desert triggers", () => {
}), }),
); );
const triggers = getTriggers(); const triggers = getTriggers();
const defendEffect = createEffect("defend", "posture"); const defendEffect = createEffect("defend", "temporary");
ctx._state.produce((draft) => { ctx._state.produce((draft) => {
draft.player.effects.defend = { data: defendEffect, stacks: 5 }; draft.player.effects.defend = { data: defendEffect, stacks: 5 };
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: "player", entityId: "player",
amount: 8, amount: 8,
sourceEntityKey: "enemy-0", sourceEntityId: "enemy-0",
}); });
expect(ctx.value.player.hp).toBe(27); 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 () => { it("should reduce damage with damageReduce", async () => {
@@ -318,9 +312,9 @@ describe("desert triggers", () => {
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: "player", entityId: "player",
amount: 8, amount: 8,
sourceEntityKey: "enemy-0", sourceEntityId: "enemy-0",
}); });
expect(ctx.value.player.hp).toBe(25); expect(ctx.value.player.hp).toBe(25);
@@ -340,9 +334,9 @@ describe("desert triggers", () => {
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: "player", entityId: "player",
amount: 5, amount: 5,
sourceEntityKey: "enemy-0", sourceEntityId: "enemy-0",
}); });
expect(ctx.value.player.hp).toBe(23); expect(ctx.value.player.hp).toBe(23);
@@ -358,16 +352,18 @@ describe("desert triggers", () => {
); );
const triggers = getTriggers(); const triggers = getTriggers();
const spikeEffect = createEffect("spike", "permanent"); const spikeEffect = createEffect("spike", "permanent");
const attackEffect = createEffect("attack", "instant");
ctx._state.produce((draft) => { ctx._state.produce((draft) => {
const enemy = draft.enemies[0]; const enemy = draft.enemies[0];
enemy.effects.spike = { data: spikeEffect, stacks: 3 }; enemy.effects.spike = { data: spikeEffect, stacks: 3 };
}); });
await triggers.onDamage.execute(ctx, { await triggers.onEffectApplied.execute(ctx, {
entityKey: "仙人掌怪-0", effect: attackEffect,
amount: 5, entityId: "仙人掌怪-0",
sourceEntityKey: "player", stacks: 5,
sourceEntityId: "player",
}); });
expect(ctx.value.player.hp).toBe(27); expect(ctx.value.player.hp).toBe(27);
@@ -382,12 +378,6 @@ describe("desert triggers", () => {
}), }),
); );
const triggers = getTriggers(); 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" }); await triggers.onEnemyIntent.execute(ctx, { enemyId: "风暴之灵-0" });
@@ -414,9 +404,9 @@ describe("desert triggers", () => {
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: "幼沙虫-0", entityId: "幼沙虫-0",
amount: 5, amount: 5,
sourceEntityKey: "player", sourceEntityId: "player",
}); });
expect(ctx.value.player.energy).toBe(2); expect(ctx.value.player.energy).toBe(2);
@@ -441,9 +431,9 @@ describe("desert triggers", () => {
let threw = false; let threw = false;
try { try {
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: "蜥蜴-0", entityId: "蜥蜴-0",
amount: 1, amount: 1,
sourceEntityKey: "player", sourceEntityId: "player",
}); });
} catch (e) { } catch (e) {
threw = true; threw = true;
@@ -469,7 +459,7 @@ describe("desert triggers", () => {
draft.player.effects.discard = { data: discardEffect, stacks: 1 }; 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.hand.childIds.length).toBe(2);
expect(ctx.value.player.deck.regions.discardPile.childIds.length).toBe(1); 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 }; 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.defend?.stacks).toBe(5);
expect(ctx.value.player.effects.defendNext).toBeUndefined(); expect(ctx.value.player.effects.defendNext).toBeUndefined();
@@ -501,7 +491,7 @@ describe("desert triggers", () => {
draft.player.effects.energyNext = { data: energyNextEffect, stacks: 2 }; 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.energy).toBe(5);
expect(ctx.value.player.effects.energyNext).toBeUndefined(); expect(ctx.value.player.effects.energyNext).toBeUndefined();
@@ -520,7 +510,7 @@ describe("desert triggers", () => {
draft.player.effects.drawNext = { data: drawNextEffect, stacks: 2 }; 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.deck.regions.hand.childIds.length).toBe(2);
expect(ctx.value.player.effects.drawNext).toBeUndefined(); expect(ctx.value.player.effects.drawNext).toBeUndefined();
@@ -542,9 +532,9 @@ describe("desert triggers", () => {
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: "仙人掌怪-0", entityId: "仙人掌怪-0",
amount: 5, amount: 5,
sourceEntityKey: "player", sourceEntityId: "player",
}); });
expect(ctx.value.enemies[0].hp).toBe(2); expect(ctx.value.enemies[0].hp).toBe(2);
@@ -564,9 +554,9 @@ describe("desert triggers", () => {
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: "仙人掌怪-0", entityId: "仙人掌怪-0",
amount: 5, amount: 5,
sourceEntityKey: "player", sourceEntityId: "player",
}); });
expect(ctx.value.enemies[0].hp).toBe(74); expect(ctx.value.enemies[0].hp).toBe(74);
@@ -588,9 +578,9 @@ describe("desert triggers", () => {
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: "player", entityId: "player",
amount: 5, amount: 5,
sourceEntityKey: "沙蝎-0", sourceEntityId: "沙蝎-0",
}); });
expect(ctx.value.player.hp).toBe(23); expect(ctx.value.player.hp).toBe(23);
@@ -611,9 +601,9 @@ describe("desert triggers", () => {
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: "player", entityId: "player",
amount: 5, amount: 5,
sourceEntityKey: "骑马枪手-0", sourceEntityId: "骑马枪手-0",
}); });
expect(ctx.value.player.hp).toBe(20); expect(ctx.value.player.hp).toBe(20);
@@ -635,10 +625,10 @@ describe("desert triggers", () => {
await triggers.onEffectApplied.execute(ctx, { await triggers.onEffectApplied.execute(ctx, {
effect: crossbowEffect, effect: crossbowEffect,
entityKey: "player", entityId: "player",
stacks: 0, stacks: 0,
cardId: "crossbow-1", cardId: "crossbow-1",
sourceEntityKey: "player", sourceEntityId: "player",
targetId: "仙人掌怪-0", targetId: "仙人掌怪-0",
}); });
@@ -659,7 +649,7 @@ describe("desert triggers", () => {
await triggers.onCardDiscarded.execute(ctx, { await triggers.onCardDiscarded.execute(ctx, {
cardId: "fatigue-1", cardId: "fatigue-1",
sourceEntityKey: "player", sourceEntityId: "player",
}); });
expect(ctx.value.enemies[0].hp).toBe(40); expect(ctx.value.enemies[0].hp).toBe(40);
@@ -676,9 +666,9 @@ describe("desert triggers", () => {
const triggers = getTriggers(); const triggers = getTriggers();
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: "player", entityId: "player",
amount: 5, amount: 5,
sourceEntityKey: "秃鹫-0", sourceEntityId: "秃鹫-0",
}); });
const vultureEyeCards = Object.values(ctx.value.player.deck.cards).filter( const vultureEyeCards = Object.values(ctx.value.player.deck.cards).filter(
@@ -33,7 +33,7 @@ function createTestCardData(id: string, name: string, desc: string): CardData {
type: "item", type: "item",
costType: "energy", costType: "energy",
costCount: 1, costCount: 1,
targetType: "single", targetType: "enemy",
effects: [], effects: [],
}; };
} }
@@ -12,7 +12,6 @@ import {
getItemAtCell, getItemAtCell,
getAdjacentItems, getAdjacentItems,
validatePlacement, validatePlacement,
type GridInventory,
type InventoryItem, 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 { createItemIn } from "@/samples/slay-the-spire-like/system/grid-inventory/factory";
@@ -36,7 +35,7 @@ function createTestCardData(id: string, name: string, desc: string): CardData {
type: "item", type: "item",
costType: "energy", costType: "energy",
costCount: 1, costCount: 1,
targetType: "single", targetType: "enemy",
effects: [], effects: [],
}; };
} }
+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" });
});
});
});
+141 -139
View File
@@ -1,174 +1,176 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { parseCommand, type Command } from '@/utils/command'; import { parseCommand, type Command } from "@/utils/command";
describe('parseCommand', () => { describe("parseCommand", () => {
it('should parse empty string', () => { it("should parse empty string", () => {
const result = parseCommand(''); const result = parseCommand("");
expect(result).toEqual({ expect(result).toEqual({
name: '', name: "",
flags: {}, flags: {},
options: {}, options: {},
params: [] params: [],
});
}); });
});
it('should parse command name only', () => { it("should parse command name only", () => {
const result = parseCommand('move'); const result = parseCommand("move");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: {}, flags: {},
options: {}, options: {},
params: [] params: [],
});
}); });
});
it('should parse command with params', () => { it("should parse command with params", () => {
const result = parseCommand('move meeple1 region1'); const result = parseCommand("move meeple1 region1");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: {}, flags: {},
options: {}, options: {},
params: ['meeple1', 'region1'] params: ["meeple1", "region1"],
});
}); });
});
it('should parse command with long flags', () => { it("should parse command with long flags", () => {
const result = parseCommand('move meeple1 --force --quiet'); const result = parseCommand("move meeple1 --force --quiet");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: { force: true, quiet: true }, flags: { force: true, quiet: true },
options: {}, options: {},
params: ['meeple1'] params: ["meeple1"],
});
}); });
});
it('should parse command with short flags', () => { it("should parse command with short flags", () => {
const result = parseCommand('move meeple1 -f -q'); const result = parseCommand("move meeple1 -f -q");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: { f: true, q: true }, flags: { f: true, q: true },
options: {}, options: {},
params: ['meeple1'] params: ["meeple1"],
});
}); });
});
it('should parse command with long options', () => { it("should parse command with long options", () => {
const result = parseCommand('move meeple1 --x 10 --y 20'); const result = parseCommand("move meeple1 --x 10 --y 20");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: {}, flags: {},
options: { x: '10', y: '20' }, options: { x: "10", y: "20" },
params: ['meeple1'] params: ["meeple1"],
});
}); });
});
it('should parse command with short options', () => { it("should parse command with short options", () => {
const result = parseCommand('move meeple1 -x 10 -y 20'); const result = parseCommand("move meeple1 -x 10 -y 20");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: {}, flags: {},
options: { x: '10', y: '20' }, options: { x: "10", y: "20" },
params: ['meeple1'] params: ["meeple1"],
});
}); });
});
it('should parse command with mixed flags和选项', () => { it("should parse command with mixed flags和选项", () => {
const result = parseCommand('move meeple1 region1 --force -x 10 -q'); const result = parseCommand("move meeple1 region1 --force -x 10 -q");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: { force: true, q: true }, flags: { force: true, q: true },
options: { x: '10' }, options: { x: "10" },
params: ['meeple1', 'region1'] params: ["meeple1", "region1"],
});
}); });
});
it('should handle extra whitespace', () => { it("should handle extra whitespace", () => {
const result = parseCommand(' move meeple1 --force '); const result = parseCommand(" move meeple1 --force ");
expect(result).toEqual({ expect(result).toEqual({
name: 'move', name: "move",
flags: { force: true }, flags: { force: true },
options: {}, options: {},
params: ['meeple1'] params: ["meeple1"],
});
}); });
});
it('should parse complex command', () => { it("should parse complex command", () => {
const result = parseCommand('place meeple1 board --x 5 --y 3 --rotate 90 --force'); const result = parseCommand(
expect(result).toEqual({ "place meeple1 board --x 5 --y 3 --rotate 90 --force",
name: 'place', );
flags: { force: true }, expect(result).toEqual({
options: { x: '5', y: '3', rotate: '90' }, name: "place",
params: ['meeple1', 'board'] flags: { force: true },
}); options: { x: "5", y: "3", rotate: "90" },
params: ["meeple1", "board"],
}); });
});
it('should treat negative number as option value', () => { it("should treat negative number as option value", () => {
const result = parseCommand('set --value -10'); const result = parseCommand("set --value -10");
expect(result).toEqual({ expect(result).toEqual({
name: 'set', name: "set",
flags: {}, flags: {},
options: { value: '-10' }, options: { value: "-10" },
params: [] params: [],
});
}); });
});
it('should parse quoted string with double quotes', () => { it("should parse quoted string with double quotes", () => {
const result = parseCommand('place tile "large castle" --x 5'); const result = parseCommand('place tile "large castle" --x 5');
expect(result).toEqual({ expect(result).toEqual({
name: 'place', name: "place",
flags: {}, flags: {},
options: { x: '5' }, options: { x: "5" },
params: ['tile', 'large castle'] params: ["tile", "large castle"],
});
}); });
});
it('should parse quoted string with single quotes', () => { it("should parse quoted string with single quotes", () => {
const result = parseCommand("place tile 'large castle' --x 5"); const result = parseCommand("place tile 'large castle' --x 5");
expect(result).toEqual({ expect(result).toEqual({
name: 'place', name: "place",
flags: {}, flags: {},
options: { x: '5' }, options: { x: "5" },
params: ['tile', 'large castle'] params: ["tile", "large castle"],
});
}); });
});
it('should handle escaped quotes', () => { it("should handle escaped quotes", () => {
const result = parseCommand('say "hello \\"world\\""'); const result = parseCommand('say "hello \\"world\\""');
expect(result).toEqual({ expect(result).toEqual({
name: 'say', name: "say",
flags: {}, flags: {},
options: {}, options: {},
params: ['hello "world"'] params: ['hello "world"'],
});
}); });
});
it('should handle escaped backslash', () => { it("should handle escaped backslash", () => {
const result = parseCommand('set path "C:\\\\Users"'); const result = parseCommand('set path "C:\\\\Users"');
expect(result).toEqual({ expect(result).toEqual({
name: 'set', name: "set",
flags: {}, flags: {},
options: {}, options: {},
params: ['path', 'C:\\Users'] params: ["path", "C:\\Users"],
});
}); });
});
it('should handle mixed quotes', () => { it("should handle mixed quotes", () => {
const result = parseCommand('cmd "hello world" \'foo bar\' --flag'); const result = parseCommand("cmd \"hello world\" 'foo bar' --flag");
expect(result).toEqual({ expect(result).toEqual({
name: 'cmd', name: "cmd",
flags: { flag: true }, flags: { flag: true },
options: {}, options: {},
params: ['hello world', 'foo bar'] params: ["hello world", "foo bar"],
});
}); });
});
it('should handle quote in middle of argument', () => { it("should handle quote in middle of argument", () => {
const result = parseCommand('cmd "hello\'s world"'); const result = parseCommand('cmd "hello\'s world"');
expect(result).toEqual({ expect(result).toEqual({
name: 'cmd', name: "cmd",
flags: {}, flags: {},
options: {}, options: {},
params: ["hello's world"] params: ["hello's world"],
});
}); });
});
}); });