refactor: decouple card effects from card data in desert sample

Moves card effects from `card.csv` to a dedicated `cardEffect.csv` file.
This allows for more granular control over card triggers (onPlay,
onDraw, onDiscard) and targets, improving the data model for the
slay-the-spire-like sample. Also updates triggers and tests to
reflect this new structure.
This commit is contained in:
hypercross 2026-04-19 23:28:56 +08:00
parent 3840c3d739
commit 2f2e4e56b5
9 changed files with 1588 additions and 1274 deletions

View File

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

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

View File

@ -1,4 +1,4 @@
import type { Effect } from './effect.csv'; import type { CardEffect } from './cardEffect.csv';
type CardTable = readonly { type CardTable = readonly {
readonly id: string; readonly id: string;
@ -8,7 +8,7 @@ type CardTable = readonly {
readonly costType: "energy" | "uses" | "none"; readonly costType: "energy" | "uses" | "none";
readonly costCount: number; readonly costCount: number;
readonly targetType: "single" | "none"; readonly targetType: "single" | "none";
readonly effects: ["onPlay" | "onDraw" | "onDiscard", "self" | "target" | "all" | "random", Effect, number][]; readonly effects: CardEffect[];
}[]; }[];
export type Card = CardTable[number]; export type Card = CardTable[number];

View File

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

View File

@ -0,0 +1,15 @@
import type { Card } from './card.csv';
import type { Effect } from './effect.csv';
type CardEffectTable = readonly {
readonly id: string;
readonly card: Card;
readonly trigger: "onPlay" | "onDraw" | "onDiscard";
readonly target: "self" | "target" | "all" | "random";
readonly effects: [Effect, number][];
}[];
export type CardEffect = CardEffectTable[number];
declare function getData(): CardEffectTable;
export default getData;

View File

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

View File

@ -1,16 +1,26 @@
export type EffectData = { export type EffectData = {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly description: string; readonly description: string;
readonly lifecycle: EffectLifecycle; readonly lifecycle: EffectLifecycle;
}; };
export type EffectLifecycle = "instant" | "temporary" | "lingering" | "permanent" | "posture" | "item" | "itemTemporary" | "itemUntilPlay" | "itemUntilDiscard" | "itemPermanent"; export type EffectLifecycle =
| "instant"
| "temporary"
| "lingering"
| "permanent"
| "posture"
| "item"
| "itemTemporary"
| "itemUntilPlay"
| "itemUntilDiscard"
| "itemPermanent";
export type EnemyData = { export type EnemyData = {
readonly id: string; readonly id: string;
readonly name: string; readonly name: string;
readonly intents: readonly IntentData[]; readonly intents: readonly IntentData[];
readonly description: string; readonly description: string;
}; };
export type CardType = "item" | "status"; export type CardType = "item" | "status";
@ -18,44 +28,62 @@ export type CardCostType = "energy" | "uses" | "none";
export type CardTargetType = "single" | "none"; export type CardTargetType = "single" | "none";
export type EffectTarget = "self" | "player" | "team"; export type EffectTarget = "self" | "player" | "team";
export type CardData = {
readonly id: string;
readonly name: string;
readonly desc: string;
readonly type: CardType;
readonly costType: CardCostType;
readonly costCount: number;
readonly targetType: CardTargetType;
readonly effects: readonly [CardEffectTrigger, CardEffectTarget, EffectData, number][];
};
export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard"; export type CardEffectTrigger = "onPlay" | "onDraw" | "onDiscard";
export type CardEffectTarget = "self" | "target" | "all" | "random" export type CardEffectTarget = "self" | "target" | "all" | "random";
export type EncounterType = "minion" | "elite" | "event" | "shop" | "camp" | "curio"; export type CardEffect = {
readonly id: string;
readonly trigger: CardEffectTrigger;
readonly target: CardEffectTarget;
readonly effects: readonly [EffectData, number][];
};
export type CardData = {
readonly id: string;
readonly name: string;
readonly desc: string;
readonly type: CardType;
readonly costType: CardCostType;
readonly costCount: number;
readonly targetType: CardTargetType;
readonly effects: readonly CardEffect[];
};
export type EncounterType =
| "minion"
| "elite"
| "event"
| "shop"
| "camp"
| "curio";
export type EncounterData = { export type EncounterData = {
readonly id: string; readonly id: string;
readonly type: EncounterType; readonly type: EncounterType;
readonly name: string; readonly name: string;
readonly description: string; readonly description: string;
readonly enemies: readonly [data: EnemyData, hp: number, effects: [EffectData, stacks: number][]][]; readonly enemies: readonly [
readonly dialogue: string; data: EnemyData,
hp: number,
effects: [EffectData, stacks: number][],
][];
readonly dialogue: string;
}; };
export type IntentData = { export type IntentData = {
readonly id: string; readonly id: string;
readonly enemy: EnemyData; readonly enemy: EnemyData;
readonly initialIntent: boolean; readonly initialIntent: boolean;
readonly nextIntents: readonly IntentData[]; readonly nextIntents: readonly IntentData[];
readonly brokenIntent: readonly IntentData[]; readonly brokenIntent: readonly IntentData[];
readonly effects: readonly [EffectTarget, EffectData, number][]; readonly effects: readonly [EffectTarget, EffectData, number][];
}; };
export type ItemData = { export type ItemData = {
readonly id: string; readonly id: string;
readonly type: string; readonly type: string;
readonly name: string; readonly name: string;
readonly shape: string; readonly shape: string;
readonly card: CardData; readonly card: CardData;
readonly price: number; readonly price: number;
readonly description: string; readonly description: string;
}; };

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,8 +1,8 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from "vitest";
import data from '@/samples/slay-the-spire-like/data'; import data from "@/samples/slay-the-spire-like/data";
describe('data import', () => { describe("data import", () => {
it('should import properly', () => { it("should import properly", () => {
expect(data.desert.effects).toBeDefined(); expect(data.desert.getEffects).toBeDefined();
}); });
}); });