Compare commits

...

7 Commits

Author SHA1 Message Date
hypercross 08c6a67d16 refactor: use English IDs for desert card effects
Replace Chinese card names with English IDs in the desert card effect
CSV to ensure consistency with the card identifiers. Update tests to
verify the data import using the new English identifiers.
2026-04-20 00:23:41 +08:00
hypercross dda8f4cfe9 style: reformat card-events.ts with 2-space indentation 2026-04-20 00:00:42 +08:00
hypercross 2f2e4e56b5 refactor: decouple card effects from card data in desert sample
Moves card effects from `card.csv` to a dedicated `cardEffect.csv` file.
This allows for more granular control over card triggers (onPlay,
onDraw, onDiscard) and targets, improving the data model for the
slay-the-spire-like sample. Also updates triggers and tests to
reflect this new structure.
2026-04-20 00:00:41 +08:00
hypercross 3840c3d739 test: expand slay-the-spire-like map generator tests 2026-04-20 00:00:41 +08:00
hypercross 43bb246ab9 build: set rootDir to current directory in tsconfig 2026-04-20 00:00:41 +08:00
hypercross 25b44fd6d1 build: update tsconfig and tsup sample configuration
- Include tests in tsconfig for better type checking
- Update tsup.samples.config.ts to use double quotes
- Add esbuild alias for `@` in sample builds
2026-04-20 00:00:41 +08:00
hypercross 601eb0f417 refactor: reformat code and introduce IGameContextExport
- Reformat `src/core/game.ts` and sample types to use 2-space
  indentation
- Add `IGameContextExport` to hide internal test properties
- Update `CombatGameContext` to use the exported context type
2026-04-20 00:00:41 +08:00
15 changed files with 2243 additions and 1851 deletions

View File

@ -1,35 +1,46 @@
import {MutableSignal, mutableSignal} from "@/utils/mutable-signal";
import { MutableSignal, mutableSignal } from "@/utils/mutable-signal";
import {
Command,
CommandRegistry, CommandResult,
CommandRegistry,
CommandResult,
CommandRunnerContextExport,
CommandSchema,
createCommandRegistry,
createCommandRunnerContext, parseCommandSchema,
createCommandRunnerContext,
parseCommandSchema,
} from "@/utils/command";
import {PromptValidator} from "@/utils/command/command-runner";
import {Mulberry32RNG, ReadonlyRNG, RNG} from "@/utils/rng";
import { PromptValidator } from "@/utils/command/command-runner";
import { Mulberry32RNG, ReadonlyRNG, RNG } from "@/utils/rng";
export interface IGameContext<TState extends Record<string, unknown> = {} > {
export interface IGameContext<TState extends Record<string, unknown> = {}> {
get value(): TState;
get rng(): ReadonlyRNG;
produce(fn: (draft: TState) => void): void;
produceAsync(fn: (draft: TState) => void): Promise<void>;
run<T>(input: string): Promise<CommandResult<T>>;
runParsed<T>(command: Command): Promise<CommandResult<T>>;
prompt: <TResult,TArgs extends any[]=any[]>(def: PromptDef<TArgs>, validator: PromptValidator<TResult,TArgs>, currentPlayer?: string | null) => Promise<TResult>;
prompt: <TResult, TArgs extends any[] = any[]>(
def: PromptDef<TArgs>,
validator: PromptValidator<TResult, TArgs>,
currentPlayer?: string | null,
) => Promise<TResult>;
// test only
_state: MutableSignal<TState>;
_commands: CommandRunnerContextExport<IGameContext<TState>>;
_rng: RNG;
}
export type IGameContextExport<TState extends Record<string, unknown> = {}> =
Omit<IGameContext<TState>, "_state" | "_commands" | "_rng">;
export function createGameContext<TState extends Record<string, unknown> = {} >(
export function createGameContext<TState extends Record<string, unknown> = {}>(
commandRegistry: CommandRegistry<IGameContext<TState>>,
initialState?: TState | (() => TState)
initialState?: TState | (() => TState),
): IGameContext<TState> {
const stateValue = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
const stateValue =
typeof initialState === "function"
? initialState()
: (initialState ?? ({} as TState));
const state = mutableSignal(stateValue);
let commands: CommandRunnerContextExport<IGameContext<TState>> = null as any;
@ -53,7 +64,12 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
return commands.runParsed<T>(command);
},
prompt(def, validator, currentPlayer) {
return commands.prompt(def.schema, validator, def.hintText, currentPlayer);
return commands.prompt(
def.schema,
validator,
def.hintText,
currentPlayer,
);
},
_state: state,
@ -61,20 +77,28 @@ export function createGameContext<TState extends Record<string, unknown> = {} >(
_rng: new Mulberry32RNG(),
};
context._commands = commands = createCommandRunnerContext(commandRegistry, context);
context._commands = commands = createCommandRunnerContext(
commandRegistry,
context,
);
return context;
}
export type PromptDef<TArgs extends any[]=any[]> = {
schema: CommandSchema,
export type PromptDef<TArgs extends any[] = any[]> = {
schema: CommandSchema;
hintText?: string;
};
export function createPromptDef<TArgs extends any[] = any[]>(
schema: CommandSchema | string,
hintText?: string,
}
export function createPromptDef<TArgs extends any[]=any[]>(schema: CommandSchema | string, hintText?: string): PromptDef<TArgs> {
schema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
): PromptDef<TArgs> {
schema = typeof schema === "string" ? parseCommandSchema(schema) : schema;
return { schema, hintText };
}
export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
export function createGameCommandRegistry<
TState extends Record<string, unknown> = {},
>() {
return createCommandRegistry<IGameContext<TState>>();
}

View File

@ -2,39 +2,37 @@
# type: 'item' = inventory item card, 'status' = status effect card
# costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
# targetType: 'single' = target one enemy, 'none' = no target
# onPlay: effects triggered when card is played
# onDraw: effects triggered when card enters hand
# onDiscard: effects triggered when card is discarded
# effects := ~cardEffect(card)
id,name,desc,type,costType,costCount,targetType,effects
string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',['onPlay'|'onDraw'|'onDiscard';'self'|'target'|'all'|'random';@effect;number][]
sword,剑,【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2]
greataxe,长斧,对全体【攻击5】,item,energy,2,none,[onPlay;all;attack;5]
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2];[onPlay;target;attack;2]
dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,[onPlay;target;attack;3];[onPlay;target;attack;3]
dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,[onPlay;target;attack;1];[onPlay;self;draw;1]
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,[onPlay;target;attack;6];[onPlay;self;crossbow;0]
shield,盾,【防御3】,item,energy,1,none,[onPlay;self;defend;3]
hat,斗笠,【防御8】,item,energy,2,none,[onPlay;self;defend;8]
cape,披风,【防御2】下回合【防御2】,item,energy,1,none,[onPlay;self;defend;2];[onPlay;self;defendNext;2]
bracer,护腕,【防御1】抓1张牌,item,energy,0,none,[onPlay;self;defend;1];[onPlay;self;draw;1]
greatshield,大盾,【防御5】,item,energy,1,none,[onPlay;self;defend;5]
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,[onPlay;self;damageReduce;3]
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,[onPlay;self;removeWound;1]
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,[onPlay;self;attackBuff;2]
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,[onPlay;self;defendBuff;2]
vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,[onPlay;self;gainEnergy;1]
focusPotion,集中药剂,抓2张牌,item,uses,3,none,[onPlay;self;draw;2]
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,[onPlay;self;removeWound;3]
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,[onPlay;self;energyNext;1];[onPlay;self;drawNext;2]
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,[onPlay;self;defendBuffUntilPlay;2]
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,[onPlay;self;drawChoice;1]
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,[onPlay;self;burnForEnergy;1]
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,[onPlay;self;attackBuffUntilPlay;3]
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,[onPlay;self;transformRandom;1]
wound,伤口,无效果占用手牌和牌堆,status,none,0,none,
venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,[onDiscard;self;attack;3]
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,[onDraw;self;curse;1]
static,静电,在手里时受电击伤害+1,status,none,0,none,[onDraw;self;static;1]
fatigue,疲劳,占用手牌,status,none,0,none,
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,[onDraw;self;expose;3]
id,name,desc,type,costType,costCount,targetType
string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none'
sword,剑,【攻击2】【攻击2】,item,energy,1,single
greataxe,长斧,对全体【攻击5】,item,energy,2,none
spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single
dagger,短刀,【攻击3】【攻击3】,item,energy,1,single
dart,飞镖,【攻击1】抓一张牌,item,energy,0,single
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single
shield,盾,【防御3】,item,energy,1,none
hat,斗笠,【防御8】,item,energy,2,none
cape,披风,【防御2】下回合【防御2】,item,energy,1,none
bracer,护腕,【防御1】抓1张牌,item,energy,0,none
greatshield,大盾,【防御5】,item,energy,1,none
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none
vitalityPotion,活力药剂,获得1点能量,item,uses,3,none
focusPotion,集中药剂,抓2张牌,item,uses,3,none
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none
wound,伤口,无效果占用手牌和牌堆,status,none,0,none
venom,蛇毒,弃掉时受到3点伤害,status,none,0,none
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none
static,静电,在手里时受电击伤害+1,status,none,0,none
fatigue,疲劳,占用手牌,status,none,0,none
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none

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 # onPlay: effects triggered when card is played # effects := ~cardEffect(card)
# onDraw: effects triggered when card enters hand
# onDiscard: effects triggered when card is discarded
6 id,name,desc,type,costType,costCount,targetType,effects id,name,desc,type,costType,costCount,targetType
7 string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',['onPlay'|'onDraw'|'onDiscard';'self'|'target'|'all'|'random';@effect;number][] string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none'
8 sword,剑,【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2] sword,剑,【攻击2】【攻击2】,item,energy,1,single
9 greataxe,长斧,对全体【攻击5】,item,energy,2,none,[onPlay;all;attack;5] greataxe,长斧,对全体【攻击5】,item,energy,2,none
10 spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2];[onPlay;target;attack;2] spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single
11 dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,[onPlay;target;attack;3];[onPlay;target;attack;3] dagger,短刀,【攻击3】【攻击3】,item,energy,1,single
12 dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,[onPlay;target;attack;1];[onPlay;self;draw;1] dart,飞镖,【攻击1】抓一张牌,item,energy,0,single
13 crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,[onPlay;target;attack;6];[onPlay;self;crossbow;0] crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single
14 shield,盾,【防御3】,item,energy,1,none,[onPlay;self;defend;3] shield,盾,【防御3】,item,energy,1,none
15 hat,斗笠,【防御8】,item,energy,2,none,[onPlay;self;defend;8] hat,斗笠,【防御8】,item,energy,2,none
16 cape,披风,【防御2】下回合【防御2】,item,energy,1,none,[onPlay;self;defend;2];[onPlay;self;defendNext;2] cape,披风,【防御2】下回合【防御2】,item,energy,1,none
17 bracer,护腕,【防御1】抓1张牌,item,energy,0,none,[onPlay;self;defend;1];[onPlay;self;draw;1] bracer,护腕,【防御1】抓1张牌,item,energy,0,none
18 greatshield,大盾,【防御5】,item,energy,1,none,[onPlay;self;defend;5] greatshield,大盾,【防御5】,item,energy,1,none
19 chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,[onPlay;self;damageReduce;3] chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none
20 bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,[onPlay;self;removeWound;1] bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none
21 poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,[onPlay;self;attackBuff;2] poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none
22 fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,[onPlay;self;defendBuff;2] fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none
23 vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,[onPlay;self;gainEnergy;1] vitalityPotion,活力药剂,获得1点能量,item,uses,3,none
24 focusPotion,集中药剂,抓2张牌,item,uses,3,none,[onPlay;self;draw;2] focusPotion,集中药剂,抓2张牌,item,uses,3,none
25 healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,[onPlay;self;removeWound;3] healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none
26 waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,[onPlay;self;energyNext;1];[onPlay;self;drawNext;2] waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none
27 rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,[onPlay;self;defendBuffUntilPlay;2] rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none
28 belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,[onPlay;self;drawChoice;1] belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none
29 torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,[onPlay;self;burnForEnergy;1] torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none
30 whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,[onPlay;self;attackBuffUntilPlay;3] whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none
31 blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,[onPlay;self;transformRandom;1] blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none
32 wound,伤口,无效果占用手牌和牌堆,status,none,0,none, wound,伤口,无效果占用手牌和牌堆,status,none,0,none
33 venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,[onDiscard;self;attack;3] venom,蛇毒,弃掉时受到3点伤害,status,none,0,none
34 curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,[onDraw;self;curse;1] curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none
35 static,静电,在手里时受电击伤害+1,status,none,0,none,[onDraw;self;static;1] static,静电,在手里时受电击伤害+1,status,none,0,none
36 fatigue,疲劳,占用手牌,status,none,0,none, fatigue,疲劳,占用手牌,status,none,0,none
37 vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,[onDraw;self;expose;3] vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none
38

View File

