Compare commits

..

No commits in common. "08c6a67d1697d0a1f7c54f3ecd927da262efe312" and "a82b6b0685c7c032f6e4bba5d7975ad8d156659c" have entirely different histories.

15 changed files with 1865 additions and 2257 deletions

View File

@ -1,104 +1,80 @@
import { MutableSignal, mutableSignal } from "@/utils/mutable-signal"; import {MutableSignal, mutableSignal} from "@/utils/mutable-signal";
import { import {
Command, Command,
CommandRegistry, CommandRegistry, CommandResult,
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";
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[]>( prompt: <TResult,TArgs extends any[]=any[]>(def: PromptDef<TArgs>, validator: PromptValidator<TResult,TArgs>, currentPlayer?: string | null) => Promise<TResult>;
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 = const stateValue = typeof initialState === 'function' ? initialState() : initialState ?? {} as TState;
typeof initialState === "function" const state = mutableSignal(stateValue);
? initialState() let commands: CommandRunnerContextExport<IGameContext<TState>> = null as any;
: (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( return commands.prompt(def.schema, validator, def.hintText, currentPlayer);
def.schema, },
validator,
def.hintText,
currentPlayer,
);
},
_state: state, _state: state,
_commands: commands, _commands: commands,
_rng: new Mulberry32RNG(), _rng: new Mulberry32RNG(),
}; };
context._commands = commands = createCommandRunnerContext( context._commands = commands = createCommandRunnerContext(commandRegistry, context);
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; hintText?: string,
}; }
export function createPromptDef<TArgs extends any[] = any[]>( export function createPromptDef<TArgs extends any[]=any[]>(schema: CommandSchema | string, hintText?: string): PromptDef<TArgs> {
schema: CommandSchema | string, schema = typeof schema === 'string' ? parseCommandSchema(schema) : schema;
hintText?: string, return { schema, hintText };
): PromptDef<TArgs> {
schema = typeof schema === "string" ? parseCommandSchema(schema) : schema;
return { schema, hintText };
} }
export function createGameCommandRegistry< export function createGameCommandRegistry<TState extends Record<string, unknown> = {} >() {
TState extends Record<string, unknown> = {}, return createCommandRegistry<IGameContext<TState>>();
>() {
return createCommandRegistry<IGameContext<TState>>();
} }

View File

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

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

View File

@ -1,4 +1,4 @@
import type { CardEffect } from './cardEffect.csv'; import type { Effect } from './effect.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: CardEffect[]; readonly effects: ["onPlay" | "onDraw" | "onDiscard", "self" | "target" | "all" | "random", Effect, number][];
}[]; }[];
export type Card = CardTable[number]; export type Card = CardTable[number];

View File

@ -1,32 +0,0 @@
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

