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

@ -6,116 +6,125 @@ import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import getEffects from "../effect.csv"; import getEffects from "../effect.csv";
export function addCardEventTriggers(triggers: Triggers) { 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
triggers.onEnemyIntent.use(async (ctx, next) => { triggers.onEnemyIntent.use(async (ctx, next) => {
await next(); await next();
const enemy = getCombatEntity(ctx.game.value, ctx.enemyId); const enemy = getCombatEntity(ctx.game.value, ctx.enemyId);
if (!enemy || !enemy.isAlive) return; if (!enemy || !enemy.isAlive) return;
const storm = enemy.effects.storm?.stacks ?? 0;
if (storm > 0) {
for (let i = 0; i < storm; i++) {
await triggers.onEffectApplied.execute(ctx.game, {
effect: findEffect("static"),
entityKey: "player",
stacks: 1,
sourceEntityKey: ctx.enemyId,
});
}
}
});
// crossbow: replay other crossbows on same target
triggers.onEffectApplied.use(async (ctx, next) => {
await next();
if (ctx.effect.id !== "crossbow" || !ctx.cardId || !ctx.targetId) return;
const { cards, regions } = ctx.game.value.player.deck;
const handIds = [...regions.hand.childIds];
for (const id of handIds) {
const card = cards[id];
if (card && card.itemId === "crossbow" && id !== ctx.cardId) {
await triggers.onCardPlayed.execute(ctx.game, {
cardId: id,
targetId: ctx.targetId,
sourceEntityKey: "player",
});
}
}
});
// burnForEnergy: consume adjacent item, gain energy when its card is played
triggers.onCardPlayed.use(async (ctx, next) => {
await next();
const card = ctx.game.value.player.deck.cards[ctx.cardId];
if (!card) return;
const playedItemId = card.itemId;
const adjacent = getAdjacentItems<GameItemMeta>(ctx.game.value.inventory, playedItemId);
for (const [adjItemId] of adjacent) {
const adjEffects = ctx.game.value.player.itemEffects[adjItemId];
if (!adjEffects) continue;
const burn = adjEffects.burnForEnergy;
if (!burn || burn.stacks <= 0) continue;
await ctx.game.produceAsync(draft => {
const item = draft.inventory.items.get(adjItemId);
if (item) {
draft.inventory.items.delete(adjItemId);
}
draft.player.energy += burn.stacks;
delete draft.player.itemEffects[adjItemId];
});
break;
}
});
// sandwormKing: heal 10 hp when player discards fatigue
triggers.onCardDiscarded.use(async (ctx, next) => {
await next();
const card = ctx.game.value.player.deck.cards[ctx.cardId];
if (!card || card.cardData.id !== "fatigue") return;
const sandwormKing = ctx.game.value.enemies.find(
e => e.enemy.id === "沙虫王" && e.isAlive
);
if (!sandwormKing) return;
await ctx.game.produceAsync(draft => {
const king = draft.enemies.find(e => e.id === sandwormKing.id);
if (king) {
king.hp = Math.min(king.hp + 10, king.maxHp);
}
});
});
// vulture: give vultureEye when vulture deals damage
triggers.onDamage.use(async (ctx, next) => {
await next();
const dealt = ctx.amount - (ctx.prevented ?? 0);
if (dealt <= 0 || !ctx.sourceEntityKey) return;
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityKey);
if (!attacker || !("enemy" in attacker) || attacker.enemy.id !== "秃鹫") return;
const storm = enemy.effects.storm?.stacks ?? 0;
if (storm > 0) {
for (let i = 0; i < storm; i++) {
await triggers.onEffectApplied.execute(ctx.game, { await triggers.onEffectApplied.execute(ctx.game, {
effect: findEffect("vultureEye"), effect: findEffect("static"),
entityKey: "player", entityKey: "player",
stacks: 1, stacks: 1,
sourceEntityKey: ctx.sourceEntityKey, sourceEntityKey: ctx.enemyId,
}); });
}
}
});
// crossbow: replay other crossbows on same target
triggers.onEffectApplied.use(async (ctx, next) => {
await next();
if (ctx.effect.id !== "crossbow" || !ctx.cardId || !ctx.targetId) return;
const { cards, regions } = ctx.game.value.player.deck;
const handIds = [...regions.hand.childIds];
for (const id of handIds) {
const card = cards[id];
if (card && card.itemId === "crossbow" && id !== ctx.cardId) {
await triggers.onCardPlayed.execute(ctx.game, {
cardId: id,
targetId: ctx.targetId,
sourceEntityKey: "player",
});
}
}
});
// burnForEnergy: consume adjacent item, gain energy when its card is played
triggers.onCardPlayed.use(async (ctx, next) => {
await next();
const card = ctx.game.value.player.deck.cards[ctx.cardId];
if (!card) return;
const playedItemId = card.itemId;
const adjacent = getAdjacentItems<GameItemMeta>(
ctx.game.value.inventory,
playedItemId,
);
for (const [adjItemId] of adjacent) {
const adjEffects = ctx.game.value.player.itemEffects[adjItemId];
if (!adjEffects) continue;
const burn = adjEffects.burnForEnergy;
if (!burn || burn.stacks <= 0) continue;
await ctx.game.produceAsync((draft) => {
const item = draft.inventory.items.get(adjItemId);
if (item) {
draft.inventory.items.delete(adjItemId);
}
draft.player.energy += burn.stacks;
delete draft.player.itemEffects[adjItemId];
});
break;
}
});
// sandwormKing: heal 10 hp when player discards fatigue
triggers.onCardDiscarded.use(async (ctx, next) => {
await next();
const card = ctx.game.value.player.deck.cards[ctx.cardId];
if (!card || card.cardData.id !== "fatigue") return;
const sandwormKing = ctx.game.value.enemies.find(
(e) => e.enemy.id === "沙虫王" && e.isAlive,
);
if (!sandwormKing) return;
await ctx.game.produceAsync((draft) => {
const king = draft.enemies.find((e) => e.id === sandwormKing.id);
if (king) {
king.hp = Math.min(king.hp + 10, king.maxHp);
}
}); });
});
// vulture: give vultureEye when vulture deals damage
triggers.onDamage.use(async (ctx, next) => {
await next();
const dealt = ctx.amount - (ctx.prevented ?? 0);
if (dealt <= 0 || !ctx.sourceEntityKey) return;
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityKey);
if (!attacker || !("enemy" in attacker) || attacker.enemy.id !== "秃鹫")
return;
await triggers.onEffectApplied.execute(ctx.game, {
effect: findEffect("vultureEye"),
entityKey: "player",
stacks: 1,
sourceEntityKey: ctx.sourceEntityKey,
});
});
} }

