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

View File

@ -2,39 +2,37 @@
# type: 'item' = inventory item card, 'status' = status effect card # type: 'item' = inventory item card, 'status' = status effect card
# costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free # costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free
# targetType: 'single' = target one enemy, 'none' = no target # targetType: 'single' = target one enemy, 'none' = no target
# onPlay: effects triggered when card is played # effects := ~cardEffect(card)
# onDraw: effects triggered when card enters hand
# onDiscard: effects triggered when card is discarded
id,name,desc,type,costType,costCount,targetType,effects id,name,desc,type,costType,costCount,targetType
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'
sword,剑,【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2] sword,剑,【攻击2】【攻击2】,item,energy,1,single
greataxe,长斧,对全体【攻击5】,item,energy,2,none,[onPlay;all;attack;5] greataxe,长斧,对全体【攻击5】,item,energy,2,none
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
dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,[onPlay;target;attack;3];[onPlay;target;attack;3] dagger,短刀,【攻击3】【攻击3】,item,energy,1,single
dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,[onPlay;target;attack;1];[onPlay;self;draw;1] dart,飞镖,【攻击1】抓一张牌,item,energy,0,single
crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,[onPlay;target;attack;6];[onPlay;self;crossbow;0] crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single
shield,盾,【防御3】,item,energy,1,none,[onPlay;self;defend;3] shield,盾,【防御3】,item,energy,1,none
hat,斗笠,【防御8】,item,energy,2,none,[onPlay;self;defend;8] hat,斗笠,【防御8】,item,energy,2,none
cape,披风,【防御2】下回合【防御2】,item,energy,1,none,[onPlay;self;defend;2];[onPlay;self;defendNext;2] cape,披风,【防御2】下回合【防御2】,item,energy,1,none
bracer,护腕,【防御1】抓1张牌,item,energy,0,none,[onPlay;self;defend;1];[onPlay;self;draw;1] bracer,护腕,【防御1】抓1张牌,item,energy,0,none
greatshield,大盾,【防御5】,item,energy,1,none,[onPlay;self;defend;5] greatshield,大盾,【防御5】,item,energy,1,none
chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,[onPlay;self;damageReduce;3] chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none
bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,[onPlay;self;removeWound;1] bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none
poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,[onPlay;self;attackBuff;2] poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none
fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,[onPlay;self;defendBuff;2] fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none
vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,[onPlay;self;gainEnergy;1] vitalityPotion,活力药剂,获得1点能量,item,uses,3,none
focusPotion,集中药剂,抓2张牌,item,uses,3,none,[onPlay;self;draw;2] focusPotion,集中药剂,抓2张牌,item,uses,3,none
healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,[onPlay;self;removeWound;3] healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none
waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,[onPlay;self;energyNext;1];[onPlay;self;drawNext;2] waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none
rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,[onPlay;self;defendBuffUntilPlay;2] rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none
belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,[onPlay;self;drawChoice;1] belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none
torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,[onPlay;self;burnForEnergy;1] torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none
whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,[onPlay;self;attackBuffUntilPlay;3] whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none
blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,[onPlay;self;transformRandom;1] blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none
wound,伤口,无效果占用手牌和牌堆,status,none,0,none, wound,伤口,无效果占用手牌和牌堆,status,none,0,none
venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,[onDiscard;self;attack;3] venom,蛇毒,弃掉时受到3点伤害,status,none,0,none
curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,[onDraw;self;curse;1] curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none
static,静电,在手里时受电击伤害+1,status,none,0,none,[onDraw;self;static;1] static,静电,在手里时受电击伤害+1,status,none,0,none
fatigue,疲劳,占用手牌,status,none,0,none, fatigue,疲劳,占用手牌,status,none,0,none
vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,[onDraw;self;expose;3] 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 { type CardTable = readonly {
readonly id: string; readonly id: string;
@ -8,7 +8,7 @@ type CardTable = readonly {
readonly costType: "energy" | "uses" | "none"; readonly costType: "energy" | "uses" | "none";
readonly costCount: number; readonly costCount: number;
readonly targetType: "single" | "none"; readonly targetType: "single" | "none";
readonly effects: ["onPlay" | "onDraw" | "onDiscard", "self" | "target" | "all" | "random", Effect, number][]; readonly effects: CardEffect[];
}[]; }[];
export type Card = CardTable[number]; 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(); const effects = getEffects();
function findEffect(id: string): EffectData { function findEffect(id: string): EffectData {
const found = effects.find(e => e.id === id); const found = effects.find((e) => e.id === id);
if (found) return found; if (found) return found;
return { id, name: id, description: "", lifecycle: "instant" } as EffectData; return {
id,
name: id,
description: "",
lifecycle: "instant",
} as EffectData;
} }
// storm: give static card to player when storm enemy attacks // storm: give static card to player when storm enemy attacks
@ -62,14 +67,17 @@ export function addCardEventTriggers(triggers: Triggers) {
if (!card) return; if (!card) return;
const playedItemId = card.itemId; 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) { for (const [adjItemId] of adjacent) {
const adjEffects = ctx.game.value.player.itemEffects[adjItemId]; const adjEffects = ctx.game.value.player.itemEffects[adjItemId];
if (!adjEffects) continue; if (!adjEffects) continue;
const burn = adjEffects.burnForEnergy; const burn = adjEffects.burnForEnergy;
if (!burn || burn.stacks <= 0) continue; if (!burn || burn.stacks <= 0) continue;
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
const item = draft.inventory.items.get(adjItemId); const item = draft.inventory.items.get(adjItemId);
if (item) { if (item) {
draft.inventory.items.delete(adjItemId); draft.inventory.items.delete(adjItemId);
@ -89,12 +97,12 @@ export function addCardEventTriggers(triggers: Triggers) {
if (!card || card.cardData.id !== "fatigue") return; if (!card || card.cardData.id !== "fatigue") return;
const sandwormKing = ctx.game.value.enemies.find( const sandwormKing = ctx.game.value.enemies.find(
e => e.enemy.id === "沙虫王" && e.isAlive (e) => e.enemy.id === "沙虫王" && e.isAlive,
); );
if (!sandwormKing) return; if (!sandwormKing) return;
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
const king = draft.enemies.find(e => e.id === sandwormKing.id); const king = draft.enemies.find((e) => e.id === sandwormKing.id);
if (king) { if (king) {
king.hp = Math.min(king.hp + 10, king.maxHp); king.hp = Math.min(king.hp + 10, king.maxHp);
} }
@ -109,7 +117,8 @@ export function addCardEventTriggers(triggers: Triggers) {
if (dealt <= 0 || !ctx.sourceEntityKey) return; if (dealt <= 0 || !ctx.sourceEntityKey) return;
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityKey); 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, { await triggers.onEffectApplied.execute(ctx.game, {
effect: findEffect("vultureEye"), effect: findEffect("vultureEye"),

View File

@ -2,9 +2,15 @@ import {CombatGameContext} from "./types";
import { import {
addEntityEffect, addEntityEffect,
addItemEffect, addItemEffect,
getAliveEnemies, onEntityPostureDamage, getAliveEnemies,
onEntityPostureDamage,
onEntityEffectUpkeep, onEntityEffectUpkeep,
onPlayerItemEffectUpkeep, onItemDiscard, onItemPlay, payCardCost, getCombatEntity, getEffectTargets onPlayerItemEffectUpkeep,
onItemDiscard,
onItemPlay,
payCardCost,
getCombatEntity,
getEffectTargets,
} from "@/samples/slay-the-spire-like/system/combat/effects"; } from "@/samples/slay-the-spire-like/system/combat/effects";
import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/prompts"; import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/prompts";
import { moveToRegion, shuffle } from "@/core/region"; import { moveToRegion, shuffle } from "@/core/region";
@ -14,68 +20,99 @@ import {getAdjacentItems} from "@/samples/slay-the-spire-like/system/grid-invent
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress"; import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress";
type TriggerTypes = { type TriggerTypes = {
onCombatStart: {}, onCombatStart: {};
onTurnStart: { entityKey: "player" | string, }, onTurnStart: { entityKey: "player" | string };
onTurnEnd: { entityKey: "player" | string, }, onTurnEnd: { entityKey: "player" | string };
onShuffle: {}, onShuffle: {};
onCardPlayed: { cardId: string, targetId?: string, sourceEntityKey?: "player" | string }, onCardPlayed: {
onCardDiscarded: { cardId: string, sourceEntityKey?: "player" | string }, cardId: string;
onCardDrawn: { cardId: string, sourceEntityKey?: "player" | string }, targetId?: string;
onDraw: {count: number}, sourceEntityKey?: "player" | string;
onEffectApplied: { effect: EffectData, entityKey: "player" | string, stacks: number, cardId?: string, sourceEntityKey?: "player" | string, targetId?: string }, };
onHpChange: { entityKey: "player" | string, amount: number}, onCardDiscarded: { cardId: string; sourceEntityKey?: "player" | string };
onDamage: { entityKey: "player" | string, amount: number, prevented?: number, sourceEntityKey?: "player" | string}, onCardDrawn: { cardId: string; sourceEntityKey?: "player" | string };
onEnemyIntent: { enemyId: string, sourceEntityKey?: "player" | string }, onDraw: { count: number };
onIntentUpdate: { enemyId: string }, 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 = { const triggers = {
onCombatStart: createTrigger("onCombatStart"), onCombatStart: createTrigger("onCombatStart"),
onTurnStart: createTrigger("onTurnStart", async ctx => { onTurnStart: createTrigger("onTurnStart", async (ctx) => {
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
const entity = getCombatEntity(draft, ctx.entityKey); const entity = getCombatEntity(draft, ctx.entityKey);
if (entity) onEntityEffectUpkeep(entity); if (entity) onEntityEffectUpkeep(entity);
if(entity === draft.player) if (entity === draft.player) onPlayerItemEffectUpkeep(draft.player);
onPlayerItemEffectUpkeep(draft.player); });
})
}), }),
onTurnEnd: createTrigger("onTurnEnd", async ctx => { onTurnEnd: createTrigger("onTurnEnd", async (ctx) => {
if (ctx.entityKey !== "player") return; if (ctx.entityKey !== "player") return;
const { regions } = ctx.game.value.player.deck; const { regions } = ctx.game.value.player.deck;
for (const cardId of Object.values(regions.hand.childIds)) { for (const cardId of Object.values(regions.hand.childIds)) {
await triggers.onCardDiscarded.execute(ctx.game, { cardId }); await triggers.onCardDiscarded.execute(ctx.game, { cardId });
} }
await ctx.game.produceAsync(draft => draft.player.energy = draft.player.maxEnergy); await ctx.game.produceAsync(
(draft) => (draft.player.energy = draft.player.maxEnergy),
);
await triggers.onDraw.execute(ctx.game, { count: 5 }); await triggers.onDraw.execute(ctx.game, { count: 5 });
}), }),
onShuffle: createTrigger("onShuffle", async ctx => { onShuffle: createTrigger("onShuffle", async (ctx) => {
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck; const { cards, regions } = draft.player.deck;
for (const cardId of Object.values(regions.discardPile.childIds)) for (const cardId of Object.values(regions.discardPile.childIds))
moveToRegion(cards[cardId], regions.discardPile, regions.drawPile); moveToRegion(cards[cardId], regions.discardPile, regions.drawPile);
shuffle(regions.drawPile, cards, ctx.game.rng); shuffle(regions.drawPile, cards, ctx.game.rng);
}); });
}), }),
onCardPlayed: createTrigger("onCardPlayed", async ctx => { onCardPlayed: createTrigger("onCardPlayed", async (ctx) => {
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck; const { cards, regions } = draft.player.deck;
const card = cards[ctx.cardId]; 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); moveToRegion(card, regions.hand, regions.discardPile);
onItemPlay(draft.player, card.itemId); 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 card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player"; const source = ctx.sourceEntityKey ?? "player";
for(const [trigger, target, effect, stacks] of card.cardData.effects){ for (const { trigger, target, effects } of card.cardData.effects) {
if(trigger !== 'onPlay') continue; if (trigger !== "onPlay") continue;
for (const [effect, stacks] of effects)
for (const entity of getEffectTargets(target, ctx.game, ctx.targetId)) 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}); await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: entity.id,
stacks,
cardId: ctx.cardId,
sourceEntityKey: source,
targetId: ctx.targetId,
});
} }
}), }),
onCardDiscarded: createTrigger("onCardDiscarded", async ctx => { onCardDiscarded: createTrigger("onCardDiscarded", async (ctx) => {
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck; const { cards, regions } = draft.player.deck;
moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile); moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile);
onItemDiscard(draft.player, cards[ctx.cardId].itemId); onItemDiscard(draft.player, cards[ctx.cardId].itemId);
@ -83,30 +120,45 @@ function createTriggers(){
const { cards, regions } = ctx.game.value.player.deck; const { cards, regions } = ctx.game.value.player.deck;
const card = cards[ctx.cardId]; const card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player"; const source = ctx.sourceEntityKey ?? "player";
for(const [trigger, target, effect, stacks] of card.cardData.effects){ for (const { trigger, target, effects } of card.cardData.effects) {
if(trigger !== 'onDiscard') continue; if (trigger !== "onDiscard") continue;
for (const [effect, stacks] of effects)
for (const entity of getEffectTargets(target, ctx.game)) for (const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: source}); await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: entity.id,
stacks,
cardId: ctx.cardId,
sourceEntityKey: source,
});
} }
}), }),
onCardDrawn: createTrigger("onCardDrawn", async ctx => { onCardDrawn: createTrigger("onCardDrawn", async (ctx) => {
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck; const { cards, regions } = draft.player.deck;
moveToRegion(cards[ctx.cardId], regions.drawPile, regions.hand); 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 card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player"; const source = ctx.sourceEntityKey ?? "player";
for(const [trigger, target, effect, stacks] of card.cardData.effects){ for (const { trigger, target, effects } of card.cardData.effects) {
if(trigger !== 'onDraw') continue; if (trigger !== "onDraw") continue;
for (const [effect, stacks] of effects)
for (const entity of getEffectTargets(target, ctx.game)) for (const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: source}); 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; let toDraw = ctx.count;
while (toDraw > 0) { while (toDraw > 0) {
let inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length; let inDraw =
ctx.game.value.player.deck.regions.drawPile.childIds.length;
if (inDraw <= 0) await triggers.onShuffle.execute(ctx.game, {}); if (inDraw <= 0) await triggers.onShuffle.execute(ctx.game, {});
inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length; inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length;
@ -118,15 +170,18 @@ function createTriggers(){
toDraw--; toDraw--;
} }
}), }),
onEffectApplied: createTrigger("onEffectApplied", async ctx => { onEffectApplied: createTrigger("onEffectApplied", async (ctx) => {
if(ctx.effect.lifecycle === 'instant') return; if (ctx.effect.lifecycle === "instant") return;
if (ctx.effect.lifecycle.startsWith("item")) { if (ctx.effect.lifecycle.startsWith("item")) {
if (ctx.cardId) { if (ctx.cardId) {
const card = ctx.game.value.player.deck.cards[ctx.cardId]; const card = ctx.game.value.player.deck.cards[ctx.cardId];
const nearby = getAdjacentItems<GameItemMeta>(ctx.game.value.inventory, card.itemId); const nearby = getAdjacentItems<GameItemMeta>(
ctx.game.value.inventory,
card.itemId,
);
for (const itemId of nearby.keys()) { for (const itemId of nearby.keys()) {
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks); addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks);
}); });
} }
@ -134,32 +189,51 @@ function createTriggers(){
return; return;
} }
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey); const entity =
ctx.entityKey === "player"
? draft.player
: draft.enemies.find((e) => e.id === ctx.entityKey);
if (entity) addEntityEffect(entity, ctx.effect, ctx.stacks); if (entity) addEntityEffect(entity, ctx.effect, ctx.stacks);
}) });
}), }),
onHpChange: createTrigger("onHpChange", async ctx => { onHpChange: createTrigger("onHpChange", async (ctx) => {
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey); const entity =
ctx.entityKey === "player"
? draft.player
: draft.enemies.find((e) => e.id === ctx.entityKey);
if (!entity) return; if (!entity) return;
entity.hp += ctx.amount; entity.hp += ctx.amount;
entity.isAlive = entity.hp > 0; 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 => { onDamage: createTrigger("onDamage", async (ctx) => {
const entity = ctx.entityKey === "player" ? ctx.game.value.player : ctx.game.value.enemies.find(e => e.id === ctx.entityKey); const entity =
ctx.entityKey === "player"
? ctx.game.value.player
: ctx.game.value.enemies.find((e) => e.id === ctx.entityKey);
if (!entity || !entity.isAlive) return; if (!entity || !entity.isAlive) return;
const dealt = Math.min(Math.max(0,entity.hp), ctx.amount - (ctx.prevented || 0)); const dealt = Math.min(
await ctx.game.produceAsync(draft => { Math.max(0, entity.hp),
ctx.amount - (ctx.prevented || 0),
);
await ctx.game.produceAsync((draft) => {
onEntityPostureDamage(entity, dealt); 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 => { onEnemyIntent: createTrigger("onEnemyIntent", async (ctx) => {
const enemy = ctx.game.value.enemies.find(e => e.id === ctx.enemyId); const enemy = ctx.game.value.enemies.find((e) => e.id === ctx.enemyId);
if (!enemy || !enemy.isAlive) return; if (!enemy || !enemy.isAlive) return;
const intent = enemy.currentIntent; const intent = enemy.currentIntent;
@ -168,12 +242,17 @@ function createTriggers(){
const source = ctx.sourceEntityKey ?? enemy.id; const source = ctx.sourceEntityKey ?? enemy.id;
for (const [target, effect, stacks] of intent.effects) { for (const [target, effect, stacks] of intent.effects) {
for (const entity of getEffectTargets(target, ctx.game)) for (const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game, { effect, entityKey: entity.id, stacks, sourceEntityKey: source }); await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: entity.id,
stacks,
sourceEntityKey: source,
});
} }
}), }),
onIntentUpdate: createTrigger("onIntentUpdate", async ctx => { onIntentUpdate: createTrigger("onIntentUpdate", async (ctx) => {
await ctx.game.produceAsync(draft => { await ctx.game.produceAsync((draft) => {
const enemy = draft.enemies.find(e => e.id === ctx.enemyId); const enemy = draft.enemies.find((e) => e.id === ctx.enemyId);
if (!enemy) return; if (!enemy) return;
const intent = enemy.currentIntent; const intent = enemy.currentIntent;
@ -186,10 +265,10 @@ function createTriggers(){
} }
}); });
}), }),
} };
return triggers; return triggers;
} }
export type Triggers = ReturnType<typeof createTriggers> export type Triggers = ReturnType<typeof createTriggers>;
export function createStartWith(build: (triggers: Triggers) => void) { export function createStartWith(build: (triggers: Triggers) => void) {
const triggers = createTriggers(); const triggers = createTriggers();
build(triggers); build(triggers);
@ -223,12 +302,20 @@ export function createStartWith(build: (triggers: Triggers) => void){
if (e === game.value) return game.value.result; if (e === game.value) return game.value.result;
throw e; throw e;
} }
} };
} }
type TriggerContext<TKey extends keyof TriggerTypes> = TriggerTypes[TKey] & { event: TKey, game: CombatGameContext }; type TriggerContext<TKey extends keyof TriggerTypes> = TriggerTypes[TKey] & {
function createTrigger<TKey extends keyof TriggerTypes>(event: TKey, fallback?: (ctx: TriggerContext<TKey>) => Promise<void>) { event: TKey;
const {use, execute} = createMiddlewareChain<TriggerContext<TKey>,void>(fallback); game: CombatGameContext;
};
function createTrigger<TKey extends keyof TriggerTypes>(
event: TKey,
fallback?: (ctx: TriggerContext<TKey>) => Promise<void>,
) {
const { use, execute } = createMiddlewareChain<TriggerContext<TKey>, void>(
fallback,
);
return { return {
use, use,
execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => { execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => {
@ -236,5 +323,5 @@ function createTrigger<TKey extends keyof TriggerTypes>(event: TKey, fallback?:
await execute(param); await execute(param);
return param; return param;
}, },
} };
} }

View File

@ -1,10 +1,13 @@
import type { PlayerDeck } from "../deck/types"; import type { PlayerDeck } from "../deck/types";
import {EnemyData, IntentData} from "@/samples/slay-the-spire-like/system/types"; import {
EnemyData,
IntentData,
} from "@/samples/slay-the-spire-like/system/types";
import { EffectData } 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 { GridInventory } from "@/samples/slay-the-spire-like/system/grid-inventory";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress"; 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 = { export type CombatEntity = {
id: string; // player is just "player" id: string; // player is just "player"
@ -19,7 +22,7 @@ export type PlayerEntity = CombatEntity & {
maxEnergy: number; maxEnergy: number;
deck: PlayerDeck; deck: PlayerDeck;
itemEffects: Record<string, EffectTable>; itemEffects: Record<string, EffectTable>;
} };
export type EnemyEntity = CombatEntity & { export type EnemyEntity = CombatEntity & {
enemy: EnemyData; enemy: EnemyData;
@ -30,11 +33,13 @@ export type EnemyEntity = CombatEntity & {
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd"; export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
export type CombatResult = "victory" | "defeat"; export type CombatResult = "victory" | "defeat";
export type LootEntry = { export type LootEntry =
| {
type: "gold"; type: "gold";
amount: number; amount: number;
} | { }
type: "item", | {
type: "item";
itemId: string; itemId: string;
}; };
@ -50,4 +55,5 @@ export type CombatState = {
loot: LootEntry[]; 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 description: string;
readonly lifecycle: EffectLifecycle; 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 = { export type EnemyData = {
readonly id: string; readonly id: string;
@ -18,6 +28,16 @@ export type CardCostType = "energy" | "uses" | "none";
export type CardTargetType = "single" | "none"; export type CardTargetType = "single" | "none";
export type EffectTarget = "self" | "player" | "team"; 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 = { export type CardData = {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
@ -26,18 +46,26 @@ export type CardData = {
readonly costType: CardCostType; readonly costType: CardCostType;
readonly costCount: number; readonly costCount: number;
readonly targetType: CardTargetType; 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 = { export type EncounterData = {
readonly id: string; readonly id: string;
readonly type: EncounterType; readonly type: EncounterType;
readonly name: string; readonly name: string;
readonly description: 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; readonly dialogue: string;
}; };

View File

@ -1,4 +1,4 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { import {
addEffect, addEffect,
addEntityEffect, addEntityEffect,
@ -12,38 +12,86 @@ import {
getCombatEntity, getCombatEntity,
canPlayCard, canPlayCard,
payCardCost, payCardCost,
} from '@/samples/slay-the-spire-like/system/combat/effects'; } 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 {
import type { EffectData } from '@/samples/slay-the-spire-like/system/types'; CombatEntity,
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory/types'; CombatState,
import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types'; EffectTable,
import type { ParsedShape } from '@/samples/slay-the-spire-like/system/utils/parse-shape'; PlayerEntity,
import type { Transform2D } from '@/samples/slay-the-spire-like/system/utils/shape-collision'; 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 { function createEffect(
return { id, name: id, description: '', lifecycle }; id: string,
lifecycle: EffectData["lifecycle"],
): EffectData {
return { id, name: id, description: "", lifecycle };
} }
function createCard(id: string, costType: 'energy' | 'uses' | 'none', costCount: number) { function createCard(
return { id, name: id, desc: '', type: 'item' as const, costType, costCount, targetType: 'none' as const, effects: [] as const }; 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 { return {
id: itemId, id: itemId,
shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape, 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, transform: {
x: 0,
y: 0,
rotation: 0,
flipX: false,
flipY: false,
} as unknown as Transform2D,
meta: { meta: {
itemData: { id: itemId, type: 'weapon', name: itemId, shape: '1x1', card: createCard(cardId, costType, costCount), price: 0, description: '' }, itemData: {
shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape, id: itemId,
depletion: costType === 'uses' ? depletion : undefined, 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 map = new Map<string, InventoryItem<GameItemMeta>>();
const occupied = new Set<string>(); const occupied = new Set<CellKey>();
for (const item of items) { for (const item of items) {
map.set(item.id, item); map.set(item.id, item);
occupied.add(`${item.transform.x},${item.transform.y}`); occupied.add(`${item.transform.x},${item.transform.y}`);
@ -65,7 +113,15 @@ function createPlayerEntity(hp = 30, maxHp = 30): PlayerEntity {
...createCombatEntity(hp, maxHp), ...createCombatEntity(hp, maxHp),
energy: 3, energy: 3,
maxEnergy: 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: {}, itemEffects: {},
}; };
} }
@ -74,334 +130,342 @@ function createEnemyEntity(id: string, hp = 10, maxHp = 10): EnemyEntity {
return { return {
...createCombatEntity(hp, maxHp), ...createCombatEntity(hp, maxHp),
id, id,
enemy: { id, name: id, description: '' }, enemy: { id, name: id, description: "" },
intents: {}, intents: {},
currentIntentId: '', currentIntentId: "",
}; };
} }
function createCombatState(playerHp = 30, enemies: EnemyEntity[] = []): CombatState { function createCombatState(
playerHp = 30,
enemies: EnemyEntity[] = [],
): CombatState {
return { return {
player: createPlayerEntity(playerHp), player: createPlayerEntity(playerHp),
enemies, enemies,
inventory: { width: 6, height: 4, items: new Map(), occupiedCells: new Set() }, inventory: {
phase: 'playerTurn', width: 6,
height: 4,
items: new Map(),
occupiedCells: new Set(),
},
phase: "playerTurn",
turnNumber: 1, turnNumber: 1,
result: null, result: null,
loot: [], loot: [],
}; };
} }
describe('combat/effects', () => { describe("combat/effects", () => {
describe('addEffect', () => { describe("addEffect", () => {
it('should add a new effect to an empty table', () => { it("should add a new effect to an empty table", () => {
const table: EffectTable = {}; const table: EffectTable = {};
const effect = createEffect('strength', 'temporary'); const effect = createEffect("strength", "temporary");
addEffect(table, effect, 3); addEffect(table, effect, 3);
expect(table['strength']).toBeDefined(); expect(table["strength"]).toBeDefined();
expect(table['strength'].data).toBe(effect); expect(table["strength"].data).toBe(effect);
expect(table['strength'].stacks).toBe(3); 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 table: EffectTable = {};
const effect = createEffect('strength', 'lingering'); const effect = createEffect("strength", "lingering");
addEffect(table, effect, 2); addEffect(table, effect, 2);
addEffect(table, effect, 3); 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 table: EffectTable = {};
const effect = createEffect('strength', 'temporary'); const effect = createEffect("strength", "temporary");
addEffect(table, effect, 3); addEffect(table, effect, 3);
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 table: EffectTable = {};
const effect = createEffect('strength', 'temporary'); const effect = createEffect("strength", "temporary");
addEffect(table, effect, 0); 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 table: EffectTable = {};
const effect = createEffect('weak', 'temporary'); const effect = createEffect("weak", "temporary");
addEffect(table, effect, -2); addEffect(table, effect, -2);
expect(table['weak'].stacks).toBe(-2); expect(table["weak"].stacks).toBe(-2);
}); });
}); });
describe('addEntityEffect', () => { describe("addEntityEffect", () => {
it('should add effect to entity.effects', () => { it("should add effect to entity.effects", () => {
const entity = createCombatEntity(); const entity = createCombatEntity();
const effect = createEffect('vulnerable', 'lingering'); const effect = createEffect("vulnerable", "lingering");
addEntityEffect(entity, effect, 2); addEntityEffect(entity, effect, 2);
expect(entity.effects['vulnerable'].stacks).toBe(2); expect(entity.effects["vulnerable"].stacks).toBe(2);
}); });
}); });
describe('addItemEffect', () => { describe("addItemEffect", () => {
it('should add effect to player.itemEffects[itemKey]', () => { it("should add effect to player.itemEffects[itemKey]", () => {
const player = createPlayerEntity(); 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 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 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, 2);
addItemEffect(player, 'sword-1', effect, 3); 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', () => { describe("onEntityEffectUpkeep", () => {
it('should remove temporary effects', () => { it("should remove temporary effects", () => {
const entity = createCombatEntity(); const entity = createCombatEntity();
const tempEffect = createEffect('temp-shield', 'temporary'); const tempEffect = createEffect("temp-shield", "temporary");
addEntityEffect(entity, tempEffect, 5); addEntityEffect(entity, tempEffect, 5);
onEntityEffectUpkeep(entity); 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 entity = createCombatEntity();
const lingeringEffect = createEffect('poison', 'lingering'); const lingeringEffect = createEffect("poison", "lingering");
addEntityEffect(entity, lingeringEffect, 3); addEntityEffect(entity, lingeringEffect, 3);
onEntityEffectUpkeep(entity); 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 entity = createCombatEntity();
const lingeringEffect = createEffect('poison', 'lingering'); const lingeringEffect = createEffect("poison", "lingering");
addEntityEffect(entity, lingeringEffect, 1); addEntityEffect(entity, lingeringEffect, 1);
onEntityEffectUpkeep(entity); 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 entity = createCombatEntity();
const permEffect = createEffect('max-hp-up', 'permanent'); const permEffect = createEffect("max-hp-up", "permanent");
addEntityEffect(entity, permEffect, 5); addEntityEffect(entity, permEffect, 5);
onEntityEffectUpkeep(entity); 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 entity = createCombatEntity();
const instantEffect = createEffect('instant-damage', 'instant'); const instantEffect = createEffect("instant-damage", "instant");
addEntityEffect(entity, instantEffect, 10); addEntityEffect(entity, instantEffect, 10);
onEntityEffectUpkeep(entity); 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 entity = createCombatEntity();
const lingeringEffect = createEffect('regen', 'lingering'); const lingeringEffect = createEffect("regen", "lingering");
addEntityEffect(entity, lingeringEffect, -3); addEntityEffect(entity, lingeringEffect, -3);
onEntityEffectUpkeep(entity); onEntityEffectUpkeep(entity);
expect(entity.effects['regen'].stacks).toBe(-2); expect(entity.effects["regen"].stacks).toBe(-2);
}); });
}); });
describe('onEntityPostureDamage', () => { describe("onEntityPostureDamage", () => {
it('should reduce posture effects by damage amount', () => { it("should reduce posture effects by damage amount", () => {
const entity = createCombatEntity(); const entity = createCombatEntity();
const postureEffect = createEffect('block', 'posture'); const postureEffect = createEffect("block", "posture");
addEntityEffect(entity, postureEffect, 10); addEntityEffect(entity, postureEffect, 10);
onEntityPostureDamage(entity, 4); 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 entity = createCombatEntity();
const postureEffect = createEffect('block', 'posture'); const postureEffect = createEffect("block", "posture");
addEntityEffect(entity, postureEffect, 3); addEntityEffect(entity, postureEffect, 3);
onEntityPostureDamage(entity, 10); 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 entity = createCombatEntity();
const postureEffect = createEffect('block', 'posture'); const postureEffect = createEffect("block", "posture");
const permEffect = createEffect('strength', 'permanent'); const permEffect = createEffect("strength", "permanent");
addEntityEffect(entity, postureEffect, 5); addEntityEffect(entity, postureEffect, 5);
addEntityEffect(entity, permEffect, 3); addEntityEffect(entity, permEffect, 3);
onEntityPostureDamage(entity, 2); onEntityPostureDamage(entity, 2);
expect(entity.effects['block'].stacks).toBe(3); expect(entity.effects["block"].stacks).toBe(3);
expect(entity.effects['strength'].stacks).toBe(3); expect(entity.effects["strength"].stacks).toBe(3);
}); });
it('should handle zero damage', () => { it("should handle zero damage", () => {
const entity = createCombatEntity(); const entity = createCombatEntity();
const postureEffect = createEffect('block', 'posture'); const postureEffect = createEffect("block", "posture");
addEntityEffect(entity, postureEffect, 5); addEntityEffect(entity, postureEffect, 5);
onEntityPostureDamage(entity, 0); onEntityPostureDamage(entity, 0);
expect(entity.effects['block'].stacks).toBe(5); expect(entity.effects["block"].stacks).toBe(5);
}); });
}); });
describe('onPlayerItemEffectUpkeep', () => { describe("onPlayerItemEffectUpkeep", () => {
it('should remove itemTemporary effects', () => { it("should remove itemTemporary effects", () => {
const player = createPlayerEntity(); 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); 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 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); 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 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); onPlayerItemEffectUpkeep(player);
expect(player.itemEffects['sword-1']['charged'].stacks).toBe(3); expect(player.itemEffects["sword-1"]["charged"].stacks).toBe(3);
}); });
}); });
describe('onItemPlay', () => { describe("onItemPlay", () => {
it('should remove itemUntilPlay effects', () => { it("should remove itemUntilPlay effects", () => {
const player = createPlayerEntity(); 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);
onItemPlay(player, 'sword-1'); 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 player = createPlayerEntity();
const permEffect = createEffect('passive', 'itemPermanent'); const permEffect = createEffect("passive", "itemPermanent");
const playEffect = createEffect('charged', 'itemUntilPlay'); const playEffect = createEffect("charged", "itemUntilPlay");
addItemEffect(player, 'sword-1', permEffect, 5); addItemEffect(player, "sword-1", permEffect, 5);
addItemEffect(player, 'sword-1', playEffect, 3); addItemEffect(player, "sword-1", playEffect, 3);
onItemPlay(player, 'sword-1'); onItemPlay(player, "sword-1");
expect(player.itemEffects['sword-1']['passive'].stacks).toBe(5); expect(player.itemEffects["sword-1"]["passive"].stacks).toBe(5);
expect(player.itemEffects['sword-1']['charged']).toBeUndefined(); 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(); const player = createPlayerEntity();
expect(() => onItemPlay(player, 'nonexistent')).not.toThrow(); expect(() => onItemPlay(player, "nonexistent")).not.toThrow();
}); });
}); });
describe('onItemDiscard', () => { describe("onItemDiscard", () => {
it('should remove itemUntilDiscard effects', () => { it("should remove itemUntilDiscard effects", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
const effect = createEffect('discard-buff', 'itemUntilDiscard'); const effect = createEffect("discard-buff", "itemUntilDiscard");
addItemEffect(player, 'sword-1', effect, 3); addItemEffect(player, "sword-1", effect, 3);
onItemDiscard(player, 'sword-1'); 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 player = createPlayerEntity();
const permEffect = createEffect('passive', 'itemPermanent'); const permEffect = createEffect("passive", "itemPermanent");
const discardEffect = createEffect('discard-buff', 'itemUntilDiscard'); const discardEffect = createEffect("discard-buff", "itemUntilDiscard");
addItemEffect(player, 'sword-1', permEffect, 5); addItemEffect(player, "sword-1", permEffect, 5);
addItemEffect(player, 'sword-1', discardEffect, 3); addItemEffect(player, "sword-1", discardEffect, 3);
onItemDiscard(player, 'sword-1'); onItemDiscard(player, "sword-1");
expect(player.itemEffects['sword-1']['passive'].stacks).toBe(5); expect(player.itemEffects["sword-1"]["passive"].stacks).toBe(5);
expect(player.itemEffects['sword-1']['discard-buff']).toBeUndefined(); 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(); const player = createPlayerEntity();
expect(() => onItemDiscard(player, 'nonexistent')).not.toThrow(); expect(() => onItemDiscard(player, "nonexistent")).not.toThrow();
}); });
}); });
describe('getAliveEnemies', () => { describe("getAliveEnemies", () => {
it('should yield only alive enemies', () => { it("should yield only alive enemies", () => {
const state = createCombatState(30, [ const state = createCombatState(30, [
createEnemyEntity('slime-1', 10, 10), createEnemyEntity("slime-1", 10, 10),
createEnemyEntity('slime-2', 0, 10), createEnemyEntity("slime-2", 0, 10),
createEnemyEntity('slime-3', 5, 10), createEnemyEntity("slime-3", 5, 10),
]); ]);
const alive = [...getAliveEnemies(state)]; const alive = [...getAliveEnemies(state)];
expect(alive.length).toBe(2); expect(alive.length).toBe(2);
expect(alive[0].id).toBe('slime-1'); expect(alive[0].id).toBe("slime-1");
expect(alive[1].id).toBe('slime-3'); 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 state = createCombatState(30, []);
const alive = [...getAliveEnemies(state)]; const alive = [...getAliveEnemies(state)];
@ -409,10 +473,10 @@ describe('combat/effects', () => {
expect(alive.length).toBe(0); 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, [ const state = createCombatState(30, [
createEnemyEntity('slime-1', 0, 10), createEnemyEntity("slime-1", 0, 10),
createEnemyEntity('slime-2', 0, 10), createEnemyEntity("slime-2", 0, 10),
]); ]);
const alive = [...getAliveEnemies(state)]; const alive = [...getAliveEnemies(state)];
@ -421,130 +485,132 @@ describe('combat/effects', () => {
}); });
}); });
describe('getCombatEntity', () => { describe("getCombatEntity", () => {
it('should return player for "player" key', () => { it('should return player for "player" key', () => {
const state = createCombatState(30); const state = createCombatState(30);
const entity = getCombatEntity(state, 'player'); const entity = getCombatEntity(state, "player");
expect(entity).toBe(state.player); expect(entity).toBe(state.player);
}); });
it('should return enemy by id', () => { it("should return enemy by id", () => {
const enemy = createEnemyEntity('boss-1', 50, 50); const enemy = createEnemyEntity("boss-1", 50, 50);
const state = createCombatState(30, [enemy]); const state = createCombatState(30, [enemy]);
const entity = getCombatEntity(state, 'boss-1'); const entity = getCombatEntity(state, "boss-1");
expect(entity).toBe(enemy); expect(entity).toBe(enemy);
}); });
it('should return undefined for non-existent enemy', () => { it("should return undefined for non-existent enemy", () => {
const state = createCombatState(30, [createEnemyEntity('slime-1')]); const state = createCombatState(30, [createEnemyEntity("slime-1")]);
const entity = getCombatEntity(state, 'nonexistent'); const entity = getCombatEntity(state, "nonexistent");
expect(entity).toBeUndefined(); expect(entity).toBeUndefined();
}); });
}); });
describe('canPlayCard', () => { describe("canPlayCard", () => {
it('should allow playing energy card when player has enough energy', () => { it("should allow playing energy card when player has enough energy", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 3; player.energy = 3;
const inventory = createInventory([]); const inventory = createInventory([]);
const result = canPlayCard(player, 'energy', 2, 'any', inventory); const result = canPlayCard(player, "energy", 2, "any", inventory);
expect(result).toBe(true); 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(); const player = createPlayerEntity();
player.energy = 1; player.energy = 1;
const inventory = createInventory([]); const inventory = createInventory([]);
const result = canPlayCard(player, 'energy', 2, 'any', inventory); const result = canPlayCard(player, "energy", 2, "any", inventory);
expect(result).toBe(false); 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 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 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); 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 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 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); 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 player = createPlayerEntity();
const inventory = createInventory([]); const inventory = createInventory([]);
const result = canPlayCard(player, 'uses', 1, 'missing', inventory); const result = canPlayCard(player, "uses", 1, "missing", inventory);
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('should always allow playing none cost card', () => { it("should always allow playing none cost card", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 0; player.energy = 0;
const inventory = createInventory([]); const inventory = createInventory([]);
const result = canPlayCard(player, 'none', 0, 'any', inventory); const result = canPlayCard(player, "none", 0, "any", inventory);
expect(result).toBe(true); expect(result).toBe(true);
}); });
}); });
describe('payCardCost', () => { describe("payCardCost", () => {
it('should deduct energy for energy cost card', () => { it("should deduct energy for energy cost card", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 3; player.energy = 3;
const inventory = createInventory([]); const inventory = createInventory([]);
payCardCost(player, 'energy', 2, 'any', inventory); payCardCost(player, "energy", 2, "any", inventory);
expect(player.energy).toBe(1); 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 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 inventory = createInventory([item]);
payCardCost(player, 'uses', 3, 'potion-1', inventory); payCardCost(player, "uses", 3, "potion-1", inventory);
expect(item.meta?.depletion).toBe(4); 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(); const player = createPlayerEntity();
player.energy = 3; player.energy = 3;
const inventory = createInventory([]); const inventory = createInventory([]);
payCardCost(player, 'none', 0, 'any', inventory); payCardCost(player, "none", 0, "any", inventory);
expect(player.energy).toBe(3); 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 player = createPlayerEntity();
const inventory = createInventory([]); 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 { describe, it, expect } from "vitest";
import { createGameContext, createGameCommandRegistry, IGameContext } from '@/core/game'; import {
import { createRegion } from '@/core/region'; createGameContext,
import { createStartWith, Triggers } from '@/samples/slay-the-spire-like/system/combat/triggers'; createGameCommandRegistry,
import { addTriggers } from '@/samples/slay-the-spire-like/data/desert/triggers'; IGameContext,
import { CombatState, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types'; } from "@/core/game";
import { EffectData } from '@/samples/slay-the-spire-like/system/types'; import { createRegion } from "@/core/region";
import { GameCard, DeckRegions } from '@/samples/slay-the-spire-like/system/deck'; import {
import { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory/types'; createStartWith,
import { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types'; createTriggers,
import { ParsedShape } from '@/samples/slay-the-spire-like/system/utils/parse-shape'; Triggers,
import { Transform2D } from '@/samples/slay-the-spire-like/system/utils/shape-collision'; } from "@/samples/slay-the-spire-like/system/combat/triggers";
import { cards, effects, enemies, items } from '@/samples/slay-the-spire-like/data/desert'; 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 { function createEffect(
const found = effects.find(e => e.id === id); id: string,
lifecycle: EffectData["lifecycle"] = "instant",
): EffectData {
const found = effects.find((e) => e.id === id);
if (found) return found; if (found) return found;
return { id, name: id, description: '', lifecycle }; return { id, name: id, description: "", lifecycle };
} }
function createDeckRegions(): DeckRegions { function createDeckRegions(): DeckRegions {
return { return {
drawPile: createRegion('drawPile', []), drawPile: createRegion("drawPile", []),
hand: createRegion('hand', []), hand: createRegion("hand", []),
discardPile: createRegion('discardPile', []), discardPile: createRegion("discardPile", []),
exhaustPile: createRegion('exhaustPile', []), exhaustPile: createRegion("exhaustPile", []),
}; };
} }
function createCard(id: string, itemId: string, costType: 'energy' | 'uses' | 'none' = 'energy', costCount = 0): GameCard { function createCard(
const cardData = cards.find(c => c.id === itemId) ?? { id: string,
id: itemId, name: itemId, desc: '', type: 'item' as const, costType, costCount, targetType: 'none' as const, effects: [], 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 { return {
id, id,
regionId: '', regionId: "",
position: [0], position: [0],
itemId, itemId,
cardData, cardData,
}; };
} }
function createEnemyEntity(enemyId: string, hp: number, maxHp: number, instanceIndex = 0): EnemyEntity { function createEnemyEntity(
const enemyData = enemies.find(e => e.id === enemyId); 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`); 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 instanceId = `${enemyId}-${instanceIndex}`;
const intentMap: Record<string, typeof intent> = {}; const intentMap: Record<string, typeof intent> = {};
for (const i of enemyData.intents) { 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 map = new Map<string, InventoryItem<GameItemMeta>>();
const occupied = new Set<string>(); const occupied = new Set<CellKey>();
for (const item of itemsList) { for (const item of itemsList) {
map.set(item.id, item); 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 }; return { width: 6, height: 4, items: map, occupiedCells: occupied };
} }
@ -75,7 +127,7 @@ function createCombatState(overrides: Partial<CombatState> = {}): CombatState {
const regions = createDeckRegions(); const regions = createDeckRegions();
return { return {
player: { player: {
id: 'player', id: "player",
effects: {}, effects: {},
hp: 30, hp: 30,
maxHp: 30, maxHp: 30,
@ -87,7 +139,7 @@ function createCombatState(overrides: Partial<CombatState> = {}): CombatState {
}, },
enemies: [], enemies: [],
inventory: createInventory([]), inventory: createInventory([]),
phase: 'playerTurn', phase: "playerTurn",
turnNumber: 1, turnNumber: 1,
result: null, result: null,
loot: [], loot: [],
@ -103,72 +155,71 @@ function createTestContext(state?: CombatState): IGameContext<CombatState> {
} }
function getTriggers(): Triggers { function getTriggers(): Triggers {
let capturedTriggers: Triggers; const triggers = createTriggers();
createStartWith(triggers => {
capturedTriggers = triggers;
addTriggers(triggers); addTriggers(triggers);
}); return triggers;
return capturedTriggers!;
} }
function addCardToHand(ctx: IGameContext<CombatState>, card: GameCard) { function addCardToHand(ctx: IGameContext<CombatState>, card: GameCard) {
ctx._state.produce(draft => { ctx._state.produce((draft) => {
draft.player.deck.cards[card.id] = card; draft.player.deck.cards[card.id] = card;
card.regionId = 'hand'; card.regionId = "hand";
draft.player.deck.regions.hand.childIds.push(card.id); draft.player.deck.regions.hand.childIds.push(card.id);
}); });
} }
function addCardToDrawPile(ctx: IGameContext<CombatState>, card: GameCard) { function addCardToDrawPile(ctx: IGameContext<CombatState>, card: GameCard) {
ctx._state.produce(draft => { ctx._state.produce((draft) => {
draft.player.deck.cards[card.id] = card; draft.player.deck.cards[card.id] = card;
card.regionId = 'drawPile'; card.regionId = "drawPile";
draft.player.deck.regions.drawPile.childIds.push(card.id); draft.player.deck.regions.drawPile.childIds.push(card.id);
}); });
} }
function addCardToDiscardPile(ctx: IGameContext<CombatState>, card: GameCard) { function addCardToDiscardPile(ctx: IGameContext<CombatState>, card: GameCard) {
ctx._state.produce(draft => { ctx._state.produce((draft) => {
draft.player.deck.cards[card.id] = card; draft.player.deck.cards[card.id] = card;
card.regionId = 'discardPile'; card.regionId = "discardPile";
draft.player.deck.regions.discardPile.childIds.push(card.id); draft.player.deck.regions.discardPile.childIds.push(card.id);
}); });
} }
function makeDummyEnemy() { function makeDummyEnemy() {
return createEnemyEntity('仙人掌怪', 999, 999); return createEnemyEntity("仙人掌怪", 999, 999);
} }
describe('desert triggers', () => { describe("desert triggers", () => {
describe('instant effects', () => { describe("instant effects", () => {
it('should apply attack effect as damage', async () => { it("should apply attack effect as damage", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
createCombatState({
enemies: [makeDummyEnemy()], enemies: [makeDummyEnemy()],
})); }),
);
const triggers = getTriggers(); const triggers = getTriggers();
const attackEffect = createEffect('attack'); const attackEffect = createEffect("attack");
await triggers.onEffectApplied.execute(ctx, { await triggers.onEffectApplied.execute(ctx, {
effect: attackEffect, effect: attackEffect,
entityKey: 'player', entityKey: "player",
stacks: 5, stacks: 5,
sourceEntityKey: 'enemy-0', sourceEntityKey: "enemy-0",
}); });
expect(ctx.value.player.hp).toBe(25); expect(ctx.value.player.hp).toBe(25);
}); });
it('should apply draw effect', async () => { it("should apply draw effect", async () => {
const ctx = createTestContext(); const ctx = createTestContext();
const triggers = getTriggers(); const triggers = getTriggers();
const drawEffect = createEffect('draw'); const drawEffect = createEffect("draw");
addCardToDrawPile(ctx, createCard('card-1', 'sword')); addCardToDrawPile(ctx, createCard("card-1", "sword"));
addCardToDrawPile(ctx, createCard('card-2', 'sword')); addCardToDrawPile(ctx, createCard("card-2", "sword"));
await triggers.onEffectApplied.execute(ctx, { await triggers.onEffectApplied.execute(ctx, {
effect: drawEffect, effect: drawEffect,
entityKey: 'player', entityKey: "player",
stacks: 2, stacks: 2,
}); });
@ -176,182 +227,203 @@ describe('desert triggers', () => {
expect(ctx.value.player.deck.regions.drawPile.childIds.length).toBe(0); 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 ctx = createTestContext();
const triggers = getTriggers(); const triggers = getTriggers();
const gainEnergyEffect = createEffect('gainEnergy'); const gainEnergyEffect = createEffect("gainEnergy");
const initialEnergy = ctx.value.player.energy; const initialEnergy = ctx.value.player.energy;
await triggers.onEffectApplied.execute(ctx, { await triggers.onEffectApplied.execute(ctx, {
effect: gainEnergyEffect, effect: gainEnergyEffect,
entityKey: 'player', entityKey: "player",
stacks: 2, stacks: 2,
}); });
expect(ctx.value.player.energy).toBe(initialEnergy + 2); expect(ctx.value.player.energy).toBe(initialEnergy + 2);
}); });
it('should remove wound cards from draw and discard piles', async () => { it("should remove wound cards from draw and discard piles", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
createCombatState({
enemies: [makeDummyEnemy()], enemies: [makeDummyEnemy()],
})); }),
);
const triggers = getTriggers(); const triggers = getTriggers();
const removeWoundEffect = createEffect('removeWound'); const removeWoundEffect = createEffect("removeWound");
addCardToDrawPile(ctx, createCard('wound-1', 'wound', 'none', 0)); addCardToDrawPile(ctx, createCard("wound-1", "wound", "none", 0));
addCardToDiscardPile(ctx, createCard('wound-2', 'wound', 'none', 0)); addCardToDiscardPile(ctx, createCard("wound-2", "wound", "none", 0));
addCardToDrawPile(ctx, createCard('sword-1', 'sword')); addCardToDrawPile(ctx, createCard("sword-1", "sword"));
await triggers.onEffectApplied.execute(ctx, { await triggers.onEffectApplied.execute(ctx, {
effect: removeWoundEffect, effect: removeWoundEffect,
entityKey: 'player', entityKey: "player",
stacks: 2, stacks: 2,
}); });
expect(ctx.value.player.deck.cards['wound-1']).toBeUndefined(); expect(ctx.value.player.deck.cards["wound-1"]).toBeUndefined();
expect(ctx.value.player.deck.cards['wound-2']).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["sword-1"]).toBeDefined();
}); });
}); });
describe('damage pipeline', () => { describe("damage pipeline", () => {
it('should prevent damage with block', async () => { it("should prevent damage with block", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
createCombatState({
enemies: [makeDummyEnemy()], enemies: [makeDummyEnemy()],
})); }),
);
const triggers = getTriggers(); 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 }; draft.player.effects.defend = { data: defendEffect, stacks: 5 };
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: 'player', entityKey: "player",
amount: 8, amount: 8,
sourceEntityKey: 'enemy-0', sourceEntityKey: "enemy-0",
}); });
expect(ctx.value.player.hp).toBe(27); expect(ctx.value.player.hp).toBe(27);
expect(ctx.value.player.effects.defend?.stacks).toBe(2); expect(ctx.value.player.effects.defend?.stacks).toBe(2);
}); });
it('should reduce damage with damageReduce', async () => { it("should reduce damage with damageReduce", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
createCombatState({
enemies: [makeDummyEnemy()], enemies: [makeDummyEnemy()],
})); }),
);
const triggers = getTriggers(); const triggers = getTriggers();
const damageReduceEffect = createEffect('damageReduce', 'temporary'); const damageReduceEffect = createEffect("damageReduce", "temporary");
ctx._state.produce(draft => { ctx._state.produce((draft) => {
draft.player.effects.damageReduce = { data: damageReduceEffect, stacks: 3 }; draft.player.effects.damageReduce = {
data: damageReduceEffect,
stacks: 3,
};
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: 'player', entityKey: "player",
amount: 8, amount: 8,
sourceEntityKey: 'enemy-0', sourceEntityKey: "enemy-0",
}); });
expect(ctx.value.player.hp).toBe(25); expect(ctx.value.player.hp).toBe(25);
}); });
it('should increase damage with expose', async () => { it("should increase damage with expose", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
createCombatState({
enemies: [makeDummyEnemy()], enemies: [makeDummyEnemy()],
})); }),
);
const triggers = getTriggers(); 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 }; draft.player.effects.expose = { data: exposeEffect, stacks: 2 };
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: 'player', entityKey: "player",
amount: 5, amount: 5,
sourceEntityKey: 'enemy-0', sourceEntityKey: "enemy-0",
}); });
expect(ctx.value.player.hp).toBe(23); expect(ctx.value.player.hp).toBe(23);
}); });
}); });
describe('spike reflection', () => { describe("spike reflection", () => {
it('should damage attacker when entity has spike', async () => { it("should damage attacker when entity has spike", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
enemies: [createEnemyEntity('仙人掌怪', 12, 12)], createCombatState({
})); enemies: [createEnemyEntity("仙人掌怪", 12, 12)],
}),
);
const triggers = getTriggers(); 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]; const enemy = draft.enemies[0];
enemy.effects.spike = { data: spikeEffect, stacks: 3 }; enemy.effects.spike = { data: spikeEffect, stacks: 3 };
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: '仙人掌怪-0', entityKey: "仙人掌怪-0",
amount: 5, amount: 5,
sourceEntityKey: 'player', sourceEntityKey: "player",
}); });
expect(ctx.value.player.hp).toBe(27); expect(ctx.value.player.hp).toBe(27);
}); });
}); });
describe('storm static card generation', () => { describe("storm static card generation", () => {
it('should give player static cards when storm enemy executes intent', async () => { it("should give player static cards when storm enemy executes intent", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
enemies: [createEnemyEntity('风暴之灵', 30, 30)], createCombatState({
})); enemies: [createEnemyEntity("风暴之灵", 30, 30)],
}),
);
const triggers = getTriggers(); 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]; const enemy = draft.enemies[0];
enemy.effects.storm = { data: stormEffect, stacks: 2 }; 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); expect(staticCards.length).toBe(2);
}); });
}); });
describe('energyDrain', () => { describe("energyDrain", () => {
it('should drain player energy when energyDrain enemy takes damage', async () => { it("should drain player energy when energyDrain enemy takes damage", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
enemies: [createEnemyEntity('幼沙虫', 18, 18)], createCombatState({
})); enemies: [createEnemyEntity("幼沙虫", 18, 18)],
}),
);
const triggers = getTriggers(); 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]; const enemy = draft.enemies[0];
enemy.effects.energyDrain = { data: energyDrainEffect, stacks: 1 }; enemy.effects.energyDrain = { data: energyDrainEffect, stacks: 1 };
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: '幼沙虫-0', entityKey: "幼沙虫-0",
amount: 5, amount: 5,
sourceEntityKey: 'player', sourceEntityKey: "player",
}); });
expect(ctx.value.player.energy).toBe(2); expect(ctx.value.player.energy).toBe(2);
}); });
}); });
describe('molt flee', () => { describe("molt flee", () => {
it('should make enemy flee when molt >= maxHp after taking damage', async () => { it("should make enemy flee when molt >= maxHp after taking damage", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
enemies: [createEnemyEntity('蜥蜴', 14, 14)], createCombatState({
})); enemies: [createEnemyEntity("蜥蜴", 14, 14)],
}),
);
const triggers = getTriggers(); 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]; const enemy = draft.enemies[0];
enemy.effects.molt = { data: moltEffect, stacks: 14 }; enemy.effects.molt = { data: moltEffect, stacks: 14 };
}); });
@ -359,233 +431,249 @@ describe('desert triggers', () => {
let threw = false; let threw = false;
try { try {
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: '蜥蜴-0', entityKey: "蜥蜴-0",
amount: 1, amount: 1,
sourceEntityKey: 'player', sourceEntityKey: "player",
}); });
} catch (e) { } catch (e) {
threw = true; threw = true;
} }
expect(threw).toBe(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); expect(ctx.value.enemies[0].isAlive).toBe(false);
}); });
}); });
describe('discard at turn start', () => { describe("discard at turn start", () => {
it('should randomly discard a card when discard effect is active', async () => { it("should randomly discard a card when discard effect is active", async () => {
const ctx = createTestContext(); const ctx = createTestContext();
const triggers = getTriggers(); const triggers = getTriggers();
const discardEffect = createEffect('discard', 'lingering'); const discardEffect = createEffect("discard", "lingering");
addCardToHand(ctx, createCard('card-1', 'sword')); addCardToHand(ctx, createCard("card-1", "sword"));
addCardToHand(ctx, createCard('card-2', 'shield')); addCardToHand(ctx, createCard("card-2", "shield"));
addCardToHand(ctx, createCard('card-3', 'dagger')); addCardToHand(ctx, createCard("card-3", "dagger"));
ctx._state.produce(draft => { ctx._state.produce((draft) => {
draft.player.effects.discard = { data: discardEffect, stacks: 1 }; draft.player.effects.discard = { data: discardEffect, stacks: 1 };
}); });
await triggers.onTurnStart.execute(ctx, { entityKey: 'player' }); await triggers.onTurnStart.execute(ctx, { entityKey: "player" });
expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2); expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2);
expect(ctx.value.player.deck.regions.discardPile.childIds.length).toBe(1); expect(ctx.value.player.deck.regions.discardPile.childIds.length).toBe(1);
}); });
}); });
describe('next-turn effects', () => { describe("next-turn effects", () => {
it('should gain block from defendNext at turn start', async () => { it("should gain block from defendNext at turn start", async () => {
const ctx = createTestContext(); const ctx = createTestContext();
const triggers = getTriggers(); 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 }; 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.defend?.stacks).toBe(5);
expect(ctx.value.player.effects.defendNext).toBeUndefined(); 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 ctx = createTestContext();
const triggers = getTriggers(); 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 }; 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.energy).toBe(5);
expect(ctx.value.player.effects.energyNext).toBeUndefined(); 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 ctx = createTestContext();
const triggers = getTriggers(); const triggers = getTriggers();
const drawNextEffect = createEffect('drawNext', 'temporary'); const drawNextEffect = createEffect("drawNext", "temporary");
addCardToDrawPile(ctx, createCard('card-1', 'sword')); addCardToDrawPile(ctx, createCard("card-1", "sword"));
addCardToDrawPile(ctx, createCard('card-2', 'sword')); addCardToDrawPile(ctx, createCard("card-2", "sword"));
addCardToDrawPile(ctx, createCard('card-3', 'sword')); addCardToDrawPile(ctx, createCard("card-3", "sword"));
ctx._state.produce(draft => { ctx._state.produce((draft) => {
draft.player.effects.drawNext = { data: drawNextEffect, stacks: 2 }; draft.player.effects.drawNext = { data: drawNextEffect, stacks: 2 };
}); });
await triggers.onTurnStart.execute(ctx, { entityKey: 'player' }); await triggers.onTurnStart.execute(ctx, { entityKey: "player" });
expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2); expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2);
expect(ctx.value.player.effects.drawNext).toBeUndefined(); expect(ctx.value.player.effects.drawNext).toBeUndefined();
}); });
}); });
describe('posture damage effects', () => { describe("posture damage effects", () => {
it('should double damage with aim', async () => { it("should double damage with aim", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
enemies: [createEnemyEntity('仙人掌怪', 12, 12)], createCombatState({
})); enemies: [createEnemyEntity("仙人掌怪", 12, 12)],
}),
);
const triggers = getTriggers(); 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 }; draft.player.effects.aim = { data: aimEffect, stacks: 2 };
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: '仙人掌怪-0', entityKey: "仙人掌怪-0",
amount: 5, amount: 5,
sourceEntityKey: 'player', sourceEntityKey: "player",
}); });
expect(ctx.value.enemies[0].hp).toBe(2); expect(ctx.value.enemies[0].hp).toBe(2);
}); });
it('should add bonus damage with roll', async () => { it("should add bonus damage with roll", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
enemies: [createEnemyEntity('仙人掌怪', 99, 99)], createCombatState({
})); enemies: [createEnemyEntity("仙人掌怪", 99, 99)],
}),
);
const triggers = getTriggers(); 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 }; draft.player.effects.roll = { data: rollEffect, stacks: 20 };
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: '仙人掌怪-0', entityKey: "仙人掌怪-0",
amount: 5, amount: 5,
sourceEntityKey: 'player', sourceEntityKey: "player",
}); });
expect(ctx.value.enemies[0].hp).toBe(74); expect(ctx.value.enemies[0].hp).toBe(74);
expect(ctx.value.player.effects.roll).toBeUndefined(); expect(ctx.value.player.effects.roll).toBeUndefined();
}); });
it('should add bonus damage with tailSting', async () => { it("should add bonus damage with tailSting", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
enemies: [createEnemyEntity('沙蝎', 10, 10)], createCombatState({
})); enemies: [createEnemyEntity("沙蝎", 10, 10)],
}),
);
const triggers = getTriggers(); 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]; const enemy = draft.enemies[0];
enemy.effects.tailSting = { data: tailStingEffect, stacks: 2 }; enemy.effects.tailSting = { data: tailStingEffect, stacks: 2 };
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: 'player', entityKey: "player",
amount: 5, amount: 5,
sourceEntityKey: '沙蝎-0', sourceEntityKey: "沙蝎-0",
}); });
expect(ctx.value.player.hp).toBe(23); expect(ctx.value.player.hp).toBe(23);
}); });
it('should double damage with charge on attacker', async () => { it("should double damage with charge on attacker", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
enemies: [createEnemyEntity('骑马枪手', 25, 25)], createCombatState({
})); enemies: [createEnemyEntity("骑马枪手", 25, 25)],
}),
);
const triggers = getTriggers(); 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]; const enemy = draft.enemies[0];
enemy.effects.charge = { data: chargeEffect, stacks: 2 }; enemy.effects.charge = { data: chargeEffect, stacks: 2 };
}); });
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: 'player', entityKey: "player",
amount: 5, amount: 5,
sourceEntityKey: '骑马枪手-0', sourceEntityKey: "骑马枪手-0",
}); });
expect(ctx.value.player.hp).toBe(20); expect(ctx.value.player.hp).toBe(20);
}); });
}); });
describe('crossbow chain', () => { describe("crossbow chain", () => {
it('should replay other crossbows on same target', async () => { it("should replay other crossbows on same target", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
enemies: [createEnemyEntity('仙人掌怪', 20, 20)], createCombatState({
})); enemies: [createEnemyEntity("仙人掌怪", 20, 20)],
}),
);
const triggers = getTriggers(); const triggers = getTriggers();
const crossbowEffect = createEffect('crossbow'); const crossbowEffect = createEffect("crossbow");
addCardToHand(ctx, createCard('crossbow-1', 'crossbow')); addCardToHand(ctx, createCard("crossbow-1", "crossbow"));
addCardToHand(ctx, createCard('crossbow-2', 'crossbow')); addCardToHand(ctx, createCard("crossbow-2", "crossbow"));
await triggers.onEffectApplied.execute(ctx, { await triggers.onEffectApplied.execute(ctx, {
effect: crossbowEffect, effect: crossbowEffect,
entityKey: 'player', entityKey: "player",
stacks: 0, stacks: 0,
cardId: 'crossbow-1', cardId: "crossbow-1",
sourceEntityKey: 'player', sourceEntityKey: "player",
targetId: '仙人掌怪-0', targetId: "仙人掌怪-0",
}); });
expect(ctx.value.enemies[0].hp).toBe(8); expect(ctx.value.enemies[0].hp).toBe(8);
}); });
}); });
describe('sandwormKing fatigue heal', () => { describe("sandwormKing fatigue heal", () => {
it('should heal sandworm king when player discards fatigue', async () => { it("should heal sandworm king when player discards fatigue", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
enemies: [createEnemyEntity('沙虫王', 30, 40)], createCombatState({
})); enemies: [createEnemyEntity("沙虫王", 30, 40)],
}),
);
const triggers = getTriggers(); const triggers = getTriggers();
addCardToHand(ctx, createCard('fatigue-1', 'fatigue', 'none', 0)); addCardToHand(ctx, createCard("fatigue-1", "fatigue", "none", 0));
await triggers.onCardDiscarded.execute(ctx, { await triggers.onCardDiscarded.execute(ctx, {
cardId: 'fatigue-1', cardId: "fatigue-1",
sourceEntityKey: 'player', sourceEntityKey: "player",
}); });
expect(ctx.value.enemies[0].hp).toBe(40); expect(ctx.value.enemies[0].hp).toBe(40);
}); });
}); });
describe('vulture on-damage', () => { describe("vulture on-damage", () => {
it('should give player vultureEye when vulture deals damage', async () => { it("should give player vultureEye when vulture deals damage", async () => {
const ctx = createTestContext(createCombatState({ const ctx = createTestContext(
enemies: [createEnemyEntity('秃鹫', 12, 12)], createCombatState({
})); enemies: [createEnemyEntity("秃鹫", 12, 12)],
}),
);
const triggers = getTriggers(); const triggers = getTriggers();
await triggers.onDamage.execute(ctx, { await triggers.onDamage.execute(ctx, {
entityKey: 'player', entityKey: "player",
amount: 5, 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); expect(vultureEyeCards.length).toBe(1);
}); });
}); });

View File

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

View File

@ -1,19 +1,26 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/system/map/generator'; import {
import { MapNodeType, MapLayerType } from '@/samples/slay-the-spire-like/system/map/types'; generatePointCrawlMap,
import { createRNG } from '@/utils/rng'; hasPath,
import { encounters } from '@/samples/slay-the-spire-like/data/desert'; } from "@/samples/slay-the-spire-like/system/map/generator";
import {
MapNodeType,
MapLayerType,
} from "@/samples/slay-the-spire-like/system/map/types";
import { createRNG } from "@/utils/rng";
import { getEncounters } from "@/samples/slay-the-spire-like/data/desert";
const encounters = getEncounters();
describe('generatePointCrawlMap', () => { describe("generatePointCrawlMap", () => {
it('should generate a map with 10 layers', () => { it("should generate a map with 10 layers", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(123), encounters);
expect(map.layers.length).toBe(10); expect(map.layers.length).toBe(10);
}); });
it('should have correct layer structure', () => { it("should have correct layer structure", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(123), encounters);
const expectedStructure = [ const expectedStructure = [
'start', "start",
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Settlement, MapLayerType.Settlement,
@ -22,7 +29,7 @@ describe('generatePointCrawlMap', () => {
MapLayerType.Settlement, MapLayerType.Settlement,
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Wild, MapLayerType.Wild,
'end', "end",
]; ];
for (let i = 0; i < expectedStructure.length; i++) { for (let i = 0; i < expectedStructure.length; i++) {
@ -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 map = generatePointCrawlMap(createRNG(123), encounters);
const expectedCounts = [1, 3, 3, 4, 3, 3, 4, 3, 3, 1]; 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 map = generatePointCrawlMap(createRNG(123), encounters);
const startNode = map.nodes.get('node-0-0'); const startNode = map.nodes.get("node-0-0");
const endNode = map.nodes.get('node-9-0'); const endNode = map.nodes.get("node-9-0");
expect(startNode?.type).toBe(MapNodeType.Start); expect(startNode?.type).toBe(MapNodeType.Start);
expect(endNode?.type).toBe(MapNodeType.End); 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 map = generatePointCrawlMap(createRNG(123), encounters);
const wildLayerIndices = [1, 2, 4, 5, 7, 8]; 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) { for (const layerIdx of wildLayerIndices) {
const layer = map.layers[layerIdx]; 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 map = generatePointCrawlMap(createRNG(123), encounters);
const settlementLayerIndices = [3, 6]; const settlementLayerIndices = [3, 6];
for (const layerIdx of settlementLayerIndices) { for (const layerIdx of settlementLayerIndices) {
const layer = map.layers[layerIdx]; 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.Camp);
expect(nodeTypes).toContain(MapNodeType.Shop); 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 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]; const wildLayer = map.layers[1];
expect(startNode?.childIds.length).toBe(3); 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 map = generatePointCrawlMap(createRNG(42), encounters);
const wildToWildTransitions = [ const wildToWildTransitions = [
{ src: 1, tgt: 2 }, { 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 map = generatePointCrawlMap(createRNG(42), encounters);
const wildToSettlementTransitions = [ const wildToSettlementTransitions = [
{ src: 2, tgt: 3 }, { 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 map = generatePointCrawlMap(createRNG(42), encounters);
const settlementToWildTransitions = [ const settlementToWildTransitions = [
{ src: 3, tgt: 4 }, { 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 map = generatePointCrawlMap(createRNG(42), encounters);
const lastWildLayer = map.layers[8]; 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) { for (const wildId of lastWildLayer.nodeIds) {
const wildNode = map.nodes.get(wildId); 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 map = generatePointCrawlMap(createRNG(123), encounters);
const startId = 'node-0-0'; const startId = "node-0-0";
const endId = 'node-9-0'; const endId = "node-9-0";
for (const nodeId of map.nodes.keys()) { for (const nodeId of map.nodes.keys()) {
if (nodeId === startId || nodeId === endId) continue; 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 map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToWildTransitions = [ const wildToWildTransitions = [
{ src: 1, tgt: 2 }, { 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 map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToSettlementTransitions = [ const wildToSettlementTransitions = [
{ src: 2, tgt: 3 }, { 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 map = generatePointCrawlMap(createRNG(12345), encounters);
const settlementToWildTransitions = [ const settlementToWildTransitions = [
{ src: 3, tgt: 4 }, { 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); const map = generatePointCrawlMap(createRNG(456), encounters);
for (const node of map.nodes.values()) { for (const node of map.nodes.values()) {
@ -298,14 +311,17 @@ describe('generatePointCrawlMap', () => {
expect(node.encounter).toBeUndefined(); expect(node.encounter).toBeUndefined();
} else { } else {
// All other nodes (minion/elite/event/camp/shop/curio) must have encounters // 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!.name).toBeTruthy();
expect(node.encounter!.description).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 // Test multiple seeds to ensure no random failure
for (let seed = 0; seed < 20; seed++) { for (let seed = 0; seed < 20; seed++) {
const map = generatePointCrawlMap(createRNG(seed), encounters); const map = generatePointCrawlMap(createRNG(seed), encounters);
@ -314,14 +330,17 @@ describe('generatePointCrawlMap', () => {
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) { if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
continue; 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!.name).toBeTruthy();
expect(node.encounter!.description).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 // 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 map = generatePointCrawlMap(createRNG(12345), encounters);
const wildPairIndices = [ const wildPairIndices = [
@ -335,12 +354,12 @@ describe('generatePointCrawlMap', () => {
const layer2 = map.layers[layer2Idx]; const layer2 = map.layers[layer2Idx];
// Count repetitions in layer 1 // 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 layer1Unique = new Set(layer1Types).size;
const layer1Repetitions = layer1Types.length - layer1Unique; const layer1Repetitions = layer1Types.length - layer1Unique;
// Count repetitions in layer 2 // 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 layer2Unique = new Set(layer2Types).size;
const layer2Repetitions = layer2Types.length - layer2Unique; 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 // Test that wild nodes connected by wild→wild edges have different types
const map = generatePointCrawlMap(createRNG(12345), encounters); const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToWildPairs = [ const wildToWildPairs = [

View File

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