@ -1,15 +0,0 @@
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,125 +6,116 @@ 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 { return { id, name: id, description: "", lifecycle: "instant" } as EffectData;
id,
name: id,
description: "",
lifecycle: "instant",
} as EffectData;
}
// storm: give static card to player when storm enemy attacks
triggers.onEnemyIntent.use(async (ctx, next) => {
await next();
const enemy = getCombatEntity(ctx.game.value, ctx.enemyId);
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 // storm: give static card to player when storm enemy attacks
triggers.onEffectApplied.use(async (ctx, next) => { triggers.onEnemyIntent.use(async (ctx, next) => {
await next(); await next();
if (ctx.effect.id !== "crossbow" || !ctx.cardId || !ctx.targetId) return; const enemy = getCombatEntity(ctx.game.value, ctx.enemyId);
if (!enemy || !enemy.isAlive) return;
const { cards, regions } = ctx.game.value.player.deck; const storm = enemy.effects.storm?.stacks ?? 0;
const handIds = [...regions.hand.childIds]; if (storm > 0) {
for (const id of handIds) { for (let i = 0; i < storm; i++) {
const card = cards[id]; await triggers.onEffectApplied.execute(ctx.game, {
if (card && card.itemId === "crossbow" && id !== ctx.cardId) { effect: findEffect("static"),
await triggers.onCardPlayed.execute(ctx.game, { entityKey: "player",
cardId: id, stacks: 1,
targetId: ctx.targetId, sourceEntityKey: ctx.enemyId,
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 // crossbow: replay other crossbows on same target
triggers.onDamage.use(async (ctx, next) => { triggers.onEffectApplied.use(async (ctx, next) => {
await next(); await next();
const dealt = ctx.amount - (ctx.prevented ?? 0); if (ctx.effect.id !== "crossbow" || !ctx.cardId || !ctx.targetId) return;
if (dealt <= 0 || !ctx.sourceEntityKey) return;
const attacker = getCombatEntity(ctx.game.value, ctx.sourceEntityKey); const { cards, regions } = ctx.game.value.player.deck;
if (!attacker || !("enemy" in attacker) || attacker.enemy.id !== "秃鹫") const handIds = [...regions.hand.childIds];
return; for (const id of handIds) {
const card = cards[id];
await triggers.onEffectApplied.execute(ctx.game, { if (card && card.itemId === "crossbow" && id !== ctx.cardId) {
effect: findEffect("vultureEye"), await triggers.onCardPlayed.execute(ctx.game, {
entityKey: "player", cardId: id,
stacks: 1, targetId: ctx.targetId,
sourceEntityKey: ctx.sourceEntityKey, 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,327 +1,240 @@
import { CombatGameContext } from "./types"; import {CombatGameContext} from "./types";
import { import {
addEntityEffect, addEntityEffect,
addItemEffect, addItemEffect,
getAliveEnemies, getAliveEnemies, onEntityPostureDamage,
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";
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: { onCardPlayed: { cardId: string, targetId?: string, sourceEntityKey?: "player" | string },
cardId: string; onCardDiscarded: { cardId: string, sourceEntityKey?: "player" | string },
targetId?: string; onCardDrawn: { cardId: string, sourceEntityKey?: "player" | string },
sourceEntityKey?: "player" | string; onDraw: {count: number},
}; onEffectApplied: { effect: EffectData, entityKey: "player" | string, stacks: number, cardId?: string, sourceEntityKey?: "player" | string, targetId?: string },
onCardDiscarded: { cardId: string; sourceEntityKey?: "player" | string }; onHpChange: { entityKey: "player" | string, amount: number},
onCardDrawn: { cardId: string; sourceEntityKey?: "player" | string }; onDamage: { entityKey: "player" | string, amount: number, prevented?: number, sourceEntityKey?: "player" | string},
onDraw: { count: number }; onEnemyIntent: { enemyId: string, sourceEntityKey?: "player" | string },
onEffectApplied: { onIntentUpdate: { enemyId: string },
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 };
};
export function createTriggers() {
const triggers = {
onCombatStart: createTrigger("onCombatStart"),
onTurnStart: createTrigger("onTurnStart", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const entity = getCombatEntity(draft, ctx.entityKey);
if (entity) onEntityEffectUpkeep(entity);
if (entity === draft.player) onPlayerItemEffectUpkeep(draft.player);
});
}),
onTurnEnd: createTrigger("onTurnEnd", async (ctx) => {
if (ctx.entityKey !== "player") return;
const { regions } = ctx.game.value.player.deck;
for (const cardId of Object.values(regions.hand.childIds)) {
await triggers.onCardDiscarded.execute(ctx.game, { cardId });
}
await ctx.game.produceAsync(
(draft) => (draft.player.energy = draft.player.maxEnergy),
);
await triggers.onDraw.execute(ctx.game, { count: 5 });
}),
onShuffle: createTrigger("onShuffle", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
for (const cardId of Object.values(regions.discardPile.childIds))
moveToRegion(cards[cardId], regions.discardPile, regions.drawPile);
shuffle(regions.drawPile, cards, ctx.game.rng);
});
}),
onCardPlayed: createTrigger("onCardPlayed", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
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,
});
}
}),
onCardDiscarded: createTrigger("onCardDiscarded", async (ctx) => {
await ctx.game.produceAsync((draft) => {
const { cards, regions } = draft.player.deck;
moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile);
onItemDiscard(draft.player, cards[ctx.cardId].itemId);
});
const { cards, regions } = ctx.game.value.player.deck;
const 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,
});
}
}),
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, effects } of card.cardData.effects) {
if (trigger !== "onDraw") continue;
for (const [effect, stacks] of effects)
for (const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game, {
effect,
entityKey: entity.id,
stacks,
cardId: ctx.cardId,
sourceEntityKey: source,
});
}
}),
onDraw: createTrigger("onDraw", async (ctx) => {
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;
if (inDraw <= 0) break;
const children = ctx.game.value.player.deck.regions.drawPile.childIds;
const cardId = children[children.length - 1];
await triggers.onCardDrawn.execute(ctx.game, { cardId });
toDraw--;
}
}),
onEffectApplied: createTrigger("onEffectApplied", async (ctx) => {
if (ctx.effect.lifecycle === "instant") return;
if (ctx.effect.lifecycle.startsWith("item")) {
if (ctx.cardId) {
const card = ctx.game.value.player.deck.cards[ctx.cardId];
const nearby = getAdjacentItems<GameItemMeta>(
ctx.game.value.inventory,
card.itemId,
);
for (const itemId of nearby.keys()) {
await ctx.game.produceAsync((draft) => {
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 { function createTriggers(){
while (true) { const triggers = {
await triggers.onTurnStart.execute(game, { entityKey: "player" }); onCombatStart: createTrigger("onCombatStart"),
while (true) { onTurnStart: createTrigger("onTurnStart", async ctx => {
const action = await promptMainAction(game); await ctx.game.produceAsync(draft => {
if (action.action === "end-turn") break; const entity = getCombatEntity(draft, ctx.entityKey);
if (action.action === "play") { if(entity) onEntityEffectUpkeep(entity);
await triggers.onCardPlayed.execute(game, action); if(entity === draft.player)
} onPlayerItemEffectUpkeep(draft.player);
} })
await triggers.onTurnEnd.execute(game, { entityKey: "player" }); }),
onTurnEnd: createTrigger("onTurnEnd", async ctx => {
if(ctx.entityKey !== "player")return;
const {regions} = ctx.game.value.player.deck;
for(const cardId of Object.values(regions.hand.childIds)){
await triggers.onCardDiscarded.execute(ctx.game,{cardId});
}
await ctx.game.produceAsync(draft => draft.player.energy = draft.player.maxEnergy);
await triggers.onDraw.execute(ctx.game,{count: 5});
}),
onShuffle: createTrigger("onShuffle", async ctx => {
await ctx.game.produceAsync(draft => {
const {cards, regions} = draft.player.deck;
for(const cardId of Object.values(regions.discardPile.childIds))
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, effect, stacks] of card.cardData.effects){
if(trigger !== 'onPlay') continue;
for(const entity of getEffectTargets(target, ctx.game, ctx.targetId))
await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: source, targetId: ctx.targetId});
}
}),
onCardDiscarded: createTrigger("onCardDiscarded", async ctx => {
await ctx.game.produceAsync(draft => {
const {cards, regions} = draft.player.deck;
moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile);
onItemDiscard(draft.player, cards[ctx.cardId].itemId);
});
const {cards, regions} = ctx.game.value.player.deck;
const card = cards[ctx.cardId];
const source = ctx.sourceEntityKey ?? "player";
for(const [trigger, target, effect, stacks] of card.cardData.effects){
if(trigger !== 'onDiscard') continue;
for(const entity of getEffectTargets(target, ctx.game))
await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: source});
}
}),
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,{});
for (const enemy of getAliveEnemies(game.value)) { inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length;
await triggers.onTurnStart.execute(game, { entityKey: enemy.id }); if(inDraw <= 0) break;
}
for (const enemy of getAliveEnemies(game.value)) { const children = ctx.game.value.player.deck.regions.drawPile.childIds;
await triggers.onEnemyIntent.execute(game, { enemyId: enemy.id }); const cardId = children[children.length - 1];
await triggers.onIntentUpdate.execute(game, { enemyId: enemy.id }); await triggers.onCardDrawn.execute(ctx.game,{cardId});
} toDraw--;
for (const enemy of getAliveEnemies(game.value)) { }
await triggers.onTurnEnd.execute(game, { entityKey: enemy.id }); }),
} onEffectApplied: createTrigger("onEffectApplied", async ctx => {
} if(ctx.effect.lifecycle === 'instant') return;
} catch (e) {
if (e === game.value) return game.value.result; if(ctx.effect.lifecycle.startsWith("item")) {
throw e; if(ctx.cardId){
const card = ctx.game.value.player.deck.cards[ctx.cardId];
const nearby = getAdjacentItems<GameItemMeta>(ctx.game.value.inventory, card.itemId);
for(const itemId of nearby.keys()){
await ctx.game.produceAsync(draft => {
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;
}
} }
};
} }
type TriggerContext<TKey extends keyof TriggerTypes> = TriggerTypes[TKey] & { type TriggerContext<TKey extends keyof TriggerTypes> = TriggerTypes[TKey] & { event: TKey, game: CombatGameContext };
event: TKey; function createTrigger<TKey extends keyof TriggerTypes>(event: TKey, fallback?: (ctx: TriggerContext<TKey>) => Promise<void>) {
game: CombatGameContext; const {use, execute} = createMiddlewareChain<TriggerContext<TKey>,void>(fallback);
}; return {
function createTrigger<TKey extends keyof TriggerTypes>( use,
event: TKey, execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => {
fallback?: (ctx: TriggerContext<TKey>) => Promise<void>, const param = {...ctx, game, event};
) { await execute(param);
const { use, execute } = createMiddlewareChain<TriggerContext<TKey>, void>( return param;
fallback, },
); }
return {
use,
execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => {
const param = { ...ctx, game, event };
await execute(param);
return param;
},
};
} }

View File

@ -1,59 +1,53 @@
import type { PlayerDeck } from "../deck/types"; import type { PlayerDeck } from "../deck/types";
import { import {EnemyData, IntentData} from "@/samples/slay-the-spire-like/system/types";
EnemyData, import {EffectData} from "@/samples/slay-the-spire-like/system/types";
IntentData, import {GridInventory} from "@/samples/slay-the-spire-like/system/grid-inventory";
} from "@/samples/slay-the-spire-like/system/types"; import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress";
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";
type: "gold"; amount: number;
amount: number; } | {
} type: "item",
| { itemId: string;
type: "item";
itemId: string;
};
export type CombatState = {
enemies: EnemyEntity[];
player: PlayerEntity;
inventory: GridInventory<GameItemMeta>;
phase: CombatPhase;
turnNumber: number;
result: CombatResult | null;
loot: LootEntry[];
}; };
export type CombatGameContext = export type CombatState = {
import("@/core/game").IGameContextExport<CombatState>; enemies: EnemyEntity[];
player: PlayerEntity;
inventory: GridInventory<GameItemMeta>;
phase: CombatPhase;
turnNumber: number;
result: CombatResult | null;
loot: LootEntry[];
};
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>;

View File

@ -1,26 +1,16 @@
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 = export type EffectLifecycle = "instant" | "temporary" | "lingering" | "permanent" | "posture" | "item" | "itemTemporary" | "itemUntilPlay" | "itemUntilDiscard" | "itemPermanent";
| "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";
@ -28,62 +18,44 @@ 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;
readonly desc: string; readonly desc: string;
readonly type: CardType; readonly type: CardType;
readonly costType: CardCostType; readonly costType: CardCostType;
readonly costCount: number; readonly costCount: number;
readonly targetType: CardTargetType; readonly targetType: CardTargetType;
readonly effects: readonly CardEffect[]; readonly effects: readonly [CardEffectTrigger, CardEffectTarget, EffectData, number][];
}; };
export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard";
export type CardEffectTarget = "self" | "target" | "all" | "random"
export type EncounterType = export type EncounterType = "minion" | "elite" | "event" | "shop" | "camp" | "curio";
| "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 [ readonly enemies: readonly [data: EnemyData, hp: number, effects: [EffectData, stacks: number][]][];
data: EnemyData, readonly dialogue: string;
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,13 +1,8 @@
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.getEffects).toBeDefined(); expect(data.desert.effects).toBeDefined();
expect( });
data.desert.getCards().find((c: CardData) => c.id === "crossbow")?.effects
?.length,
).toBe(2);
});
}); });