@ -1,4 +1,4 @@
import type { Effect } from './effect.csv';
import type { CardEffect } from './cardEffect.csv';
type CardTable = readonly {
readonly id: string;
@ -8,7 +8,7 @@ type CardTable = readonly {
readonly costType: "energy" | "uses" | "none";
readonly costCount: number;
readonly targetType: "single" | "none";
readonly effects: ["onPlay" | "onDraw" | "onDiscard", "self" | "target" | "all" | "random", Effect, number][];
readonly effects: CardEffect[];
}[];
export type Card = CardTable[number];

View File

@ -0,0 +1,32 @@
id,card,trigger,target,effects
string,@card,'onPlay'|'onDraw'|'onDiscard','self'|'target'|'all'|'random',[@effect;number][]
sword,sword,onPlay,target,[attack;2];[attack;2]
greataxe,greataxe,onPlay,all,[attack;5]
spear,spear,onPlay,target,[attack;2];[attack;2];[attack;2]
dagger,dagger,onPlay,target,[attack;3];[attack;3]
dart,dart,onPlay,target,[attack;1]
dart-draw,dart,onPlay,self,[draw;1]
crossbow,crossbow,onPlay,target,[attack;6]
crossbow-combo,crossbow,onPlay,self,[crossbow;0]
shield,shield,onPlay,self,[defend;3]
hat,hat,onPlay,self,[defend;8]
cape,cape,onPlay,self,[defend;2];[defendNext;2]
bracer,bracer,onPlay,self,[defend;1];[draw;1]
greatshield,greatshield,onPlay,self,[defend;5]
chainmail,chainmail,onPlay,self,[damageReduce;3]
bandage,bandage,onPlay,self,[removeWound;1]
poisonPotion,poisonPotion,onPlay,self,[attackBuff;2]
fortifyPotion,fortifyPotion,onPlay,self,[defendBuff;2]
vitalityPotion,vitalityPotion,onPlay,self,[gainEnergy;1]
focusPotion,focusPotion,onPlay,self,[draw;2]
healingPotion,healingPotion,onPlay,self,[removeWound;3]
waterBag,waterBag,onPlay,self,[energyNext;1];[drawNext;2]
rope,rope,onPlay,self,[defendBuffUntilPlay;2]
belt,belt,onPlay,self,[drawChoice;1]
torch,torch,onPlay,self,[burnForEnergy;1]
whetstone,whetstone,onPlay,self,[attackBuffUntilPlay;3]
blacksmithHammer,blacksmithHammer,onPlay,self,[transformRandom;1]
venom,venom,onDiscard,self,[attack;3]
curse,curse,onDraw,self,[curse;1]
static,static,onDraw,self,[static;1]
vultureEye,vultureEye,onDraw,self,[expose;3]
1 id card trigger target effects
2 string @card 'onPlay'|'onDraw'|'onDiscard' 'self'|'target'|'all'|'random' [@effect;number][]
3 sword sword onPlay target [attack;2];[attack;2]
4 greataxe greataxe onPlay all [attack;5]
5 spear spear onPlay target [attack;2];[attack;2];[attack;2]
6 dagger dagger onPlay target [attack;3];[attack;3]
7 dart dart onPlay target [attack;1]
8 dart-draw dart onPlay self [draw;1]
9 crossbow crossbow onPlay target [attack;6]
10 crossbow-combo crossbow onPlay self [crossbow;0]
11 shield shield onPlay self [defend;3]
12 hat hat onPlay self [defend;8]
13 cape cape onPlay self [defend;2];[defendNext;2]
14 bracer bracer onPlay self [defend;1];[draw;1]
15 greatshield greatshield onPlay self [defend;5]
16 chainmail chainmail onPlay self [damageReduce;3]
17 bandage bandage onPlay self [removeWound;1]
18 poisonPotion poisonPotion onPlay self [attackBuff;2]
19 fortifyPotion fortifyPotion onPlay self [defendBuff;2]
20 vitalityPotion vitalityPotion onPlay self [gainEnergy;1]
21 focusPotion focusPotion onPlay self [draw;2]
22 healingPotion healingPotion onPlay self [removeWound;3]
23 waterBag waterBag onPlay self [energyNext;1];[drawNext;2]
24 rope rope onPlay self [defendBuffUntilPlay;2]
25 belt belt onPlay self [drawChoice;1]
26 torch torch onPlay self [burnForEnergy;1]
27 whetstone whetstone onPlay self [attackBuffUntilPlay;3]
28 blacksmithHammer blacksmithHammer onPlay self [transformRandom;1]
29 venom venom onDiscard self [attack;3]
30 curse curse onDraw self [curse;1]
31 static static onDraw self [static;1]
32 vultureEye vultureEye onDraw self [expose;3]

View File

@ -0,0 +1,15 @@
import type { Card } from './card.csv';
import type { Effect } from './effect.csv';
type CardEffectTable = readonly {
readonly id: string;
readonly card: Card;
readonly trigger: "onPlay" | "onDraw" | "onDiscard";
readonly target: "self" | "target" | "all" | "random";
readonly effects: [Effect, number][];
}[];
export type CardEffect = CardEffectTable[number];
declare function getData(): CardEffectTable;
export default getData;

View File

@ -9,9 +9,14 @@ export function addCardEventTriggers(triggers: Triggers) {
const effects = getEffects();
function findEffect(id: string): EffectData {
const found = effects.find(e => e.id === id);
const found = effects.find((e) => e.id === id);
if (found) return found;
return { id, name: id, description: "", lifecycle: "instant" } as EffectData;
return {
id,
name: id,
description: "",
lifecycle: "instant",
} as EffectData;
}
// storm: give static card to player when storm enemy attacks
@ -62,14 +67,17 @@ export function addCardEventTriggers(triggers: Triggers) {
if (!card) return;
const playedItemId = card.itemId;
const adjacent = getAdjacentItems<GameItemMeta>(ctx.game.value.inventory, playedItemId);
const adjacent = getAdjacentItems<GameItemMeta>(
ctx.game.value.inventory,
playedItemId,
);
for (const [adjItemId] of adjacent) {
const adjEffects = ctx.game.value.player.itemEffects[adjItemId];
if (!adjEffects) continue;
const burn = adjEffects.burnForEnergy;
if (!burn || burn.stacks <= 0) continue;
await ctx.game.produceAsync(draft => {
await ctx.game.produceAsync((draft) => {
const item = draft.inventory.items.get(adjItemId);
if (item) {
draft.inventory.items.delete(adjItemId);
@ -89,12 +97,12 @@ export function addCardEventTriggers(triggers: Triggers) {
if (!card || card.cardData.id !== "fatigue") return;
const sandwormKing = ctx.game.value.enemies.find(
e => e.enemy.id === "沙虫王" && e.isAlive
(e) => e.enemy.id === "沙虫王" && e.isAlive,
);
if (!sandwormKing) return;
await ctx.game.produceAsync(draft => {
const king = draft.enemies.find(e => e.id === sandwormKing.id);
await ctx.game.produceAsync((draft) => {
const king = draft.enemies.find((e) => e.id === sandwormKing.id);
if (king) {
king.hp = Math.min(king.hp + 10, king.maxHp);
}
@ -109,7 +117,8 @@ export function addCardEventTriggers(triggers: Triggers) {
if (dealt <= 0 || !ctx.sourceEntityKey) return;
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityKey);
if (!attacker || !("enemy" in attacker) || attacker.enemy.id !== "秃鹫") return;
if (!attacker || !("enemy" in attacker) || attacker.enemy.id !== "秃鹫")
return;
await triggers.onEffectApplied.execute(ctx.game, {
effect: findEffect("vultureEye"),

View File

@ -1,132 +1,187 @@
import {CombatGameContext} from "./types";
import { CombatGameContext } from "./types";
import {
addEntityEffect,
addItemEffect,
getAliveEnemies, onEntityPostureDamage,
getAliveEnemies,
onEntityPostureDamage,
onEntityEffectUpkeep,
onPlayerItemEffectUpkeep, onItemDiscard, onItemPlay, payCardCost, getCombatEntity, getEffectTargets
onPlayerItemEffectUpkeep,
onItemDiscard,
onItemPlay,
payCardCost,
getCombatEntity,
getEffectTargets,
} from "@/samples/slay-the-spire-like/system/combat/effects";
import {promptMainAction} from "@/samples/slay-the-spire-like/system/combat/prompts";
import {moveToRegion, shuffle} from "@/core/region";
import {createMiddlewareChain} from "@/utils/middleware";
import {EffectData} from "@/samples/slay-the-spire-like/system/types";
import {getAdjacentItems} from "@/samples/slay-the-spire-like/system/grid-inventory";
import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress";
import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/prompts";
import { moveToRegion, shuffle } from "@/core/region";
import { createMiddlewareChain } from "@/utils/middleware";
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import { getAdjacentItems } from "@/samples/slay-the-spire-like/system/grid-inventory";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress";
type TriggerTypes = {
onCombatStart: {},
onTurnStart: { entityKey: "player" | string, },
onTurnEnd: { entityKey: "player" | string, },
onShuffle: {},
onCardPlayed: { cardId: string, targetId?: string, sourceEntityKey?: "player" | string },
onCardDiscarded: { cardId: string, sourceEntityKey?: "player" | string },
onCardDrawn: { cardId: string, sourceEntityKey?: "player" | string },
onDraw: {count: number},
onEffectApplied: { effect: EffectData, entityKey: "player" | string, stacks: number, cardId?: string, sourceEntityKey?: "player" | string, targetId?: string },
onHpChange: { entityKey: "player" | string, amount: number},
onDamage: { entityKey: "player" | string, amount: number, prevented?: number, sourceEntityKey?: "player" | string},
onEnemyIntent: { enemyId: string, sourceEntityKey?: "player" | string },
onIntentUpdate: { enemyId: string },
}
onCombatStart: {};
onTurnStart: { entityKey: "player" | string };
onTurnEnd: { entityKey: "player" | string };
onShuffle: {};
onCardPlayed: {
cardId: string;
targetId?: string;
sourceEntityKey?: "player" | string;
};
onCardDiscarded: { cardId: string; sourceEntityKey?: "player" | string };
onCardDrawn: { cardId: string; sourceEntityKey?: "player" | string };
onDraw: { count: number };
onEffectApplied: {
effect: EffectData;
entityKey: "player" | string;
stacks: number;
cardId?: string;
sourceEntityKey?: "player" | string;
targetId?: string;
};
onHpChange: { entityKey: "player" | string; amount: number };
onDamage: {
entityKey: "player" | string;
amount: number;
prevented?: number;
sourceEntityKey?: "player" | string;
};
onEnemyIntent: { enemyId: string; sourceEntityKey?: "player" | string };
onIntentUpdate: { enemyId: string };
};
function createTriggers(){
export function createTriggers() {
const triggers = {
onCombatStart: createTrigger("onCombatStart"),
onTurnStart: createTrigger("onTurnStart", async ctx => {
await ctx.game.produceAsync(draft => {
onTurnStart: createTrigger("onTurnStart", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const entity = getCombatEntity(draft, ctx.entityKey);
if(entity) onEntityEffectUpkeep(entity);
if(entity === draft.player)
onPlayerItemEffectUpkeep(draft.player);
})
if (entity) onEntityEffectUpkeep(entity);
if (entity === draft.player) onPlayerItemEffectUpkeep(draft.player);
});
}),
onTurnEnd: createTrigger("onTurnEnd", async ctx => {
if(ctx.entityKey !== "player")return;
const {regions} = ctx.game.value.player.deck;
for(const cardId of Object.values(regions.hand.childIds)){
await triggers.onCardDiscarded.execute(ctx.game,{cardId});
onTurnEnd: createTrigger("onTurnEnd", async (ctx) => {
if (ctx.entityKey !== "player") return;
const { regions } = ctx.game.value.player.deck;
for (const cardId of Object.values(regions.hand.childIds)) {
await triggers.onCardDiscarded.execute(ctx.game, { cardId });
}
await ctx.game.produceAsync(draft => draft.player.energy = draft.player.maxEnergy);
await triggers.onDraw.execute(ctx.game,{count: 5});
await ctx.game.produceAsync(
(draft) => (draft.player.energy = draft.player.maxEnergy),
);
await triggers.onDraw.execute(ctx.game, { count: 5 });
}),
onShuffle: createTrigger("onShuffle", async ctx => {
await ctx.game.produceAsync(draft => {
const {cards, regions} = draft.player.deck;
for(const cardId of Object.values(regions.discardPile.childIds))
onShuffle: createTrigger("onShuffle", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
for (const cardId of Object.values(regions.discardPile.childIds))
moveToRegion(cards[cardId], regions.discardPile, regions.drawPile);
shuffle(regions.drawPile, cards, ctx.game.rng);
});
}),
onCardPlayed: createTrigger("onCardPlayed", async ctx => {
await ctx.game.produceAsync(draft => {
const {cards, regions} = draft.player.deck;
onCardPlayed: createTrigger("onCardPlayed", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
const card = cards[ctx.cardId];
payCardCost(draft.player, card.cardData.costType, card.cardData.costCount, card.itemId, draft.inventory);
payCardCost(
draft.player,
card.cardData.costType,
card.cardData.costCount,
card.itemId,
draft.inventory,
);
moveToRegion(card, regions.hand, regions.discardPile);
onItemPlay(draft.player, card.itemId);
});
const {cards, regions} = ctx.game.value.player.deck;
const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player";
for(const [trigger, target, effect, stacks] of card.cardData.effects){
if(trigger !== 'onPlay') continue;
for(const entity of getEffectTargets(target, ctx.game, ctx.targetId))
await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: source, targetId: ctx.targetId});
for (const { trigger, target, effects } of card.cardData.effects) {
if (trigger !== "onPlay") continue;
for (const [effect, stacks] of effects)
for (const entity of getEffectTargets(target, ctx.game, ctx.targetId))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: entity.id,
stacks,
cardId: ctx.cardId,
sourceEntityKey: source,
targetId: ctx.targetId,
});
}
}),
onCardDiscarded: createTrigger("onCardDiscarded", async ctx => {
await ctx.game.produceAsync(draft => {
const {cards, regions} = draft.player.deck;
onCardDiscarded: createTrigger("onCardDiscarded", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile);
onItemDiscard(draft.player, cards[ctx.cardId].itemId);
});
const {cards, regions} = ctx.game.value.player.deck;
const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player";
for(const [trigger, target, effect, stacks] of card.cardData.effects){
if(trigger !== 'onDiscard') continue;
for(const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: source});
for (const { trigger, target, effects } of card.cardData.effects) {
if (trigger !== "onDiscard") continue;
for (const [effect, stacks] of effects)
for (const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: entity.id,
stacks,
cardId: ctx.cardId,
sourceEntityKey: source,
});
}
}),
onCardDrawn: createTrigger("onCardDrawn", async ctx => {
await ctx.game.produceAsync(draft => {
const {cards, regions} = draft.player.deck;
onCardDrawn: createTrigger("onCardDrawn", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
moveToRegion(cards[ctx.cardId], regions.drawPile, regions.hand);
});
const {cards, regions} = ctx.game.value.player.deck;
const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player";
for(const [trigger, target, effect, stacks] of card.cardData.effects){
if(trigger !== 'onDraw') continue;
for(const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: source});
for (const { trigger, target, effects } of card.cardData.effects) {
if (trigger !== "onDraw") continue;
for (const [effect, stacks] of effects)
for (const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: entity.id,
stacks,
cardId: ctx.cardId,
sourceEntityKey: source,
});
}
}),
onDraw: createTrigger("onDraw", async ctx => {
onDraw: createTrigger("onDraw", async (ctx) => {
let toDraw = ctx.count;
while(toDraw > 0){
let inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length;
if(inDraw <= 0) await triggers.onShuffle.execute(ctx.game,{});
while (toDraw > 0) {
let inDraw =
ctx.game.value.player.deck.regions.drawPile.childIds.length;
if (inDraw <= 0) await triggers.onShuffle.execute(ctx.game, {});
inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length;
if(inDraw <= 0) break;
if (inDraw <= 0) break;
const children = ctx.game.value.player.deck.regions.drawPile.childIds;
const cardId = children[children.length - 1];
await triggers.onCardDrawn.execute(ctx.game,{cardId});
await triggers.onCardDrawn.execute(ctx.game, { cardId });
toDraw--;
}
}),
onEffectApplied: createTrigger("onEffectApplied", async ctx => {
if(ctx.effect.lifecycle === 'instant') return;
onEffectApplied: createTrigger("onEffectApplied", async (ctx) => {
if (ctx.effect.lifecycle === "instant") return;
if(ctx.effect.lifecycle.startsWith("item")) {
if(ctx.cardId){
if (ctx.effect.lifecycle.startsWith("item")) {
if (ctx.cardId) {
const card = ctx.game.value.player.deck.cards[ctx.cardId];
const nearby = getAdjacentItems<GameItemMeta>(ctx.game.value.inventory, card.itemId);
for(const itemId of nearby.keys()){
await ctx.game.produceAsync(draft => {
const nearby = getAdjacentItems<GameItemMeta>(
ctx.game.value.inventory,
card.itemId,
);
for (const itemId of nearby.keys()) {
await ctx.game.produceAsync((draft) => {
addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks);
});
}
@ -134,71 +189,95 @@ function createTriggers(){
return;
}
await ctx.game.produceAsync(draft => {
const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey);
if(entity) addEntityEffect(entity, ctx.effect, ctx.stacks);
})
await ctx.game.produceAsync((draft) => {
const entity =
ctx.entityKey === "player"
? draft.player
: draft.enemies.find((e) => e.id === ctx.entityKey);
if (entity) addEntityEffect(entity, ctx.effect, ctx.stacks);
});
}),
onHpChange: createTrigger("onHpChange", async ctx => {
await ctx.game.produceAsync(draft => {
const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey);
if(!entity) return;
onHpChange: createTrigger("onHpChange", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const entity =
ctx.entityKey === "player"
? draft.player
: draft.enemies.find((e) => e.id === ctx.entityKey);
if (!entity) return;
entity.hp += ctx.amount;
entity.isAlive = entity.hp > 0;
draft.result = !draft.player.isAlive ? "defeat" : draft.enemies.every(e => !e.isAlive) ? "victory" : null;
draft.result = !draft.player.isAlive
? "defeat"
: draft.enemies.every((e) => !e.isAlive)
? "victory"
: null;
});
if(ctx.game.value.result) throw ctx.game.value;
if (ctx.game.value.result) throw ctx.game.value;
}),
onDamage: createTrigger("onDamage", async ctx => {
const entity = ctx.entityKey === "player" ? ctx.game.value.player : ctx.game.value.enemies.find(e => e.id === ctx.entityKey);
if(!entity || !entity.isAlive) return;
const dealt = Math.min(Math.max(0,entity.hp), ctx.amount - (ctx.prevented || 0));
await ctx.game.produceAsync(draft => {
onDamage: createTrigger("onDamage", async (ctx) => {
const entity =
ctx.entityKey === "player"
? ctx.game.value.player
: ctx.game.value.enemies.find((e) => e.id === ctx.entityKey);
if (!entity || !entity.isAlive) return;
const dealt = Math.min(
Math.max(0, entity.hp),
ctx.amount - (ctx.prevented || 0),
);
await ctx.game.produceAsync((draft) => {
onEntityPostureDamage(entity, dealt);
});
await triggers.onHpChange.execute(ctx.game,{entityKey: ctx.entityKey, amount: -dealt});
await triggers.onHpChange.execute(ctx.game, {
entityKey: ctx.entityKey,
amount: -dealt,
});
}),
onEnemyIntent: createTrigger("onEnemyIntent", async ctx => {
const enemy = ctx.game.value.enemies.find(e => e.id === ctx.enemyId);
if(!enemy || !enemy.isAlive) return;
onEnemyIntent: createTrigger("onEnemyIntent", async (ctx) => {
const enemy = ctx.game.value.enemies.find((e) => e.id === ctx.enemyId);
if (!enemy || !enemy.isAlive) return;
const intent = enemy.currentIntent;
if(!intent) return;
if (!intent) return;
const source = ctx.sourceEntityKey ?? enemy.id;
for(const [target, effect, stacks] of intent.effects){
for(const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game, { effect, entityKey: entity.id, stacks, sourceEntityKey: source });
for (const [target, effect, stacks] of intent.effects) {
for (const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: entity.id,
stacks,
sourceEntityKey: source,
});
}
}),
onIntentUpdate: createTrigger("onIntentUpdate", async ctx => {
await ctx.game.produceAsync(draft => {
const enemy = draft.enemies.find(e => e.id === ctx.enemyId);
if(!enemy) return;
onIntentUpdate: createTrigger("onIntentUpdate", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const enemy = draft.enemies.find((e) => e.id === ctx.enemyId);
if (!enemy) return;
const intent = enemy.currentIntent;
if(!intent) return;
if (!intent) return;
const nextIntents = intent.nextIntents;
if(nextIntents.length > 0){
if (nextIntents.length > 0) {
const nextIndex = ctx.game.rng.nextInt(nextIntents.length);
enemy.currentIntent = nextIntents[nextIndex];
}
});
}),
}
};
return triggers;
}
export type Triggers = ReturnType<typeof createTriggers>
export function createStartWith(build: (triggers: Triggers) => void){
export type Triggers = ReturnType<typeof createTriggers>;
export function createStartWith(build: (triggers: Triggers) => void) {
const triggers = createTriggers();
build(triggers);
return async function(game: CombatGameContext){
await triggers.onCombatStart.execute(game,{});
return async function (game: CombatGameContext) {
await triggers.onCombatStart.execute(game, {});
try {
while (true) {
await triggers.onTurnStart.execute(game, {entityKey: "player"});
await triggers.onTurnStart.execute(game, { entityKey: "player" });
while (true) {
const action = await promptMainAction(game);
if (action.action === "end-turn") break;
@ -206,35 +285,43 @@ export function createStartWith(build: (triggers: Triggers) => void){
await triggers.onCardPlayed.execute(game, action);
}
}
await triggers.onTurnEnd.execute(game, {entityKey: "player"});
await triggers.onTurnEnd.execute(game, { entityKey: "player" });
for (const enemy of getAliveEnemies(game.value)) {
await triggers.onTurnStart.execute(game, {entityKey: enemy.id});
await triggers.onTurnStart.execute(game, { entityKey: enemy.id });
}
for (const enemy of getAliveEnemies(game.value)) {
await triggers.onEnemyIntent.execute(game, {enemyId: enemy.id});
await triggers.onIntentUpdate.execute(game, {enemyId: enemy.id});
await triggers.onEnemyIntent.execute(game, { enemyId: enemy.id });
await triggers.onIntentUpdate.execute(game, { enemyId: enemy.id });
}
for (const enemy of getAliveEnemies(game.value)) {
await triggers.onTurnEnd.execute(game, {entityKey: enemy.id});
await triggers.onTurnEnd.execute(game, { entityKey: enemy.id });
}
}
}catch(e){
if(e === game.value) return game.value.result;
} catch (e) {
if (e === game.value) return game.value.result;
throw e;
}
}
};
}
type TriggerContext<TKey extends keyof TriggerTypes> = TriggerTypes[TKey] & { event: TKey, game: CombatGameContext };
function createTrigger<TKey extends keyof TriggerTypes>(event: TKey, fallback?: (ctx: TriggerContext<TKey>) => Promise<void>) {
const {use, execute} = createMiddlewareChain<TriggerContext<TKey>,void>(fallback);
type TriggerContext<TKey extends keyof TriggerTypes> = TriggerTypes[TKey] & {
event: TKey;
game: CombatGameContext;
};
function createTrigger<TKey extends keyof TriggerTypes>(
event: TKey,
fallback?: (ctx: TriggerContext<TKey>) => Promise<void>,
) {
const { use, execute } = createMiddlewareChain<TriggerContext<TKey>, void>(
fallback,
);
return {
use,
execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => {
const param = {...ctx, game, event};
const param = { ...ctx, game, event };
await execute(param);
return param;
},
}
};
}

View File

@ -1,10 +1,13 @@
import type { PlayerDeck } from "../deck/types";
import {EnemyData, IntentData} from "@/samples/slay-the-spire-like/system/types";
import {EffectData} from "@/samples/slay-the-spire-like/system/types";
import {GridInventory} from "@/samples/slay-the-spire-like/system/grid-inventory";
import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress";
import {
EnemyData,
IntentData,
} from "@/samples/slay-the-spire-like/system/types";
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import { GridInventory } from "@/samples/slay-the-spire-like/system/grid-inventory";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress";
export type EffectTable = Record<string, {data: EffectData, stacks: number}>;
export type EffectTable = Record<string, { data: EffectData; stacks: number }>;
export type CombatEntity = {
id: string; // player is just "player"
@ -19,7 +22,7 @@ export type PlayerEntity = CombatEntity & {
maxEnergy: number;
deck: PlayerDeck;
itemEffects: Record<string, EffectTable>;
}
};
export type EnemyEntity = CombatEntity & {
enemy: EnemyData;
@ -30,13 +33,15 @@ export type EnemyEntity = CombatEntity & {
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
export type CombatResult = "victory" | "defeat";
export type LootEntry = {
export type LootEntry =
| {
type: "gold";
amount: number;
} | {
type: "item",
}
| {
type: "item";
itemId: string;
};
};
export type CombatState = {
enemies: EnemyEntity[];
@ -50,4 +55,5 @@ export type CombatState = {
loot: LootEntry[];
};
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>;
export type CombatGameContext =
import("@/core/game").IGameContextExport<CombatState>;

View File

@ -4,7 +4,17 @@ export type EffectData = {
readonly description: string;
readonly lifecycle: EffectLifecycle;
};
export type EffectLifecycle = "instant" | "temporary" | "lingering" | "permanent" | "posture" | "item" | "itemTemporary" | "itemUntilPlay" | "itemUntilDiscard" | "itemPermanent";
export type EffectLifecycle =
| "instant"
| "temporary"
| "lingering"
| "permanent"
| "posture"
| "item"
| "itemTemporary"
| "itemUntilPlay"
| "itemUntilDiscard"
| "itemPermanent";
export type EnemyData = {
readonly id: string;
@ -18,6 +28,16 @@ export type CardCostType = "energy" | "uses" | "none";
export type CardTargetType = "single" | "none";
export type EffectTarget = "self" | "player" | "team";
export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard";
export type CardEffectTarget = "self" | "target" | "all" | "random";
export type CardEffect = {
readonly id: string;
readonly trigger: CardEffectTrigger;
readonly target: CardEffectTarget;
readonly effects: readonly [EffectData, number][];
};
export type CardData = {
readonly id: string;
readonly name: string;
@ -26,18 +46,26 @@ export type CardData = {
readonly costType: CardCostType;
readonly costCount: number;
readonly targetType: CardTargetType;
readonly effects: readonly [CardEffectTrigger, CardEffectTarget, EffectData, number][];
readonly effects: readonly CardEffect[];
};
export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard";
export type CardEffectTarget = "self" | "target" | "all" | "random"
export type EncounterType = "minion" | "elite" | "event" | "shop" | "camp" | "curio";
export type EncounterType =
| "minion"
| "elite"
| "event"
| "shop"
| "camp"
| "curio";
export type EncounterData = {
readonly id: string;
readonly type: EncounterType;
readonly name: string;
readonly description: string;
readonly enemies: readonly [data: EnemyData, hp: number, effects: [EffectData, stacks: number][]][];
readonly enemies: readonly [
data: EnemyData,
hp: number,
effects: [EffectData, stacks: number][],
][];
readonly dialogue: string;
};

View File

@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest';
import { describe, it, expect } from "vitest";
import {
addEffect,
addEntityEffect,
@ -12,38 +12,86 @@ import {
getCombatEntity,
canPlayCard,
payCardCost,
} from '@/samples/slay-the-spire-like/system/combat/effects';
import type { CombatEntity, CombatState, EffectTable, PlayerEntity, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types';
import type { EffectData } from '@/samples/slay-the-spire-like/system/types';
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory/types';
import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types';
import type { ParsedShape } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
import type { Transform2D } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
} from "@/samples/slay-the-spire-like/system/combat/effects";
import type {
CombatEntity,
CombatState,
EffectTable,
PlayerEntity,
EnemyEntity,
} from "@/samples/slay-the-spire-like/system/combat/types";
import type { EffectData } from "@/samples/slay-the-spire-like/system/types";
import type {
CellKey,
GridInventory,
InventoryItem,
} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress/types";
import type { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
import type { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
function createEffect(id: string, lifecycle: EffectData['lifecycle']): EffectData {
return { id, name: id, description: '', lifecycle };
function createEffect(
id: string,
lifecycle: EffectData["lifecycle"],
): EffectData {
return { id, name: id, description: "", lifecycle };
}
function createCard(id: string, costType: 'energy' | 'uses' | 'none', costCount: number) {
return { id, name: id, desc: '', type: 'item' as const, costType, costCount, targetType: 'none' as const, effects: [] as const };
function createCard(
id: string,
costType: "energy" | "uses" | "none",
costCount: number,
) {
return {
id,
name: id,
desc: "",
type: "item" as const,
costType,
costCount,
targetType: "none" as const,
effects: [] as const,
};
}
function createItem(itemId: string, cardId: string, costType: 'energy' | 'uses' | 'none', costCount: number, depletion = 0): InventoryItem<GameItemMeta> {
function createItem(
itemId: string,
cardId: string,
costType: "energy" | "uses" | "none",
costCount: number,
depletion = 0,
): InventoryItem<GameItemMeta> {
return {
id: itemId,
shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
transform: { x: 0, y: 0, rotation: 0, flipX: false, flipY: false } as unknown as Transform2D,
shape: { id: "1x1", cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
transform: {
x: 0,
y: 0,
rotation: 0,
flipX: false,
flipY: false,
} as unknown as Transform2D,
meta: {
itemData: { id: itemId, type: 'weapon', name: itemId, shape: '1x1', card: createCard(cardId, costType, costCount), price: 0, description: '' },
shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
depletion: costType === 'uses' ? depletion : undefined,
itemData: {
id: itemId,
type: "weapon",
name: itemId,
shape: "1x1",
card: createCard(cardId, costType, costCount),
price: 0,
description: "",
},
shape: { id: "1x1", cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
depletion: costType === "uses" ? depletion : undefined,
},
};
}
function createInventory(items: InventoryItem<GameItemMeta>[]): GridInventory<GameItemMeta> {
function createInventory(
items: InventoryItem<GameItemMeta>[],
): GridInventory<GameItemMeta> {
const map = new Map<string, InventoryItem<GameItemMeta>>();
const occupied = new Set<string>();
const occupied = new Set<CellKey>();
for (const item of items) {
map.set(item.id, item);
occupied.add(`${item.transform.x},${item.transform.y}`);
@ -65,7 +113,15 @@ function createPlayerEntity(hp = 30, maxHp = 30): PlayerEntity {
...createCombatEntity(hp, maxHp),
energy: 3,
maxEnergy: 3,
deck: { cards: {}, regions: { drawPile: { id: 'drawPile', axes: [], childIds: [], partMap: {} }, hand: { id: 'hand', axes: [], childIds: [], partMap: {} }, discardPile: { id: 'discardPile', axes: [], childIds: [], partMap: {} }, exhaustPile: { id: 'exhaustPile', axes: [], childIds: [], partMap: {} } } },
deck: {
cards: {},
regions: {
drawPile: { id: "drawPile", axes: [], childIds: [], partMap: {} },
hand: { id: "hand", axes: [], childIds: [], partMap: {} },
discardPile: { id: "discardPile", axes: [], childIds: [], partMap: {} },
exhaustPile: { id: "exhaustPile", axes: [], childIds: [], partMap: {} },
},
},
itemEffects: {},
};
}
@ -74,334 +130,342 @@ function createEnemyEntity(id: string, hp = 10, maxHp = 10): EnemyEntity {
return {
...createCombatEntity(hp, maxHp),
id,
enemy: { id, name: id, description: '' },
enemy: { id, name: id, description: "" },
intents: {},
currentIntentId: '',
currentIntentId: "",
};
}
function createCombatState(playerHp = 30, enemies: EnemyEntity[] = []): CombatState {
function createCombatState(
playerHp = 30,
enemies: EnemyEntity[] = [],
): CombatState {
return {
player: createPlayerEntity(playerHp),
enemies,
inventory: { width: 6, height: 4, items: new Map(), occupiedCells: new Set() },
phase: 'playerTurn',
inventory: {
width: 6,
height: 4,
items: new Map(),
occupiedCells: new Set(),
},
phase: "playerTurn",
turnNumber: 1,
result: null,
loot: [],
};
}
describe('combat/effects', () => {
describe('addEffect', () => {
it('should add a new effect to an empty table', () => {
describe("combat/effects", () => {
describe("addEffect", () => {
it("should add a new effect to an empty table", () => {
const table: EffectTable = {};
const effect = createEffect('strength', 'temporary');
const effect = createEffect("strength", "temporary");
addEffect(table, effect, 3);
expect(table['strength']).toBeDefined();
expect(table['strength'].data).toBe(effect);
expect(table['strength'].stacks).toBe(3);
expect(table["strength"]).toBeDefined();
expect(table["strength"].data).toBe(effect);
expect(table["strength"].stacks).toBe(3);
});
it('should stack with existing effect of same id', () => {
it("should stack with existing effect of same id", () => {
const table: EffectTable = {};
const effect = createEffect('strength', 'lingering');
const effect = createEffect("strength", "lingering");
addEffect(table, effect, 2);
addEffect(table, effect, 3);
expect(table['strength'].stacks).toBe(5);
expect(table["strength"].stacks).toBe(5);
});
it('should remove effect when stacks reach 0', () => {
it("should remove effect when stacks reach 0", () => {
const table: EffectTable = {};
const effect = createEffect('strength', 'temporary');
const effect = createEffect("strength", "temporary");
addEffect(table, effect, 3);
addEffect(table, effect, -3);
expect(table['strength']).toBeUndefined();
expect(table["strength"]).toBeUndefined();
});
it('should not add effect when stacks is 0', () => {
it("should not add effect when stacks is 0", () => {
const table: EffectTable = {};
const effect = createEffect('strength', 'temporary');
const effect = createEffect("strength", "temporary");
addEffect(table, effect, 0);
expect(table['strength']).toBeUndefined();
expect(table["strength"]).toBeUndefined();
});
it('should handle negative stacks', () => {
it("should handle negative stacks", () => {
const table: EffectTable = {};
const effect = createEffect('weak', 'temporary');
const effect = createEffect("weak", "temporary");
addEffect(table, effect, -2);
expect(table['weak'].stacks).toBe(-2);
expect(table["weak"].stacks).toBe(-2);
});
});
describe('addEntityEffect', () => {
it('should add effect to entity.effects', () => {
describe("addEntityEffect", () => {
it("should add effect to entity.effects", () => {
const entity = createCombatEntity();
const effect = createEffect('vulnerable', 'lingering');
const effect = createEffect("vulnerable", "lingering");
addEntityEffect(entity, effect, 2);
expect(entity.effects['vulnerable'].stacks).toBe(2);
expect(entity.effects["vulnerable"].stacks).toBe(2);
});
});
describe('addItemEffect', () => {
it('should add effect to player.itemEffects[itemKey]', () => {
describe("addItemEffect", () => {
it("should add effect to player.itemEffects[itemKey]", () => {
const player = createPlayerEntity();
const effect = createEffect('adjacent-buff', 'itemTemporary');
const effect = createEffect("adjacent-buff", "itemTemporary");
addItemEffect(player, 'sword-1', effect, 3);
addItemEffect(player, "sword-1", effect, 3);
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(3);
expect(player.itemEffects["sword-1"]["adjacent-buff"].stacks).toBe(3);
});
it('should initialize itemEffects entry if not present', () => {
it("should initialize itemEffects entry if not present", () => {
const player = createPlayerEntity();
const effect = createEffect('adjacent-buff', 'itemTemporary');
const effect = createEffect("adjacent-buff", "itemTemporary");
addItemEffect(player, 'new-item', effect, 1);
addItemEffect(player, "new-item", effect, 1);
expect(player.itemEffects['new-item']).toBeDefined();
expect(player.itemEffects["new-item"]).toBeDefined();
});
it('should stack with existing item effect', () => {
it("should stack with existing item effect", () => {
const player = createPlayerEntity();
const effect = createEffect('adjacent-buff', 'itemTemporary');
const effect = createEffect("adjacent-buff", "itemTemporary");
addItemEffect(player, 'sword-1', effect, 2);
addItemEffect(player, 'sword-1', effect, 3);
addItemEffect(player, "sword-1", effect, 2);
addItemEffect(player, "sword-1", effect, 3);
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(5);
expect(player.itemEffects["sword-1"]["adjacent-buff"].stacks).toBe(5);
});
});
describe('onEntityEffectUpkeep', () => {
it('should remove temporary effects', () => {
describe("onEntityEffectUpkeep", () => {
it("should remove temporary effects", () => {
const entity = createCombatEntity();
const tempEffect = createEffect('temp-shield', 'temporary');
const tempEffect = createEffect("temp-shield", "temporary");
addEntityEffect(entity, tempEffect, 5);
onEntityEffectUpkeep(entity);
expect(entity.effects['temp-shield']).toBeUndefined();
expect(entity.effects["temp-shield"]).toBeUndefined();
});
it('should decrement lingering effects by 1', () => {
it("should decrement lingering effects by 1", () => {
const entity = createCombatEntity();
const lingeringEffect = createEffect('poison', 'lingering');
const lingeringEffect = createEffect("poison", "lingering");
addEntityEffect(entity, lingeringEffect, 3);
onEntityEffectUpkeep(entity);
expect(entity.effects['poison'].stacks).toBe(2);
expect(entity.effects["poison"].stacks).toBe(2);
});
it('should remove lingering effects when stacks reach 0', () => {
it("should remove lingering effects when stacks reach 0", () => {
const entity = createCombatEntity();
const lingeringEffect = createEffect('poison', 'lingering');
const lingeringEffect = createEffect("poison", "lingering");
addEntityEffect(entity, lingeringEffect, 1);
onEntityEffectUpkeep(entity);
expect(entity.effects['poison']).toBeUndefined();
expect(entity.effects["poison"]).toBeUndefined();
});
it('should not affect permanent effects', () => {
it("should not affect permanent effects", () => {
const entity = createCombatEntity();
const permEffect = createEffect('max-hp-up', 'permanent');
const permEffect = createEffect("max-hp-up", "permanent");
addEntityEffect(entity, permEffect, 5);
onEntityEffectUpkeep(entity);
expect(entity.effects['max-hp-up'].stacks).toBe(5);
expect(entity.effects["max-hp-up"].stacks).toBe(5);
});
it('should not affect instant effects', () => {
it("should not affect instant effects", () => {
const entity = createCombatEntity();
const instantEffect = createEffect('instant-damage', 'instant');
const instantEffect = createEffect("instant-damage", "instant");
addEntityEffect(entity, instantEffect, 10);
onEntityEffectUpkeep(entity);
expect(entity.effects['instant-damage'].stacks).toBe(10);
expect(entity.effects["instant-damage"].stacks).toBe(10);
});
it('should increment lingering effects with negative stacks', () => {
it("should increment lingering effects with negative stacks", () => {
const entity = createCombatEntity();
const lingeringEffect = createEffect('regen', 'lingering');
const lingeringEffect = createEffect("regen", "lingering");
addEntityEffect(entity, lingeringEffect, -3);
onEntityEffectUpkeep(entity);
expect(entity.effects['regen'].stacks).toBe(-2);
expect(entity.effects["regen"].stacks).toBe(-2);
});
});
describe('onEntityPostureDamage', () => {
it('should reduce posture effects by damage amount', () => {
describe("onEntityPostureDamage", () => {
it("should reduce posture effects by damage amount", () => {
const entity = createCombatEntity();
const postureEffect = createEffect('block', 'posture');
const postureEffect = createEffect("block", "posture");
addEntityEffect(entity, postureEffect, 10);
onEntityPostureDamage(entity, 4);
expect(entity.effects['block'].stacks).toBe(6);
expect(entity.effects["block"].stacks).toBe(6);
});
it('should not reduce posture effects below 0', () => {
it("should not reduce posture effects below 0", () => {
const entity = createCombatEntity();
const postureEffect = createEffect('block', 'posture');
const postureEffect = createEffect("block", "posture");
addEntityEffect(entity, postureEffect, 3);
onEntityPostureDamage(entity, 10);
expect(entity.effects['block']).toBeUndefined();
expect(entity.effects["block"]).toBeUndefined();
});
it('should not affect non-posture effects', () => {
it("should not affect non-posture effects", () => {
const entity = createCombatEntity();
const postureEffect = createEffect('block', 'posture');
const permEffect = createEffect('strength', 'permanent');
const postureEffect = createEffect("block", "posture");
const permEffect = createEffect("strength", "permanent");
addEntityEffect(entity, postureEffect, 5);
addEntityEffect(entity, permEffect, 3);
onEntityPostureDamage(entity, 2);
expect(entity.effects['block'].stacks).toBe(3);
expect(entity.effects['strength'].stacks).toBe(3);
expect(entity.effects["block"].stacks).toBe(3);
expect(entity.effects["strength"].stacks).toBe(3);
});
it('should handle zero damage', () => {
it("should handle zero damage", () => {
const entity = createCombatEntity();
const postureEffect = createEffect('block', 'posture');
const postureEffect = createEffect("block", "posture");
addEntityEffect(entity, postureEffect, 5);
onEntityPostureDamage(entity, 0);
expect(entity.effects['block'].stacks).toBe(5);
expect(entity.effects["block"].stacks).toBe(5);
});
});
describe('onPlayerItemEffectUpkeep', () => {
it('should remove itemTemporary effects', () => {
describe("onPlayerItemEffectUpkeep", () => {
it("should remove itemTemporary effects", () => {
const player = createPlayerEntity();
const effect = createEffect('adjacent-buff', 'itemTemporary');
const effect = createEffect("adjacent-buff", "itemTemporary");
addItemEffect(player, 'sword-1', effect, 5);
addItemEffect(player, "sword-1", effect, 5);
onPlayerItemEffectUpkeep(player);
expect(player.itemEffects['sword-1']['adjacent-buff']).toBeUndefined();
expect(player.itemEffects["sword-1"]["adjacent-buff"]).toBeUndefined();
});
it('should not affect itemPermanent effects', () => {
it("should not affect itemPermanent effects", () => {
const player = createPlayerEntity();
const effect = createEffect('adjacent-buff', 'itemPermanent');
const effect = createEffect("adjacent-buff", "itemPermanent");
addItemEffect(player, 'sword-1', effect, 5);
addItemEffect(player, "sword-1", effect, 5);
onPlayerItemEffectUpkeep(player);
expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(5);
expect(player.itemEffects["sword-1"]["adjacent-buff"].stacks).toBe(5);
});
it('should not affect itemUntilPlay effects', () => {
it("should not affect itemUntilPlay effects", () => {
const player = createPlayerEntity();
const effect = createEffect('charged', 'itemUntilPlay');
const effect = createEffect("charged", "itemUntilPlay");
addItemEffect(player, 'sword-1', effect, 3);
addItemEffect(player, "sword-1", effect, 3);
onPlayerItemEffectUpkeep(player);
expect(player.itemEffects['sword-1']['charged'].stacks).toBe(3);
expect(player.itemEffects["sword-1"]["charged"].stacks).toBe(3);
});
});
describe('onItemPlay', () => {
it('should remove itemUntilPlay effects', () => {
describe("onItemPlay", () => {
it("should remove itemUntilPlay effects", () => {
const player = createPlayerEntity();
const effect = createEffect('charged', 'itemUntilPlay');
const effect = createEffect("charged", "itemUntilPlay");
addItemEffect(player, 'sword-1', effect, 3);
onItemPlay(player, 'sword-1');
addItemEffect(player, "sword-1", effect, 3);
onItemPlay(player, "sword-1");
expect(player.itemEffects['sword-1']['charged']).toBeUndefined();
expect(player.itemEffects["sword-1"]["charged"]).toBeUndefined();
});
it('should not affect other lifecycle effects', () => {
it("should not affect other lifecycle effects", () => {
const player = createPlayerEntity();
const permEffect = createEffect('passive', 'itemPermanent');
const playEffect = createEffect('charged', 'itemUntilPlay');
const permEffect = createEffect("passive", "itemPermanent");
const playEffect = createEffect("charged", "itemUntilPlay");
addItemEffect(player, 'sword-1', permEffect, 5);
addItemEffect(player, 'sword-1', playEffect, 3);
onItemPlay(player, 'sword-1');
addItemEffect(player, "sword-1", permEffect, 5);
addItemEffect(player, "sword-1", playEffect, 3);
onItemPlay(player, "sword-1");
expect(player.itemEffects['sword-1']['passive'].stacks).toBe(5);
expect(player.itemEffects['sword-1']['charged']).toBeUndefined();
expect(player.itemEffects["sword-1"]["passive"].stacks).toBe(5);
expect(player.itemEffects["sword-1"]["charged"]).toBeUndefined();
});
it('should do nothing for item with no effects', () => {
it("should do nothing for item with no effects", () => {
const player = createPlayerEntity();
expect(() => onItemPlay(player, 'nonexistent')).not.toThrow();
expect(() => onItemPlay(player, "nonexistent")).not.toThrow();
});
});
describe('onItemDiscard', () => {
it('should remove itemUntilDiscard effects', () => {
describe("onItemDiscard", () => {
it("should remove itemUntilDiscard effects", () => {
const player = createPlayerEntity();
const effect = createEffect('discard-buff', 'itemUntilDiscard');
const effect = createEffect("discard-buff", "itemUntilDiscard");
addItemEffect(player, 'sword-1', effect, 3);
onItemDiscard(player, 'sword-1');
addItemEffect(player, "sword-1", effect, 3);
onItemDiscard(player, "sword-1");
expect(player.itemEffects['sword-1']['discard-buff']).toBeUndefined();
expect(player.itemEffects["sword-1"]["discard-buff"]).toBeUndefined();
});
it('should not affect other lifecycle effects', () => {
it("should not affect other lifecycle effects", () => {
const player = createPlayerEntity();
const permEffect = createEffect('passive', 'itemPermanent');
const discardEffect = createEffect('discard-buff', 'itemUntilDiscard');
const permEffect = createEffect("passive", "itemPermanent");
const discardEffect = createEffect("discard-buff", "itemUntilDiscard");
addItemEffect(player, 'sword-1', permEffect, 5);
addItemEffect(player, 'sword-1', discardEffect, 3);
onItemDiscard(player, 'sword-1');
addItemEffect(player, "sword-1", permEffect, 5);
addItemEffect(player, "sword-1", discardEffect, 3);
onItemDiscard(player, "sword-1");
expect(player.itemEffects['sword-1']['passive'].stacks).toBe(5);
expect(player.itemEffects['sword-1']['discard-buff']).toBeUndefined();
expect(player.itemEffects["sword-1"]["passive"].stacks).toBe(5);
expect(player.itemEffects["sword-1"]["discard-buff"]).toBeUndefined();
});
it('should do nothing for item with no effects', () => {
it("should do nothing for item with no effects", () => {
const player = createPlayerEntity();
expect(() => onItemDiscard(player, 'nonexistent')).not.toThrow();
expect(() => onItemDiscard(player, "nonexistent")).not.toThrow();
});
});
describe('getAliveEnemies', () => {
it('should yield only alive enemies', () => {
describe("getAliveEnemies", () => {
it("should yield only alive enemies", () => {
const state = createCombatState(30, [
createEnemyEntity('slime-1', 10, 10),
createEnemyEntity('slime-2', 0, 10),
createEnemyEntity('slime-3', 5, 10),
createEnemyEntity("slime-1", 10, 10),
createEnemyEntity("slime-2", 0, 10),
createEnemyEntity("slime-3", 5, 10),
]);
const alive = [...getAliveEnemies(state)];
expect(alive.length).toBe(2);
expect(alive[0].id).toBe('slime-1');
expect(alive[1].id).toBe('slime-3');
expect(alive[0].id).toBe("slime-1");
expect(alive[1].id).toBe("slime-3");
});
it('should return empty for no enemies', () => {
it("should return empty for no enemies", () => {
const state = createCombatState(30, []);
const alive = [...getAliveEnemies(state)];
@ -409,10 +473,10 @@ describe('combat/effects', () => {
expect(alive.length).toBe(0);
});
it('should return empty when all enemies are dead', () => {
it("should return empty when all enemies are dead", () => {
const state = createCombatState(30, [
createEnemyEntity('slime-1', 0, 10),
createEnemyEntity('slime-2', 0, 10),
createEnemyEntity("slime-1", 0, 10),
createEnemyEntity("slime-2", 0, 10),
]);
const alive = [...getAliveEnemies(state)];
@ -421,130 +485,132 @@ describe('combat/effects', () => {
});
});
describe('getCombatEntity', () => {
describe("getCombatEntity", () => {
it('should return player for "player" key', () => {
const state = createCombatState(30);
const entity = getCombatEntity(state, 'player');
const entity = getCombatEntity(state, "player");
expect(entity).toBe(state.player);
});
it('should return enemy by id', () => {
const enemy = createEnemyEntity('boss-1', 50, 50);
it("should return enemy by id", () => {
const enemy = createEnemyEntity("boss-1", 50, 50);
const state = createCombatState(30, [enemy]);
const entity = getCombatEntity(state, 'boss-1');
const entity = getCombatEntity(state, "boss-1");
expect(entity).toBe(enemy);
});
it('should return undefined for non-existent enemy', () => {
const state = createCombatState(30, [createEnemyEntity('slime-1')]);
it("should return undefined for non-existent enemy", () => {
const state = createCombatState(30, [createEnemyEntity("slime-1")]);
const entity = getCombatEntity(state, 'nonexistent');
const entity = getCombatEntity(state, "nonexistent");
expect(entity).toBeUndefined();
});
});
describe('canPlayCard', () => {
it('should allow playing energy card when player has enough energy', () => {
describe("canPlayCard", () => {
it("should allow playing energy card when player has enough energy", () => {
const player = createPlayerEntity();
player.energy = 3;
const inventory = createInventory([]);
const result = canPlayCard(player, 'energy', 2, 'any', inventory);
const result = canPlayCard(player, "energy", 2, "any", inventory);
expect(result).toBe(true);
});
it('should reject playing energy card when player lacks energy', () => {
it("should reject playing energy card when player lacks energy", () => {
const player = createPlayerEntity();
player.energy = 1;
const inventory = createInventory([]);
const result = canPlayCard(player, 'energy', 2, 'any', inventory);
const result = canPlayCard(player, "energy", 2, "any", inventory);
expect(result).toBe(false);
});
it('should allow playing uses card when item has remaining uses', () => {
it("should allow playing uses card when item has remaining uses", () => {
const player = createPlayerEntity();
const item = createItem('potion-1', 'potion-card', 'uses', 3, 1);
const item = createItem("potion-1", "potion-card", "uses", 3, 1);
const inventory = createInventory([item]);
const result = canPlayCard(player, 'uses', 3, 'potion-1', inventory);
const result = canPlayCard(player, "uses", 3, "potion-1", inventory);
expect(result).toBe(true);
});
it('should reject playing uses card when item is depleted', () => {
it("should reject playing uses card when item is depleted", () => {
const player = createPlayerEntity();
const item = createItem('potion-1', 'potion-card', 'uses', 3, 3);
const item = createItem("potion-1", "potion-card", "uses", 3, 3);
const inventory = createInventory([item]);
const result = canPlayCard(player, 'uses', 3, 'potion-1', inventory);
const result = canPlayCard(player, "uses", 3, "potion-1", inventory);
expect(result).toBe(false);
});
it('should reject playing uses card when item not in inventory', () => {
it("should reject playing uses card when item not in inventory", () => {
const player = createPlayerEntity();
const inventory = createInventory([]);
const result = canPlayCard(player, 'uses', 1, 'missing', inventory);
const result = canPlayCard(player, "uses", 1, "missing", inventory);
expect(result).toBe(false);
});
it('should always allow playing none cost card', () => {
it("should always allow playing none cost card", () => {
const player = createPlayerEntity();
player.energy = 0;
const inventory = createInventory([]);
const result = canPlayCard(player, 'none', 0, 'any', inventory);
const result = canPlayCard(player, "none", 0, "any", inventory);
expect(result).toBe(true);
});
});
describe('payCardCost', () => {
it('should deduct energy for energy cost card', () => {
describe("payCardCost", () => {
it("should deduct energy for energy cost card", () => {
const player = createPlayerEntity();
player.energy = 3;
const inventory = createInventory([]);
payCardCost(player, 'energy', 2, 'any', inventory);
payCardCost(player, "energy", 2, "any", inventory);
expect(player.energy).toBe(1);
});
it('should increment depletion for uses cost card', () => {
it("should increment depletion for uses cost card", () => {
const player = createPlayerEntity();
const item = createItem('potion-1', 'potion-card', 'uses', 3, 1);
const item = createItem("potion-1", "potion-card", "uses", 3, 1);
const inventory = createInventory([item]);
payCardCost(player, 'uses', 3, 'potion-1', inventory);
payCardCost(player, "uses", 3, "potion-1", inventory);
expect(item.meta?.depletion).toBe(4);
});
it('should do nothing for none cost card', () => {
it("should do nothing for none cost card", () => {
const player = createPlayerEntity();
player.energy = 3;
const inventory = createInventory([]);
payCardCost(player, 'none', 0, 'any', inventory);
payCardCost(player, "none", 0, "any", inventory);
expect(player.energy).toBe(3);
});
it('should handle missing item gracefully for uses cost', () => {
it("should handle missing item gracefully for uses cost", () => {
const player = createPlayerEntity();
const inventory = createInventory([]);
expect(() => payCardCost(player, 'uses', 1, 'missing', inventory)).not.toThrow();
expect(() =>
payCardCost(player, "uses", 1, "missing", inventory),
).not.toThrow();
});
});
});

View File

@ -1,49 +1,99 @@
import { describe, it, expect } from 'vitest';
import { createGameContext, createGameCommandRegistry, IGameContext } from '@/core/game';
import { createRegion } from '@/core/region';
import { createStartWith, Triggers } from '@/samples/slay-the-spire-like/system/combat/triggers';
import { addTriggers } from '@/samples/slay-the-spire-like/data/desert/triggers';
import { CombatState, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types';
import { EffectData } from '@/samples/slay-the-spire-like/system/types';
import { GameCard, DeckRegions } from '@/samples/slay-the-spire-like/system/deck';
import { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory/types';
import { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types';
import { ParsedShape } from '@/samples/slay-the-spire-like/system/utils/parse-shape';
import { Transform2D } from '@/samples/slay-the-spire-like/system/utils/shape-collision';
import { cards, effects, enemies, items } from '@/samples/slay-the-spire-like/data/desert';
import { describe, it, expect } from "vitest";
import {
createGameContext,
createGameCommandRegistry,
IGameContext,
} from "@/core/game";
import { createRegion } from "@/core/region";
import {
createStartWith,
createTriggers,
Triggers,
} from "@/samples/slay-the-spire-like/system/combat/triggers";
import { addTriggers } from "@/samples/slay-the-spire-like/data/desert/triggers";
import {
CombatState,
EnemyEntity,
} from "@/samples/slay-the-spire-like/system/combat/types";
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import {
GameCard,
DeckRegions,
} from "@/samples/slay-the-spire-like/system/deck";
import {
CellKey,
GridInventory,
InventoryItem,
} from "@/samples/slay-the-spire-like/system/grid-inventory/types";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress/types";
import { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
import { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
import {
getCards,
getEffects,
getEncounters,
getEnemies,
getItems,
} from "@/samples/slay-the-spire-like/data/desert";
const cards = getCards();
const effects = getEffects();
const encounters = getEncounters();
const items = getItems();
const enemies = getEnemies();
function createEffect(id: string, lifecycle: EffectData['lifecycle'] = 'instant'): EffectData {
const found = effects.find(e => e.id === id);
function createEffect(
id: string,
lifecycle: EffectData["lifecycle"] = "instant",
): EffectData {
const found = effects.find((e) => e.id === id);
if (found) return found;
return { id, name: id, description: '', lifecycle };
return { id, name: id, description: "", lifecycle };
}
function createDeckRegions(): DeckRegions {
return {
drawPile: createRegion('drawPile', []),
hand: createRegion('hand', []),
discardPile: createRegion('discardPile', []),
exhaustPile: createRegion('exhaustPile', []),
drawPile: createRegion("drawPile", []),
hand: createRegion("hand", []),
discardPile: createRegion("discardPile", []),
exhaustPile: createRegion("exhaustPile", []),
};
}
function createCard(id: string, itemId: string, costType: 'energy' | 'uses' | 'none' = 'energy', costCount = 0): GameCard {
const cardData = cards.find(c => c.id === itemId) ?? {
id: itemId, name: itemId, desc: '', type: 'item' as const, costType, costCount, targetType: 'none' as const, effects: [],
function createCard(
id: string,
itemId: string,
costType: "energy" | "uses" | "none" = "energy",
costCount = 0,
): GameCard {
const cardData = cards.find((c) => c.id === itemId) ?? {
id: itemId,
name: itemId,
desc: "",
type: "item" as const,
costType,
costCount,
targetType: "none" as const,
effects: [],
};
return {
id,
regionId: '',
regionId: "",
position: [0],
itemId,
cardData,
};
}
function createEnemyEntity(enemyId: string, hp: number, maxHp: number, instanceIndex = 0): EnemyEntity {
const enemyData = enemies.find(e => e.id === enemyId);
function createEnemyEntity(
enemyId: string,
hp: number,
maxHp: number,
instanceIndex = 0,
): EnemyEntity {
const enemyData = enemies.find((e) => e.id === enemyId);
if (!enemyData) throw new Error(`Enemy "${enemyId}" not found`);
const intent = enemyData.intents.find(i => i.initialIntent) ?? enemyData.intents[0];
const intent =
enemyData.intents.find((i) => i.initialIntent) ?? enemyData.intents[0];
const instanceId = `${enemyId}-${instanceIndex}`;
const intentMap: Record<string, typeof intent> = {};
for (const i of enemyData.intents) {
@ -61,12 +111,14 @@ function createEnemyEntity(enemyId: string, hp: number, maxHp: number, instanceI
};
}
function createInventory(itemsList: InventoryItem<GameItemMeta>[]): GridInventory<GameItemMeta> {
function createInventory(
itemsList: InventoryItem<GameItemMeta>[],
): GridInventory<GameItemMeta> {
const map = new Map<string, InventoryItem<GameItemMeta>>();
const occupied = new Set<string>();
const occupied = new Set<CellKey>();
for (const item of itemsList) {
map.set(item.id, item);
occupied.add(`${item.transform.x},${item.transform.y}`);
occupied.add(`${item.transform.offset.x},${item.transform.offset.y}`);
}
return { width: 6, height: 4, items: map, occupiedCells: occupied };
}
@ -75,7 +127,7 @@ function createCombatState(overrides: Partial<CombatState> = {}): CombatState {
const regions = createDeckRegions();
return {
player: {
id: 'player',
id: "player",
effects: {},
hp: 30,
maxHp: 30,
@ -87,7 +139,7 @@ function createCombatState(overrides: Partial<CombatState> = {}): CombatState {
},
enemies: [],
inventory: createInventory([]),
phase: 'playerTurn',
phase: "playerTurn",
turnNumber: 1,
result: null,
loot: [],
@ -103,72 +155,71 @@ function createTestContext(state?: CombatState): IGameContext<CombatState> {
}
function getTriggers(): Triggers {
let capturedTriggers: Triggers;
createStartWith(triggers => {
capturedTriggers = triggers;
const triggers = createTriggers();
addTriggers(triggers);
});
return capturedTriggers!;
return triggers;
}
function addCardToHand(ctx: IGameContext<CombatState>, card: GameCard) {
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
draft.player.deck.cards[card.id] = card;
card.regionId = 'hand';
card.regionId = "hand";
draft.player.deck.regions.hand.childIds.push(card.id);
});
}
function addCardToDrawPile(ctx: IGameContext<CombatState>, card: GameCard) {
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
draft.player.deck.cards[card.id] = card;
card.regionId = 'drawPile';
card.regionId = "drawPile";
draft.player.deck.regions.drawPile.childIds.push(card.id);
});
}
function addCardToDiscardPile(ctx: IGameContext<CombatState>, card: GameCard) {
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
draft.player.deck.cards[card.id] = card;
card.regionId = 'discardPile';
card.regionId = "discardPile";
draft.player.deck.regions.discardPile.childIds.push(card.id);
});
}
function makeDummyEnemy() {
return createEnemyEntity('仙人掌怪', 999, 999);
return createEnemyEntity("仙人掌怪", 999, 999);
}
describe('desert triggers', () => {
describe('instant effects', () => {
it('should apply attack effect as damage', async () => {
const ctx = createTestContext(createCombatState({
describe("desert triggers", () => {
describe("instant effects", () => {
it("should apply attack effect as damage", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [makeDummyEnemy()],
}));
}),
);
const triggers = getTriggers();
const attackEffect = createEffect('attack');
const attackEffect = createEffect("attack");
await triggers.onEffectApplied.execute(ctx, {
effect: attackEffect,
entityKey: 'player',
entityKey: "player",
stacks: 5,
sourceEntityKey: 'enemy-0',
sourceEntityKey: "enemy-0",
});
expect(ctx.value.player.hp).toBe(25);
});
it('should apply draw effect', async () => {
it("should apply draw effect", async () => {
const ctx = createTestContext();
const triggers = getTriggers();
const drawEffect = createEffect('draw');
const drawEffect = createEffect("draw");
addCardToDrawPile(ctx, createCard('card-1', 'sword'));
addCardToDrawPile(ctx, createCard('card-2', 'sword'));
addCardToDrawPile(ctx, createCard("card-1", "sword"));
addCardToDrawPile(ctx, createCard("card-2", "sword"));
await triggers.onEffectApplied.execute(ctx, {
effect: drawEffect,
entityKey: 'player',
entityKey: "player",
stacks: 2,
});
@ -176,182 +227,203 @@ describe('desert triggers', () => {
expect(ctx.value.player.deck.regions.drawPile.childIds.length).toBe(0);
});
it('should apply gainEnergy effect', async () => {
it("should apply gainEnergy effect", async () => {
const ctx = createTestContext();
const triggers = getTriggers();
const gainEnergyEffect = createEffect('gainEnergy');
const gainEnergyEffect = createEffect("gainEnergy");
const initialEnergy = ctx.value.player.energy;
await triggers.onEffectApplied.execute(ctx, {
effect: gainEnergyEffect,
entityKey: 'player',
entityKey: "player",
stacks: 2,
});
expect(ctx.value.player.energy).toBe(initialEnergy + 2);
});
it('should remove wound cards from draw and discard piles', async () => {
const ctx = createTestContext(createCombatState({
it("should remove wound cards from draw and discard piles", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [makeDummyEnemy()],
}));
}),
);
const triggers = getTriggers();
const removeWoundEffect = createEffect('removeWound');
const removeWoundEffect = createEffect("removeWound");
addCardToDrawPile(ctx, createCard('wound-1', 'wound', 'none', 0));
addCardToDiscardPile(ctx, createCard('wound-2', 'wound', 'none', 0));
addCardToDrawPile(ctx, createCard('sword-1', 'sword'));
addCardToDrawPile(ctx, createCard("wound-1", "wound", "none", 0));
addCardToDiscardPile(ctx, createCard("wound-2", "wound", "none", 0));
addCardToDrawPile(ctx, createCard("sword-1", "sword"));
await triggers.onEffectApplied.execute(ctx, {
effect: removeWoundEffect,
entityKey: 'player',
entityKey: "player",
stacks: 2,
});
expect(ctx.value.player.deck.cards['wound-1']).toBeUndefined();
expect(ctx.value.player.deck.cards['wound-2']).toBeUndefined();
expect(ctx.value.player.deck.cards['sword-1']).toBeDefined();
expect(ctx.value.player.deck.cards["wound-1"]).toBeUndefined();
expect(ctx.value.player.deck.cards["wound-2"]).toBeUndefined();
expect(ctx.value.player.deck.cards["sword-1"]).toBeDefined();
});
});
describe('damage pipeline', () => {
it('should prevent damage with block', async () => {
const ctx = createTestContext(createCombatState({
describe("damage pipeline", () => {
it("should prevent damage with block", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [makeDummyEnemy()],
}));
}),
);
const triggers = getTriggers();
const defendEffect = createEffect('defend', 'posture');
const defendEffect = createEffect("defend", "posture");
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
draft.player.effects.defend = { data: defendEffect, stacks: 5 };
});
await triggers.onDamage.execute(ctx, {
entityKey: 'player',
entityKey: "player",
amount: 8,
sourceEntityKey: 'enemy-0',
sourceEntityKey: "enemy-0",
});
expect(ctx.value.player.hp).toBe(27);
expect(ctx.value.player.effects.defend?.stacks).toBe(2);
});
it('should reduce damage with damageReduce', async () => {
const ctx = createTestContext(createCombatState({
it("should reduce damage with damageReduce", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [makeDummyEnemy()],
}));
}),
);
const triggers = getTriggers();
const damageReduceEffect = createEffect('damageReduce', 'temporary');
const damageReduceEffect = createEffect("damageReduce", "temporary");
ctx._state.produce(draft => {
draft.player.effects.damageReduce = { data: damageReduceEffect, stacks: 3 };
ctx._state.produce((draft) => {
draft.player.effects.damageReduce = {
data: damageReduceEffect,
stacks: 3,
};
});
await triggers.onDamage.execute(ctx, {
entityKey: 'player',
entityKey: "player",
amount: 8,
sourceEntityKey: 'enemy-0',
sourceEntityKey: "enemy-0",
});
expect(ctx.value.player.hp).toBe(25);
});
it('should increase damage with expose', async () => {
const ctx = createTestContext(createCombatState({
it("should increase damage with expose", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [makeDummyEnemy()],
}));
}),
);
const triggers = getTriggers();
const exposeEffect = createEffect('expose', 'temporary');
const exposeEffect = createEffect("expose", "temporary");
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
draft.player.effects.expose = { data: exposeEffect, stacks: 2 };
});
await triggers.onDamage.execute(ctx, {
entityKey: 'player',
entityKey: "player",
amount: 5,
sourceEntityKey: 'enemy-0',
sourceEntityKey: "enemy-0",
});
expect(ctx.value.player.hp).toBe(23);
});
});
describe('spike reflection', () => {
it('should damage attacker when entity has spike', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('仙人掌怪', 12, 12)],
}));
describe("spike reflection", () => {
it("should damage attacker when entity has spike", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [createEnemyEntity("仙人掌怪", 12, 12)],
}),
);
const triggers = getTriggers();
const spikeEffect = createEffect('spike', 'permanent');
const spikeEffect = createEffect("spike", "permanent");
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
const enemy = draft.enemies[0];
enemy.effects.spike = { data: spikeEffect, stacks: 3 };
});
await triggers.onDamage.execute(ctx, {
entityKey: '仙人掌怪-0',
entityKey: "仙人掌怪-0",
amount: 5,
sourceEntityKey: 'player',
sourceEntityKey: "player",
});
expect(ctx.value.player.hp).toBe(27);
});
});
describe('storm static card generation', () => {
it('should give player static cards when storm enemy executes intent', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('风暴之灵', 30, 30)],
}));
describe("storm static card generation", () => {
it("should give player static cards when storm enemy executes intent", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [createEnemyEntity("风暴之灵", 30, 30)],
}),
);
const triggers = getTriggers();
const stormEffect = createEffect('storm', 'permanent');
const stormEffect = createEffect("storm", "permanent");
ctx._state.produce(draft => {
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" });
const staticCards = Object.values(ctx.value.player.deck.cards).filter((c: GameCard) => c.itemId === 'static');
const staticCards = Object.values(ctx.value.player.deck.cards).filter(
(c: GameCard) => c.itemId === "static",
);
expect(staticCards.length).toBe(2);
});
});
describe('energyDrain', () => {
it('should drain player energy when energyDrain enemy takes damage', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('幼沙虫', 18, 18)],
}));
describe("energyDrain", () => {
it("should drain player energy when energyDrain enemy takes damage", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [createEnemyEntity("幼沙虫", 18, 18)],
}),
);
const triggers = getTriggers();
const energyDrainEffect = createEffect('energyDrain', 'lingering');
const energyDrainEffect = createEffect("energyDrain", "lingering");
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
const enemy = draft.enemies[0];
enemy.effects.energyDrain = { data: energyDrainEffect, stacks: 1 };
});
await triggers.onDamage.execute(ctx, {
entityKey: '幼沙虫-0',
entityKey: "幼沙虫-0",
amount: 5,
sourceEntityKey: 'player',
sourceEntityKey: "player",
});
expect(ctx.value.player.energy).toBe(2);
});
});
describe('molt flee', () => {
it('should make enemy flee when molt >= maxHp after taking damage', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('蜥蜴', 14, 14)],
}));
describe("molt flee", () => {
it("should make enemy flee when molt >= maxHp after taking damage", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [createEnemyEntity("蜥蜴", 14, 14)],
}),
);
const triggers = getTriggers();
const moltEffect = createEffect('molt', 'posture');
const moltEffect = createEffect("molt", "posture");
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
const enemy = draft.enemies[0];
enemy.effects.molt = { data: moltEffect, stacks: 14 };
});
@ -359,233 +431,249 @@ describe('desert triggers', () => {
let threw = false;
try {
await triggers.onDamage.execute(ctx, {
entityKey: '蜥蜴-0',
entityKey: "蜥蜴-0",
amount: 1,
sourceEntityKey: 'player',
sourceEntityKey: "player",
});
} catch (e) {
threw = true;
}
expect(threw).toBe(true);
expect(ctx.value.result).toBe('victory');
expect(ctx.value.result).toBe("victory");
expect(ctx.value.enemies[0].isAlive).toBe(false);
});
});
describe('discard at turn start', () => {
it('should randomly discard a card when discard effect is active', async () => {
describe("discard at turn start", () => {
it("should randomly discard a card when discard effect is active", async () => {
const ctx = createTestContext();
const triggers = getTriggers();
const discardEffect = createEffect('discard', 'lingering');
const discardEffect = createEffect("discard", "lingering");
addCardToHand(ctx, createCard('card-1', 'sword'));
addCardToHand(ctx, createCard('card-2', 'shield'));
addCardToHand(ctx, createCard('card-3', 'dagger'));
addCardToHand(ctx, createCard("card-1", "sword"));
addCardToHand(ctx, createCard("card-2", "shield"));
addCardToHand(ctx, createCard("card-3", "dagger"));
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
draft.player.effects.discard = { data: discardEffect, stacks: 1 };
});
await triggers.onTurnStart.execute(ctx, { entityKey: 'player' });
await triggers.onTurnStart.execute(ctx, { entityKey: "player" });
expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2);
expect(ctx.value.player.deck.regions.discardPile.childIds.length).toBe(1);
});
});
describe('next-turn effects', () => {
it('should gain block from defendNext at turn start', async () => {
describe("next-turn effects", () => {
it("should gain block from defendNext at turn start", async () => {
const ctx = createTestContext();
const triggers = getTriggers();
const defendNextEffect = createEffect('defendNext', 'temporary');
const defendNextEffect = createEffect("defendNext", "temporary");
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
draft.player.effects.defendNext = { data: defendNextEffect, stacks: 5 };
});
await triggers.onTurnStart.execute(ctx, { entityKey: 'player' });
await triggers.onTurnStart.execute(ctx, { entityKey: "player" });
expect(ctx.value.player.effects.defend?.stacks).toBe(5);
expect(ctx.value.player.effects.defendNext).toBeUndefined();
});
it('should gain energy from energyNext at turn start', async () => {
it("should gain energy from energyNext at turn start", async () => {
const ctx = createTestContext();
const triggers = getTriggers();
const energyNextEffect = createEffect('energyNext', 'temporary');
const energyNextEffect = createEffect("energyNext", "temporary");
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
draft.player.effects.energyNext = { data: energyNextEffect, stacks: 2 };
});
await triggers.onTurnStart.execute(ctx, { entityKey: 'player' });
await triggers.onTurnStart.execute(ctx, { entityKey: "player" });
expect(ctx.value.player.energy).toBe(5);
expect(ctx.value.player.effects.energyNext).toBeUndefined();
});
it('should draw extra cards from drawNext at turn start', async () => {
it("should draw extra cards from drawNext at turn start", async () => {
const ctx = createTestContext();
const triggers = getTriggers();
const drawNextEffect = createEffect('drawNext', 'temporary');
const drawNextEffect = createEffect("drawNext", "temporary");
addCardToDrawPile(ctx, createCard('card-1', 'sword'));
addCardToDrawPile(ctx, createCard('card-2', 'sword'));
addCardToDrawPile(ctx, createCard('card-3', 'sword'));
addCardToDrawPile(ctx, createCard("card-1", "sword"));
addCardToDrawPile(ctx, createCard("card-2", "sword"));
addCardToDrawPile(ctx, createCard("card-3", "sword"));
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
draft.player.effects.drawNext = { data: drawNextEffect, stacks: 2 };
});
await triggers.onTurnStart.execute(ctx, { entityKey: 'player' });
await triggers.onTurnStart.execute(ctx, { entityKey: "player" });
expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2);
expect(ctx.value.player.effects.drawNext).toBeUndefined();
});
});
describe('posture damage effects', () => {
it('should double damage with aim', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('仙人掌怪', 12, 12)],
}));
describe("posture damage effects", () => {
it("should double damage with aim", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [createEnemyEntity("仙人掌怪", 12, 12)],
}),
);
const triggers = getTriggers();
const aimEffect = createEffect('aim', 'posture');
const aimEffect = createEffect("aim", "posture");
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
draft.player.effects.aim = { data: aimEffect, stacks: 2 };
});
await triggers.onDamage.execute(ctx, {
entityKey: '仙人掌怪-0',
entityKey: "仙人掌怪-0",
amount: 5,
sourceEntityKey: 'player',
sourceEntityKey: "player",
});
expect(ctx.value.enemies[0].hp).toBe(2);
});
it('should add bonus damage with roll', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('仙人掌怪', 99, 99)],
}));
it("should add bonus damage with roll", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [createEnemyEntity("仙人掌怪", 99, 99)],
}),
);
const triggers = getTriggers();
const rollEffect = createEffect('roll', 'posture');
const rollEffect = createEffect("roll", "posture");
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
draft.player.effects.roll = { data: rollEffect, stacks: 20 };
});
await triggers.onDamage.execute(ctx, {
entityKey: '仙人掌怪-0',
entityKey: "仙人掌怪-0",
amount: 5,
sourceEntityKey: 'player',
sourceEntityKey: "player",
});
expect(ctx.value.enemies[0].hp).toBe(74);
expect(ctx.value.player.effects.roll).toBeUndefined();
});
it('should add bonus damage with tailSting', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('沙蝎', 10, 10)],
}));
it("should add bonus damage with tailSting", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [createEnemyEntity("沙蝎", 10, 10)],
}),
);
const triggers = getTriggers();
const tailStingEffect = createEffect('tailSting', 'posture');
const tailStingEffect = createEffect("tailSting", "posture");
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
const enemy = draft.enemies[0];
enemy.effects.tailSting = { data: tailStingEffect, stacks: 2 };
});
await triggers.onDamage.execute(ctx, {
entityKey: 'player',
entityKey: "player",
amount: 5,
sourceEntityKey: '沙蝎-0',
sourceEntityKey: "沙蝎-0",
});
expect(ctx.value.player.hp).toBe(23);
});
it('should double damage with charge on attacker', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('骑马枪手', 25, 25)],
}));
it("should double damage with charge on attacker", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [createEnemyEntity("骑马枪手", 25, 25)],
}),
);
const triggers = getTriggers();
const chargeEffect = createEffect('charge', 'lingering');
const chargeEffect = createEffect("charge", "lingering");
ctx._state.produce(draft => {
ctx._state.produce((draft) => {
const enemy = draft.enemies[0];
enemy.effects.charge = { data: chargeEffect, stacks: 2 };
});
await triggers.onDamage.execute(ctx, {
entityKey: 'player',
entityKey: "player",
amount: 5,
sourceEntityKey: '骑马枪手-0',
sourceEntityKey: "骑马枪手-0",
});
expect(ctx.value.player.hp).toBe(20);
});
});
describe('crossbow chain', () => {
it('should replay other crossbows on same target', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('仙人掌怪', 20, 20)],
}));
describe("crossbow chain", () => {
it("should replay other crossbows on same target", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [createEnemyEntity("仙人掌怪", 20, 20)],
}),
);
const triggers = getTriggers();
const crossbowEffect = createEffect('crossbow');
const crossbowEffect = createEffect("crossbow");
addCardToHand(ctx, createCard('crossbow-1', 'crossbow'));
addCardToHand(ctx, createCard('crossbow-2', 'crossbow'));
addCardToHand(ctx, createCard("crossbow-1", "crossbow"));
addCardToHand(ctx, createCard("crossbow-2", "crossbow"));
await triggers.onEffectApplied.execute(ctx, {
effect: crossbowEffect,
entityKey: 'player',
entityKey: "player",
stacks: 0,
cardId: 'crossbow-1',
sourceEntityKey: 'player',
targetId: '仙人掌怪-0',
cardId: "crossbow-1",
sourceEntityKey: "player",
targetId: "仙人掌怪-0",
});
expect(ctx.value.enemies[0].hp).toBe(8);
});
});
describe('sandwormKing fatigue heal', () => {
it('should heal sandworm king when player discards fatigue', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('沙虫王', 30, 40)],
}));
describe("sandwormKing fatigue heal", () => {
it("should heal sandworm king when player discards fatigue", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [createEnemyEntity("沙虫王", 30, 40)],
}),
);
const triggers = getTriggers();
addCardToHand(ctx, createCard('fatigue-1', 'fatigue', 'none', 0));
addCardToHand(ctx, createCard("fatigue-1", "fatigue", "none", 0));
await triggers.onCardDiscarded.execute(ctx, {
cardId: 'fatigue-1',
sourceEntityKey: 'player',
cardId: "fatigue-1",
sourceEntityKey: "player",
});
expect(ctx.value.enemies[0].hp).toBe(40);
});
});
describe('vulture on-damage', () => {
it('should give player vultureEye when vulture deals damage', async () => {
const ctx = createTestContext(createCombatState({
enemies: [createEnemyEntity('秃鹫', 12, 12)],
}));
describe("vulture on-damage", () => {
it("should give player vultureEye when vulture deals damage", async () => {
const ctx = createTestContext(
createCombatState({
enemies: [createEnemyEntity("秃鹫", 12, 12)],
}),
);
const triggers = getTriggers();
await triggers.onDamage.execute(ctx, {
entityKey: 'player',
entityKey: "player",
amount: 5,
sourceEntityKey: '秃鹫-0',
sourceEntityKey: "秃鹫-0",
});
const vultureEyeCards = Object.values(ctx.value.player.deck.cards).filter((c: GameCard) => c.itemId === 'vultureEye');
const vultureEyeCards = Object.values(ctx.value.player.deck.cards).filter(
(c: GameCard) => c.itemId === "vultureEye",
);
expect(vultureEyeCards.length).toBe(1);
});
});

View File

@ -1,8 +1,13 @@
import { describe, it, expect } from 'vitest';
import data from '@/samples/slay-the-spire-like/data';
import { describe, it, expect } from "vitest";
import data from "@/samples/slay-the-spire-like/data";
import { CardData } from "@/samples/slay-the-spire-like";
describe('data import', () => {
it('should import properly', () => {
expect(data.desert.effects).toBeDefined();
describe("data import", () => {
it("should import properly", () => {
expect(data.desert.getEffects).toBeDefined();
expect(
data.desert.getCards().find((c: CardData) => c.id === "crossbow")?.effects
?.length,
).toBe(2);
});
});

View File

@ -1,19 +1,26 @@
import { describe, it, expect } from 'vitest';
import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/system/map/generator';
import { MapNodeType, MapLayerType } from '@/samples/slay-the-spire-like/system/map/types';
import { createRNG } from '@/utils/rng';
import { encounters } from '@/samples/slay-the-spire-like/data/desert';
import { describe, it, expect } from "vitest";
import {
generatePointCrawlMap,
hasPath,
} from "@/samples/slay-the-spire-like/system/map/generator";
import {
MapNodeType,
MapLayerType,
} from "@/samples/slay-the-spire-like/system/map/types";
import { createRNG } from "@/utils/rng";
import { getEncounters } from "@/samples/slay-the-spire-like/data/desert";
const encounters = getEncounters();
describe('generatePointCrawlMap', () => {
it('should generate a map with 10 layers', () => {
describe("generatePointCrawlMap", () => {
it("should generate a map with 10 layers", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
expect(map.layers.length).toBe(10);
});
it('should have correct layer structure', () => {
it("should have correct layer structure", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const expectedStructure = [
'start',
"start",
MapLayerType.Wild,
MapLayerType.Wild,
MapLayerType.Settlement,
@ -22,7 +29,7 @@ describe('generatePointCrawlMap', () => {
MapLayerType.Settlement,
MapLayerType.Wild,
MapLayerType.Wild,
'end',
"end",
];
for (let i = 0; i < expectedStructure.length; i++) {
@ -30,7 +37,7 @@ describe('generatePointCrawlMap', () => {
}
});
it('should have correct node counts per layer', () => {
it("should have correct node counts per layer", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const expectedCounts = [1, 3, 3, 4, 3, 3, 4, 3, 3, 1];
@ -39,19 +46,23 @@ describe('generatePointCrawlMap', () => {
}
});
it('should have Start and End nodes with correct types', () => {
it("should have Start and End nodes with correct types", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const startNode = map.nodes.get('node-0-0');
const endNode = map.nodes.get('node-9-0');
const startNode = map.nodes.get("node-0-0");
const endNode = map.nodes.get("node-9-0");
expect(startNode?.type).toBe(MapNodeType.Start);
expect(endNode?.type).toBe(MapNodeType.End);
});
it('should have wild layers with minion/elite/event types', () => {
it("should have wild layers with minion/elite/event types", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const wildLayerIndices = [1, 2, 4, 5, 7, 8];
const validWildTypes = new Set([MapNodeType.Minion, MapNodeType.Elite, MapNodeType.Event]);
const validWildTypes = new Set([
MapNodeType.Minion,
MapNodeType.Elite,
MapNodeType.Event,
]);
for (const layerIdx of wildLayerIndices) {
const layer = map.layers[layerIdx];
@ -63,13 +74,13 @@ describe('generatePointCrawlMap', () => {
}
});
it('should have settlement layers with at least 1 camp, 1 shop, 1 curio', () => {
it("should have settlement layers with at least 1 camp, 1 shop, 1 curio", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const settlementLayerIndices = [3, 6];
for (const layerIdx of settlementLayerIndices) {
const layer = map.layers[layerIdx];
const nodeTypes = layer.nodeIds.map(id => map.nodes.get(id)!.type);
const nodeTypes = layer.nodeIds.map((id) => map.nodes.get(id)!.type);
expect(nodeTypes).toContain(MapNodeType.Camp);
expect(nodeTypes).toContain(MapNodeType.Shop);
@ -78,16 +89,18 @@ describe('generatePointCrawlMap', () => {
}
});
it('should have Start connected to all 3 wild nodes', () => {
it("should have Start connected to all 3 wild nodes", () => {
const map = generatePointCrawlMap(createRNG(42), encounters);
const startNode = map.nodes.get('node-0-0');
const startNode = map.nodes.get("node-0-0");
const wildLayer = map.layers[1];
expect(startNode?.childIds.length).toBe(3);
expect(startNode?.childIds).toEqual(expect.arrayContaining(wildLayer.nodeIds));
expect(startNode?.childIds).toEqual(
expect.arrayContaining(wildLayer.nodeIds),
);
});
it('should have each wild node connect to 1 wild node in wild→wild layers', () => {
it("should have each wild node connect to 1 wild node in wild→wild layers", () => {
const map = generatePointCrawlMap(createRNG(42), encounters);
const wildToWildTransitions = [
{ src: 1, tgt: 2 },
@ -106,7 +119,7 @@ describe('generatePointCrawlMap', () => {
}
});
it('should have each wild node connect to 2 settlement nodes in wild→settlement layers', () => {
it("should have each wild node connect to 2 settlement nodes in wild→settlement layers", () => {
const map = generatePointCrawlMap(createRNG(42), encounters);
const wildToSettlementTransitions = [
{ src: 2, tgt: 3 },
@ -126,7 +139,7 @@ describe('generatePointCrawlMap', () => {
}
});
it('should have settlement nodes connect correctly (1-2-2-1 pattern)', () => {
it("should have settlement nodes connect correctly (1-2-2-1 pattern)", () => {
const map = generatePointCrawlMap(createRNG(42), encounters);
const settlementToWildTransitions = [
{ src: 3, tgt: 4 },
@ -154,10 +167,10 @@ describe('generatePointCrawlMap', () => {
}
});
it('should have all 3 wild nodes connect to End', () => {
it("should have all 3 wild nodes connect to End", () => {
const map = generatePointCrawlMap(createRNG(42), encounters);
const lastWildLayer = map.layers[8];
const endNode = map.nodes.get('node-9-0');
const endNode = map.nodes.get("node-9-0");
for (const wildId of lastWildLayer.nodeIds) {
const wildNode = map.nodes.get(wildId);
@ -165,10 +178,10 @@ describe('generatePointCrawlMap', () => {
}
});
it('should have all nodes reachable from Start and can reach End', () => {
it("should have all nodes reachable from Start and can reach End", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const startId = 'node-0-0';
const endId = 'node-9-0';
const startId = "node-0-0";
const endId = "node-9-0";
for (const nodeId of map.nodes.keys()) {
if (nodeId === startId || nodeId === endId) continue;
@ -177,7 +190,7 @@ describe('generatePointCrawlMap', () => {
}
});
it('should not have crossing edges in wild→wild transitions', () => {
it("should not have crossing edges in wild→wild transitions", () => {
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToWildTransitions = [
{ src: 1, tgt: 2 },
@ -215,7 +228,7 @@ describe('generatePointCrawlMap', () => {
}
});
it('should not have crossing edges in wild→settlement transitions', () => {
it("should not have crossing edges in wild→settlement transitions", () => {
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToSettlementTransitions = [
{ src: 2, tgt: 3 },
@ -252,7 +265,7 @@ describe('generatePointCrawlMap', () => {
}
});
it('should not have crossing edges in settlement→wild transitions', () => {
it("should not have crossing edges in settlement→wild transitions", () => {
const map = generatePointCrawlMap(createRNG(12345), encounters);
const settlementToWildTransitions = [
{ src: 3, tgt: 4 },
@ -289,7 +302,7 @@ describe('generatePointCrawlMap', () => {
}
});
it('should assign encounters to all non-Start/End nodes', () => {
it("should assign encounters to all non-Start/End nodes", () => {
const map = generatePointCrawlMap(createRNG(456), encounters);
for (const node of map.nodes.values()) {
@ -298,14 +311,17 @@ describe('generatePointCrawlMap', () => {
expect(node.encounter).toBeUndefined();
} else {
// All other nodes (minion/elite/event/camp/shop/curio) must have encounters
expect(node.encounter, `Node ${node.id} (${node.type}) should have encounter data`).toBeDefined();
expect(
node.encounter,
`Node ${node.id} (${node.type}) should have encounter data`,
).toBeDefined();
expect(node.encounter!.name).toBeTruthy();
expect(node.encounter!.description).toBeTruthy();
}
}
});
it('should assign encounters to all nodes across multiple seeds', () => {
it("should assign encounters to all nodes across multiple seeds", () => {
// Test multiple seeds to ensure no random failure
for (let seed = 0; seed < 20; seed++) {
const map = generatePointCrawlMap(createRNG(seed), encounters);
@ -314,14 +330,17 @@ describe('generatePointCrawlMap', () => {
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
continue;
}
expect(node.encounter, `Seed ${seed}: Node ${node.id} (${node.type}) missing encounter`).toBeDefined();
expect(
node.encounter,
`Seed ${seed}: Node ${node.id} (${node.type}) missing encounter`,
).toBeDefined();
expect(node.encounter!.name).toBeTruthy();
expect(node.encounter!.description).toBeTruthy();
}
}
});
it('should minimize same-layer repetitions in wild layer pairs', () => {
it("should minimize same-layer repetitions in wild layer pairs", () => {
// Test that wild layers in pairs (1-2, 4-5, 7-8) have minimal duplicate types within each layer
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildPairIndices = [
@ -335,12 +354,12 @@ describe('generatePointCrawlMap', () => {
const layer2 = map.layers[layer2Idx];
// Count repetitions in layer 1
const layer1Types = layer1.nodeIds.map(id => map.nodes.get(id)!.type);
const layer1Types = layer1.nodeIds.map((id) => map.nodes.get(id)!.type);
const layer1Unique = new Set(layer1Types).size;
const layer1Repetitions = layer1Types.length - layer1Unique;
// Count repetitions in layer 2
const layer2Types = layer2.nodeIds.map(id => map.nodes.get(id)!.type);
const layer2Types = layer2.nodeIds.map((id) => map.nodes.get(id)!.type);
const layer2Unique = new Set(layer2Types).size;
const layer2Repetitions = layer2Types.length - layer2Unique;
@ -351,7 +370,7 @@ describe('generatePointCrawlMap', () => {
}
});
it('should minimize adjacent repetitions in wild→wild connections', () => {
it("should minimize adjacent repetitions in wild→wild connections", () => {
// Test that wild nodes connected by wild→wild edges have different types
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToWildPairs = [

View File

@ -11,12 +11,13 @@
"declaration": true,
"declarationMap": true,
"outDir": "./dist",
"rootDir": "./src",
"rootDir": ".",
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
"@/*": ["src/*"],
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist", "tests"]
},
"include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist"],
}

View File

@ -1,13 +1,13 @@
import { defineConfig } from 'tsup';
import { fileURLToPath } from 'url';
import * as fs from 'fs';
import * as path from 'path';
import {csvLoader} from 'inline-schema/csv-loader/esbuild';
import {yarnSpinnerPlugin} from 'yarn-spinner-loader/esbuild';
import type { Plugin } from 'esbuild';
import { defineConfig } from "tsup";
import { fileURLToPath } from "url";
import * as fs from "fs";
import * as path from "path";
import { csvLoader } from "inline-schema/csv-loader/esbuild";
import { yarnSpinnerPlugin } from "yarn-spinner-loader/esbuild";
import type { Plugin } from "esbuild";
const srcDir = fileURLToPath(new URL('./src', import.meta.url));
const samplesDir = fileURLToPath(new URL('./src/samples', import.meta.url));
const srcDir = fileURLToPath(new URL("./src", import.meta.url));
const samplesDir = fileURLToPath(new URL("./src/samples", import.meta.url));
// Auto-discover samples entry points
function getSamplesEntries(): Record<string, string> {
@ -18,13 +18,13 @@ function getSamplesEntries(): Record<string, string> {
const fullPath = path.join(samplesDir, item);
if (fs.statSync(fullPath).isDirectory()) {
// Directory sample (e.g. boop) - look for index.ts
const indexPath = path.join(fullPath, 'index.ts');
const indexPath = path.join(fullPath, "index.ts");
if (fs.existsSync(indexPath)) {
entries[item] = indexPath;
}
} else if (item.endsWith('.ts')) {
} else if (item.endsWith(".ts")) {
// Single file sample (e.g. tic-tac-toe.ts)
entries[item.replace('.ts', '')] = fullPath;
entries[item.replace(".ts", "")] = fullPath;
}
}
return entries;
@ -37,20 +37,20 @@ const samplesEntries = getSamplesEntries();
*/
function rewriteBoardgameImports(): Plugin {
return {
name: 'rewrite-boardgame-imports',
name: "rewrite-boardgame-imports",
setup(build) {
build.onResolve({ filter: /^@\/(core|utils)\// }, args => {
build.onResolve({ filter: /^@\/(core|utils)\// }, (args) => {
// Mark these as external and rewrite to 'boardgame-core'
return {
path: 'boardgame-core',
path: "boardgame-core",
external: true,
};
});
// Also handle @/index imports
build.onResolve({ filter: /^@\/index$/ }, args => {
build.onResolve({ filter: /^@\/index$/ }, (args) => {
return {
path: 'boardgame-core',
path: "boardgame-core",
external: true,
};
});
@ -60,11 +60,25 @@ function rewriteBoardgameImports(): Plugin {
export default defineConfig({
entry: samplesEntries,
format: ['esm'],
format: ["esm"],
dts: true,
clean: true,
sourcemap: true,
outDir: 'dist/samples',
external: ['@preact/signals-core', 'mutative', 'inline-schema', 'boardgame-core'],
esbuildPlugins: [csvLoader({ writeToDisk: true }), rewriteBoardgameImports(), yarnSpinnerPlugin()],
outDir: "dist/samples",
external: [
"@preact/signals-core",
"mutative",
"inline-schema",
"boardgame-core",
],
esbuildPlugins: [
csvLoader({ writeToDisk: true }),
rewriteBoardgameImports(),
yarnSpinnerPlugin(),
],
esbuildOptions(options) {
options.alias = {
"@": srcDir,
};
},
});