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.
This commit is contained in:
hypercross 2026-04-19 23:28:56 +08:00
parent 3840c3d739
commit 2f2e4e56b5
9 changed files with 1588 additions and 1274 deletions

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,剑,onPlay,target,[attack;2];[attack;2]
greataxe,长斧,onPlay,all,[attack;5]
spear,长枪,onPlay,target,[attack;2];[attack;2];[attack;2]
dagger,短刀,onPlay,target,[attack;3];[attack;3]
dart,飞镖,onPlay,target,[attack;1]
dart-draw,飞镖,onPlay,self,[draw;1]
crossbow,十字弩,onPlay,target,[attack;6]
crossbow-combo,十字弩,onPlay,self,[crossbow;0]
shield,盾,onPlay,self,[defend;3]
hat,斗笠,onPlay,self,[defend;8]
cape,披风,onPlay,self,[defend;2];[defendNext;2]
bracer,护腕,onPlay,self,[defend;1];[draw;1]
greatshield,大盾,onPlay,self,[defend;5]
chainmail,锁子甲,onPlay,self,[damageReduce;3]
bandage,绷带,onPlay,self,[removeWound;1]
poisonPotion,淬毒药剂,onPlay,self,[attackBuff;2]
fortifyPotion,强固药剂,onPlay,self,[defendBuff;2]
vitalityPotion,活力药剂,onPlay,self,[gainEnergy;1]
focusPotion,集中药剂,onPlay,self,[draw;2]
healingPotion,治疗药剂,onPlay,self,[removeWound;3]
waterBag,水袋,onPlay,self,[energyNext;1];[drawNext;2]
rope,绳索,onPlay,self,[defendBuffUntilPlay;2]
belt,腰带,onPlay,self,[drawChoice;1]
torch,火把,onPlay,self,[burnForEnergy;1]
whetstone,磨刀石,onPlay,self,[attackBuffUntilPlay;3]
blacksmithHammer,铁匠锤,onPlay,self,[transformRandom;1]
venom,蛇毒,onDiscard,self,[attack;3]
curse,诅咒,onDraw,self,[curse;1]
static,静电,onDraw,self,[static;1]
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 onPlay target [attack;2];[attack;2]
4 greataxe 长斧 onPlay all [attack;5]
5 spear 长枪 onPlay target [attack;2];[attack;2];[attack;2]
6 dagger 短刀 onPlay target [attack;3];[attack;3]
7 dart 飞镖 onPlay target [attack;1]
8 dart-draw 飞镖 onPlay self [draw;1]
9 crossbow 十字弩 onPlay target [attack;6]
10 crossbow-combo 十字弩 onPlay self [crossbow;0]
11 shield onPlay self [defend;3]
12 hat 斗笠 onPlay self [defend;8]
13 cape 披风 onPlay self [defend;2];[defendNext;2]
14 bracer 护腕 onPlay self [defend;1];[draw;1]
15 greatshield 大盾 onPlay self [defend;5]
16 chainmail 锁子甲 onPlay self [damageReduce;3]
17 bandage 绷带 onPlay self [removeWound;1]
18 poisonPotion 淬毒药剂 onPlay self [attackBuff;2]
19 fortifyPotion 强固药剂 onPlay self [defendBuff;2]
20 vitalityPotion 活力药剂 onPlay self [gainEnergy;1]
21 focusPotion 集中药剂 onPlay self [draw;2]
22 healingPotion 治疗药剂 onPlay self [removeWound;3]
23 waterBag 水袋 onPlay self [energyNext;1];[drawNext;2]
24 rope 绳索 onPlay self [defendBuffUntilPlay;2]
25 belt 腰带 onPlay self [drawChoice;1]
26 torch 火把 onPlay self [burnForEnergy;1]
27 whetstone 磨刀石 onPlay self [attackBuffUntilPlay;3]
28 blacksmithHammer 铁匠锤 onPlay self [transformRandom;1]
29 venom 蛇毒 onDiscard self [attack;3]
30 curse 诅咒 onDraw self [curse;1]
31 static 静电 onDraw self [static;1]
32 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

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,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";
describe('data import', () => { describe("data import", () => {
it('should import properly', () => { it("should import properly", () => {
expect(data.desert.effects).toBeDefined(); expect(data.desert.getEffects).toBeDefined();
}); });
}); });