From 2f2e4e56b537033a0097cd62bb5a308d6d50cb18 Mon Sep 17 00:00:00 2001 From: hypercross Date: Sun, 19 Apr 2026 23:28:56 +0800 Subject: [PATCH] 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. --- .../slay-the-spire-like/data/desert/card.csv | 68 +- .../data/desert/card.csv.d.ts | 4 +- .../data/desert/cardEffect.csv | 32 + .../data/desert/cardEffect.csv.d.ts | 15 + .../system/combat/triggers.ts | 535 ++++---- .../slay-the-spire-like/system/types.ts | 108 +- .../combat/effects.test.ts | 1008 ++++++++------- .../combat/triggers.test.ts | 1080 +++++++++-------- .../slay-the-spire-like/data/index.test.ts | 12 +- 9 files changed, 1588 insertions(+), 1274 deletions(-) create mode 100644 src/samples/slay-the-spire-like/data/desert/cardEffect.csv create mode 100644 src/samples/slay-the-spire-like/data/desert/cardEffect.csv.d.ts diff --git a/src/samples/slay-the-spire-like/data/desert/card.csv b/src/samples/slay-the-spire-like/data/desert/card.csv index 8923f31..3920a9c 100644 --- a/src/samples/slay-the-spire-like/data/desert/card.csv +++ b/src/samples/slay-the-spire-like/data/desert/card.csv @@ -2,39 +2,37 @@ # type: 'item' = inventory item card, 'status' = status effect card # costType: 'energy' = costs energy per turn, 'uses' = limited uses, 'none' = free # targetType: 'single' = target one enemy, 'none' = no target -# onPlay: effects triggered when card is played -# onDraw: effects triggered when card enters hand -# onDiscard: effects triggered when card is discarded +# effects := ~cardEffect(card) -id,name,desc,type,costType,costCount,targetType,effects -string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none',['onPlay'|'onDraw'|'onDiscard';'self'|'target'|'all'|'random';@effect;number][] -sword,剑,【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2] -greataxe,长斧,对全体【攻击5】,item,energy,2,none,[onPlay;all;attack;5] -spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single,[onPlay;target;attack;2];[onPlay;target;attack;2];[onPlay;target;attack;2] -dagger,短刀,【攻击3】【攻击3】,item,energy,1,single,[onPlay;target;attack;3];[onPlay;target;attack;3] -dart,飞镖,【攻击1】抓一张牌,item,energy,0,single,[onPlay;target;attack;1];[onPlay;self;draw;1] -crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single,[onPlay;target;attack;6];[onPlay;self;crossbow;0] -shield,盾,【防御3】,item,energy,1,none,[onPlay;self;defend;3] -hat,斗笠,【防御8】,item,energy,2,none,[onPlay;self;defend;8] -cape,披风,【防御2】下回合【防御2】,item,energy,1,none,[onPlay;self;defend;2];[onPlay;self;defendNext;2] -bracer,护腕,【防御1】抓1张牌,item,energy,0,none,[onPlay;self;defend;1];[onPlay;self;draw;1] -greatshield,大盾,【防御5】,item,energy,1,none,[onPlay;self;defend;5] -chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none,[onPlay;self;damageReduce;3] -bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none,[onPlay;self;removeWound;1] -poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none,[onPlay;self;attackBuff;2] -fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none,[onPlay;self;defendBuff;2] -vitalityPotion,活力药剂,获得1点能量,item,uses,3,none,[onPlay;self;gainEnergy;1] -focusPotion,集中药剂,抓2张牌,item,uses,3,none,[onPlay;self;draw;2] -healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none,[onPlay;self;removeWound;3] -waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none,[onPlay;self;energyNext;1];[onPlay;self;drawNext;2] -rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none,[onPlay;self;defendBuffUntilPlay;2] -belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none,[onPlay;self;drawChoice;1] -torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none,[onPlay;self;burnForEnergy;1] -whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none,[onPlay;self;attackBuffUntilPlay;3] -blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none,[onPlay;self;transformRandom;1] -wound,伤口,无效果占用手牌和牌堆,status,none,0,none, -venom,蛇毒,弃掉时受到3点伤害,status,none,0,none,[onDiscard;self;attack;3] -curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none,[onDraw;self;curse;1] -static,静电,在手里时受电击伤害+1,status,none,0,none,[onDraw;self;static;1] -fatigue,疲劳,占用手牌,status,none,0,none, -vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none,[onDraw;self;expose;3] +id,name,desc,type,costType,costCount,targetType +string,string,string,'item'|'status','energy'|'uses'|'none',int,'single'|'none' +sword,剑,【攻击2】【攻击2】,item,energy,1,single +greataxe,长斧,对全体【攻击5】,item,energy,2,none +spear,长枪,【攻击2】【攻击2】【攻击2】,item,energy,1,single +dagger,短刀,【攻击3】【攻击3】,item,energy,1,single +dart,飞镖,【攻击1】抓一张牌,item,energy,0,single +crossbow,十字弩,【攻击6】对同一目标打出其他十字弩,item,energy,2,single +shield,盾,【防御3】,item,energy,1,none +hat,斗笠,【防御8】,item,energy,2,none +cape,披风,【防御2】下回合【防御2】,item,energy,1,none +bracer,护腕,【防御1】抓1张牌,item,energy,0,none +greatshield,大盾,【防御5】,item,energy,1,none +chainmail,锁子甲,本回合受到伤害-3,item,energy,1,none +bandage,绷带,从牌堆或弃牌堆随机移除1张伤口,item,uses,3,none +poisonPotion,淬毒药剂,周围物品的【攻击】+2,item,uses,3,none +fortifyPotion,强固药剂,周围物品的【防御】+2,item,uses,3,none +vitalityPotion,活力药剂,获得1点能量,item,uses,3,none +focusPotion,集中药剂,抓2张牌,item,uses,3,none +healingPotion,治疗药剂,从牌堆或弃牌堆移除3张伤口,item,uses,3,none +waterBag,水袋,下回合开始时获得1能量抓2张牌,item,energy,1,none +rope,绳索,周围物品的牌【防御】+2直到打出,item,energy,1,none +belt,腰带,从牌堆周围物品的牌当中选择一张加入手牌,item,energy,0,none +torch,火把,下次打出周围物品的牌时将其消耗并获得1能量,item,energy,1,none +whetstone,磨刀石,周围物品的牌【攻击】+3直到打出,item,energy,1,none +blacksmithHammer,铁匠锤,从牌堆/弃牌堆选择一张牌随机变为一张周围物品的牌,item,energy,1,none +wound,伤口,无效果占用手牌和牌堆,status,none,0,none +venom,蛇毒,弃掉时受到3点伤害,status,none,0,none +curse,诅咒,受攻击时物品攻击-1直到弃掉一张该物品的牌,status,none,0,none +static,静电,在手里时受电击伤害+1,status,none,0,none +fatigue,疲劳,占用手牌,status,none,0,none +vultureEye,秃鹫之眼,抓到时获得3层暴露,status,none,0,none diff --git a/src/samples/slay-the-spire-like/data/desert/card.csv.d.ts b/src/samples/slay-the-spire-like/data/desert/card.csv.d.ts index 2d29738..bbcc7a5 100644 --- a/src/samples/slay-the-spire-like/data/desert/card.csv.d.ts +++ b/src/samples/slay-the-spire-like/data/desert/card.csv.d.ts @@ -1,4 +1,4 @@ -import type { Effect } from './effect.csv'; +import type { CardEffect } from './cardEffect.csv'; type CardTable = readonly { readonly id: string; @@ -8,7 +8,7 @@ type CardTable = readonly { readonly costType: "energy" | "uses" | "none"; readonly costCount: number; readonly targetType: "single" | "none"; - readonly effects: ["onPlay" | "onDraw" | "onDiscard", "self" | "target" | "all" | "random", Effect, number][]; + readonly effects: CardEffect[]; }[]; export type Card = CardTable[number]; diff --git a/src/samples/slay-the-spire-like/data/desert/cardEffect.csv b/src/samples/slay-the-spire-like/data/desert/cardEffect.csv new file mode 100644 index 0000000..60548d8 --- /dev/null +++ b/src/samples/slay-the-spire-like/data/desert/cardEffect.csv @@ -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] diff --git a/src/samples/slay-the-spire-like/data/desert/cardEffect.csv.d.ts b/src/samples/slay-the-spire-like/data/desert/cardEffect.csv.d.ts new file mode 100644 index 0000000..78c6614 --- /dev/null +++ b/src/samples/slay-the-spire-like/data/desert/cardEffect.csv.d.ts @@ -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; diff --git a/src/samples/slay-the-spire-like/system/combat/triggers.ts b/src/samples/slay-the-spire-like/system/combat/triggers.ts index dac0304..458ade0 100644 --- a/src/samples/slay-the-spire-like/system/combat/triggers.ts +++ b/src/samples/slay-the-spire-like/system/combat/triggers.ts @@ -1,240 +1,327 @@ -import {CombatGameContext} from "./types"; +import { CombatGameContext } from "./types"; import { - addEntityEffect, - addItemEffect, - getAliveEnemies, onEntityPostureDamage, - onEntityEffectUpkeep, - onPlayerItemEffectUpkeep, onItemDiscard, onItemPlay, payCardCost, getCombatEntity, getEffectTargets + addEntityEffect, + addItemEffect, + getAliveEnemies, + onEntityPostureDamage, + onEntityEffectUpkeep, + onPlayerItemEffectUpkeep, + onItemDiscard, + onItemPlay, + payCardCost, + getCombatEntity, + getEffectTargets, } from "@/samples/slay-the-spire-like/system/combat/effects"; -import {promptMainAction} from "@/samples/slay-the-spire-like/system/combat/prompts"; -import {moveToRegion, shuffle} from "@/core/region"; -import {createMiddlewareChain} from "@/utils/middleware"; -import {EffectData} from "@/samples/slay-the-spire-like/system/types"; -import {getAdjacentItems} from "@/samples/slay-the-spire-like/system/grid-inventory"; -import {GameItemMeta} from "@/samples/slay-the-spire-like/system/progress"; +import { promptMainAction } from "@/samples/slay-the-spire-like/system/combat/prompts"; +import { moveToRegion, shuffle } from "@/core/region"; +import { createMiddlewareChain } from "@/utils/middleware"; +import { EffectData } from "@/samples/slay-the-spire-like/system/types"; +import { getAdjacentItems } from "@/samples/slay-the-spire-like/system/grid-inventory"; +import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress"; type TriggerTypes = { - onCombatStart: {}, - onTurnStart: { entityKey: "player" | string, }, - onTurnEnd: { entityKey: "player" | string, }, - onShuffle: {}, - onCardPlayed: { cardId: string, targetId?: string, sourceEntityKey?: "player" | string }, - onCardDiscarded: { cardId: string, sourceEntityKey?: "player" | string }, - onCardDrawn: { cardId: string, sourceEntityKey?: "player" | string }, - onDraw: {count: number}, - onEffectApplied: { effect: EffectData, entityKey: "player" | string, stacks: number, cardId?: string, sourceEntityKey?: "player" | string, targetId?: string }, - onHpChange: { entityKey: "player" | string, amount: number}, - onDamage: { entityKey: "player" | string, amount: number, prevented?: number, sourceEntityKey?: "player" | string}, - onEnemyIntent: { enemyId: string, sourceEntityKey?: "player" | string }, - onIntentUpdate: { enemyId: string }, -} + onCombatStart: {}; + onTurnStart: { entityKey: "player" | string }; + onTurnEnd: { entityKey: "player" | string }; + onShuffle: {}; + onCardPlayed: { + cardId: string; + targetId?: string; + sourceEntityKey?: "player" | string; + }; + onCardDiscarded: { cardId: string; sourceEntityKey?: "player" | string }; + onCardDrawn: { cardId: string; sourceEntityKey?: "player" | string }; + onDraw: { count: number }; + onEffectApplied: { + effect: EffectData; + entityKey: "player" | string; + stacks: number; + cardId?: string; + sourceEntityKey?: "player" | string; + targetId?: string; + }; + onHpChange: { entityKey: "player" | string; amount: number }; + onDamage: { + entityKey: "player" | string; + amount: number; + prevented?: number; + sourceEntityKey?: "player" | string; + }; + onEnemyIntent: { enemyId: string; sourceEntityKey?: "player" | string }; + onIntentUpdate: { enemyId: string }; +}; -function createTriggers(){ - const triggers = { - onCombatStart: createTrigger("onCombatStart"), - onTurnStart: createTrigger("onTurnStart", async ctx => { - await ctx.game.produceAsync(draft => { - const entity = getCombatEntity(draft, ctx.entityKey); - if(entity) onEntityEffectUpkeep(entity); - if(entity === draft.player) - onPlayerItemEffectUpkeep(draft.player); - }) - }), - onTurnEnd: createTrigger("onTurnEnd", async ctx => { - if(ctx.entityKey !== "player")return; - const {regions} = ctx.game.value.player.deck; - for(const cardId of Object.values(regions.hand.childIds)){ - await triggers.onCardDiscarded.execute(ctx.game,{cardId}); - } - await ctx.game.produceAsync(draft => draft.player.energy = draft.player.maxEnergy); - await triggers.onDraw.execute(ctx.game,{count: 5}); - }), - onShuffle: createTrigger("onShuffle", async ctx => { - await ctx.game.produceAsync(draft => { - const {cards, regions} = draft.player.deck; - for(const cardId of Object.values(regions.discardPile.childIds)) - moveToRegion(cards[cardId], regions.discardPile, regions.drawPile); - shuffle(regions.drawPile, cards, ctx.game.rng); +export function createTriggers() { + const triggers = { + onCombatStart: createTrigger("onCombatStart"), + onTurnStart: createTrigger("onTurnStart", async (ctx) => { + await ctx.game.produceAsync((draft) => { + const entity = getCombatEntity(draft, ctx.entityKey); + if (entity) onEntityEffectUpkeep(entity); + if (entity === draft.player) onPlayerItemEffectUpkeep(draft.player); + }); + }), + onTurnEnd: createTrigger("onTurnEnd", async (ctx) => { + if (ctx.entityKey !== "player") return; + const { regions } = ctx.game.value.player.deck; + for (const cardId of Object.values(regions.hand.childIds)) { + await triggers.onCardDiscarded.execute(ctx.game, { cardId }); + } + await ctx.game.produceAsync( + (draft) => (draft.player.energy = draft.player.maxEnergy), + ); + await triggers.onDraw.execute(ctx.game, { count: 5 }); + }), + onShuffle: createTrigger("onShuffle", async (ctx) => { + await ctx.game.produceAsync((draft) => { + const { cards, regions } = draft.player.deck; + for (const cardId of Object.values(regions.discardPile.childIds)) + moveToRegion(cards[cardId], regions.discardPile, regions.drawPile); + shuffle(regions.drawPile, cards, ctx.game.rng); + }); + }), + onCardPlayed: createTrigger("onCardPlayed", async (ctx) => { + await ctx.game.produceAsync((draft) => { + const { cards, regions } = draft.player.deck; + const card = cards[ctx.cardId]; + payCardCost( + draft.player, + card.cardData.costType, + card.cardData.costCount, + card.itemId, + draft.inventory, + ); + moveToRegion(card, regions.hand, regions.discardPile); + onItemPlay(draft.player, card.itemId); + }); + const { cards, regions } = ctx.game.value.player.deck; + const card = cards[ctx.cardId]; + const source = ctx.sourceEntityKey ?? "player"; + for (const { trigger, target, effects } of card.cardData.effects) { + if (trigger !== "onPlay") continue; + for (const [effect, stacks] of effects) + for (const entity of getEffectTargets(target, ctx.game, ctx.targetId)) + await triggers.onEffectApplied.execute(ctx.game, { + effect, + entityKey: entity.id, + stacks, + cardId: ctx.cardId, + sourceEntityKey: source, + targetId: ctx.targetId, }); - }), - onCardPlayed: createTrigger("onCardPlayed", async ctx => { - await ctx.game.produceAsync(draft => { - const {cards, regions} = draft.player.deck; - const card = cards[ctx.cardId]; - payCardCost(draft.player, card.cardData.costType, card.cardData.costCount, card.itemId, draft.inventory); - moveToRegion(card, regions.hand, regions.discardPile); - onItemPlay(draft.player, card.itemId); + } + }), + onCardDiscarded: createTrigger("onCardDiscarded", async (ctx) => { + await ctx.game.produceAsync((draft) => { + const { cards, regions } = draft.player.deck; + moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile); + onItemDiscard(draft.player, cards[ctx.cardId].itemId); + }); + const { cards, regions } = ctx.game.value.player.deck; + const card = cards[ctx.cardId]; + const source = ctx.sourceEntityKey ?? "player"; + for (const { trigger, target, effects } of card.cardData.effects) { + if (trigger !== "onDiscard") continue; + for (const [effect, stacks] of effects) + for (const entity of getEffectTargets(target, ctx.game)) + await triggers.onEffectApplied.execute(ctx.game, { + effect, + entityKey: entity.id, + stacks, + cardId: ctx.cardId, + sourceEntityKey: source, }); - const {cards, regions} = ctx.game.value.player.deck; - const card = cards[ctx.cardId]; - const source = ctx.sourceEntityKey ?? "player"; - for(const [trigger, target, effect, stacks] of card.cardData.effects){ - if(trigger !== 'onPlay') continue; - for(const entity of getEffectTargets(target, ctx.game, ctx.targetId)) - await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: source, targetId: ctx.targetId}); - } - }), - onCardDiscarded: createTrigger("onCardDiscarded", async ctx => { - await ctx.game.produceAsync(draft => { - const {cards, regions} = draft.player.deck; - moveToRegion(cards[ctx.cardId], regions.hand, regions.discardPile); - onItemDiscard(draft.player, cards[ctx.cardId].itemId); + } + }), + onCardDrawn: createTrigger("onCardDrawn", async (ctx) => { + await ctx.game.produceAsync((draft) => { + const { cards, regions } = draft.player.deck; + moveToRegion(cards[ctx.cardId], regions.drawPile, regions.hand); + }); + const { cards, regions } = ctx.game.value.player.deck; + const card = cards[ctx.cardId]; + const source = ctx.sourceEntityKey ?? "player"; + for (const { trigger, target, effects } of card.cardData.effects) { + if (trigger !== "onDraw") continue; + for (const [effect, stacks] of effects) + for (const entity of getEffectTargets(target, ctx.game)) + await triggers.onEffectApplied.execute(ctx.game, { + effect, + entityKey: entity.id, + stacks, + cardId: ctx.cardId, + sourceEntityKey: source, }); - const {cards, regions} = ctx.game.value.player.deck; - const card = cards[ctx.cardId]; - const source = ctx.sourceEntityKey ?? "player"; - for(const [trigger, target, effect, stacks] of card.cardData.effects){ - if(trigger !== 'onDiscard') continue; - for(const entity of getEffectTargets(target, ctx.game)) - await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: source}); - } - }), - onCardDrawn: createTrigger("onCardDrawn", async ctx => { - await ctx.game.produceAsync(draft => { - const {cards, regions} = draft.player.deck; - moveToRegion(cards[ctx.cardId], regions.drawPile, regions.hand); - }); - const {cards, regions} = ctx.game.value.player.deck; - const card = cards[ctx.cardId]; - const source = ctx.sourceEntityKey ?? "player"; - for(const [trigger, target, effect, stacks] of card.cardData.effects){ - if(trigger !== 'onDraw') continue; - for(const entity of getEffectTargets(target, ctx.game)) - await triggers.onEffectApplied.execute(ctx.game,{effect, entityKey: entity.id, stacks, cardId: ctx.cardId, sourceEntityKey: source}); - } - }), - onDraw: createTrigger("onDraw", async ctx => { - let toDraw = ctx.count; - while(toDraw > 0){ - let inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length; - if(inDraw <= 0) await triggers.onShuffle.execute(ctx.game,{}); + } + }), + onDraw: createTrigger("onDraw", async (ctx) => { + let toDraw = ctx.count; + while (toDraw > 0) { + let inDraw = + ctx.game.value.player.deck.regions.drawPile.childIds.length; + if (inDraw <= 0) await triggers.onShuffle.execute(ctx.game, {}); - inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length; - if(inDraw <= 0) break; + inDraw = ctx.game.value.player.deck.regions.drawPile.childIds.length; + if (inDraw <= 0) break; - const children = ctx.game.value.player.deck.regions.drawPile.childIds; - const cardId = children[children.length - 1]; - await triggers.onCardDrawn.execute(ctx.game,{cardId}); - toDraw--; - } - }), - onEffectApplied: createTrigger("onEffectApplied", async ctx => { - if(ctx.effect.lifecycle === 'instant') return; + const children = ctx.game.value.player.deck.regions.drawPile.childIds; + const cardId = children[children.length - 1]; + await triggers.onCardDrawn.execute(ctx.game, { cardId }); + toDraw--; + } + }), + onEffectApplied: createTrigger("onEffectApplied", async (ctx) => { + if (ctx.effect.lifecycle === "instant") return; - if(ctx.effect.lifecycle.startsWith("item")) { - if(ctx.cardId){ - const card = ctx.game.value.player.deck.cards[ctx.cardId]; - const nearby = getAdjacentItems(ctx.game.value.inventory, card.itemId); - for(const itemId of nearby.keys()){ - await ctx.game.produceAsync(draft => { - addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks); - }); - } - } - return; - } - - await ctx.game.produceAsync(draft => { - const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey); - if(entity) addEntityEffect(entity, ctx.effect, ctx.stacks); - }) - }), - onHpChange: createTrigger("onHpChange", async ctx => { - await ctx.game.produceAsync(draft => { - const entity = ctx.entityKey === "player" ? draft.player : draft.enemies.find(e => e.id === ctx.entityKey); - if(!entity) return; - entity.hp += ctx.amount; - entity.isAlive = entity.hp > 0; - draft.result = !draft.player.isAlive ? "defeat" : draft.enemies.every(e => !e.isAlive) ? "victory" : null; + if (ctx.effect.lifecycle.startsWith("item")) { + if (ctx.cardId) { + const card = ctx.game.value.player.deck.cards[ctx.cardId]; + const nearby = getAdjacentItems( + ctx.game.value.inventory, + card.itemId, + ); + for (const itemId of nearby.keys()) { + await ctx.game.produceAsync((draft) => { + addItemEffect(draft.player, itemId, ctx.effect, ctx.stacks); }); - if(ctx.game.value.result) throw ctx.game.value; - }), - onDamage: createTrigger("onDamage", async ctx => { - const entity = ctx.entityKey === "player" ? ctx.game.value.player : ctx.game.value.enemies.find(e => e.id === ctx.entityKey); - if(!entity || !entity.isAlive) return; - const dealt = Math.min(Math.max(0,entity.hp), ctx.amount - (ctx.prevented || 0)); - await ctx.game.produceAsync(draft => { - onEntityPostureDamage(entity, dealt); - }); - await triggers.onHpChange.execute(ctx.game,{entityKey: ctx.entityKey, amount: -dealt}); - }), - onEnemyIntent: createTrigger("onEnemyIntent", async ctx => { - const enemy = ctx.game.value.enemies.find(e => e.id === ctx.enemyId); - if(!enemy || !enemy.isAlive) return; - - const intent = enemy.currentIntent; - if(!intent) return; - - const source = ctx.sourceEntityKey ?? enemy.id; - for(const [target, effect, stacks] of intent.effects){ - for(const entity of getEffectTargets(target, ctx.game)) - await triggers.onEffectApplied.execute(ctx.game, { effect, entityKey: entity.id, stacks, sourceEntityKey: source }); - } - }), - onIntentUpdate: createTrigger("onIntentUpdate", async ctx => { - await ctx.game.produceAsync(draft => { - const enemy = draft.enemies.find(e => e.id === ctx.enemyId); - if(!enemy) return; - - const intent = enemy.currentIntent; - if(!intent) return; - - const nextIntents = intent.nextIntents; - if(nextIntents.length > 0){ - const nextIndex = ctx.game.rng.nextInt(nextIntents.length); - enemy.currentIntent = nextIntents[nextIndex]; - } - }); - }), - } - return triggers; -} -export type Triggers = ReturnType -export function createStartWith(build: (triggers: Triggers) => void){ - const triggers = createTriggers(); - build(triggers); - return async function(game: CombatGameContext){ - await triggers.onCombatStart.execute(game,{}); - - try { - while (true) { - await triggers.onTurnStart.execute(game, {entityKey: "player"}); - while (true) { - const action = await promptMainAction(game); - if (action.action === "end-turn") break; - if (action.action === "play") { - await triggers.onCardPlayed.execute(game, action); - } - } - await triggers.onTurnEnd.execute(game, {entityKey: "player"}); - - for (const enemy of getAliveEnemies(game.value)) { - await triggers.onTurnStart.execute(game, {entityKey: enemy.id}); - } - for (const enemy of getAliveEnemies(game.value)) { - await triggers.onEnemyIntent.execute(game, {enemyId: enemy.id}); - await triggers.onIntentUpdate.execute(game, {enemyId: enemy.id}); - } - for (const enemy of getAliveEnemies(game.value)) { - await triggers.onTurnEnd.execute(game, {entityKey: enemy.id}); - } - } - }catch(e){ - if(e === game.value) return game.value.result; - throw e; + } } + return; + } + + await ctx.game.produceAsync((draft) => { + const entity = + ctx.entityKey === "player" + ? draft.player + : draft.enemies.find((e) => e.id === ctx.entityKey); + if (entity) addEntityEffect(entity, ctx.effect, ctx.stacks); + }); + }), + onHpChange: createTrigger("onHpChange", async (ctx) => { + await ctx.game.produceAsync((draft) => { + const entity = + ctx.entityKey === "player" + ? draft.player + : draft.enemies.find((e) => e.id === ctx.entityKey); + if (!entity) return; + entity.hp += ctx.amount; + entity.isAlive = entity.hp > 0; + draft.result = !draft.player.isAlive + ? "defeat" + : draft.enemies.every((e) => !e.isAlive) + ? "victory" + : null; + }); + if (ctx.game.value.result) throw ctx.game.value; + }), + onDamage: createTrigger("onDamage", async (ctx) => { + const entity = + ctx.entityKey === "player" + ? ctx.game.value.player + : ctx.game.value.enemies.find((e) => e.id === ctx.entityKey); + if (!entity || !entity.isAlive) return; + const dealt = Math.min( + Math.max(0, entity.hp), + ctx.amount - (ctx.prevented || 0), + ); + await ctx.game.produceAsync((draft) => { + onEntityPostureDamage(entity, dealt); + }); + await triggers.onHpChange.execute(ctx.game, { + entityKey: ctx.entityKey, + amount: -dealt, + }); + }), + onEnemyIntent: createTrigger("onEnemyIntent", async (ctx) => { + const enemy = ctx.game.value.enemies.find((e) => e.id === ctx.enemyId); + if (!enemy || !enemy.isAlive) return; + + const intent = enemy.currentIntent; + if (!intent) return; + + const source = ctx.sourceEntityKey ?? enemy.id; + for (const [target, effect, stacks] of intent.effects) { + for (const entity of getEffectTargets(target, ctx.game)) + await triggers.onEffectApplied.execute(ctx.game, { + effect, + entityKey: entity.id, + stacks, + sourceEntityKey: source, + }); + } + }), + onIntentUpdate: createTrigger("onIntentUpdate", async (ctx) => { + await ctx.game.produceAsync((draft) => { + const enemy = draft.enemies.find((e) => e.id === ctx.enemyId); + if (!enemy) return; + + const intent = enemy.currentIntent; + if (!intent) return; + + const nextIntents = intent.nextIntents; + if (nextIntents.length > 0) { + const nextIndex = ctx.game.rng.nextInt(nextIntents.length); + enemy.currentIntent = nextIntents[nextIndex]; + } + }); + }), + }; + return triggers; +} +export type Triggers = ReturnType; +export function createStartWith(build: (triggers: Triggers) => void) { + const triggers = createTriggers(); + build(triggers); + return async function (game: CombatGameContext) { + await triggers.onCombatStart.execute(game, {}); + + try { + while (true) { + await triggers.onTurnStart.execute(game, { entityKey: "player" }); + while (true) { + const action = await promptMainAction(game); + if (action.action === "end-turn") break; + if (action.action === "play") { + await triggers.onCardPlayed.execute(game, action); + } + } + await triggers.onTurnEnd.execute(game, { entityKey: "player" }); + + for (const enemy of getAliveEnemies(game.value)) { + await triggers.onTurnStart.execute(game, { entityKey: enemy.id }); + } + for (const enemy of getAliveEnemies(game.value)) { + await triggers.onEnemyIntent.execute(game, { enemyId: enemy.id }); + await triggers.onIntentUpdate.execute(game, { enemyId: enemy.id }); + } + for (const enemy of getAliveEnemies(game.value)) { + await triggers.onTurnEnd.execute(game, { entityKey: enemy.id }); + } + } + } catch (e) { + if (e === game.value) return game.value.result; + throw e; } + }; } -type TriggerContext = TriggerTypes[TKey] & { event: TKey, game: CombatGameContext }; -function createTrigger(event: TKey, fallback?: (ctx: TriggerContext) => Promise) { - const {use, execute} = createMiddlewareChain,void>(fallback); - return { - use, - execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => { - const param = {...ctx, game, event}; - await execute(param); - return param; - }, - } -} \ No newline at end of file +type TriggerContext = TriggerTypes[TKey] & { + event: TKey; + game: CombatGameContext; +}; +function createTrigger( + event: TKey, + fallback?: (ctx: TriggerContext) => Promise, +) { + const { use, execute } = createMiddlewareChain, void>( + fallback, + ); + return { + use, + execute: async (game: CombatGameContext, ctx: TriggerTypes[TKey]) => { + const param = { ...ctx, game, event }; + await execute(param); + return param; + }, + }; +} diff --git a/src/samples/slay-the-spire-like/system/types.ts b/src/samples/slay-the-spire-like/system/types.ts index 506da9a..80c1b57 100644 --- a/src/samples/slay-the-spire-like/system/types.ts +++ b/src/samples/slay-the-spire-like/system/types.ts @@ -1,16 +1,26 @@ export type EffectData = { - readonly id: string; - readonly name: string; - readonly description: string; - readonly lifecycle: EffectLifecycle; + readonly id: string; + readonly name: string; + readonly description: string; + readonly lifecycle: EffectLifecycle; }; -export type EffectLifecycle = "instant" | "temporary" | "lingering" | "permanent" | "posture" | "item" | "itemTemporary" | "itemUntilPlay" | "itemUntilDiscard" | "itemPermanent"; +export type EffectLifecycle = + | "instant" + | "temporary" + | "lingering" + | "permanent" + | "posture" + | "item" + | "itemTemporary" + | "itemUntilPlay" + | "itemUntilDiscard" + | "itemPermanent"; export type EnemyData = { - readonly id: string; - readonly name: string; - readonly intents: readonly IntentData[]; - readonly description: string; + readonly id: string; + readonly name: string; + readonly intents: readonly IntentData[]; + readonly description: string; }; export type CardType = "item" | "status"; @@ -18,44 +28,62 @@ export type CardCostType = "energy" | "uses" | "none"; export type CardTargetType = "single" | "none"; export type EffectTarget = "self" | "player" | "team"; -export type CardData = { - readonly id: string; - readonly name: string; - readonly desc: string; - readonly type: CardType; - readonly costType: CardCostType; - readonly costCount: number; - readonly targetType: CardTargetType; - readonly effects: readonly [CardEffectTrigger, CardEffectTarget, EffectData, number][]; -}; export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard"; -export type CardEffectTarget = "self" | "target" | "all" | "random" +export type CardEffectTarget = "self" | "target" | "all" | "random"; -export type EncounterType = "minion" | "elite" | "event" | "shop" | "camp" | "curio"; +export type CardEffect = { + readonly id: string; + readonly trigger: CardEffectTrigger; + readonly target: CardEffectTarget; + readonly effects: readonly [EffectData, number][]; +}; + +export type CardData = { + readonly id: string; + readonly name: string; + readonly desc: string; + readonly type: CardType; + readonly costType: CardCostType; + readonly costCount: number; + readonly targetType: CardTargetType; + readonly effects: readonly CardEffect[]; +}; + +export type EncounterType = + | "minion" + | "elite" + | "event" + | "shop" + | "camp" + | "curio"; export type EncounterData = { - readonly id: string; - readonly type: EncounterType; - readonly name: string; - readonly description: string; - readonly enemies: readonly [data: EnemyData, hp: number, effects: [EffectData, stacks: number][]][]; - readonly dialogue: string; + readonly id: string; + readonly type: EncounterType; + readonly name: string; + readonly description: string; + readonly enemies: readonly [ + data: EnemyData, + hp: number, + effects: [EffectData, stacks: number][], + ][]; + readonly dialogue: string; }; export type IntentData = { - readonly id: string; - readonly enemy: EnemyData; - readonly initialIntent: boolean; - readonly nextIntents: readonly IntentData[]; - readonly brokenIntent: readonly IntentData[]; - readonly effects: readonly [EffectTarget, EffectData, number][]; + readonly id: string; + readonly enemy: EnemyData; + readonly initialIntent: boolean; + readonly nextIntents: readonly IntentData[]; + readonly brokenIntent: readonly IntentData[]; + readonly effects: readonly [EffectTarget, EffectData, number][]; }; export type ItemData = { - readonly id: string; - readonly type: string; - readonly name: string; - readonly shape: string; - readonly card: CardData; - readonly price: number; - readonly description: string; + readonly id: string; + readonly type: string; + readonly name: string; + readonly shape: string; + readonly card: CardData; + readonly price: number; + readonly description: string; }; diff --git a/tests/samples/slay-the-spire-like/combat/effects.test.ts b/tests/samples/slay-the-spire-like/combat/effects.test.ts index a70d399..4563fc1 100644 --- a/tests/samples/slay-the-spire-like/combat/effects.test.ts +++ b/tests/samples/slay-the-spire-like/combat/effects.test.ts @@ -1,550 +1,616 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; import { - addEffect, - addEntityEffect, - addItemEffect, - onEntityEffectUpkeep, - onEntityPostureDamage, - onPlayerItemEffectUpkeep, - onItemPlay, - onItemDiscard, - getAliveEnemies, - getCombatEntity, - canPlayCard, - payCardCost, -} from '@/samples/slay-the-spire-like/system/combat/effects'; -import type { CombatEntity, CombatState, EffectTable, PlayerEntity, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types'; -import type { EffectData } from '@/samples/slay-the-spire-like/system/types'; -import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory/types'; -import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types'; -import type { ParsedShape } from '@/samples/slay-the-spire-like/system/utils/parse-shape'; -import type { Transform2D } from '@/samples/slay-the-spire-like/system/utils/shape-collision'; + addEffect, + addEntityEffect, + addItemEffect, + onEntityEffectUpkeep, + onEntityPostureDamage, + onPlayerItemEffectUpkeep, + onItemPlay, + onItemDiscard, + getAliveEnemies, + getCombatEntity, + canPlayCard, + payCardCost, +} from "@/samples/slay-the-spire-like/system/combat/effects"; +import type { + CombatEntity, + CombatState, + EffectTable, + PlayerEntity, + EnemyEntity, +} from "@/samples/slay-the-spire-like/system/combat/types"; +import type { EffectData } from "@/samples/slay-the-spire-like/system/types"; +import type { + CellKey, + GridInventory, + InventoryItem, +} from "@/samples/slay-the-spire-like/system/grid-inventory/types"; +import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress/types"; +import type { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape"; +import type { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision"; -function createEffect(id: string, lifecycle: EffectData['lifecycle']): EffectData { - return { id, name: id, description: '', lifecycle }; +function createEffect( + id: string, + lifecycle: EffectData["lifecycle"], +): EffectData { + return { id, name: id, description: "", lifecycle }; } -function createCard(id: string, costType: 'energy' | 'uses' | 'none', costCount: number) { - return { id, name: id, desc: '', type: 'item' as const, costType, costCount, targetType: 'none' as const, effects: [] as const }; +function createCard( + id: string, + costType: "energy" | "uses" | "none", + costCount: number, +) { + return { + id, + name: id, + desc: "", + type: "item" as const, + costType, + costCount, + targetType: "none" as const, + effects: [] as const, + }; } -function createItem(itemId: string, cardId: string, costType: 'energy' | 'uses' | 'none', costCount: number, depletion = 0): InventoryItem { - return { +function createItem( + itemId: string, + cardId: string, + costType: "energy" | "uses" | "none", + costCount: number, + depletion = 0, +): InventoryItem { + return { + id: itemId, + shape: { id: "1x1", cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape, + transform: { + x: 0, + y: 0, + rotation: 0, + flipX: false, + flipY: false, + } as unknown as Transform2D, + meta: { + itemData: { id: itemId, - shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape, - transform: { x: 0, y: 0, rotation: 0, flipX: false, flipY: false } as unknown as Transform2D, - meta: { - itemData: { id: itemId, type: 'weapon', name: itemId, shape: '1x1', card: createCard(cardId, costType, costCount), price: 0, description: '' }, - shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape, - depletion: costType === 'uses' ? depletion : undefined, - }, - }; + 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[]): GridInventory { - const map = new Map>(); - const occupied = new Set(); - for (const item of items) { - map.set(item.id, item); - occupied.add(`${item.transform.x},${item.transform.y}`); - } - return { width: 6, height: 4, items: map, occupiedCells: occupied }; +function createInventory( + items: InventoryItem[], +): GridInventory { + const map = new Map>(); + const occupied = new Set(); + for (const item of items) { + map.set(item.id, item); + occupied.add(`${item.transform.x},${item.transform.y}`); + } + return { width: 6, height: 4, items: map, occupiedCells: occupied }; } function createCombatEntity(hp = 10, maxHp = 10): CombatEntity { - return { - effects: {}, - hp, - maxHp, - isAlive: hp > 0, - }; + return { + effects: {}, + hp, + maxHp, + isAlive: hp > 0, + }; } function createPlayerEntity(hp = 30, maxHp = 30): PlayerEntity { - return { - ...createCombatEntity(hp, maxHp), - energy: 3, - maxEnergy: 3, - deck: { cards: {}, regions: { drawPile: { id: 'drawPile', axes: [], childIds: [], partMap: {} }, hand: { id: 'hand', axes: [], childIds: [], partMap: {} }, discardPile: { id: 'discardPile', axes: [], childIds: [], partMap: {} }, exhaustPile: { id: 'exhaustPile', axes: [], childIds: [], partMap: {} } } }, - itemEffects: {}, - }; + return { + ...createCombatEntity(hp, maxHp), + energy: 3, + maxEnergy: 3, + deck: { + cards: {}, + regions: { + drawPile: { id: "drawPile", axes: [], childIds: [], partMap: {} }, + hand: { id: "hand", axes: [], childIds: [], partMap: {} }, + discardPile: { id: "discardPile", axes: [], childIds: [], partMap: {} }, + exhaustPile: { id: "exhaustPile", axes: [], childIds: [], partMap: {} }, + }, + }, + itemEffects: {}, + }; } function createEnemyEntity(id: string, hp = 10, maxHp = 10): EnemyEntity { - return { - ...createCombatEntity(hp, maxHp), - id, - enemy: { id, name: id, description: '' }, - intents: {}, - currentIntentId: '', - }; + return { + ...createCombatEntity(hp, maxHp), + id, + enemy: { id, name: id, description: "" }, + intents: {}, + currentIntentId: "", + }; } -function createCombatState(playerHp = 30, enemies: EnemyEntity[] = []): CombatState { - return { - player: createPlayerEntity(playerHp), - enemies, - inventory: { width: 6, height: 4, items: new Map(), occupiedCells: new Set() }, - phase: 'playerTurn', - turnNumber: 1, - result: null, - loot: [], - }; +function createCombatState( + playerHp = 30, + enemies: EnemyEntity[] = [], +): CombatState { + return { + player: createPlayerEntity(playerHp), + enemies, + inventory: { + width: 6, + height: 4, + items: new Map(), + occupiedCells: new Set(), + }, + phase: "playerTurn", + turnNumber: 1, + result: null, + loot: [], + }; } -describe('combat/effects', () => { - describe('addEffect', () => { - it('should add a new effect to an empty table', () => { - const table: EffectTable = {}; - const effect = createEffect('strength', 'temporary'); +describe("combat/effects", () => { + describe("addEffect", () => { + it("should add a new effect to an empty table", () => { + const table: EffectTable = {}; + const effect = createEffect("strength", "temporary"); - addEffect(table, effect, 3); + addEffect(table, effect, 3); - expect(table['strength']).toBeDefined(); - expect(table['strength'].data).toBe(effect); - expect(table['strength'].stacks).toBe(3); - }); - - it('should stack with existing effect of same id', () => { - const table: EffectTable = {}; - const effect = createEffect('strength', 'lingering'); - - addEffect(table, effect, 2); - addEffect(table, effect, 3); - - expect(table['strength'].stacks).toBe(5); - }); - - it('should remove effect when stacks reach 0', () => { - const table: EffectTable = {}; - const effect = createEffect('strength', 'temporary'); - - addEffect(table, effect, 3); - addEffect(table, effect, -3); - - expect(table['strength']).toBeUndefined(); - }); - - it('should not add effect when stacks is 0', () => { - const table: EffectTable = {}; - const effect = createEffect('strength', 'temporary'); - - addEffect(table, effect, 0); - - expect(table['strength']).toBeUndefined(); - }); - - it('should handle negative stacks', () => { - const table: EffectTable = {}; - const effect = createEffect('weak', 'temporary'); - - addEffect(table, effect, -2); - - expect(table['weak'].stacks).toBe(-2); - }); + expect(table["strength"]).toBeDefined(); + expect(table["strength"].data).toBe(effect); + expect(table["strength"].stacks).toBe(3); }); - describe('addEntityEffect', () => { - it('should add effect to entity.effects', () => { - const entity = createCombatEntity(); - const effect = createEffect('vulnerable', 'lingering'); + it("should stack with existing effect of same id", () => { + const table: EffectTable = {}; + const effect = createEffect("strength", "lingering"); - addEntityEffect(entity, effect, 2); + addEffect(table, effect, 2); + addEffect(table, effect, 3); - expect(entity.effects['vulnerable'].stacks).toBe(2); - }); + expect(table["strength"].stacks).toBe(5); }); - describe('addItemEffect', () => { - it('should add effect to player.itemEffects[itemKey]', () => { - const player = createPlayerEntity(); - const effect = createEffect('adjacent-buff', 'itemTemporary'); + it("should remove effect when stacks reach 0", () => { + const table: EffectTable = {}; + const effect = createEffect("strength", "temporary"); - addItemEffect(player, 'sword-1', effect, 3); + addEffect(table, effect, 3); + addEffect(table, effect, -3); - expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(3); - }); - - it('should initialize itemEffects entry if not present', () => { - const player = createPlayerEntity(); - const effect = createEffect('adjacent-buff', 'itemTemporary'); - - addItemEffect(player, 'new-item', effect, 1); - - expect(player.itemEffects['new-item']).toBeDefined(); - }); - - it('should stack with existing item effect', () => { - const player = createPlayerEntity(); - const effect = createEffect('adjacent-buff', 'itemTemporary'); - - addItemEffect(player, 'sword-1', effect, 2); - addItemEffect(player, 'sword-1', effect, 3); - - expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(5); - }); + expect(table["strength"]).toBeUndefined(); }); - describe('onEntityEffectUpkeep', () => { - it('should remove temporary effects', () => { - const entity = createCombatEntity(); - const tempEffect = createEffect('temp-shield', 'temporary'); + it("should not add effect when stacks is 0", () => { + const table: EffectTable = {}; + const effect = createEffect("strength", "temporary"); - addEntityEffect(entity, tempEffect, 5); - onEntityEffectUpkeep(entity); + addEffect(table, effect, 0); - expect(entity.effects['temp-shield']).toBeUndefined(); - }); - - it('should decrement lingering effects by 1', () => { - const entity = createCombatEntity(); - const lingeringEffect = createEffect('poison', 'lingering'); - - addEntityEffect(entity, lingeringEffect, 3); - onEntityEffectUpkeep(entity); - - expect(entity.effects['poison'].stacks).toBe(2); - }); - - it('should remove lingering effects when stacks reach 0', () => { - const entity = createCombatEntity(); - const lingeringEffect = createEffect('poison', 'lingering'); - - addEntityEffect(entity, lingeringEffect, 1); - onEntityEffectUpkeep(entity); - - expect(entity.effects['poison']).toBeUndefined(); - }); - - it('should not affect permanent effects', () => { - const entity = createCombatEntity(); - const permEffect = createEffect('max-hp-up', 'permanent'); - - addEntityEffect(entity, permEffect, 5); - onEntityEffectUpkeep(entity); - - expect(entity.effects['max-hp-up'].stacks).toBe(5); - }); - - it('should not affect instant effects', () => { - const entity = createCombatEntity(); - const instantEffect = createEffect('instant-damage', 'instant'); - - addEntityEffect(entity, instantEffect, 10); - onEntityEffectUpkeep(entity); - - expect(entity.effects['instant-damage'].stacks).toBe(10); - }); - - it('should increment lingering effects with negative stacks', () => { - const entity = createCombatEntity(); - const lingeringEffect = createEffect('regen', 'lingering'); - - addEntityEffect(entity, lingeringEffect, -3); - onEntityEffectUpkeep(entity); - - expect(entity.effects['regen'].stacks).toBe(-2); - }); + expect(table["strength"]).toBeUndefined(); }); - describe('onEntityPostureDamage', () => { - it('should reduce posture effects by damage amount', () => { - const entity = createCombatEntity(); - const postureEffect = createEffect('block', 'posture'); + it("should handle negative stacks", () => { + const table: EffectTable = {}; + const effect = createEffect("weak", "temporary"); - addEntityEffect(entity, postureEffect, 10); - onEntityPostureDamage(entity, 4); + addEffect(table, effect, -2); - expect(entity.effects['block'].stacks).toBe(6); - }); + expect(table["weak"].stacks).toBe(-2); + }); + }); - it('should not reduce posture effects below 0', () => { - const entity = createCombatEntity(); - const postureEffect = createEffect('block', 'posture'); + describe("addEntityEffect", () => { + it("should add effect to entity.effects", () => { + const entity = createCombatEntity(); + const effect = createEffect("vulnerable", "lingering"); - addEntityEffect(entity, postureEffect, 3); - onEntityPostureDamage(entity, 10); + addEntityEffect(entity, effect, 2); - expect(entity.effects['block']).toBeUndefined(); - }); + expect(entity.effects["vulnerable"].stacks).toBe(2); + }); + }); - it('should not affect non-posture effects', () => { - const entity = createCombatEntity(); - const postureEffect = createEffect('block', 'posture'); - const permEffect = createEffect('strength', 'permanent'); + describe("addItemEffect", () => { + it("should add effect to player.itemEffects[itemKey]", () => { + const player = createPlayerEntity(); + const effect = createEffect("adjacent-buff", "itemTemporary"); - addEntityEffect(entity, postureEffect, 5); - addEntityEffect(entity, permEffect, 3); - onEntityPostureDamage(entity, 2); + addItemEffect(player, "sword-1", effect, 3); - expect(entity.effects['block'].stacks).toBe(3); - expect(entity.effects['strength'].stacks).toBe(3); - }); - - it('should handle zero damage', () => { - const entity = createCombatEntity(); - const postureEffect = createEffect('block', 'posture'); - - addEntityEffect(entity, postureEffect, 5); - onEntityPostureDamage(entity, 0); - - expect(entity.effects['block'].stacks).toBe(5); - }); + expect(player.itemEffects["sword-1"]["adjacent-buff"].stacks).toBe(3); }); - describe('onPlayerItemEffectUpkeep', () => { - it('should remove itemTemporary effects', () => { - const player = createPlayerEntity(); - const effect = createEffect('adjacent-buff', 'itemTemporary'); + it("should initialize itemEffects entry if not present", () => { + const player = createPlayerEntity(); + const effect = createEffect("adjacent-buff", "itemTemporary"); - addItemEffect(player, 'sword-1', effect, 5); - onPlayerItemEffectUpkeep(player); + addItemEffect(player, "new-item", effect, 1); - expect(player.itemEffects['sword-1']['adjacent-buff']).toBeUndefined(); - }); - - it('should not affect itemPermanent effects', () => { - const player = createPlayerEntity(); - const effect = createEffect('adjacent-buff', 'itemPermanent'); - - addItemEffect(player, 'sword-1', effect, 5); - onPlayerItemEffectUpkeep(player); - - expect(player.itemEffects['sword-1']['adjacent-buff'].stacks).toBe(5); - }); - - it('should not affect itemUntilPlay effects', () => { - const player = createPlayerEntity(); - const effect = createEffect('charged', 'itemUntilPlay'); - - addItemEffect(player, 'sword-1', effect, 3); - onPlayerItemEffectUpkeep(player); - - expect(player.itemEffects['sword-1']['charged'].stacks).toBe(3); - }); + expect(player.itemEffects["new-item"]).toBeDefined(); }); - describe('onItemPlay', () => { - it('should remove itemUntilPlay effects', () => { - const player = createPlayerEntity(); - const effect = createEffect('charged', 'itemUntilPlay'); + it("should stack with existing item effect", () => { + const player = createPlayerEntity(); + const effect = createEffect("adjacent-buff", "itemTemporary"); - addItemEffect(player, 'sword-1', effect, 3); - onItemPlay(player, 'sword-1'); + addItemEffect(player, "sword-1", effect, 2); + addItemEffect(player, "sword-1", effect, 3); - expect(player.itemEffects['sword-1']['charged']).toBeUndefined(); - }); + expect(player.itemEffects["sword-1"]["adjacent-buff"].stacks).toBe(5); + }); + }); - it('should not affect other lifecycle effects', () => { - const player = createPlayerEntity(); - const permEffect = createEffect('passive', 'itemPermanent'); - const playEffect = createEffect('charged', 'itemUntilPlay'); + describe("onEntityEffectUpkeep", () => { + it("should remove temporary effects", () => { + const entity = createCombatEntity(); + const tempEffect = createEffect("temp-shield", "temporary"); - addItemEffect(player, 'sword-1', permEffect, 5); - addItemEffect(player, 'sword-1', playEffect, 3); - onItemPlay(player, 'sword-1'); + addEntityEffect(entity, tempEffect, 5); + onEntityEffectUpkeep(entity); - expect(player.itemEffects['sword-1']['passive'].stacks).toBe(5); - expect(player.itemEffects['sword-1']['charged']).toBeUndefined(); - }); - - it('should do nothing for item with no effects', () => { - const player = createPlayerEntity(); - - expect(() => onItemPlay(player, 'nonexistent')).not.toThrow(); - }); + expect(entity.effects["temp-shield"]).toBeUndefined(); }); - describe('onItemDiscard', () => { - it('should remove itemUntilDiscard effects', () => { - const player = createPlayerEntity(); - const effect = createEffect('discard-buff', 'itemUntilDiscard'); + it("should decrement lingering effects by 1", () => { + const entity = createCombatEntity(); + const lingeringEffect = createEffect("poison", "lingering"); - addItemEffect(player, 'sword-1', effect, 3); - onItemDiscard(player, 'sword-1'); + addEntityEffect(entity, lingeringEffect, 3); + onEntityEffectUpkeep(entity); - expect(player.itemEffects['sword-1']['discard-buff']).toBeUndefined(); - }); - - it('should not affect other lifecycle effects', () => { - const player = createPlayerEntity(); - const permEffect = createEffect('passive', 'itemPermanent'); - const discardEffect = createEffect('discard-buff', 'itemUntilDiscard'); - - addItemEffect(player, 'sword-1', permEffect, 5); - addItemEffect(player, 'sword-1', discardEffect, 3); - onItemDiscard(player, 'sword-1'); - - expect(player.itemEffects['sword-1']['passive'].stacks).toBe(5); - expect(player.itemEffects['sword-1']['discard-buff']).toBeUndefined(); - }); - - it('should do nothing for item with no effects', () => { - const player = createPlayerEntity(); - - expect(() => onItemDiscard(player, 'nonexistent')).not.toThrow(); - }); + expect(entity.effects["poison"].stacks).toBe(2); }); - describe('getAliveEnemies', () => { - it('should yield only alive enemies', () => { - const state = createCombatState(30, [ - createEnemyEntity('slime-1', 10, 10), - createEnemyEntity('slime-2', 0, 10), - createEnemyEntity('slime-3', 5, 10), - ]); + it("should remove lingering effects when stacks reach 0", () => { + const entity = createCombatEntity(); + const lingeringEffect = createEffect("poison", "lingering"); - const alive = [...getAliveEnemies(state)]; + addEntityEffect(entity, lingeringEffect, 1); + onEntityEffectUpkeep(entity); - expect(alive.length).toBe(2); - expect(alive[0].id).toBe('slime-1'); - expect(alive[1].id).toBe('slime-3'); - }); - - it('should return empty for no enemies', () => { - const state = createCombatState(30, []); - - const alive = [...getAliveEnemies(state)]; - - expect(alive.length).toBe(0); - }); - - it('should return empty when all enemies are dead', () => { - const state = createCombatState(30, [ - createEnemyEntity('slime-1', 0, 10), - createEnemyEntity('slime-2', 0, 10), - ]); - - const alive = [...getAliveEnemies(state)]; - - expect(alive.length).toBe(0); - }); + expect(entity.effects["poison"]).toBeUndefined(); }); - describe('getCombatEntity', () => { - it('should return player for "player" key', () => { - const state = createCombatState(30); + it("should not affect permanent effects", () => { + const entity = createCombatEntity(); + const permEffect = createEffect("max-hp-up", "permanent"); - const entity = getCombatEntity(state, 'player'); + addEntityEffect(entity, permEffect, 5); + onEntityEffectUpkeep(entity); - expect(entity).toBe(state.player); - }); - - it('should return enemy by id', () => { - const enemy = createEnemyEntity('boss-1', 50, 50); - const state = createCombatState(30, [enemy]); - - const entity = getCombatEntity(state, 'boss-1'); - - expect(entity).toBe(enemy); - }); - - it('should return undefined for non-existent enemy', () => { - const state = createCombatState(30, [createEnemyEntity('slime-1')]); - - const entity = getCombatEntity(state, 'nonexistent'); - - expect(entity).toBeUndefined(); - }); + expect(entity.effects["max-hp-up"].stacks).toBe(5); }); - describe('canPlayCard', () => { - it('should allow playing energy card when player has enough energy', () => { - const player = createPlayerEntity(); - player.energy = 3; - const inventory = createInventory([]); + it("should not affect instant effects", () => { + const entity = createCombatEntity(); + const instantEffect = createEffect("instant-damage", "instant"); - const result = canPlayCard(player, 'energy', 2, 'any', inventory); + addEntityEffect(entity, instantEffect, 10); + onEntityEffectUpkeep(entity); - expect(result).toBe(true); - }); - - it('should reject playing energy card when player lacks energy', () => { - const player = createPlayerEntity(); - player.energy = 1; - const inventory = createInventory([]); - - const result = canPlayCard(player, 'energy', 2, 'any', inventory); - - expect(result).toBe(false); - }); - - it('should allow playing uses card when item has remaining uses', () => { - const player = createPlayerEntity(); - const item = createItem('potion-1', 'potion-card', 'uses', 3, 1); - const inventory = createInventory([item]); - - const result = canPlayCard(player, 'uses', 3, 'potion-1', inventory); - - expect(result).toBe(true); - }); - - it('should reject playing uses card when item is depleted', () => { - const player = createPlayerEntity(); - const item = createItem('potion-1', 'potion-card', 'uses', 3, 3); - const inventory = createInventory([item]); - - const result = canPlayCard(player, 'uses', 3, 'potion-1', inventory); - - expect(result).toBe(false); - }); - - it('should reject playing uses card when item not in inventory', () => { - const player = createPlayerEntity(); - const inventory = createInventory([]); - - const result = canPlayCard(player, 'uses', 1, 'missing', inventory); - - expect(result).toBe(false); - }); - - it('should always allow playing none cost card', () => { - const player = createPlayerEntity(); - player.energy = 0; - const inventory = createInventory([]); - - const result = canPlayCard(player, 'none', 0, 'any', inventory); - - expect(result).toBe(true); - }); + expect(entity.effects["instant-damage"].stacks).toBe(10); }); - describe('payCardCost', () => { - it('should deduct energy for energy cost card', () => { - const player = createPlayerEntity(); - player.energy = 3; - const inventory = createInventory([]); + it("should increment lingering effects with negative stacks", () => { + const entity = createCombatEntity(); + const lingeringEffect = createEffect("regen", "lingering"); - payCardCost(player, 'energy', 2, 'any', inventory); + addEntityEffect(entity, lingeringEffect, -3); + onEntityEffectUpkeep(entity); - expect(player.energy).toBe(1); - }); - - it('should increment depletion for uses cost card', () => { - const player = createPlayerEntity(); - const item = createItem('potion-1', 'potion-card', 'uses', 3, 1); - const inventory = createInventory([item]); - - payCardCost(player, 'uses', 3, 'potion-1', inventory); - - expect(item.meta?.depletion).toBe(4); - }); - - it('should do nothing for none cost card', () => { - const player = createPlayerEntity(); - player.energy = 3; - const inventory = createInventory([]); - - payCardCost(player, 'none', 0, 'any', inventory); - - expect(player.energy).toBe(3); - }); - - it('should handle missing item gracefully for uses cost', () => { - const player = createPlayerEntity(); - const inventory = createInventory([]); - - expect(() => payCardCost(player, 'uses', 1, 'missing', inventory)).not.toThrow(); - }); + expect(entity.effects["regen"].stacks).toBe(-2); }); + }); + + describe("onEntityPostureDamage", () => { + it("should reduce posture effects by damage amount", () => { + const entity = createCombatEntity(); + const postureEffect = createEffect("block", "posture"); + + addEntityEffect(entity, postureEffect, 10); + onEntityPostureDamage(entity, 4); + + expect(entity.effects["block"].stacks).toBe(6); + }); + + it("should not reduce posture effects below 0", () => { + const entity = createCombatEntity(); + const postureEffect = createEffect("block", "posture"); + + addEntityEffect(entity, postureEffect, 3); + onEntityPostureDamage(entity, 10); + + expect(entity.effects["block"]).toBeUndefined(); + }); + + it("should not affect non-posture effects", () => { + const entity = createCombatEntity(); + const postureEffect = createEffect("block", "posture"); + const permEffect = createEffect("strength", "permanent"); + + addEntityEffect(entity, postureEffect, 5); + addEntityEffect(entity, permEffect, 3); + onEntityPostureDamage(entity, 2); + + expect(entity.effects["block"].stacks).toBe(3); + expect(entity.effects["strength"].stacks).toBe(3); + }); + + it("should handle zero damage", () => { + const entity = createCombatEntity(); + const postureEffect = createEffect("block", "posture"); + + addEntityEffect(entity, postureEffect, 5); + onEntityPostureDamage(entity, 0); + + expect(entity.effects["block"].stacks).toBe(5); + }); + }); + + describe("onPlayerItemEffectUpkeep", () => { + it("should remove itemTemporary effects", () => { + const player = createPlayerEntity(); + const effect = createEffect("adjacent-buff", "itemTemporary"); + + addItemEffect(player, "sword-1", effect, 5); + onPlayerItemEffectUpkeep(player); + + expect(player.itemEffects["sword-1"]["adjacent-buff"]).toBeUndefined(); + }); + + it("should not affect itemPermanent effects", () => { + const player = createPlayerEntity(); + const effect = createEffect("adjacent-buff", "itemPermanent"); + + addItemEffect(player, "sword-1", effect, 5); + onPlayerItemEffectUpkeep(player); + + expect(player.itemEffects["sword-1"]["adjacent-buff"].stacks).toBe(5); + }); + + it("should not affect itemUntilPlay effects", () => { + const player = createPlayerEntity(); + const effect = createEffect("charged", "itemUntilPlay"); + + addItemEffect(player, "sword-1", effect, 3); + onPlayerItemEffectUpkeep(player); + + expect(player.itemEffects["sword-1"]["charged"].stacks).toBe(3); + }); + }); + + describe("onItemPlay", () => { + it("should remove itemUntilPlay effects", () => { + const player = createPlayerEntity(); + const effect = createEffect("charged", "itemUntilPlay"); + + addItemEffect(player, "sword-1", effect, 3); + onItemPlay(player, "sword-1"); + + expect(player.itemEffects["sword-1"]["charged"]).toBeUndefined(); + }); + + it("should not affect other lifecycle effects", () => { + const player = createPlayerEntity(); + const permEffect = createEffect("passive", "itemPermanent"); + const playEffect = createEffect("charged", "itemUntilPlay"); + + addItemEffect(player, "sword-1", permEffect, 5); + addItemEffect(player, "sword-1", playEffect, 3); + onItemPlay(player, "sword-1"); + + expect(player.itemEffects["sword-1"]["passive"].stacks).toBe(5); + expect(player.itemEffects["sword-1"]["charged"]).toBeUndefined(); + }); + + it("should do nothing for item with no effects", () => { + const player = createPlayerEntity(); + + expect(() => onItemPlay(player, "nonexistent")).not.toThrow(); + }); + }); + + describe("onItemDiscard", () => { + it("should remove itemUntilDiscard effects", () => { + const player = createPlayerEntity(); + const effect = createEffect("discard-buff", "itemUntilDiscard"); + + addItemEffect(player, "sword-1", effect, 3); + onItemDiscard(player, "sword-1"); + + expect(player.itemEffects["sword-1"]["discard-buff"]).toBeUndefined(); + }); + + it("should not affect other lifecycle effects", () => { + const player = createPlayerEntity(); + const permEffect = createEffect("passive", "itemPermanent"); + const discardEffect = createEffect("discard-buff", "itemUntilDiscard"); + + addItemEffect(player, "sword-1", permEffect, 5); + addItemEffect(player, "sword-1", discardEffect, 3); + onItemDiscard(player, "sword-1"); + + expect(player.itemEffects["sword-1"]["passive"].stacks).toBe(5); + expect(player.itemEffects["sword-1"]["discard-buff"]).toBeUndefined(); + }); + + it("should do nothing for item with no effects", () => { + const player = createPlayerEntity(); + + expect(() => onItemDiscard(player, "nonexistent")).not.toThrow(); + }); + }); + + describe("getAliveEnemies", () => { + it("should yield only alive enemies", () => { + const state = createCombatState(30, [ + createEnemyEntity("slime-1", 10, 10), + createEnemyEntity("slime-2", 0, 10), + createEnemyEntity("slime-3", 5, 10), + ]); + + const alive = [...getAliveEnemies(state)]; + + expect(alive.length).toBe(2); + expect(alive[0].id).toBe("slime-1"); + expect(alive[1].id).toBe("slime-3"); + }); + + it("should return empty for no enemies", () => { + const state = createCombatState(30, []); + + const alive = [...getAliveEnemies(state)]; + + expect(alive.length).toBe(0); + }); + + it("should return empty when all enemies are dead", () => { + const state = createCombatState(30, [ + createEnemyEntity("slime-1", 0, 10), + createEnemyEntity("slime-2", 0, 10), + ]); + + const alive = [...getAliveEnemies(state)]; + + expect(alive.length).toBe(0); + }); + }); + + describe("getCombatEntity", () => { + it('should return player for "player" key', () => { + const state = createCombatState(30); + + const entity = getCombatEntity(state, "player"); + + expect(entity).toBe(state.player); + }); + + it("should return enemy by id", () => { + const enemy = createEnemyEntity("boss-1", 50, 50); + const state = createCombatState(30, [enemy]); + + const entity = getCombatEntity(state, "boss-1"); + + expect(entity).toBe(enemy); + }); + + it("should return undefined for non-existent enemy", () => { + const state = createCombatState(30, [createEnemyEntity("slime-1")]); + + const entity = getCombatEntity(state, "nonexistent"); + + expect(entity).toBeUndefined(); + }); + }); + + describe("canPlayCard", () => { + it("should allow playing energy card when player has enough energy", () => { + const player = createPlayerEntity(); + player.energy = 3; + const inventory = createInventory([]); + + const result = canPlayCard(player, "energy", 2, "any", inventory); + + expect(result).toBe(true); + }); + + it("should reject playing energy card when player lacks energy", () => { + const player = createPlayerEntity(); + player.energy = 1; + const inventory = createInventory([]); + + const result = canPlayCard(player, "energy", 2, "any", inventory); + + expect(result).toBe(false); + }); + + it("should allow playing uses card when item has remaining uses", () => { + const player = createPlayerEntity(); + const item = createItem("potion-1", "potion-card", "uses", 3, 1); + const inventory = createInventory([item]); + + const result = canPlayCard(player, "uses", 3, "potion-1", inventory); + + expect(result).toBe(true); + }); + + it("should reject playing uses card when item is depleted", () => { + const player = createPlayerEntity(); + const item = createItem("potion-1", "potion-card", "uses", 3, 3); + const inventory = createInventory([item]); + + const result = canPlayCard(player, "uses", 3, "potion-1", inventory); + + expect(result).toBe(false); + }); + + it("should reject playing uses card when item not in inventory", () => { + const player = createPlayerEntity(); + const inventory = createInventory([]); + + const result = canPlayCard(player, "uses", 1, "missing", inventory); + + expect(result).toBe(false); + }); + + it("should always allow playing none cost card", () => { + const player = createPlayerEntity(); + player.energy = 0; + const inventory = createInventory([]); + + const result = canPlayCard(player, "none", 0, "any", inventory); + + expect(result).toBe(true); + }); + }); + + describe("payCardCost", () => { + it("should deduct energy for energy cost card", () => { + const player = createPlayerEntity(); + player.energy = 3; + const inventory = createInventory([]); + + payCardCost(player, "energy", 2, "any", inventory); + + expect(player.energy).toBe(1); + }); + + it("should increment depletion for uses cost card", () => { + const player = createPlayerEntity(); + const item = createItem("potion-1", "potion-card", "uses", 3, 1); + const inventory = createInventory([item]); + + payCardCost(player, "uses", 3, "potion-1", inventory); + + expect(item.meta?.depletion).toBe(4); + }); + + it("should do nothing for none cost card", () => { + const player = createPlayerEntity(); + player.energy = 3; + const inventory = createInventory([]); + + payCardCost(player, "none", 0, "any", inventory); + + expect(player.energy).toBe(3); + }); + + it("should handle missing item gracefully for uses cost", () => { + const player = createPlayerEntity(); + const inventory = createInventory([]); + + expect(() => + payCardCost(player, "uses", 1, "missing", inventory), + ).not.toThrow(); + }); + }); }); diff --git a/tests/samples/slay-the-spire-like/combat/triggers.test.ts b/tests/samples/slay-the-spire-like/combat/triggers.test.ts index c1aa1bf..e7815f2 100644 --- a/tests/samples/slay-the-spire-like/combat/triggers.test.ts +++ b/tests/samples/slay-the-spire-like/combat/triggers.test.ts @@ -1,592 +1,680 @@ -import { describe, it, expect } from 'vitest'; -import { createGameContext, createGameCommandRegistry, IGameContext } from '@/core/game'; -import { createRegion } from '@/core/region'; -import { createStartWith, Triggers } from '@/samples/slay-the-spire-like/system/combat/triggers'; -import { addTriggers } from '@/samples/slay-the-spire-like/data/desert/triggers'; -import { CombatState, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types'; -import { EffectData } from '@/samples/slay-the-spire-like/system/types'; -import { GameCard, DeckRegions } from '@/samples/slay-the-spire-like/system/deck'; -import { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory/types'; -import { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/types'; -import { ParsedShape } from '@/samples/slay-the-spire-like/system/utils/parse-shape'; -import { Transform2D } from '@/samples/slay-the-spire-like/system/utils/shape-collision'; -import { cards, effects, enemies, items } from '@/samples/slay-the-spire-like/data/desert'; +import { describe, it, expect } from "vitest"; +import { + createGameContext, + createGameCommandRegistry, + IGameContext, +} from "@/core/game"; +import { createRegion } from "@/core/region"; +import { + createStartWith, + createTriggers, + Triggers, +} from "@/samples/slay-the-spire-like/system/combat/triggers"; +import { addTriggers } from "@/samples/slay-the-spire-like/data/desert/triggers"; +import { + CombatState, + EnemyEntity, +} from "@/samples/slay-the-spire-like/system/combat/types"; +import { EffectData } from "@/samples/slay-the-spire-like/system/types"; +import { + GameCard, + DeckRegions, +} from "@/samples/slay-the-spire-like/system/deck"; +import { + CellKey, + GridInventory, + InventoryItem, +} from "@/samples/slay-the-spire-like/system/grid-inventory/types"; +import { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress/types"; +import { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape"; +import { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision"; +import { + getCards, + getEffects, + getEncounters, + getEnemies, + getItems, +} from "@/samples/slay-the-spire-like/data/desert"; +const cards = getCards(); +const effects = getEffects(); +const encounters = getEncounters(); +const items = getItems(); +const enemies = getEnemies(); -function createEffect(id: string, lifecycle: EffectData['lifecycle'] = 'instant'): EffectData { - const found = effects.find(e => e.id === id); - if (found) return found; - return { id, name: id, description: '', lifecycle }; +function createEffect( + id: string, + lifecycle: EffectData["lifecycle"] = "instant", +): EffectData { + const found = effects.find((e) => e.id === id); + if (found) return found; + return { id, name: id, description: "", lifecycle }; } function createDeckRegions(): DeckRegions { - return { - drawPile: createRegion('drawPile', []), - hand: createRegion('hand', []), - discardPile: createRegion('discardPile', []), - exhaustPile: createRegion('exhaustPile', []), - }; + return { + drawPile: createRegion("drawPile", []), + hand: createRegion("hand", []), + discardPile: createRegion("discardPile", []), + exhaustPile: createRegion("exhaustPile", []), + }; } -function createCard(id: string, itemId: string, costType: 'energy' | 'uses' | 'none' = 'energy', costCount = 0): GameCard { - const cardData = cards.find(c => c.id === itemId) ?? { - id: itemId, name: itemId, desc: '', type: 'item' as const, costType, costCount, targetType: 'none' as const, effects: [], - }; - return { - id, - regionId: '', - position: [0], - itemId, - cardData, - }; +function createCard( + id: string, + itemId: string, + costType: "energy" | "uses" | "none" = "energy", + costCount = 0, +): GameCard { + const cardData = cards.find((c) => c.id === itemId) ?? { + id: itemId, + name: itemId, + desc: "", + type: "item" as const, + costType, + costCount, + targetType: "none" as const, + effects: [], + }; + return { + id, + regionId: "", + position: [0], + itemId, + cardData, + }; } -function createEnemyEntity(enemyId: string, hp: number, maxHp: number, instanceIndex = 0): EnemyEntity { - const enemyData = enemies.find(e => e.id === enemyId); - if (!enemyData) throw new Error(`Enemy "${enemyId}" not found`); - const intent = enemyData.intents.find(i => i.initialIntent) ?? enemyData.intents[0]; - const instanceId = `${enemyId}-${instanceIndex}`; - const intentMap: Record = {}; - for (const i of enemyData.intents) { - intentMap[i.id] = i; - } - return { - id: instanceId, - enemy: enemyData, - hp, - maxHp, - isAlive: true, - effects: {}, - intents: intentMap, - currentIntent: intent, - }; +function createEnemyEntity( + enemyId: string, + hp: number, + maxHp: number, + instanceIndex = 0, +): EnemyEntity { + const enemyData = enemies.find((e) => e.id === enemyId); + if (!enemyData) throw new Error(`Enemy "${enemyId}" not found`); + const intent = + enemyData.intents.find((i) => i.initialIntent) ?? enemyData.intents[0]; + const instanceId = `${enemyId}-${instanceIndex}`; + const intentMap: Record = {}; + for (const i of enemyData.intents) { + intentMap[i.id] = i; + } + return { + id: instanceId, + enemy: enemyData, + hp, + maxHp, + isAlive: true, + effects: {}, + intents: intentMap, + currentIntent: intent, + }; } -function createInventory(itemsList: InventoryItem[]): GridInventory { - const map = new Map>(); - const occupied = new Set(); - for (const item of itemsList) { - map.set(item.id, item); - occupied.add(`${item.transform.x},${item.transform.y}`); - } - return { width: 6, height: 4, items: map, occupiedCells: occupied }; +function createInventory( + itemsList: InventoryItem[], +): GridInventory { + const map = new Map>(); + const occupied = new Set(); + for (const item of itemsList) { + map.set(item.id, item); + occupied.add(`${item.transform.offset.x},${item.transform.offset.y}`); + } + return { width: 6, height: 4, items: map, occupiedCells: occupied }; } function createCombatState(overrides: Partial = {}): CombatState { - const regions = createDeckRegions(); - return { - player: { - id: 'player', - effects: {}, - hp: 30, - maxHp: 30, - isAlive: true, - energy: 3, - maxEnergy: 3, - deck: { cards: {}, regions }, - itemEffects: {}, - }, - enemies: [], - inventory: createInventory([]), - phase: 'playerTurn', - turnNumber: 1, - result: null, - loot: [], - ...overrides, - }; + const regions = createDeckRegions(); + return { + player: { + id: "player", + effects: {}, + hp: 30, + maxHp: 30, + isAlive: true, + energy: 3, + maxEnergy: 3, + deck: { cards: {}, regions }, + itemEffects: {}, + }, + enemies: [], + inventory: createInventory([]), + phase: "playerTurn", + turnNumber: 1, + result: null, + loot: [], + ...overrides, + }; } function createTestContext(state?: CombatState): IGameContext { - const registry = createGameCommandRegistry(); - const ctx = createGameContext(registry, state ?? createCombatState()); - ctx._rng.setSeed(42); - return ctx; + const registry = createGameCommandRegistry(); + const ctx = createGameContext(registry, state ?? createCombatState()); + ctx._rng.setSeed(42); + return ctx; } function getTriggers(): Triggers { - let capturedTriggers: Triggers; - createStartWith(triggers => { - capturedTriggers = triggers; - addTriggers(triggers); - }); - return capturedTriggers!; + const triggers = createTriggers(); + addTriggers(triggers); + return triggers; } function addCardToHand(ctx: IGameContext, card: GameCard) { - ctx._state.produce(draft => { - draft.player.deck.cards[card.id] = card; - card.regionId = 'hand'; - draft.player.deck.regions.hand.childIds.push(card.id); - }); + ctx._state.produce((draft) => { + draft.player.deck.cards[card.id] = card; + card.regionId = "hand"; + draft.player.deck.regions.hand.childIds.push(card.id); + }); } function addCardToDrawPile(ctx: IGameContext, card: GameCard) { - ctx._state.produce(draft => { - draft.player.deck.cards[card.id] = card; - card.regionId = 'drawPile'; - draft.player.deck.regions.drawPile.childIds.push(card.id); - }); + ctx._state.produce((draft) => { + draft.player.deck.cards[card.id] = card; + card.regionId = "drawPile"; + draft.player.deck.regions.drawPile.childIds.push(card.id); + }); } function addCardToDiscardPile(ctx: IGameContext, card: GameCard) { - ctx._state.produce(draft => { - draft.player.deck.cards[card.id] = card; - card.regionId = 'discardPile'; - draft.player.deck.regions.discardPile.childIds.push(card.id); - }); + ctx._state.produce((draft) => { + draft.player.deck.cards[card.id] = card; + card.regionId = "discardPile"; + draft.player.deck.regions.discardPile.childIds.push(card.id); + }); } function makeDummyEnemy() { - return createEnemyEntity('仙人掌怪', 999, 999); + return createEnemyEntity("仙人掌怪", 999, 999); } -describe('desert triggers', () => { - describe('instant effects', () => { - it('should apply attack effect as damage', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [makeDummyEnemy()], - })); - const triggers = getTriggers(); - const attackEffect = createEffect('attack'); +describe("desert triggers", () => { + describe("instant effects", () => { + it("should apply attack effect as damage", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [makeDummyEnemy()], + }), + ); + const triggers = getTriggers(); + const attackEffect = createEffect("attack"); - await triggers.onEffectApplied.execute(ctx, { - effect: attackEffect, - entityKey: 'player', - stacks: 5, - sourceEntityKey: 'enemy-0', - }); + await triggers.onEffectApplied.execute(ctx, { + effect: attackEffect, + entityKey: "player", + stacks: 5, + sourceEntityKey: "enemy-0", + }); - expect(ctx.value.player.hp).toBe(25); - }); - - it('should apply draw effect', async () => { - const ctx = createTestContext(); - const triggers = getTriggers(); - const drawEffect = createEffect('draw'); - - addCardToDrawPile(ctx, createCard('card-1', 'sword')); - addCardToDrawPile(ctx, createCard('card-2', 'sword')); - - await triggers.onEffectApplied.execute(ctx, { - effect: drawEffect, - entityKey: 'player', - stacks: 2, - }); - - expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2); - expect(ctx.value.player.deck.regions.drawPile.childIds.length).toBe(0); - }); - - it('should apply gainEnergy effect', async () => { - const ctx = createTestContext(); - const triggers = getTriggers(); - const gainEnergyEffect = createEffect('gainEnergy'); - - const initialEnergy = ctx.value.player.energy; - await triggers.onEffectApplied.execute(ctx, { - effect: gainEnergyEffect, - entityKey: 'player', - stacks: 2, - }); - - expect(ctx.value.player.energy).toBe(initialEnergy + 2); - }); - - it('should remove wound cards from draw and discard piles', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [makeDummyEnemy()], - })); - const triggers = getTriggers(); - const removeWoundEffect = createEffect('removeWound'); - - addCardToDrawPile(ctx, createCard('wound-1', 'wound', 'none', 0)); - addCardToDiscardPile(ctx, createCard('wound-2', 'wound', 'none', 0)); - addCardToDrawPile(ctx, createCard('sword-1', 'sword')); - - await triggers.onEffectApplied.execute(ctx, { - effect: removeWoundEffect, - entityKey: 'player', - stacks: 2, - }); - - expect(ctx.value.player.deck.cards['wound-1']).toBeUndefined(); - expect(ctx.value.player.deck.cards['wound-2']).toBeUndefined(); - expect(ctx.value.player.deck.cards['sword-1']).toBeDefined(); - }); + expect(ctx.value.player.hp).toBe(25); }); - describe('damage pipeline', () => { - it('should prevent damage with block', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [makeDummyEnemy()], - })); - const triggers = getTriggers(); - const defendEffect = createEffect('defend', 'posture'); + it("should apply draw effect", async () => { + const ctx = createTestContext(); + const triggers = getTriggers(); + const drawEffect = createEffect("draw"); - ctx._state.produce(draft => { - draft.player.effects.defend = { data: defendEffect, stacks: 5 }; - }); + addCardToDrawPile(ctx, createCard("card-1", "sword")); + addCardToDrawPile(ctx, createCard("card-2", "sword")); - await triggers.onDamage.execute(ctx, { - entityKey: 'player', - amount: 8, - sourceEntityKey: 'enemy-0', - }); + await triggers.onEffectApplied.execute(ctx, { + effect: drawEffect, + entityKey: "player", + stacks: 2, + }); - expect(ctx.value.player.hp).toBe(27); - expect(ctx.value.player.effects.defend?.stacks).toBe(2); - }); - - it('should reduce damage with damageReduce', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [makeDummyEnemy()], - })); - const triggers = getTriggers(); - const damageReduceEffect = createEffect('damageReduce', 'temporary'); - - ctx._state.produce(draft => { - draft.player.effects.damageReduce = { data: damageReduceEffect, stacks: 3 }; - }); - - await triggers.onDamage.execute(ctx, { - entityKey: 'player', - amount: 8, - sourceEntityKey: 'enemy-0', - }); - - expect(ctx.value.player.hp).toBe(25); - }); - - it('should increase damage with expose', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [makeDummyEnemy()], - })); - const triggers = getTriggers(); - const exposeEffect = createEffect('expose', 'temporary'); - - ctx._state.produce(draft => { - draft.player.effects.expose = { data: exposeEffect, stacks: 2 }; - }); - - await triggers.onDamage.execute(ctx, { - entityKey: 'player', - amount: 5, - sourceEntityKey: 'enemy-0', - }); - - expect(ctx.value.player.hp).toBe(23); - }); + expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2); + expect(ctx.value.player.deck.regions.drawPile.childIds.length).toBe(0); }); - describe('spike reflection', () => { - it('should damage attacker when entity has spike', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [createEnemyEntity('仙人掌怪', 12, 12)], - })); - const triggers = getTriggers(); - const spikeEffect = createEffect('spike', 'permanent'); + it("should apply gainEnergy effect", async () => { + const ctx = createTestContext(); + const triggers = getTriggers(); + const gainEnergyEffect = createEffect("gainEnergy"); - ctx._state.produce(draft => { - const enemy = draft.enemies[0]; - enemy.effects.spike = { data: spikeEffect, stacks: 3 }; - }); + const initialEnergy = ctx.value.player.energy; + await triggers.onEffectApplied.execute(ctx, { + effect: gainEnergyEffect, + entityKey: "player", + stacks: 2, + }); - await triggers.onDamage.execute(ctx, { - entityKey: '仙人掌怪-0', - amount: 5, - sourceEntityKey: 'player', - }); - - expect(ctx.value.player.hp).toBe(27); - }); + expect(ctx.value.player.energy).toBe(initialEnergy + 2); }); - describe('storm static card generation', () => { - it('should give player static cards when storm enemy executes intent', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [createEnemyEntity('风暴之灵', 30, 30)], - })); - const triggers = getTriggers(); - const stormEffect = createEffect('storm', 'permanent'); + it("should remove wound cards from draw and discard piles", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [makeDummyEnemy()], + }), + ); + const triggers = getTriggers(); + const removeWoundEffect = createEffect("removeWound"); - ctx._state.produce(draft => { - const enemy = draft.enemies[0]; - enemy.effects.storm = { data: stormEffect, stacks: 2 }; - }); + addCardToDrawPile(ctx, createCard("wound-1", "wound", "none", 0)); + addCardToDiscardPile(ctx, createCard("wound-2", "wound", "none", 0)); + addCardToDrawPile(ctx, createCard("sword-1", "sword")); - await triggers.onEnemyIntent.execute(ctx, { enemyId: '风暴之灵-0' }); + await triggers.onEffectApplied.execute(ctx, { + effect: removeWoundEffect, + entityKey: "player", + stacks: 2, + }); - const staticCards = Object.values(ctx.value.player.deck.cards).filter((c: GameCard) => c.itemId === 'static'); - expect(staticCards.length).toBe(2); - }); + expect(ctx.value.player.deck.cards["wound-1"]).toBeUndefined(); + expect(ctx.value.player.deck.cards["wound-2"]).toBeUndefined(); + expect(ctx.value.player.deck.cards["sword-1"]).toBeDefined(); + }); + }); + + describe("damage pipeline", () => { + it("should prevent damage with block", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [makeDummyEnemy()], + }), + ); + const triggers = getTriggers(); + const defendEffect = createEffect("defend", "posture"); + + ctx._state.produce((draft) => { + draft.player.effects.defend = { data: defendEffect, stacks: 5 }; + }); + + await triggers.onDamage.execute(ctx, { + entityKey: "player", + amount: 8, + sourceEntityKey: "enemy-0", + }); + + expect(ctx.value.player.hp).toBe(27); + expect(ctx.value.player.effects.defend?.stacks).toBe(2); }); - describe('energyDrain', () => { - it('should drain player energy when energyDrain enemy takes damage', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [createEnemyEntity('幼沙虫', 18, 18)], - })); - const triggers = getTriggers(); - const energyDrainEffect = createEffect('energyDrain', 'lingering'); + it("should reduce damage with damageReduce", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [makeDummyEnemy()], + }), + ); + const triggers = getTriggers(); + const damageReduceEffect = createEffect("damageReduce", "temporary"); - ctx._state.produce(draft => { - const enemy = draft.enemies[0]; - enemy.effects.energyDrain = { data: energyDrainEffect, stacks: 1 }; - }); + ctx._state.produce((draft) => { + draft.player.effects.damageReduce = { + data: damageReduceEffect, + stacks: 3, + }; + }); - await triggers.onDamage.execute(ctx, { - entityKey: '幼沙虫-0', - amount: 5, - sourceEntityKey: 'player', - }); + await triggers.onDamage.execute(ctx, { + entityKey: "player", + amount: 8, + sourceEntityKey: "enemy-0", + }); - expect(ctx.value.player.energy).toBe(2); - }); + expect(ctx.value.player.hp).toBe(25); }); - describe('molt flee', () => { - it('should make enemy flee when molt >= maxHp after taking damage', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [createEnemyEntity('蜥蜴', 14, 14)], - })); - const triggers = getTriggers(); - const moltEffect = createEffect('molt', 'posture'); + it("should increase damage with expose", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [makeDummyEnemy()], + }), + ); + const triggers = getTriggers(); + const exposeEffect = createEffect("expose", "temporary"); - ctx._state.produce(draft => { - const enemy = draft.enemies[0]; - enemy.effects.molt = { data: moltEffect, stacks: 14 }; - }); + ctx._state.produce((draft) => { + draft.player.effects.expose = { data: exposeEffect, stacks: 2 }; + }); - let threw = false; - try { - await triggers.onDamage.execute(ctx, { - entityKey: '蜥蜴-0', - amount: 1, - sourceEntityKey: 'player', - }); - } catch (e) { - threw = true; - } + await triggers.onDamage.execute(ctx, { + entityKey: "player", + amount: 5, + sourceEntityKey: "enemy-0", + }); - expect(threw).toBe(true); - expect(ctx.value.result).toBe('victory'); - expect(ctx.value.enemies[0].isAlive).toBe(false); + expect(ctx.value.player.hp).toBe(23); + }); + }); + + describe("spike reflection", () => { + it("should damage attacker when entity has spike", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [createEnemyEntity("仙人掌怪", 12, 12)], + }), + ); + const triggers = getTriggers(); + const spikeEffect = createEffect("spike", "permanent"); + + ctx._state.produce((draft) => { + const enemy = draft.enemies[0]; + enemy.effects.spike = { data: spikeEffect, stacks: 3 }; + }); + + await triggers.onDamage.execute(ctx, { + entityKey: "仙人掌怪-0", + amount: 5, + sourceEntityKey: "player", + }); + + expect(ctx.value.player.hp).toBe(27); + }); + }); + + describe("storm static card generation", () => { + it("should give player static cards when storm enemy executes intent", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [createEnemyEntity("风暴之灵", 30, 30)], + }), + ); + const triggers = getTriggers(); + const stormEffect = createEffect("storm", "permanent"); + + ctx._state.produce((draft) => { + const enemy = draft.enemies[0]; + enemy.effects.storm = { data: stormEffect, stacks: 2 }; + }); + + await triggers.onEnemyIntent.execute(ctx, { enemyId: "风暴之灵-0" }); + + const staticCards = Object.values(ctx.value.player.deck.cards).filter( + (c: GameCard) => c.itemId === "static", + ); + expect(staticCards.length).toBe(2); + }); + }); + + describe("energyDrain", () => { + it("should drain player energy when energyDrain enemy takes damage", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [createEnemyEntity("幼沙虫", 18, 18)], + }), + ); + const triggers = getTriggers(); + const energyDrainEffect = createEffect("energyDrain", "lingering"); + + ctx._state.produce((draft) => { + const enemy = draft.enemies[0]; + enemy.effects.energyDrain = { data: energyDrainEffect, stacks: 1 }; + }); + + await triggers.onDamage.execute(ctx, { + entityKey: "幼沙虫-0", + amount: 5, + sourceEntityKey: "player", + }); + + expect(ctx.value.player.energy).toBe(2); + }); + }); + + describe("molt flee", () => { + it("should make enemy flee when molt >= maxHp after taking damage", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [createEnemyEntity("蜥蜴", 14, 14)], + }), + ); + const triggers = getTriggers(); + const moltEffect = createEffect("molt", "posture"); + + ctx._state.produce((draft) => { + const enemy = draft.enemies[0]; + enemy.effects.molt = { data: moltEffect, stacks: 14 }; + }); + + let threw = false; + try { + await triggers.onDamage.execute(ctx, { + entityKey: "蜥蜴-0", + amount: 1, + sourceEntityKey: "player", }); + } catch (e) { + threw = true; + } + + expect(threw).toBe(true); + expect(ctx.value.result).toBe("victory"); + expect(ctx.value.enemies[0].isAlive).toBe(false); + }); + }); + + describe("discard at turn start", () => { + it("should randomly discard a card when discard effect is active", async () => { + const ctx = createTestContext(); + const triggers = getTriggers(); + const discardEffect = createEffect("discard", "lingering"); + + addCardToHand(ctx, createCard("card-1", "sword")); + addCardToHand(ctx, createCard("card-2", "shield")); + addCardToHand(ctx, createCard("card-3", "dagger")); + + ctx._state.produce((draft) => { + draft.player.effects.discard = { data: discardEffect, stacks: 1 }; + }); + + await triggers.onTurnStart.execute(ctx, { entityKey: "player" }); + + expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2); + expect(ctx.value.player.deck.regions.discardPile.childIds.length).toBe(1); + }); + }); + + describe("next-turn effects", () => { + it("should gain block from defendNext at turn start", async () => { + const ctx = createTestContext(); + const triggers = getTriggers(); + const defendNextEffect = createEffect("defendNext", "temporary"); + + ctx._state.produce((draft) => { + draft.player.effects.defendNext = { data: defendNextEffect, stacks: 5 }; + }); + + await triggers.onTurnStart.execute(ctx, { entityKey: "player" }); + + expect(ctx.value.player.effects.defend?.stacks).toBe(5); + expect(ctx.value.player.effects.defendNext).toBeUndefined(); }); - describe('discard at turn start', () => { - it('should randomly discard a card when discard effect is active', async () => { - const ctx = createTestContext(); - const triggers = getTriggers(); - const discardEffect = createEffect('discard', 'lingering'); + it("should gain energy from energyNext at turn start", async () => { + const ctx = createTestContext(); + const triggers = getTriggers(); + const energyNextEffect = createEffect("energyNext", "temporary"); - addCardToHand(ctx, createCard('card-1', 'sword')); - addCardToHand(ctx, createCard('card-2', 'shield')); - addCardToHand(ctx, createCard('card-3', 'dagger')); + ctx._state.produce((draft) => { + draft.player.effects.energyNext = { data: energyNextEffect, stacks: 2 }; + }); - ctx._state.produce(draft => { - draft.player.effects.discard = { data: discardEffect, stacks: 1 }; - }); + await triggers.onTurnStart.execute(ctx, { entityKey: "player" }); - await triggers.onTurnStart.execute(ctx, { entityKey: 'player' }); - - expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2); - expect(ctx.value.player.deck.regions.discardPile.childIds.length).toBe(1); - }); + expect(ctx.value.player.energy).toBe(5); + expect(ctx.value.player.effects.energyNext).toBeUndefined(); }); - describe('next-turn effects', () => { - it('should gain block from defendNext at turn start', async () => { - const ctx = createTestContext(); - const triggers = getTriggers(); - const defendNextEffect = createEffect('defendNext', 'temporary'); + it("should draw extra cards from drawNext at turn start", async () => { + const ctx = createTestContext(); + const triggers = getTriggers(); + const drawNextEffect = createEffect("drawNext", "temporary"); - ctx._state.produce(draft => { - draft.player.effects.defendNext = { data: defendNextEffect, stacks: 5 }; - }); + addCardToDrawPile(ctx, createCard("card-1", "sword")); + addCardToDrawPile(ctx, createCard("card-2", "sword")); + addCardToDrawPile(ctx, createCard("card-3", "sword")); - await triggers.onTurnStart.execute(ctx, { entityKey: 'player' }); + ctx._state.produce((draft) => { + draft.player.effects.drawNext = { data: drawNextEffect, stacks: 2 }; + }); - expect(ctx.value.player.effects.defend?.stacks).toBe(5); - expect(ctx.value.player.effects.defendNext).toBeUndefined(); - }); + await triggers.onTurnStart.execute(ctx, { entityKey: "player" }); - it('should gain energy from energyNext at turn start', async () => { - const ctx = createTestContext(); - const triggers = getTriggers(); - const energyNextEffect = createEffect('energyNext', 'temporary'); + expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2); + expect(ctx.value.player.effects.drawNext).toBeUndefined(); + }); + }); - ctx._state.produce(draft => { - draft.player.effects.energyNext = { data: energyNextEffect, stacks: 2 }; - }); + describe("posture damage effects", () => { + it("should double damage with aim", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [createEnemyEntity("仙人掌怪", 12, 12)], + }), + ); + const triggers = getTriggers(); + const aimEffect = createEffect("aim", "posture"); - await triggers.onTurnStart.execute(ctx, { entityKey: 'player' }); + ctx._state.produce((draft) => { + draft.player.effects.aim = { data: aimEffect, stacks: 2 }; + }); - expect(ctx.value.player.energy).toBe(5); - expect(ctx.value.player.effects.energyNext).toBeUndefined(); - }); + await triggers.onDamage.execute(ctx, { + entityKey: "仙人掌怪-0", + amount: 5, + sourceEntityKey: "player", + }); - it('should draw extra cards from drawNext at turn start', async () => { - const ctx = createTestContext(); - const triggers = getTriggers(); - const drawNextEffect = createEffect('drawNext', 'temporary'); - - addCardToDrawPile(ctx, createCard('card-1', 'sword')); - addCardToDrawPile(ctx, createCard('card-2', 'sword')); - addCardToDrawPile(ctx, createCard('card-3', 'sword')); - - ctx._state.produce(draft => { - draft.player.effects.drawNext = { data: drawNextEffect, stacks: 2 }; - }); - - await triggers.onTurnStart.execute(ctx, { entityKey: 'player' }); - - expect(ctx.value.player.deck.regions.hand.childIds.length).toBe(2); - expect(ctx.value.player.effects.drawNext).toBeUndefined(); - }); + expect(ctx.value.enemies[0].hp).toBe(2); }); - describe('posture damage effects', () => { - it('should double damage with aim', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [createEnemyEntity('仙人掌怪', 12, 12)], - })); - const triggers = getTriggers(); - const aimEffect = createEffect('aim', 'posture'); + it("should add bonus damage with roll", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [createEnemyEntity("仙人掌怪", 99, 99)], + }), + ); + const triggers = getTriggers(); + const rollEffect = createEffect("roll", "posture"); - ctx._state.produce(draft => { - draft.player.effects.aim = { data: aimEffect, stacks: 2 }; - }); + ctx._state.produce((draft) => { + draft.player.effects.roll = { data: rollEffect, stacks: 20 }; + }); - await triggers.onDamage.execute(ctx, { - entityKey: '仙人掌怪-0', - amount: 5, - sourceEntityKey: 'player', - }); + await triggers.onDamage.execute(ctx, { + entityKey: "仙人掌怪-0", + amount: 5, + sourceEntityKey: "player", + }); - expect(ctx.value.enemies[0].hp).toBe(2); - }); - - it('should add bonus damage with roll', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [createEnemyEntity('仙人掌怪', 99, 99)], - })); - const triggers = getTriggers(); - const rollEffect = createEffect('roll', 'posture'); - - ctx._state.produce(draft => { - draft.player.effects.roll = { data: rollEffect, stacks: 20 }; - }); - - await triggers.onDamage.execute(ctx, { - entityKey: '仙人掌怪-0', - amount: 5, - sourceEntityKey: 'player', - }); - - expect(ctx.value.enemies[0].hp).toBe(74); - expect(ctx.value.player.effects.roll).toBeUndefined(); - }); - - it('should add bonus damage with tailSting', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [createEnemyEntity('沙蝎', 10, 10)], - })); - const triggers = getTriggers(); - const tailStingEffect = createEffect('tailSting', 'posture'); - - ctx._state.produce(draft => { - const enemy = draft.enemies[0]; - enemy.effects.tailSting = { data: tailStingEffect, stacks: 2 }; - }); - - await triggers.onDamage.execute(ctx, { - entityKey: 'player', - amount: 5, - sourceEntityKey: '沙蝎-0', - }); - - expect(ctx.value.player.hp).toBe(23); - }); - - it('should double damage with charge on attacker', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [createEnemyEntity('骑马枪手', 25, 25)], - })); - const triggers = getTriggers(); - const chargeEffect = createEffect('charge', 'lingering'); - - ctx._state.produce(draft => { - const enemy = draft.enemies[0]; - enemy.effects.charge = { data: chargeEffect, stacks: 2 }; - }); - - await triggers.onDamage.execute(ctx, { - entityKey: 'player', - amount: 5, - sourceEntityKey: '骑马枪手-0', - }); - - expect(ctx.value.player.hp).toBe(20); - }); + expect(ctx.value.enemies[0].hp).toBe(74); + expect(ctx.value.player.effects.roll).toBeUndefined(); }); - describe('crossbow chain', () => { - it('should replay other crossbows on same target', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [createEnemyEntity('仙人掌怪', 20, 20)], - })); - const triggers = getTriggers(); - const crossbowEffect = createEffect('crossbow'); + it("should add bonus damage with tailSting", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [createEnemyEntity("沙蝎", 10, 10)], + }), + ); + const triggers = getTriggers(); + const tailStingEffect = createEffect("tailSting", "posture"); - addCardToHand(ctx, createCard('crossbow-1', 'crossbow')); - addCardToHand(ctx, createCard('crossbow-2', 'crossbow')); + ctx._state.produce((draft) => { + const enemy = draft.enemies[0]; + enemy.effects.tailSting = { data: tailStingEffect, stacks: 2 }; + }); - await triggers.onEffectApplied.execute(ctx, { - effect: crossbowEffect, - entityKey: 'player', - stacks: 0, - cardId: 'crossbow-1', - sourceEntityKey: 'player', - targetId: '仙人掌怪-0', - }); + await triggers.onDamage.execute(ctx, { + entityKey: "player", + amount: 5, + sourceEntityKey: "沙蝎-0", + }); - expect(ctx.value.enemies[0].hp).toBe(8); - }); + expect(ctx.value.player.hp).toBe(23); }); - describe('sandwormKing fatigue heal', () => { - it('should heal sandworm king when player discards fatigue', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [createEnemyEntity('沙虫王', 30, 40)], - })); - const triggers = getTriggers(); + it("should double damage with charge on attacker", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [createEnemyEntity("骑马枪手", 25, 25)], + }), + ); + const triggers = getTriggers(); + const chargeEffect = createEffect("charge", "lingering"); - addCardToHand(ctx, createCard('fatigue-1', 'fatigue', 'none', 0)); + ctx._state.produce((draft) => { + const enemy = draft.enemies[0]; + enemy.effects.charge = { data: chargeEffect, stacks: 2 }; + }); - await triggers.onCardDiscarded.execute(ctx, { - cardId: 'fatigue-1', - sourceEntityKey: 'player', - }); + await triggers.onDamage.execute(ctx, { + entityKey: "player", + amount: 5, + sourceEntityKey: "骑马枪手-0", + }); - expect(ctx.value.enemies[0].hp).toBe(40); - }); + expect(ctx.value.player.hp).toBe(20); }); + }); - describe('vulture on-damage', () => { - it('should give player vultureEye when vulture deals damage', async () => { - const ctx = createTestContext(createCombatState({ - enemies: [createEnemyEntity('秃鹫', 12, 12)], - })); - const triggers = getTriggers(); + describe("crossbow chain", () => { + it("should replay other crossbows on same target", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [createEnemyEntity("仙人掌怪", 20, 20)], + }), + ); + const triggers = getTriggers(); + const crossbowEffect = createEffect("crossbow"); - await triggers.onDamage.execute(ctx, { - entityKey: 'player', - amount: 5, - sourceEntityKey: '秃鹫-0', - }); + addCardToHand(ctx, createCard("crossbow-1", "crossbow")); + addCardToHand(ctx, createCard("crossbow-2", "crossbow")); - const vultureEyeCards = Object.values(ctx.value.player.deck.cards).filter((c: GameCard) => c.itemId === 'vultureEye'); - expect(vultureEyeCards.length).toBe(1); - }); + await triggers.onEffectApplied.execute(ctx, { + effect: crossbowEffect, + entityKey: "player", + stacks: 0, + cardId: "crossbow-1", + sourceEntityKey: "player", + targetId: "仙人掌怪-0", + }); + + expect(ctx.value.enemies[0].hp).toBe(8); }); + }); + + describe("sandwormKing fatigue heal", () => { + it("should heal sandworm king when player discards fatigue", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [createEnemyEntity("沙虫王", 30, 40)], + }), + ); + const triggers = getTriggers(); + + addCardToHand(ctx, createCard("fatigue-1", "fatigue", "none", 0)); + + await triggers.onCardDiscarded.execute(ctx, { + cardId: "fatigue-1", + sourceEntityKey: "player", + }); + + expect(ctx.value.enemies[0].hp).toBe(40); + }); + }); + + describe("vulture on-damage", () => { + it("should give player vultureEye when vulture deals damage", async () => { + const ctx = createTestContext( + createCombatState({ + enemies: [createEnemyEntity("秃鹫", 12, 12)], + }), + ); + const triggers = getTriggers(); + + await triggers.onDamage.execute(ctx, { + entityKey: "player", + amount: 5, + sourceEntityKey: "秃鹫-0", + }); + + const vultureEyeCards = Object.values(ctx.value.player.deck.cards).filter( + (c: GameCard) => c.itemId === "vultureEye", + ); + expect(vultureEyeCards.length).toBe(1); + }); + }); }); diff --git a/tests/samples/slay-the-spire-like/data/index.test.ts b/tests/samples/slay-the-spire-like/data/index.test.ts index 3c2aa1d..9c3b7de 100644 --- a/tests/samples/slay-the-spire-like/data/index.test.ts +++ b/tests/samples/slay-the-spire-like/data/index.test.ts @@ -1,8 +1,8 @@ -import { describe, it, expect } from 'vitest'; -import data from '@/samples/slay-the-spire-like/data'; +import { describe, it, expect } from "vitest"; +import data from "@/samples/slay-the-spire-like/data"; -describe('data import', () => { - it('should import properly', () => { - expect(data.desert.effects).toBeDefined(); - }); +describe("data import", () => { + it("should import properly", () => { + expect(data.desert.getEffects).toBeDefined(); + }); });