View File

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

View File

@ -1,53 +1,59 @@
import type { PlayerDeck } from "../deck/types"; import type { PlayerDeck } from "../deck/types";
import {EnemyData, IntentData} from "@/samples/slay-the-spire-like/system/types"; import {
import {EffectData} from "@/samples/slay-the-spire-like/system/types"; EnemyData,
import {GridInventory} from "@/samples/slay-the-spire-like/system/grid-inventory"; IntentData,
import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress"; } from "@/samples/slay-the-spire-like/system/types";
import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import { GridInventory } from "@/samples/slay-the-spire-like/system/grid-inventory";
import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress";
export type EffectTable = Record<string, {data: EffectData, stacks: number}>; export type EffectTable = Record<string, { data: EffectData; stacks: number }>;
export type CombatEntity = { export type CombatEntity = {
id: string; // player is just "player" id: string; // player is just "player"
effects: EffectTable; effects: EffectTable;
hp: number; hp: number;
maxHp: number; maxHp: number;
isAlive: boolean; isAlive: boolean;
}; };
export type PlayerEntity = CombatEntity & { export type PlayerEntity = CombatEntity & {
energy: number; energy: number;
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;
intents: Record<string, IntentData>; intents: Record<string, IntentData>;
currentIntent: IntentData; currentIntent: IntentData;
}; };
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd"; export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
export type CombatResult = "victory" | "defeat"; export type CombatResult = "victory" | "defeat";
export type LootEntry = { export type LootEntry =
type: "gold"; | {
amount: number; type: "gold";
} | { amount: number;
type: "item", }
itemId: string; | {
}; type: "item";
itemId: string;
};
export type CombatState = { export type CombatState = {
enemies: EnemyEntity[]; enemies: EnemyEntity[];
player: PlayerEntity; player: PlayerEntity;
inventory: GridInventory<GameItemMeta>; inventory: GridInventory<GameItemMeta>;
phase: CombatPhase; phase: CombatPhase;
turnNumber: number; turnNumber: number;
result: CombatResult | null; result: CombatResult | null;
loot: LootEntry[]; loot: LootEntry[];
}; };
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>; export type CombatGameContext =
import("@/core/game").IGameContextExport<CombatState>;

View File

@ -1,16 +1,26 @@
export type EffectData = { export type EffectData = {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
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;
readonly name: string; readonly name: string;
readonly intents: readonly IntentData[]; readonly intents: readonly IntentData[];
readonly description: string; readonly description: string;
}; };
export type CardType = "item" | "status"; export type CardType = "item" | "status";
@ -18,44 +28,62 @@ 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 CardData = {
readonly id: string;
readonly name: string;
readonly desc: string;
readonly type: CardType;
readonly costType: CardCostType;
readonly costCount: number;
readonly targetType: CardTargetType;
readonly effects: readonly [CardEffectTrigger, CardEffectTarget, EffectData, number][];
};
export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard"; export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard";
export type CardEffectTarget = "self" | "target" | "all" | "random" export type CardEffectTarget = "self" | "target" | "all" | "random";
export type EncounterType = "minion" | "elite" | "event" | "shop" | "camp" | "curio"; export type CardEffect = {
readonly id: string;
readonly trigger: CardEffectTrigger;
readonly target: CardEffectTarget;
readonly effects: readonly [EffectData, number][];
};
export type CardData = {
readonly id: string;
readonly name: string;
readonly desc: string;
readonly type: CardType;
readonly costType: CardCostType;
readonly costCount: number;
readonly targetType: CardTargetType;
readonly effects: readonly CardEffect[];
};
export type EncounterType =
| "minion"
| "elite"
| "event"
| "shop"
| "camp"
| "curio";
export type EncounterData = { 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 [
readonly dialogue: string; data: EnemyData,
hp: number,
effects: [EffectData, stacks: number][],
][];
readonly dialogue: string;
}; };
export type IntentData = { export type IntentData = {
readonly id: string; readonly id: string;
readonly enemy: EnemyData; readonly enemy: EnemyData;
readonly initialIntent: boolean; readonly initialIntent: boolean;
readonly nextIntents: readonly IntentData[]; readonly nextIntents: readonly IntentData[];
readonly brokenIntent: readonly IntentData[]; readonly brokenIntent: readonly IntentData[];
readonly effects: readonly [EffectTarget, EffectData, number][]; readonly effects: readonly [EffectTarget, EffectData, number][];
}; };
export type ItemData = { export type ItemData = {
readonly id: string; readonly id: string;
readonly type: string; readonly type: string;
readonly name: string; readonly name: string;
readonly shape: string; readonly shape: string;
readonly card: CardData; readonly card: CardData;
readonly price: number; readonly price: number;
readonly description: string; readonly description: string;
}; };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

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,386 +1,405 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/system/map/generator'; import {
import { MapNodeType, MapLayerType } from '@/samples/slay-the-spire-like/system/map/types'; generatePointCrawlMap,
import { createRNG } from '@/utils/rng'; hasPath,
import { encounters } from '@/samples/slay-the-spire-like/data/desert'; } from "@/samples/slay-the-spire-like/system/map/generator";
import {
MapNodeType,
MapLayerType,
} from "@/samples/slay-the-spire-like/system/map/types";
import { createRNG } from "@/utils/rng";
import { getEncounters } from "@/samples/slay-the-spire-like/data/desert";
const encounters = getEncounters();
describe('generatePointCrawlMap', () => { describe("generatePointCrawlMap", () => {
it('should generate a map with 10 layers', () => { it("should generate a map with 10 layers", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(123), encounters);
expect(map.layers.length).toBe(10); expect(map.layers.length).toBe(10);
}); });
it('should have correct layer structure', () => { it("should have correct layer structure", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(123), encounters);
const expectedStructure = [ const expectedStructure = [
'start', "start",
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Settlement, MapLayerType.Settlement,
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Settlement, MapLayerType.Settlement,
MapLayerType.Wild, MapLayerType.Wild,
MapLayerType.Wild, MapLayerType.Wild,
'end', "end",
]; ];
for (let i = 0; i < expectedStructure.length; i++) { for (let i = 0; i < expectedStructure.length; i++) {
expect(map.layers[i].layerType).toBe(expectedStructure[i]); expect(map.layers[i].layerType).toBe(expectedStructure[i]);
}
});
it("should have correct node counts per layer", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const expectedCounts = [1, 3, 3, 4, 3, 3, 4, 3, 3, 1];
for (let i = 0; i < expectedCounts.length; i++) {
expect(map.layers[i].nodeIds.length).toBe(expectedCounts[i]);
}
});
it("should have Start and End nodes with correct types", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const startNode = map.nodes.get("node-0-0");
const endNode = map.nodes.get("node-9-0");
expect(startNode?.type).toBe(MapNodeType.Start);
expect(endNode?.type).toBe(MapNodeType.End);
});
it("should have wild layers with minion/elite/event types", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const wildLayerIndices = [1, 2, 4, 5, 7, 8];
const validWildTypes = new Set([
MapNodeType.Minion,
MapNodeType.Elite,
MapNodeType.Event,
]);
for (const layerIdx of wildLayerIndices) {
const layer = map.layers[layerIdx];
for (const nodeId of layer.nodeIds) {
const node = map.nodes.get(nodeId);
expect(node).toBeDefined();
expect(validWildTypes.has(node!.type)).toBe(true);
}
}
});
it("should have settlement layers with at least 1 camp, 1 shop, 1 curio", () => {
const map = generatePointCrawlMap(createRNG(123), encounters);
const settlementLayerIndices = [3, 6];
for (const layerIdx of settlementLayerIndices) {
const layer = map.layers[layerIdx];
const nodeTypes = layer.nodeIds.map((id) => map.nodes.get(id)!.type);
expect(nodeTypes).toContain(MapNodeType.Camp);
expect(nodeTypes).toContain(MapNodeType.Shop);
expect(nodeTypes).toContain(MapNodeType.Curio);
expect(nodeTypes.length).toBe(4);
}
});
it("should have Start connected to all 3 wild nodes", () => {
const map = generatePointCrawlMap(createRNG(42), encounters);
const startNode = map.nodes.get("node-0-0");
const wildLayer = map.layers[1];
expect(startNode?.childIds.length).toBe(3);
expect(startNode?.childIds).toEqual(
expect.arrayContaining(wildLayer.nodeIds),
);
});
it("should have each wild node connect to 1 wild node in wild→wild layers", () => {
const map = generatePointCrawlMap(createRNG(42), encounters);
const wildToWildTransitions = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
{ src: 7, tgt: 8 },
];
for (const transition of wildToWildTransitions) {
const srcLayer = map.layers[transition.src];
for (const srcId of srcLayer.nodeIds) {
const srcNode = map.nodes.get(srcId);
expect(srcNode?.childIds.length).toBe(1);
const childLayer = map.layers[transition.tgt];
expect(childLayer.nodeIds).toContain(srcNode!.childIds[0]);
}
}
});
it("should have each wild node connect to 2 settlement nodes in wild→settlement layers", () => {
const map = generatePointCrawlMap(createRNG(42), encounters);
const wildToSettlementTransitions = [
{ src: 2, tgt: 3 },
{ src: 5, tgt: 6 },
];
for (const transition of wildToSettlementTransitions) {
const srcLayer = map.layers[transition.src];
for (const srcId of srcLayer.nodeIds) {
const srcNode = map.nodes.get(srcId);
expect(srcNode?.childIds.length).toBe(2);
const childLayer = map.layers[transition.tgt];
for (const childId of srcNode!.childIds) {
expect(childLayer.nodeIds).toContain(childId);
} }
}); }
}
});
it('should have correct node counts per layer', () => { it("should have settlement nodes connect correctly (1-2-2-1 pattern)", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(42), encounters);
const expectedCounts = [1, 3, 3, 4, 3, 3, 4, 3, 3, 1]; const settlementToWildTransitions = [
{ src: 3, tgt: 4 },
{ src: 6, tgt: 7 },
];
for (let i = 0; i < expectedCounts.length; i++) { for (const transition of settlementToWildTransitions) {
expect(map.layers[i].nodeIds.length).toBe(expectedCounts[i]); const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt];
// First and last settlement connect to 1 wild
const firstSettlement = map.nodes.get(srcLayer.nodeIds[0]);
expect(firstSettlement?.childIds.length).toBe(1);
const lastSettlement = map.nodes.get(srcLayer.nodeIds[3]);
expect(lastSettlement?.childIds.length).toBe(1);
// Middle two settlements connect to 2 wilds
for (let i = 1; i <= 2; i++) {
const midSettlement = map.nodes.get(srcLayer.nodeIds[i]);
expect(midSettlement?.childIds.length).toBe(2);
for (const childId of midSettlement!.childIds) {
expect(tgtLayer.nodeIds).toContain(childId);
} }
}); }
}
});
it('should have Start and End nodes with correct types', () => { it("should have all 3 wild nodes connect to End", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(42), encounters);
const startNode = map.nodes.get('node-0-0'); const lastWildLayer = map.layers[8];
const endNode = map.nodes.get('node-9-0'); const endNode = map.nodes.get("node-9-0");
expect(startNode?.type).toBe(MapNodeType.Start); for (const wildId of lastWildLayer.nodeIds) {
expect(endNode?.type).toBe(MapNodeType.End); const wildNode = map.nodes.get(wildId);
}); expect(wildNode?.childIds).toEqual([endNode!.id]);
}
});
it('should have wild layers with minion/elite/event types', () => { it("should have all nodes reachable from Start and can reach End", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(123), encounters);
const wildLayerIndices = [1, 2, 4, 5, 7, 8]; const startId = "node-0-0";
const validWildTypes = new Set([MapNodeType.Minion, MapNodeType.Elite, MapNodeType.Event]); const endId = "node-9-0";
for (const layerIdx of wildLayerIndices) { for (const nodeId of map.nodes.keys()) {
const layer = map.layers[layerIdx]; if (nodeId === startId || nodeId === endId) continue;
for (const nodeId of layer.nodeIds) { expect(hasPath(map, startId, nodeId)).toBe(true);
const node = map.nodes.get(nodeId); expect(hasPath(map, nodeId, endId)).toBe(true);
expect(node).toBeDefined(); }
expect(validWildTypes.has(node!.type)).toBe(true); });
}
it("should not have crossing edges in wild→wild transitions", () => {
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToWildTransitions = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
{ src: 7, tgt: 8 },
];
for (const transition of wildToWildTransitions) {
const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt];
// Collect edges as pairs of indices
const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
for (let s = 0; s < srcLayer.nodeIds.length; s++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
for (const tgtId of srcNode!.childIds) {
const t = tgtLayer.nodeIds.indexOf(tgtId);
edges.push({ srcIndex: s, tgtIndex: t });
} }
}); }
it('should have settlement layers with at least 1 camp, 1 shop, 1 curio', () => { // Check for crossings
const map = generatePointCrawlMap(createRNG(123), encounters); for (let e1 = 0; e1 < edges.length; e1++) {
const settlementLayerIndices = [3, 6]; for (let e2 = e1 + 1; e2 < edges.length; e2++) {
const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
for (const layerIdx of settlementLayerIndices) { if (s1 === s2) continue;
const layer = map.layers[layerIdx]; if (t1 === t2) continue;
const nodeTypes = layer.nodeIds.map(id => map.nodes.get(id)!.type);
expect(nodeTypes).toContain(MapNodeType.Camp); const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(nodeTypes).toContain(MapNodeType.Shop); expect(crosses).toBe(false);
expect(nodeTypes).toContain(MapNodeType.Curio);
expect(nodeTypes.length).toBe(4);
} }
}); }
}
});
it('should have Start connected to all 3 wild nodes', () => { it("should not have crossing edges in wild→settlement transitions", () => {
const map = generatePointCrawlMap(createRNG(42), encounters); const map = generatePointCrawlMap(createRNG(12345), encounters);
const startNode = map.nodes.get('node-0-0'); const wildToSettlementTransitions = [
const wildLayer = map.layers[1]; { src: 2, tgt: 3 },
{ src: 5, tgt: 6 },
];
expect(startNode?.childIds.length).toBe(3); for (const transition of wildToSettlementTransitions) {
expect(startNode?.childIds).toEqual(expect.arrayContaining(wildLayer.nodeIds)); const srcLayer = map.layers[transition.src];
}); const tgtLayer = map.layers[transition.tgt];
it('should have each wild node connect to 1 wild node in wild→wild layers', () => { // Collect edges as pairs of indices
const map = generatePointCrawlMap(createRNG(42), encounters); const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
const wildToWildTransitions = [ for (let s = 0; s < srcLayer.nodeIds.length; s++) {
{ src: 1, tgt: 2 }, const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
{ src: 4, tgt: 5 }, for (const tgtId of srcNode!.childIds) {
{ src: 7, tgt: 8 }, const t = tgtLayer.nodeIds.indexOf(tgtId);
]; edges.push({ srcIndex: s, tgtIndex: t });
for (const transition of wildToWildTransitions) {
const srcLayer = map.layers[transition.src];
for (const srcId of srcLayer.nodeIds) {
const srcNode = map.nodes.get(srcId);
expect(srcNode?.childIds.length).toBe(1);
const childLayer = map.layers[transition.tgt];
expect(childLayer.nodeIds).toContain(srcNode!.childIds[0]);
}
} }
}); }
it('should have each wild node connect to 2 settlement nodes in wild→settlement layers', () => { // Check for crossings
const map = generatePointCrawlMap(createRNG(42), encounters); for (let e1 = 0; e1 < edges.length; e1++) {
const wildToSettlementTransitions = [ for (let e2 = e1 + 1; e2 < edges.length; e2++) {
{ src: 2, tgt: 3 }, const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
{ src: 5, tgt: 6 }, const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
];
for (const transition of wildToSettlementTransitions) { if (s1 === s2) continue;
const srcLayer = map.layers[transition.src]; if (t1 === t2) continue;
for (const srcId of srcLayer.nodeIds) {
const srcNode = map.nodes.get(srcId); const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(srcNode?.childIds.length).toBe(2); expect(crosses).toBe(false);
const childLayer = map.layers[transition.tgt];
for (const childId of srcNode!.childIds) {
expect(childLayer.nodeIds).toContain(childId);
}
}
} }
}); }
}
});
it('should have settlement nodes connect correctly (1-2-2-1 pattern)', () => { it("should not have crossing edges in settlement→wild transitions", () => {
const map = generatePointCrawlMap(createRNG(42), encounters); const map = generatePointCrawlMap(createRNG(12345), encounters);
const settlementToWildTransitions = [ const settlementToWildTransitions = [
{ src: 3, tgt: 4 }, { src: 3, tgt: 4 },
{ src: 6, tgt: 7 }, { src: 6, tgt: 7 },
]; ];
for (const transition of settlementToWildTransitions) { for (const transition of settlementToWildTransitions) {
const srcLayer = map.layers[transition.src]; const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt]; const tgtLayer = map.layers[transition.tgt];
// First and last settlement connect to 1 wild // Collect edges as pairs of indices
const firstSettlement = map.nodes.get(srcLayer.nodeIds[0]); const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
expect(firstSettlement?.childIds.length).toBe(1); for (let s = 0; s < srcLayer.nodeIds.length; s++) {
const lastSettlement = map.nodes.get(srcLayer.nodeIds[3]); const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
expect(lastSettlement?.childIds.length).toBe(1); for (const tgtId of srcNode!.childIds) {
const t = tgtLayer.nodeIds.indexOf(tgtId);
// Middle two settlements connect to 2 wilds edges.push({ srcIndex: s, tgtIndex: t });
for (let i = 1; i <= 2; i++) {
const midSettlement = map.nodes.get(srcLayer.nodeIds[i]);
expect(midSettlement?.childIds.length).toBe(2);
for (const childId of midSettlement!.childIds) {
expect(tgtLayer.nodeIds).toContain(childId);
}
}
} }
}); }
it('should have all 3 wild nodes connect to End', () => { // Check for crossings
const map = generatePointCrawlMap(createRNG(42), encounters); for (let e1 = 0; e1 < edges.length; e1++) {
const lastWildLayer = map.layers[8]; for (let e2 = e1 + 1; e2 < edges.length; e2++) {
const endNode = map.nodes.get('node-9-0'); const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
for (const wildId of lastWildLayer.nodeIds) { if (s1 === s2) continue;
const wildNode = map.nodes.get(wildId); if (t1 === t2) continue;
expect(wildNode?.childIds).toEqual([endNode!.id]);
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(crosses).toBe(false);
} }
}); }
}
});
it('should have all nodes reachable from Start and can reach End', () => { it("should assign encounters to all non-Start/End nodes", () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(456), encounters);
const startId = 'node-0-0';
const endId = 'node-9-0';
for (const nodeId of map.nodes.keys()) { for (const node of map.nodes.values()) {
if (nodeId === startId || nodeId === endId) continue; if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
expect(hasPath(map, startId, nodeId)).toBe(true); // Start and End nodes should not have encounters
expect(hasPath(map, nodeId, endId)).toBe(true); expect(node.encounter).toBeUndefined();
} else {
// All other nodes (minion/elite/event/camp/shop/curio) must have encounters
expect(
node.encounter,
`Node ${node.id} (${node.type}) should have encounter data`,
).toBeDefined();
expect(node.encounter!.name).toBeTruthy();
expect(node.encounter!.description).toBeTruthy();
}
}
});
it("should assign encounters to all nodes across multiple seeds", () => {
// Test multiple seeds to ensure no random failure
for (let seed = 0; seed < 20; seed++) {
const map = generatePointCrawlMap(createRNG(seed), encounters);
for (const node of map.nodes.values()) {
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
continue;
} }
}); expect(
node.encounter,
`Seed ${seed}: Node ${node.id} (${node.type}) missing encounter`,
).toBeDefined();
expect(node.encounter!.name).toBeTruthy();
expect(node.encounter!.description).toBeTruthy();
}
}
});
it('should not have crossing edges in wild→wild transitions', () => { it("should minimize same-layer repetitions in wild layer pairs", () => {
const map = generatePointCrawlMap(createRNG(12345), encounters); // Test that wild layers in pairs (1-2, 4-5, 7-8) have minimal duplicate types within each layer
const wildToWildTransitions = [ const map = generatePointCrawlMap(createRNG(12345), encounters);
{ src: 1, tgt: 2 }, const wildPairIndices = [
{ src: 4, tgt: 5 }, [1, 2],
{ src: 7, tgt: 8 }, [4, 5],
]; [7, 8],
];
for (const transition of wildToWildTransitions) { for (const [layer1Idx, layer2Idx] of wildPairIndices) {
const srcLayer = map.layers[transition.src]; const layer1 = map.layers[layer1Idx];
const tgtLayer = map.layers[transition.tgt]; const layer2 = map.layers[layer2Idx];
// Collect edges as pairs of indices // Count repetitions in layer 1
const edges: Array<{ srcIndex: number; tgtIndex: number }> = []; const layer1Types = layer1.nodeIds.map((id) => map.nodes.get(id)!.type);
for (let s = 0; s < srcLayer.nodeIds.length; s++) { const layer1Unique = new Set(layer1Types).size;
const srcNode = map.nodes.get(srcLayer.nodeIds[s]); const layer1Repetitions = layer1Types.length - layer1Unique;
for (const tgtId of srcNode!.childIds) {
const t = tgtLayer.nodeIds.indexOf(tgtId);
edges.push({ srcIndex: s, tgtIndex: t });
}
}
// Check for crossings // Count repetitions in layer 2
for (let e1 = 0; e1 < edges.length; e1++) { const layer2Types = layer2.nodeIds.map((id) => map.nodes.get(id)!.type);
for (let e2 = e1 + 1; e2 < edges.length; e2++) { const layer2Unique = new Set(layer2Types).size;
const { srcIndex: s1, tgtIndex: t1 } = edges[e1]; const layer2Repetitions = layer2Types.length - layer2Unique;
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
if (s1 === s2) continue; // With optimal selection, we expect fewer repetitions than pure random
if (t1 === t2) continue; // On average, random would have ~1.5 repetitions per 3-node layer
// With 3 attempts, we should typically get 0-1 repetitions
expect(layer1Repetitions + layer2Repetitions).toBeLessThanOrEqual(2);
}
});
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2); it("should minimize adjacent repetitions in wild→wild connections", () => {
expect(crosses).toBe(false); // Test that wild nodes connected by wild→wild edges have different types
} const map = generatePointCrawlMap(createRNG(12345), encounters);
} const wildToWildPairs = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
{ src: 7, tgt: 8 },
];
let totalAdjacentRepetitions = 0;
for (const pair of wildToWildPairs) {
const srcLayer = map.layers[pair.src];
const tgtLayer = map.layers[pair.tgt];
// Each wild node connects to exactly 1 wild node in next layer (1-to-1)
for (let i = 0; i < srcLayer.nodeIds.length; i++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[i])!;
const tgtId = srcNode.childIds[0];
const tgtNode = map.nodes.get(tgtId)!;
if (srcNode.type === tgtNode.type) {
totalAdjacentRepetitions++;
} }
}); }
}
it('should not have crossing edges in wild→settlement transitions', () => { // With 3 wild pairs and 3 nodes each, that's 9 connections total
const map = generatePointCrawlMap(createRNG(12345), encounters); // Random would have ~3 repetitions (1/3 chance per connection)
const wildToSettlementTransitions = [ // With optimal selection of 3 attempts, should be much lower (0-2)
{ src: 2, tgt: 3 }, expect(totalAdjacentRepetitions).toBeLessThanOrEqual(3);
{ src: 5, tgt: 6 }, });
];
for (const transition of wildToSettlementTransitions) {
const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt];
// Collect edges as pairs of indices
const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
for (let s = 0; s < srcLayer.nodeIds.length; s++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
for (const tgtId of srcNode!.childIds) {
const t = tgtLayer.nodeIds.indexOf(tgtId);
edges.push({ srcIndex: s, tgtIndex: t });
}
}
// Check for crossings
for (let e1 = 0; e1 < edges.length; e1++) {
for (let e2 = e1 + 1; e2 < edges.length; e2++) {
const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
if (s1 === s2) continue;
if (t1 === t2) continue;
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(crosses).toBe(false);
}
}
}
});
it('should not have crossing edges in settlement→wild transitions', () => {
const map = generatePointCrawlMap(createRNG(12345), encounters);
const settlementToWildTransitions = [
{ src: 3, tgt: 4 },
{ src: 6, tgt: 7 },
];
for (const transition of settlementToWildTransitions) {
const srcLayer = map.layers[transition.src];
const tgtLayer = map.layers[transition.tgt];
// Collect edges as pairs of indices
const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
for (let s = 0; s < srcLayer.nodeIds.length; s++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[s]);
for (const tgtId of srcNode!.childIds) {
const t = tgtLayer.nodeIds.indexOf(tgtId);
edges.push({ srcIndex: s, tgtIndex: t });
}
}
// Check for crossings
for (let e1 = 0; e1 < edges.length; e1++) {
for (let e2 = e1 + 1; e2 < edges.length; e2++) {
const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
if (s1 === s2) continue;
if (t1 === t2) continue;
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(crosses).toBe(false);
}
}
}
});
it('should assign encounters to all non-Start/End nodes', () => {
const map = generatePointCrawlMap(createRNG(456), encounters);
for (const node of map.nodes.values()) {
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
// Start and End nodes should not have encounters
expect(node.encounter).toBeUndefined();
} else {
// All other nodes (minion/elite/event/camp/shop/curio) must have encounters
expect(node.encounter, `Node ${node.id} (${node.type}) should have encounter data`).toBeDefined();
expect(node.encounter!.name).toBeTruthy();
expect(node.encounter!.description).toBeTruthy();
}
}
});
it('should assign encounters to all nodes across multiple seeds', () => {
// Test multiple seeds to ensure no random failure
for (let seed = 0; seed < 20; seed++) {
const map = generatePointCrawlMap(createRNG(seed), encounters);
for (const node of map.nodes.values()) {
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) {
continue;
}
expect(node.encounter, `Seed ${seed}: Node ${node.id} (${node.type}) missing encounter`).toBeDefined();
expect(node.encounter!.name).toBeTruthy();
expect(node.encounter!.description).toBeTruthy();
}
}
});
it('should minimize same-layer repetitions in wild layer pairs', () => {
// Test that wild layers in pairs (1-2, 4-5, 7-8) have minimal duplicate types within each layer
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildPairIndices = [
[1, 2],
[4, 5],
[7, 8],
];
for (const [layer1Idx, layer2Idx] of wildPairIndices) {
const layer1 = map.layers[layer1Idx];
const layer2 = map.layers[layer2Idx];
// Count repetitions in layer 1
const layer1Types = layer1.nodeIds.map(id => map.nodes.get(id)!.type);
const layer1Unique = new Set(layer1Types).size;
const layer1Repetitions = layer1Types.length - layer1Unique;
// Count repetitions in layer 2
const layer2Types = layer2.nodeIds.map(id => map.nodes.get(id)!.type);
const layer2Unique = new Set(layer2Types).size;
const layer2Repetitions = layer2Types.length - layer2Unique;
// With optimal selection, we expect fewer repetitions than pure random
// On average, random would have ~1.5 repetitions per 3-node layer
// With 3 attempts, we should typically get 0-1 repetitions
expect(layer1Repetitions + layer2Repetitions).toBeLessThanOrEqual(2);
}
});
it('should minimize adjacent repetitions in wild→wild connections', () => {
// Test that wild nodes connected by wild→wild edges have different types
const map = generatePointCrawlMap(createRNG(12345), encounters);
const wildToWildPairs = [
{ src: 1, tgt: 2 },
{ src: 4, tgt: 5 },
{ src: 7, tgt: 8 },
];
let totalAdjacentRepetitions = 0;
for (const pair of wildToWildPairs) {
const srcLayer = map.layers[pair.src];
const tgtLayer = map.layers[pair.tgt];
// Each wild node connects to exactly 1 wild node in next layer (1-to-1)
for (let i = 0; i < srcLayer.nodeIds.length; i++) {
const srcNode = map.nodes.get(srcLayer.nodeIds[i])!;
const tgtId = srcNode.childIds[0];
const tgtNode = map.nodes.get(tgtId)!;
if (srcNode.type === tgtNode.type) {
totalAdjacentRepetitions++;
}
}
}
// With 3 wild pairs and 3 nodes each, that's 9 connections total
// Random would have ~3 repetitions (1/3 chance per connection)
// With optimal selection of 3 attempts, should be much lower (0-2)
expect(totalAdjacentRepetitions).toBeLessThanOrEqual(3);
});
}); });

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/**/*"], "include": ["src/**/*", "tests/**/*"],
"exclude": ["node_modules", "dist", "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,
};
},
}); });