View File

@ -1,405 +1,386 @@
import { describe, it, expect } from "vitest"; import { describe, it, expect } from 'vitest';
import { import { generatePointCrawlMap, hasPath } from '@/samples/slay-the-spire-like/system/map/generator';
generatePointCrawlMap, import { MapNodeType, MapLayerType } from '@/samples/slay-the-spire-like/system/map/types';
hasPath, import { createRNG } from '@/utils/rng';
} from "@/samples/slay-the-spire-like/system/map/generator"; import { encounters } from '@/samples/slay-the-spire-like/data/desert';
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 settlement nodes connect correctly (1-2-2-1 pattern)", () => { it('should have correct node counts per layer', () => {
const map = generatePointCrawlMap(createRNG(42), encounters); const map = generatePointCrawlMap(createRNG(123), encounters);
const settlementToWildTransitions = [ const expectedCounts = [1, 3, 3, 4, 3, 3, 4, 3, 3, 1];
{ src: 3, tgt: 4 },
{ src: 6, tgt: 7 },
];
for (const transition of settlementToWildTransitions) { for (let i = 0; i < expectedCounts.length; i++) {
const srcLayer = map.layers[transition.src]; expect(map.layers[i].nodeIds.length).toBe(expectedCounts[i]);
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 all 3 wild nodes connect to End", () => { it('should have Start and End nodes with correct types', () => {
const map = generatePointCrawlMap(createRNG(42), encounters); const map = generatePointCrawlMap(createRNG(123), encounters);
const lastWildLayer = map.layers[8]; const startNode = map.nodes.get('node-0-0');
const endNode = map.nodes.get("node-9-0"); const endNode = map.nodes.get('node-9-0');
for (const wildId of lastWildLayer.nodeIds) { expect(startNode?.type).toBe(MapNodeType.Start);
const wildNode = map.nodes.get(wildId); expect(endNode?.type).toBe(MapNodeType.End);
expect(wildNode?.childIds).toEqual([endNode!.id]); });
}
});
it("should have all nodes reachable from Start and can reach End", () => { it('should have wild layers with minion/elite/event types', () => {
const map = generatePointCrawlMap(createRNG(123), encounters); const map = generatePointCrawlMap(createRNG(123), encounters);
const startId = "node-0-0"; const wildLayerIndices = [1, 2, 4, 5, 7, 8];
const endId = "node-9-0"; const validWildTypes = new Set([MapNodeType.Minion, MapNodeType.Elite, MapNodeType.Event]);
for (const nodeId of map.nodes.keys()) { for (const layerIdx of wildLayerIndices) {
if (nodeId === startId || nodeId === endId) continue; const layer = map.layers[layerIdx];
expect(hasPath(map, startId, nodeId)).toBe(true); for (const nodeId of layer.nodeIds) {
expect(hasPath(map, nodeId, endId)).toBe(true); const node = map.nodes.get(nodeId);
} 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 });
} }
} });
// Check for crossings it('should have settlement layers with at least 1 camp, 1 shop, 1 curio', () => {
for (let e1 = 0; e1 < edges.length; e1++) { const map = generatePointCrawlMap(createRNG(123), encounters);
for (let e2 = e1 + 1; e2 < edges.length; e2++) { const settlementLayerIndices = [3, 6];
const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
if (s1 === s2) continue; for (const layerIdx of settlementLayerIndices) {
if (t1 === t2) continue; const layer = map.layers[layerIdx];
const nodeTypes = layer.nodeIds.map(id => map.nodes.get(id)!.type);
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2); expect(nodeTypes).toContain(MapNodeType.Camp);
expect(crosses).toBe(false); expect(nodeTypes).toContain(MapNodeType.Shop);
expect(nodeTypes).toContain(MapNodeType.Curio);
expect(nodeTypes.length).toBe(4);
} }
} });
}
});
it("should not have crossing edges in wild→settlement transitions", () => { it('should have Start connected to all 3 wild nodes', () => {
const map = generatePointCrawlMap(createRNG(12345), encounters); const map = generatePointCrawlMap(createRNG(42), encounters);
const wildToSettlementTransitions = [ const startNode = map.nodes.get('node-0-0');
{ src: 2, tgt: 3 }, const wildLayer = map.layers[1];
{ src: 5, tgt: 6 },
];
for (const transition of wildToSettlementTransitions) { expect(startNode?.childIds.length).toBe(3);
const srcLayer = map.layers[transition.src]; expect(startNode?.childIds).toEqual(expect.arrayContaining(wildLayer.nodeIds));
const tgtLayer = map.layers[transition.tgt]; });
// Collect edges as pairs of indices it('should have each wild node connect to 1 wild node in wild→wild layers', () => {
const edges: Array<{ srcIndex: number; tgtIndex: number }> = []; const map = generatePointCrawlMap(createRNG(42), encounters);
for (let s = 0; s < srcLayer.nodeIds.length; s++) { const wildToWildTransitions = [
const srcNode = map.nodes.get(srcLayer.nodeIds[s]); { src: 1, tgt: 2 },
for (const tgtId of srcNode!.childIds) { { src: 4, tgt: 5 },
const t = tgtLayer.nodeIds.indexOf(tgtId); { src: 7, tgt: 8 },
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]);
}
} }
} });
// Check for crossings it('should have each wild node connect to 2 settlement nodes in wild→settlement layers', () => {
for (let e1 = 0; e1 < edges.length; e1++) { const map = generatePointCrawlMap(createRNG(42), encounters);
for (let e2 = e1 + 1; e2 < edges.length; e2++) { const wildToSettlementTransitions = [
const { srcIndex: s1, tgtIndex: t1 } = edges[e1]; { src: 2, tgt: 3 },
const { srcIndex: s2, tgtIndex: t2 } = edges[e2]; { src: 5, tgt: 6 },
];
if (s1 === s2) continue; for (const transition of wildToSettlementTransitions) {
if (t1 === t2) continue; const srcLayer = map.layers[transition.src];
for (const srcId of srcLayer.nodeIds) {
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2); const srcNode = map.nodes.get(srcId);
expect(crosses).toBe(false); 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 not have crossing edges in settlement→wild transitions", () => { it('should have settlement nodes connect correctly (1-2-2-1 pattern)', () => {
const map = generatePointCrawlMap(createRNG(12345), encounters); const map = generatePointCrawlMap(createRNG(42), 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];
// Collect edges as pairs of indices // First and last settlement connect to 1 wild
const edges: Array<{ srcIndex: number; tgtIndex: number }> = []; const firstSettlement = map.nodes.get(srcLayer.nodeIds[0]);
for (let s = 0; s < srcLayer.nodeIds.length; s++) { expect(firstSettlement?.childIds.length).toBe(1);
const srcNode = map.nodes.get(srcLayer.nodeIds[s]); const lastSettlement = map.nodes.get(srcLayer.nodeIds[3]);
for (const tgtId of srcNode!.childIds) { expect(lastSettlement?.childIds.length).toBe(1);
const t = tgtLayer.nodeIds.indexOf(tgtId);
edges.push({ srcIndex: s, tgtIndex: t }); // 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);
}
}
} }
} });
// Check for crossings it('should have all 3 wild nodes connect to End', () => {
for (let e1 = 0; e1 < edges.length; e1++) { const map = generatePointCrawlMap(createRNG(42), encounters);
for (let e2 = e1 + 1; e2 < edges.length; e2++) { const lastWildLayer = map.layers[8];
const { srcIndex: s1, tgtIndex: t1 } = edges[e1]; const endNode = map.nodes.get('node-9-0');
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
if (s1 === s2) continue; for (const wildId of lastWildLayer.nodeIds) {
if (t1 === t2) continue; const wildNode = map.nodes.get(wildId);
expect(wildNode?.childIds).toEqual([endNode!.id]);
const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
expect(crosses).toBe(false);
} }
} });
}
});
it("should assign encounters to all non-Start/End nodes", () => { it('should have all nodes reachable from Start and can reach End', () => {
const map = generatePointCrawlMap(createRNG(456), encounters); const map = generatePointCrawlMap(createRNG(123), encounters);
const startId = 'node-0-0';
const endId = 'node-9-0';
for (const node of map.nodes.values()) { for (const nodeId of map.nodes.keys()) {
if (node.type === MapNodeType.Start || node.type === MapNodeType.End) { if (nodeId === startId || nodeId === endId) continue;
// Start and End nodes should not have encounters expect(hasPath(map, startId, nodeId)).toBe(true);
expect(node.encounter).toBeUndefined(); expect(hasPath(map, nodeId, endId)).toBe(true);
} 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", () => { it('should not have crossing edges in wild→wild transitions', () => {
// 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 wildToWildTransitions = [
const wildPairIndices = [ { src: 1, tgt: 2 },
[1, 2], { src: 4, tgt: 5 },
[4, 5], { src: 7, tgt: 8 },
[7, 8], ];
];
for (const [layer1Idx, layer2Idx] of wildPairIndices) { for (const transition of wildToWildTransitions) {
const layer1 = map.layers[layer1Idx]; const srcLayer = map.layers[transition.src];
const layer2 = map.layers[layer2Idx]; const tgtLayer = map.layers[transition.tgt];
// Count repetitions in layer 1 // Collect edges as pairs of indices
const layer1Types = layer1.nodeIds.map((id) => map.nodes.get(id)!.type); const edges: Array<{ srcIndex: number; tgtIndex: number }> = [];
const layer1Unique = new Set(layer1Types).size; for (let s = 0; s < srcLayer.nodeIds.length; s++) {
const layer1Repetitions = layer1Types.length - layer1Unique; 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 });
}
}
// Count repetitions in layer 2 // Check for crossings
const layer2Types = layer2.nodeIds.map((id) => map.nodes.get(id)!.type); for (let e1 = 0; e1 < edges.length; e1++) {
const layer2Unique = new Set(layer2Types).size; for (let e2 = e1 + 1; e2 < edges.length; e2++) {
const layer2Repetitions = layer2Types.length - layer2Unique; const { srcIndex: s1, tgtIndex: t1 } = edges[e1];
const { srcIndex: s2, tgtIndex: t2 } = edges[e2];
// With optimal selection, we expect fewer repetitions than pure random if (s1 === s2) continue;
// On average, random would have ~1.5 repetitions per 3-node layer if (t1 === t2) continue;
// With 3 attempts, we should typically get 0-1 repetitions
expect(layer1Repetitions + layer2Repetitions).toBeLessThanOrEqual(2);
}
});
it("should minimize adjacent repetitions in wild→wild connections", () => { const crosses = (s1 < s2 && t1 > t2) || (s1 > s2 && t1 < t2);
// Test that wild nodes connected by wild→wild edges have different types expect(crosses).toBe(false);
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 it('should not have crossing edges in wild→settlement transitions', () => {
// Random would have ~3 repetitions (1/3 chance per connection) const map = generatePointCrawlMap(createRNG(12345), encounters);
// With optimal selection of 3 attempts, should be much lower (0-2) const wildToSettlementTransitions = [
expect(totalAdjacentRepetitions).toBeLessThanOrEqual(3); { src: 2, tgt: 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,13 +11,12 @@
"declaration": true, "declaration": true,
"declarationMap": true, "declarationMap": true,
"outDir": "./dist", "outDir": "./dist",
"rootDir": "./src",
"rootDir": ".",
"baseUrl": ".", "baseUrl": ".",
"paths": { "paths": {
"@/*": ["src/*"], "@/*": ["src/*"]
}, }
}, },
"include": ["src/**/*", "tests/**/*"], "include": ["src/**/*"],
"exclude": ["node_modules", "dist"], "exclude": ["node_modules", "dist", "tests"]
} }

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,25 +60,11 @@ 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: [ external: ['@preact/signals-core', 'mutative', 'inline-schema', 'boardgame-core'],
"@preact/signals-core", esbuildPlugins: [csvLoader({ writeToDisk: true }), rewriteBoardgameImports(), yarnSpinnerPlugin()],
"mutative",
"inline-schema",
"boardgame-core",
],
esbuildPlugins: [
csvLoader({ writeToDisk: true }),
rewriteBoardgameImports(),
yarnSpinnerPlugin(),
],
esbuildOptions(options) {
options.alias = {
"@": srcDir,
};
},
}); });