feat: slay-the-spire-like combat procedures
This commit is contained in:
parent
976ee43ed3
commit
94c7c91745
|
|
@ -0,0 +1,538 @@
|
|||
import type { EffectDesert } from "../data/effectDesert.csv";
|
||||
import type { StatusCardDesert } from "../data/statusCardDesert.csv";
|
||||
import { effectDesertData, statusCardDesertData } from "../data";
|
||||
import { createStatusCard } from "../deck/factory";
|
||||
import type { PlayerDeck, GameCard } from "../deck/types";
|
||||
import type {
|
||||
BuffTable,
|
||||
CombatEffectEntry,
|
||||
CombatState,
|
||||
EffectTarget,
|
||||
EffectTiming,
|
||||
EnemyState,
|
||||
ItemBuff,
|
||||
PlayerCombatState,
|
||||
} from "./types";
|
||||
import {
|
||||
drawCardsToHand,
|
||||
addFatigueCards,
|
||||
discardCard,
|
||||
exhaustCard,
|
||||
getEffectData,
|
||||
discardHand,
|
||||
} from "./state";
|
||||
|
||||
export type DamageResult = {
|
||||
damageDealt: number;
|
||||
blockedByDefend: number;
|
||||
targetDied: boolean;
|
||||
};
|
||||
|
||||
export function applyDamage(
|
||||
state: CombatState,
|
||||
targetKey: "player" | string,
|
||||
amount: number,
|
||||
sourceKey?: "player" | string,
|
||||
): DamageResult {
|
||||
if (amount <= 0) return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
|
||||
|
||||
let actualDamage = amount;
|
||||
let blockedByDefend = 0;
|
||||
|
||||
if (targetKey === "player") {
|
||||
const defendStacks = state.player.buffs["defend"] ?? 0;
|
||||
if (defendStacks > 0) {
|
||||
blockedByDefend = Math.min(defendStacks, actualDamage);
|
||||
actualDamage -= blockedByDefend;
|
||||
state.player.buffs["defend"] = defendStacks - blockedByDefend;
|
||||
if (state.player.buffs["defend"] === 0) {
|
||||
delete state.player.buffs["defend"];
|
||||
}
|
||||
}
|
||||
|
||||
const damageReduce = state.player.buffs["damageReduce"] ?? 0;
|
||||
if (damageReduce > 0 && actualDamage > 0) {
|
||||
actualDamage = Math.max(0, actualDamage - damageReduce);
|
||||
}
|
||||
|
||||
if (actualDamage > 0) {
|
||||
state.player.hp = Math.max(0, state.player.hp - actualDamage);
|
||||
state.player.damageTakenThisTurn += actualDamage;
|
||||
state.player.damagedThisTurn = true;
|
||||
}
|
||||
|
||||
if (blockedByDefend > 0 && defendStacks - blockedByDefend <= 0) {
|
||||
for (const enemyId of state.enemyOrder) {
|
||||
const enemy = state.enemies[enemyId];
|
||||
if (enemy.isAlive && enemy.buffs["defend"] !== undefined) {
|
||||
// Not relevant for player, skip
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
damageDealt: actualDamage,
|
||||
blockedByDefend,
|
||||
targetDied: state.player.hp <= 0,
|
||||
};
|
||||
}
|
||||
|
||||
const enemy = state.enemies[targetKey];
|
||||
if (!enemy || !enemy.isAlive) {
|
||||
return { damageDealt: 0, blockedByDefend: 0, targetDied: false };
|
||||
}
|
||||
|
||||
const defendStacks = enemy.buffs["defend"] ?? 0;
|
||||
if (defendStacks > 0) {
|
||||
blockedByDefend = Math.min(defendStacks, actualDamage);
|
||||
actualDamage -= blockedByDefend;
|
||||
enemy.buffs["defend"] = defendStacks - blockedByDefend;
|
||||
if (enemy.buffs["defend"] === 0) {
|
||||
delete enemy.buffs["defend"];
|
||||
}
|
||||
|
||||
if (defendStacks > 0 && defendStacks - blockedByDefend <= 0) {
|
||||
enemy.hadDefendBroken = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (actualDamage > 0) {
|
||||
enemy.hp = Math.max(0, enemy.hp - actualDamage);
|
||||
if (enemy.hp <= 0) {
|
||||
enemy.isAlive = false;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
damageDealt: actualDamage,
|
||||
blockedByDefend,
|
||||
targetDied: !enemy.isAlive,
|
||||
};
|
||||
}
|
||||
|
||||
export function applyDefend(
|
||||
targetBuffs: BuffTable,
|
||||
amount: number,
|
||||
): void {
|
||||
if (amount <= 0) return;
|
||||
targetBuffs["defend"] = (targetBuffs["defend"] ?? 0) + amount;
|
||||
}
|
||||
|
||||
export function applyBuff(
|
||||
buffs: BuffTable,
|
||||
effectId: string,
|
||||
timing: EffectTiming,
|
||||
stacks: number,
|
||||
): void {
|
||||
if (stacks <= 0) return;
|
||||
buffs[effectId] = (buffs[effectId] ?? 0) + stacks;
|
||||
}
|
||||
|
||||
export function removeBuff(buffs: BuffTable, effectId: string, stacks?: number): number {
|
||||
const current = buffs[effectId] ?? 0;
|
||||
if (stacks === undefined || stacks >= current) {
|
||||
delete buffs[effectId];
|
||||
return current;
|
||||
}
|
||||
buffs[effectId] = current - stacks;
|
||||
return stacks;
|
||||
}
|
||||
|
||||
export function updateBuffs(buffs: BuffTable): void {
|
||||
const toDelete: string[] = [];
|
||||
const toDecrement: string[] = [];
|
||||
|
||||
for (const [effectId] of Object.entries(buffs)) {
|
||||
const effectData = getEffectData(effectId);
|
||||
if (!effectData) continue;
|
||||
|
||||
switch (effectData.timing) {
|
||||
case "temporary":
|
||||
toDelete.push(effectId);
|
||||
break;
|
||||
case "lingering":
|
||||
toDecrement.push(effectId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of toDelete) {
|
||||
delete buffs[id];
|
||||
}
|
||||
|
||||
for (const id of toDecrement) {
|
||||
buffs[id] = (buffs[id] ?? 0) - 1;
|
||||
if (buffs[id] <= 0) {
|
||||
delete buffs[id];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export type ResolveEffectContext = {
|
||||
state: CombatState;
|
||||
rng: { nextInt: (n: number) => number };
|
||||
};
|
||||
|
||||
export function resolveEffect(
|
||||
ctx: ResolveEffectContext,
|
||||
target: EffectTarget,
|
||||
effect: EffectDesert,
|
||||
stacks: number,
|
||||
sourceKey?: "player" | string,
|
||||
sourceCardId?: string,
|
||||
): void {
|
||||
const { state } = ctx;
|
||||
const timing = effect.timing;
|
||||
|
||||
switch (timing) {
|
||||
case "instant":
|
||||
resolveInstantEffect(ctx, target, effect, stacks, sourceKey, sourceCardId);
|
||||
break;
|
||||
case "posture":
|
||||
applyBuffToTarget(state, target, effect.id, stacks, sourceKey);
|
||||
break;
|
||||
case "temporary":
|
||||
case "lingering":
|
||||
case "permanent":
|
||||
applyBuffToTarget(state, target, effect.id, stacks, sourceKey);
|
||||
break;
|
||||
case "card":
|
||||
addStatusCardToDiscard(state, effect.id, stacks);
|
||||
break;
|
||||
case "cardDraw":
|
||||
addStatusCardToDrawPile(state, effect.id, stacks);
|
||||
break;
|
||||
case "cardHand":
|
||||
addStatusCardToHand(state, effect.id, stacks);
|
||||
break;
|
||||
case "item":
|
||||
case "itemUntilPlayed":
|
||||
applyItemBuff(state, effect.id, timing, stacks, sourceCardId);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function applyBuffToTarget(
|
||||
state: CombatState,
|
||||
target: EffectTarget,
|
||||
effectId: string,
|
||||
stacks: number,
|
||||
sourceKey?: "player" | string,
|
||||
): void {
|
||||
if (target === "self") {
|
||||
if (sourceKey === "player") {
|
||||
applyBuff(state.player.buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
|
||||
} else if (sourceKey && state.enemies[sourceKey]) {
|
||||
applyBuff(state.enemies[sourceKey].buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
|
||||
}
|
||||
} else if (target === "player" || target === "team") {
|
||||
applyBuff(state.player.buffs, effectId, getEffectData(effectId)?.timing ?? "permanent", stacks);
|
||||
} else if (target === "target" || target === "all" || target === "random") {
|
||||
// For attack/defend effects, these are handled by resolveInstantEffect
|
||||
}
|
||||
}
|
||||
|
||||
function resolveInstantEffect(
|
||||
ctx: ResolveEffectContext,
|
||||
target: EffectTarget,
|
||||
effect: EffectDesert,
|
||||
stacks: number,
|
||||
sourceKey?: "player" | string,
|
||||
sourceCardId?: string,
|
||||
): void {
|
||||
const { state, rng } = ctx;
|
||||
|
||||
switch (effect.id) {
|
||||
case "attack": {
|
||||
const damageAmount = stacks;
|
||||
if (target === "all") {
|
||||
for (const enemyId of state.enemyOrder) {
|
||||
const enemy = state.enemies[enemyId];
|
||||
if (enemy.isAlive) {
|
||||
applyDamage(state, enemyId, damageAmount, sourceKey);
|
||||
}
|
||||
}
|
||||
} else if (target === "random") {
|
||||
const aliveEnemies = state.enemyOrder.filter(id => state.enemies[id].isAlive);
|
||||
if (aliveEnemies.length > 0) {
|
||||
const targetId = aliveEnemies[rng.nextInt(aliveEnemies.length)];
|
||||
applyDamage(state, targetId, damageAmount, sourceKey);
|
||||
}
|
||||
} else if (target === "target") {
|
||||
if (sourceKey && sourceKey !== "player" && state.enemies[sourceKey]?.isAlive) {
|
||||
applyDamage(state, "player", damageAmount, sourceKey);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "defend": {
|
||||
if (target === "self" && sourceKey === "player") {
|
||||
applyDefend(state.player.buffs, stacks);
|
||||
} else if (target === "self" && sourceKey && state.enemies[sourceKey]) {
|
||||
applyDefend(state.enemies[sourceKey].buffs, stacks);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "draw": {
|
||||
drawCardsToHand(state.player.deck, stacks);
|
||||
break;
|
||||
}
|
||||
case "gainEnergy": {
|
||||
state.player.energy += stacks;
|
||||
break;
|
||||
}
|
||||
case "removeWound": {
|
||||
removeWoundCards(state.player.deck, stacks);
|
||||
break;
|
||||
}
|
||||
case "tailSting": {
|
||||
applyDamage(state, "player", stacks, sourceKey);
|
||||
break;
|
||||
}
|
||||
case "rollDamage": {
|
||||
const rollStacks = sourceKey && sourceKey !== "player"
|
||||
? state.enemies[sourceKey]?.buffs["roll"] ?? 0
|
||||
: 0;
|
||||
if (rollStacks >= 10) {
|
||||
const damageFromRoll = Math.floor(rollStacks / 10) * 10;
|
||||
applyDamage(state, "player", Math.floor(damageFromRoll / 10), sourceKey);
|
||||
removeBuff(state.enemies[sourceKey!].buffs, "roll", damageFromRoll);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "crossbow": {
|
||||
break;
|
||||
}
|
||||
case "discard": {
|
||||
break;
|
||||
}
|
||||
case "summonMummy":
|
||||
case "summonSandwormLarva":
|
||||
case "reviveMummy": {
|
||||
break;
|
||||
}
|
||||
case "drawChoice":
|
||||
case "transformRandom": {
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function addStatusCardToDiscard(state: CombatState, effectId: string, count: number): void {
|
||||
const cardDef = statusCardDesertData.find(c => c.id === effectId);
|
||||
if (!cardDef) return;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cardId = `status-${effectId}-${Date.now()}-${i}`;
|
||||
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
|
||||
state.player.deck.cards[card.id] = card;
|
||||
state.player.deck.discardPile.push(card.id);
|
||||
}
|
||||
}
|
||||
|
||||
function addStatusCardToDrawPile(state: CombatState, effectId: string, count: number): void {
|
||||
const cardDef = statusCardDesertData.find(c => c.id === effectId);
|
||||
if (!cardDef) return;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cardId = `status-${effectId}-${Date.now()}-${i}`;
|
||||
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
|
||||
state.player.deck.cards[card.id] = card;
|
||||
state.player.deck.drawPile.push(card.id);
|
||||
}
|
||||
}
|
||||
|
||||
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
|
||||
const cardDef = statusCardDesertData.find(c => c.id === effectId);
|
||||
if (!cardDef) return;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cardId = `status-${effectId}-${Date.now()}-${i}`;
|
||||
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
|
||||
state.player.deck.cards[card.id] = card;
|
||||
state.player.deck.hand.push(card.id);
|
||||
}
|
||||
}
|
||||
|
||||
function applyItemBuff(
|
||||
state: CombatState,
|
||||
effectId: string,
|
||||
timing: EffectTiming,
|
||||
stacks: number,
|
||||
sourceCardId?: string,
|
||||
): void {
|
||||
if (!sourceCardId) return;
|
||||
|
||||
const card = state.player.deck.cards[sourceCardId];
|
||||
if (!card || !card.sourceItemId) return;
|
||||
|
||||
const itemBuff: ItemBuff = {
|
||||
effectId,
|
||||
stacks,
|
||||
timing,
|
||||
sourceItemId: card.sourceItemId,
|
||||
targetItemId: card.sourceItemId,
|
||||
};
|
||||
state.itemBuffs.push(itemBuff);
|
||||
}
|
||||
|
||||
function removeWoundCards(deck: PlayerDeck, count: number): void {
|
||||
let removed = 0;
|
||||
|
||||
for (let i = deck.drawPile.length - 1; i >= 0 && removed < count; i--) {
|
||||
const card = deck.cards[deck.drawPile[i]];
|
||||
if (card && card.itemData === null && card.displayName === "伤口") {
|
||||
delete deck.cards[deck.drawPile[i]];
|
||||
deck.drawPile.splice(i, 1);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = deck.discardPile.length - 1; i >= 0 && removed < count; i--) {
|
||||
const card = deck.cards[deck.discardPile[i]];
|
||||
if (card && card.itemData === null && card.displayName === "伤口") {
|
||||
delete deck.cards[deck.discardPile[i]];
|
||||
deck.discardPile.splice(i, 1);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveCardEffects(
|
||||
ctx: ResolveEffectContext,
|
||||
cardId: string,
|
||||
targetEnemyId?: string,
|
||||
): void {
|
||||
const { state } = ctx;
|
||||
const card = state.player.deck.cards[cardId];
|
||||
if (!card || !card.itemData) return;
|
||||
|
||||
const sourceKey: "player" | string = "player";
|
||||
|
||||
const effects = card.itemData.effects as unknown as CombatEffectEntry[];
|
||||
for (const entry of effects) {
|
||||
const [target, effect, stacks] = entry;
|
||||
|
||||
if (target === "target") {
|
||||
if (targetEnemyId && state.enemies[targetEnemyId]?.isAlive) {
|
||||
if (effect.id === "attack") {
|
||||
const actualDamage = getModifiedAttackDamage(state, cardId, stacks);
|
||||
applyDamage(state, targetEnemyId, actualDamage, "player");
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolveEffect(ctx, target, effect, stacks, sourceKey, cardId);
|
||||
}
|
||||
}
|
||||
|
||||
export function getModifiedAttackDamage(
|
||||
state: CombatState,
|
||||
cardId: string,
|
||||
baseDamage: number,
|
||||
): number {
|
||||
let damage = baseDamage;
|
||||
|
||||
const attackBuff = state.itemBuffs
|
||||
.filter(b => b.effectId === "attackBuff" || b.effectId === "attackBuffUntilPlay")
|
||||
.filter(b => {
|
||||
const card = state.player.deck.cards[cardId];
|
||||
return card && card.sourceItemId === b.targetItemId;
|
||||
})
|
||||
.reduce((sum, b) => sum + b.stacks, 0);
|
||||
damage += attackBuff;
|
||||
|
||||
return Math.max(0, damage);
|
||||
}
|
||||
|
||||
export function getModifiedDefendAmount(
|
||||
state: CombatState,
|
||||
cardId: string,
|
||||
baseDefend: number,
|
||||
): number {
|
||||
let defend = baseDefend;
|
||||
|
||||
const defendBuff = state.itemBuffs
|
||||
.filter(b => b.effectId === "defendBuff" || b.effectId === "defendBuffUntilPlay")
|
||||
.filter(b => {
|
||||
const card = state.player.deck.cards[cardId];
|
||||
return card && card.sourceItemId === b.targetItemId;
|
||||
})
|
||||
.reduce((sum, b) => sum + b.stacks, 0);
|
||||
defend += defendBuff;
|
||||
|
||||
return Math.max(0, defend);
|
||||
}
|
||||
|
||||
export function canPlayCard(
|
||||
state: CombatState,
|
||||
cardId: string,
|
||||
): { canPlay: boolean; reason?: string } {
|
||||
const card = state.player.deck.cards[cardId];
|
||||
if (!card) return { canPlay: false, reason: "卡牌不存在" };
|
||||
|
||||
if (!card.itemData) return { canPlay: false, reason: "状态牌不可打出" };
|
||||
|
||||
const handIdx = state.player.deck.hand.indexOf(cardId);
|
||||
if (handIdx < 0) return { canPlay: false, reason: "卡牌不在手牌中" };
|
||||
|
||||
if (card.itemData.costType === "energy") {
|
||||
if (state.player.energy < card.itemData.costCount) {
|
||||
return { canPlay: false, reason: "能量不足" };
|
||||
}
|
||||
}
|
||||
|
||||
return { canPlay: true };
|
||||
}
|
||||
|
||||
export function playCard(
|
||||
ctx: ResolveEffectContext,
|
||||
cardId: string,
|
||||
targetEnemyId?: string,
|
||||
): { success: boolean; reason?: string } {
|
||||
const { state } = ctx;
|
||||
const check = canPlayCard(state, cardId);
|
||||
if (!check.canPlay) return { success: false, reason: check.reason };
|
||||
|
||||
const card = state.player.deck.cards[cardId];
|
||||
if (!card || !card.itemData) return { success: false, reason: "卡牌无效" };
|
||||
|
||||
if (card.itemData.costType === "energy") {
|
||||
state.player.energy -= card.itemData.costCount;
|
||||
}
|
||||
|
||||
resolveCardEffects(ctx, cardId, targetEnemyId);
|
||||
|
||||
if (card.itemData.costType === "uses") {
|
||||
exhaustCard(state.player.deck, cardId);
|
||||
} else {
|
||||
discardCard(state.player.deck, cardId);
|
||||
}
|
||||
|
||||
expireItemBuffsOnCardPlayed(state, cardId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
function expireItemBuffsOnCardPlayed(state: CombatState, cardId: string): void {
|
||||
const card = state.player.deck.cards[cardId];
|
||||
if (!card || !card.sourceItemId) return;
|
||||
|
||||
state.itemBuffs = state.itemBuffs.filter(buff => {
|
||||
if (buff.timing === "itemUntilPlayed" && buff.sourceItemId === card.sourceItemId) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function areAllEnemiesDead(state: CombatState): boolean {
|
||||
return state.enemyOrder.every(id => !state.enemies[id].isAlive);
|
||||
}
|
||||
|
||||
export function isPlayerDead(state: CombatState): boolean {
|
||||
return state.player.hp <= 0;
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
export type {
|
||||
BuffTable,
|
||||
CombatEffectEntry,
|
||||
CombatEntity,
|
||||
CombatGameContext,
|
||||
CombatPhase,
|
||||
CombatResult,
|
||||
CombatState,
|
||||
EffectTarget,
|
||||
EffectTiming,
|
||||
EnemyState,
|
||||
ItemBuff,
|
||||
LootEntry,
|
||||
PlayerCombatState,
|
||||
} from "./types";
|
||||
|
||||
export {
|
||||
createCombatState,
|
||||
createEnemyInstance,
|
||||
createPlayerCombatState,
|
||||
drawCardsToHand,
|
||||
reshuffleDiscardIntoDraw,
|
||||
addFatigueCards,
|
||||
discardHand,
|
||||
discardCard,
|
||||
exhaustCard,
|
||||
getEnemyCurrentIntent,
|
||||
advanceEnemyIntent,
|
||||
getEffectTiming,
|
||||
getEffectData,
|
||||
INITIAL_HAND_SIZE,
|
||||
DEFAULT_MAX_ENERGY,
|
||||
FATIGUE_CARDS_PER_SHUFFLE,
|
||||
} from "./state";
|
||||
|
||||
export {
|
||||
applyDamage,
|
||||
applyDefend,
|
||||
applyBuff,
|
||||
removeBuff,
|
||||
updateBuffs,
|
||||
resolveEffect,
|
||||
resolveCardEffects,
|
||||
getModifiedAttackDamage,
|
||||
getModifiedDefendAmount,
|
||||
canPlayCard,
|
||||
playCard,
|
||||
areAllEnemiesDead,
|
||||
isPlayerDead,
|
||||
} from "./effects";
|
||||
|
||||
export type {
|
||||
TriggerContext,
|
||||
BuffTriggerBehavior,
|
||||
CombatTriggerRegistry,
|
||||
TriggerEvent,
|
||||
} from "./triggers";
|
||||
|
||||
export {
|
||||
createCombatTriggerRegistry,
|
||||
dispatchTrigger,
|
||||
dispatchAttackedTrigger,
|
||||
dispatchDamageTrigger,
|
||||
dispatchOutgoingDamageTrigger,
|
||||
dispatchIncomingDamageTrigger,
|
||||
dispatchShuffleTrigger,
|
||||
} from "./triggers";
|
||||
|
||||
export { prompts } from "./prompts";
|
||||
|
||||
export { runCombat } from "./procedure";
|
||||
|
|
@ -0,0 +1,323 @@
|
|||
import type { IGameContext } from "@/core/game";
|
||||
import type { CombatState, CombatResult, CombatGameContext, CombatEffectEntry } from "./types";
|
||||
import type { CombatTriggerRegistry, TriggerContext } from "./triggers";
|
||||
import { createCombatTriggerRegistry, dispatchTrigger, dispatchShuffleTrigger, dispatchOutgoingDamageTrigger, dispatchIncomingDamageTrigger, dispatchDamageTrigger } from "./triggers";
|
||||
import { prompts } from "./prompts";
|
||||
import {
|
||||
drawCardsToHand,
|
||||
addFatigueCards,
|
||||
discardHand,
|
||||
discardCard,
|
||||
getEnemyCurrentIntent,
|
||||
advanceEnemyIntent,
|
||||
DEFAULT_MAX_ENERGY,
|
||||
FATIGUE_CARDS_PER_SHUFFLE,
|
||||
} from "./state";
|
||||
import {
|
||||
applyDamage,
|
||||
applyDefend,
|
||||
updateBuffs,
|
||||
canPlayCard,
|
||||
playCard,
|
||||
areAllEnemiesDead,
|
||||
isPlayerDead,
|
||||
resolveCardEffects,
|
||||
removeBuff,
|
||||
} from "./effects";
|
||||
|
||||
export async function runCombat(
|
||||
game: CombatGameContext,
|
||||
): Promise<CombatResult> {
|
||||
const triggerRegistry = createCombatTriggerRegistry();
|
||||
|
||||
game.produce(state => {
|
||||
state.phase = "playerTurn";
|
||||
state.player.energy = state.player.maxEnergy;
|
||||
state.player.damageTakenThisTurn = 0;
|
||||
state.player.damagedThisTurn = false;
|
||||
state.player.cardsDiscardedThisTurn = 0;
|
||||
});
|
||||
|
||||
while (true) {
|
||||
const currentState = game.value;
|
||||
|
||||
if (currentState.result) {
|
||||
return currentState.result;
|
||||
}
|
||||
|
||||
if (currentState.phase === "playerTurn") {
|
||||
await runPlayerTurn(game, triggerRegistry);
|
||||
} else if (currentState.phase === "enemyTurn") {
|
||||
await runEnemyTurn(game, triggerRegistry);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
if (isPlayerDead(game.value)) {
|
||||
game.produce(state => {
|
||||
state.result = "defeat";
|
||||
state.phase = "combatEnd";
|
||||
});
|
||||
return "defeat";
|
||||
}
|
||||
|
||||
if (areAllEnemiesDead(game.value)) {
|
||||
game.produce(state => {
|
||||
state.result = "victory";
|
||||
state.phase = "combatEnd";
|
||||
state.loot = generateLoot(state);
|
||||
});
|
||||
return "victory";
|
||||
}
|
||||
|
||||
if (game.value.result === "fled") {
|
||||
game.produce(state => {
|
||||
state.phase = "combatEnd";
|
||||
});
|
||||
return "fled";
|
||||
}
|
||||
}
|
||||
|
||||
return game.value.result ?? "defeat";
|
||||
}
|
||||
|
||||
async function runPlayerTurn(
|
||||
game: CombatGameContext,
|
||||
triggerRegistry: CombatTriggerRegistry,
|
||||
): Promise<void> {
|
||||
const triggerCtx = createTriggerContext(game);
|
||||
|
||||
game.produce(state => {
|
||||
updateBuffs(state.player.buffs);
|
||||
state.player.damageTakenThisTurn = 0;
|
||||
state.player.damagedThisTurn = false;
|
||||
state.player.cardsDiscardedThisTurn = 0;
|
||||
|
||||
if (state.player.buffs["energyNext"]) {
|
||||
state.player.energy += state.player.buffs["energyNext"];
|
||||
}
|
||||
if (state.player.buffs["drawNext"]) {
|
||||
drawCardsToHand(state.player.deck, state.player.buffs["drawNext"]);
|
||||
}
|
||||
if (state.player.buffs["defendNext"]) {
|
||||
applyDefend(state.player.buffs, state.player.buffs["defendNext"]);
|
||||
}
|
||||
});
|
||||
|
||||
dispatchTrigger(triggerCtx, "onTurnStart", "player", triggerRegistry);
|
||||
|
||||
while (game.value.phase === "playerTurn") {
|
||||
const action = await game.prompt<{ action: "play" | "end"; cardId?: string; targetId?: string }>(
|
||||
prompts.playCard,
|
||||
(cardId, targetId) => {
|
||||
const state = game.value;
|
||||
if (!cardId) throw "请选择卡牌";
|
||||
|
||||
const check = canPlayCard(state, cardId);
|
||||
if (!check.canPlay) throw check.reason ?? "无法打出";
|
||||
|
||||
const card = state.player.deck.cards[cardId];
|
||||
if (card?.itemData?.targetType === "single") {
|
||||
const aliveEnemies = state.enemyOrder.filter(id => state.enemies[id].isAlive);
|
||||
if (!targetId && aliveEnemies.length > 0) {
|
||||
throw "请指定目标";
|
||||
}
|
||||
if (targetId && !state.enemies[targetId]?.isAlive) {
|
||||
throw "目标无效";
|
||||
}
|
||||
}
|
||||
|
||||
return { action: "play" as const, cardId, targetId };
|
||||
},
|
||||
"player"
|
||||
);
|
||||
|
||||
if (action.action === "play" && action.cardId) {
|
||||
const ctx = createEffectContext(game);
|
||||
game.produce(state => {
|
||||
playCard({ state, rng: game._rng }, action.cardId!, action.targetId);
|
||||
});
|
||||
|
||||
if (areAllEnemiesDead(game.value)) {
|
||||
return;
|
||||
}
|
||||
if (isPlayerDead(game.value)) {
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
const endAction = await game.prompt<{ action: "end" }>(
|
||||
prompts.endTurn,
|
||||
() => {
|
||||
return { action: "end" as const };
|
||||
},
|
||||
"player"
|
||||
);
|
||||
|
||||
dispatchTrigger(createTriggerContext(game), "onTurnEnd", "player", triggerRegistry);
|
||||
|
||||
game.produce(state => {
|
||||
for (const cardId of [...state.player.deck.hand]) {
|
||||
state.player.cardsDiscardedThisTurn++;
|
||||
}
|
||||
discardHand(state.player.deck);
|
||||
});
|
||||
|
||||
game.produce(state => {
|
||||
if (state.player.deck.drawPile.length === 0) {
|
||||
reshuffleWithFatigue(state);
|
||||
dispatchShuffleTrigger(createTriggerContext(game), triggerRegistry);
|
||||
}
|
||||
drawCardsToHand(state.player.deck, 5);
|
||||
state.player.energy = state.player.maxEnergy;
|
||||
});
|
||||
|
||||
game.produce(state => {
|
||||
state.phase = "enemyTurn";
|
||||
});
|
||||
}
|
||||
|
||||
async function runEnemyTurn(
|
||||
game: CombatGameContext,
|
||||
triggerRegistry: CombatTriggerRegistry,
|
||||
): Promise<void> {
|
||||
const state = game.value;
|
||||
|
||||
game.produce(state => {
|
||||
for (const enemyId of state.enemyOrder) {
|
||||
const enemy = state.enemies[enemyId];
|
||||
if (!enemy.isAlive) continue;
|
||||
updateBuffs(enemy.buffs);
|
||||
}
|
||||
});
|
||||
|
||||
const triggerCtx = createTriggerContext(game);
|
||||
for (const enemyId of game.value.enemyOrder) {
|
||||
const enemy = game.value.enemies[enemyId];
|
||||
if (!enemy.isAlive) continue;
|
||||
dispatchTrigger(triggerCtx, "onTurnStart", enemyId, triggerRegistry);
|
||||
}
|
||||
|
||||
game.produce(state => {
|
||||
for (const enemyId of state.enemyOrder) {
|
||||
const enemy = state.enemies[enemyId];
|
||||
if (!enemy.isAlive) continue;
|
||||
|
||||
const intent = getEnemyCurrentIntent(enemy);
|
||||
if (!intent) continue;
|
||||
|
||||
const effects = intent.effects as unknown as CombatEffectEntry[];
|
||||
for (const entry of effects) {
|
||||
const [target, effect, stacks] = entry;
|
||||
|
||||
if (effect.id === "attack") {
|
||||
let damage = stacks;
|
||||
damage = dispatchOutgoingDamageTrigger(createTriggerContext(game), enemyId, damage, triggerRegistry);
|
||||
damage = dispatchIncomingDamageTrigger(createTriggerContext(game), "player", damage, triggerRegistry);
|
||||
|
||||
const result = applyDamage(state, "player", damage, enemyId);
|
||||
if (result.damageDealt > 0) {
|
||||
dispatchDamageTrigger(createTriggerContext(game), "player", result.damageDealt, triggerRegistry);
|
||||
}
|
||||
} else if (effect.id === "defend") {
|
||||
if (target === "self") {
|
||||
applyDefend(enemy.buffs, stacks);
|
||||
}
|
||||
} else {
|
||||
resolveEnemyEffect(state, enemyId, target, effect, stacks);
|
||||
}
|
||||
}
|
||||
|
||||
advanceEnemyIntent(enemy);
|
||||
}
|
||||
});
|
||||
|
||||
for (const enemyId of game.value.enemyOrder) {
|
||||
const enemy = game.value.enemies[enemyId];
|
||||
if (!enemy.isAlive) continue;
|
||||
dispatchTrigger(createTriggerContext(game), "onTurnEnd", enemyId, triggerRegistry);
|
||||
}
|
||||
|
||||
game.produce(state => {
|
||||
state.phase = "playerTurn";
|
||||
state.turnNumber++;
|
||||
});
|
||||
}
|
||||
|
||||
function resolveEnemyEffect(
|
||||
state: CombatState,
|
||||
enemyId: string,
|
||||
target: string,
|
||||
effect: { id: string; timing: string },
|
||||
stacks: number,
|
||||
): void {
|
||||
switch (effect.id) {
|
||||
case "spike":
|
||||
case "venom":
|
||||
case "curse":
|
||||
case "aim":
|
||||
case "roll":
|
||||
case "vultureEye":
|
||||
case "tailSting":
|
||||
case "energyDrain":
|
||||
case "molt":
|
||||
case "storm":
|
||||
case "static":
|
||||
case "charge":
|
||||
case "discard":
|
||||
state.enemies[enemyId].buffs[effect.id] = (state.enemies[enemyId].buffs[effect.id] ?? 0) + stacks;
|
||||
break;
|
||||
case "summonMummy":
|
||||
case "summonSandwormLarva":
|
||||
case "reviveMummy":
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function reshuffleWithFatigue(state: CombatState): void {
|
||||
if (state.player.deck.discardPile.length === 0) return;
|
||||
|
||||
state.player.deck.drawPile.push(...state.player.deck.discardPile);
|
||||
state.player.deck.discardPile = [];
|
||||
|
||||
addFatigueCards(state.player.deck, FATIGUE_CARDS_PER_SHUFFLE, { value: state.fatigueAddedCount });
|
||||
state.fatigueAddedCount += FATIGUE_CARDS_PER_SHUFFLE;
|
||||
|
||||
for (let i = state.player.deck.drawPile.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[state.player.deck.drawPile[i], state.player.deck.drawPile[j]] = [state.player.deck.drawPile[j], state.player.deck.drawPile[i]];
|
||||
}
|
||||
}
|
||||
|
||||
function createTriggerContext(game: CombatGameContext): TriggerContext {
|
||||
return {
|
||||
state: game.value,
|
||||
rng: game._rng,
|
||||
};
|
||||
}
|
||||
|
||||
function createEffectContext(game: CombatGameContext) {
|
||||
return {
|
||||
state: game.value,
|
||||
rng: game._rng,
|
||||
};
|
||||
}
|
||||
|
||||
function generateLoot(state: CombatState): CombatState["loot"] {
|
||||
const loot: CombatState["loot"] = [];
|
||||
let totalGold = 0;
|
||||
for (const enemyId of state.enemyOrder) {
|
||||
const enemy = state.enemies[enemyId];
|
||||
totalGold += Math.floor(enemy.maxHp * 0.5);
|
||||
}
|
||||
if (totalGold > 0) {
|
||||
loot.push({ type: "gold", amount: totalGold });
|
||||
}
|
||||
return loot;
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { createPromptDef } from "@/core/game";
|
||||
|
||||
export const prompts = {
|
||||
playCard: createPromptDef<[string, string?]>(
|
||||
"play-card <cardId:string> [targetId:string]",
|
||||
"选择卡牌并指定目标"
|
||||
),
|
||||
endTurn: createPromptDef<[]>(
|
||||
"end-turn",
|
||||
"结束回合"
|
||||
),
|
||||
};
|
||||
|
|
@ -0,0 +1,242 @@
|
|||
import type { GridInventory } from "../grid-inventory/types";
|
||||
import type { GameItemMeta, PlayerState } from "../progress/types";
|
||||
import type { PlayerDeck } from "../deck/types";
|
||||
import type { EnemyDesert } from "../data/enemyDesert.csv";
|
||||
import type { EnemyIntentDesert } from "../data/enemyIntentDesert.csv";
|
||||
import type { EncounterDesert } from "../data/encounterDesert.csv";
|
||||
import type { EffectDesert } from "../data/effectDesert.csv";
|
||||
import { generateDeckFromInventory, createStatusCard } from "../deck/factory";
|
||||
import { enemyDesertData, enemyIntentDesertData, effectDesertData } from "../data";
|
||||
import type {
|
||||
BuffTable,
|
||||
CombatState,
|
||||
CombatPhase,
|
||||
EnemyState,
|
||||
PlayerCombatState,
|
||||
ItemBuff,
|
||||
LootEntry,
|
||||
} from "./types";
|
||||
|
||||
const INITIAL_HAND_SIZE = 5;
|
||||
const DEFAULT_MAX_ENERGY = 3;
|
||||
const FATIGUE_CARDS_PER_SHUFFLE = 2;
|
||||
|
||||
export function createEnemyInstance(
|
||||
templateId: string,
|
||||
enemyData: EnemyDesert,
|
||||
bonusHp: number,
|
||||
idCounter: { value: number },
|
||||
): EnemyState {
|
||||
idCounter.value++;
|
||||
const id = `enemy-${idCounter.value}`;
|
||||
const maxHp = enemyData.initHp + bonusHp;
|
||||
const hp = maxHp;
|
||||
|
||||
const buffs: BuffTable = {};
|
||||
for (const [effect, stacks] of enemyData.initBuffs) {
|
||||
buffs[effect.id] = (buffs[effect.id] ?? 0) + stacks;
|
||||
}
|
||||
|
||||
const intentData = buildIntentLookup(templateId);
|
||||
|
||||
return {
|
||||
id,
|
||||
templateId,
|
||||
hp,
|
||||
maxHp,
|
||||
buffs,
|
||||
currentIntentId: enemyData.initialIntent,
|
||||
intentData,
|
||||
isAlive: true,
|
||||
hadDefendBroken: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildIntentLookup(enemyTemplateId: string): Record<string, EnemyIntentDesert> {
|
||||
const lookup: Record<string, EnemyIntentDesert> = {};
|
||||
for (const intent of enemyIntentDesertData) {
|
||||
if (intent.enemy.id === enemyTemplateId) {
|
||||
lookup[intent.id] = intent;
|
||||
}
|
||||
}
|
||||
return lookup;
|
||||
}
|
||||
|
||||
export function createPlayerCombatState(
|
||||
playerState: PlayerState,
|
||||
inventory: GridInventory<GameItemMeta>,
|
||||
): PlayerCombatState {
|
||||
const deck = generateDeckFromInventory(inventory);
|
||||
return {
|
||||
hp: playerState.currentHp,
|
||||
maxHp: playerState.maxHp,
|
||||
energy: DEFAULT_MAX_ENERGY,
|
||||
maxEnergy: DEFAULT_MAX_ENERGY,
|
||||
buffs: {},
|
||||
deck,
|
||||
damageTakenThisTurn: 0,
|
||||
damagedThisTurn: false,
|
||||
cardsDiscardedThisTurn: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function createCombatState(
|
||||
playerState: PlayerState,
|
||||
inventory: GridInventory<GameItemMeta>,
|
||||
encounter: EncounterDesert,
|
||||
): CombatState {
|
||||
const idCounter = { value: 0 };
|
||||
const player = createPlayerCombatState(playerState, inventory);
|
||||
|
||||
const enemies: Record<string, EnemyState> = {};
|
||||
const enemyOrder: string[] = [];
|
||||
const enemyTemplateData: Record<string, EnemyDesert> = {};
|
||||
|
||||
for (const [enemyRef, bonusHp] of encounter.enemies) {
|
||||
const enemyInstance = createEnemyInstance(
|
||||
enemyRef.id,
|
||||
enemyRef,
|
||||
bonusHp,
|
||||
idCounter,
|
||||
);
|
||||
enemies[enemyInstance.id] = enemyInstance;
|
||||
enemyOrder.push(enemyInstance.id);
|
||||
enemyTemplateData[enemyInstance.templateId] = enemyRef;
|
||||
}
|
||||
|
||||
shuffleDeck(player.deck.drawPile, buildSimpleRNG(0));
|
||||
|
||||
drawCardsToHand(player.deck, INITIAL_HAND_SIZE);
|
||||
|
||||
return {
|
||||
enemies,
|
||||
enemyOrder,
|
||||
player,
|
||||
phase: "playerTurn" as CombatPhase,
|
||||
turnNumber: 1,
|
||||
result: null,
|
||||
loot: [],
|
||||
itemBuffs: [],
|
||||
fatigueAddedCount: 0,
|
||||
enemyTemplateData,
|
||||
};
|
||||
}
|
||||
|
||||
export function drawCardsToHand(deck: PlayerDeck, count: number): string[] {
|
||||
const drawn: string[] = [];
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (deck.drawPile.length === 0) {
|
||||
reshuffleDiscardIntoDraw(deck);
|
||||
}
|
||||
if (deck.drawPile.length === 0) break;
|
||||
|
||||
const cardId = deck.drawPile.shift()!;
|
||||
deck.hand.push(cardId);
|
||||
drawn.push(cardId);
|
||||
}
|
||||
return drawn;
|
||||
}
|
||||
|
||||
export function reshuffleDiscardIntoDraw(deck: PlayerDeck): void {
|
||||
if (deck.discardPile.length === 0) return;
|
||||
|
||||
deck.drawPile.push(...deck.discardPile);
|
||||
deck.discardPile = [];
|
||||
|
||||
for (let i = deck.drawPile.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[deck.drawPile[i], deck.drawPile[j]] = [deck.drawPile[j], deck.drawPile[i]];
|
||||
}
|
||||
}
|
||||
|
||||
export function addFatigueCards(deck: PlayerDeck, count: number, fatigueCounter: { value: number }): number {
|
||||
let added = 0;
|
||||
for (let i = 0; i < count; i++) {
|
||||
fatigueCounter.value++;
|
||||
const card = createStatusCard(
|
||||
`fatigue-${fatigueCounter.value}`,
|
||||
"疲劳",
|
||||
"1费/消耗",
|
||||
);
|
||||
deck.cards[card.id] = card;
|
||||
deck.drawPile.push(card.id);
|
||||
added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
export function discardHand(deck: PlayerDeck): void {
|
||||
const handCards = [...deck.hand];
|
||||
deck.discardPile.push(...handCards);
|
||||
deck.hand = [];
|
||||
}
|
||||
|
||||
export function discardCard(deck: PlayerDeck, cardId: string): void {
|
||||
const handIdx = deck.hand.indexOf(cardId);
|
||||
if (handIdx >= 0) {
|
||||
deck.hand.splice(handIdx, 1);
|
||||
deck.discardPile.push(cardId);
|
||||
}
|
||||
}
|
||||
|
||||
export function exhaustCard(deck: PlayerDeck, cardId: string): void {
|
||||
const handIdx = deck.hand.indexOf(cardId);
|
||||
if (handIdx >= 0) {
|
||||
deck.hand.splice(handIdx, 1);
|
||||
deck.exhaustPile.push(cardId);
|
||||
}
|
||||
}
|
||||
|
||||
export function getEnemyCurrentIntent(enemy: EnemyState): EnemyIntentDesert | undefined {
|
||||
return enemy.intentData[enemy.currentIntentId];
|
||||
}
|
||||
|
||||
export function advanceEnemyIntent(enemy: EnemyState): void {
|
||||
const current = getEnemyCurrentIntent(enemy);
|
||||
if (!current) return;
|
||||
|
||||
if (enemy.hadDefendBroken && current.brokenIntent.length > 0) {
|
||||
const idx = Math.floor(Math.random() * current.brokenIntent.length);
|
||||
enemy.currentIntentId = current.brokenIntent[idx].id;
|
||||
enemy.hadDefendBroken = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (current.nextIntents.length > 0) {
|
||||
const idx = Math.floor(Math.random() * current.nextIntents.length);
|
||||
enemy.currentIntentId = current.nextIntents[idx].id;
|
||||
return;
|
||||
}
|
||||
|
||||
enemy.currentIntentId = current.id;
|
||||
}
|
||||
|
||||
function shuffleDeck(drawPile: string[], rng: { nextInt: (n: number) => number }): void {
|
||||
for (let i = drawPile.length - 1; i > 0; i--) {
|
||||
const j = rng.nextInt(i + 1);
|
||||
[drawPile[i], drawPile[j]] = [drawPile[j], drawPile[i]];
|
||||
}
|
||||
}
|
||||
|
||||
function buildSimpleRNG(seed: number) {
|
||||
let s = seed;
|
||||
return {
|
||||
nextInt(max: number) {
|
||||
s = (s + 0x6d2b79f5) | 0;
|
||||
let t = Math.imul(s ^ (s >>> 15), s | 1);
|
||||
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
||||
return Math.floor(((t ^ (t >>> 14)) >>> 0) / 4294967296 * max);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getEffectTiming(effectId: string): EffectDesert["timing"] | undefined {
|
||||
const effect = effectDesertData.find(e => e.id === effectId);
|
||||
return effect?.timing;
|
||||
}
|
||||
|
||||
export function getEffectData(effectId: string): EffectDesert | undefined {
|
||||
return effectDesertData.find(e => e.id === effectId);
|
||||
}
|
||||
|
||||
export { INITIAL_HAND_SIZE, DEFAULT_MAX_ENERGY, FATIGUE_CARDS_PER_SHUFFLE };
|
||||
|
|
@ -0,0 +1,331 @@
|
|||
import type { EffectDesert } from "../data/effectDesert.csv";
|
||||
import { statusCardDesertData } from "../data";
|
||||
import { createStatusCard } from "../deck/factory";
|
||||
import type { BuffTable, CombatEffectEntry, CombatState } from "./types";
|
||||
import { applyDamage, removeBuff } from "./effects";
|
||||
|
||||
export type TriggerContext = {
|
||||
state: CombatState;
|
||||
rng: { nextInt: (n: number) => number };
|
||||
};
|
||||
|
||||
export type BuffTriggerBehavior = {
|
||||
onTurnStart?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
|
||||
onTurnEnd?: (ctx: TriggerContext, entityKey: "player" | string, stacks: number) => void;
|
||||
onAttacked?: (ctx: TriggerContext, attackerKey: "player" | string, defenderKey: "player" | string, damage: number, stacks: number) => number;
|
||||
onDamage?: (ctx: TriggerContext, targetKey: "player" | string, damage: number, stacks: number) => void;
|
||||
modifyOutgoingDamage?: (ctx: TriggerContext, sourceKey: "player" | string, damage: number, stacks: number) => number;
|
||||
modifyIncomingDamage?: (ctx: TriggerContext, targetKey: "player" | string, damage: number, stacks: number) => number;
|
||||
onShuffle?: (ctx: TriggerContext, stacks: number) => void;
|
||||
onCardPlayed?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
|
||||
onCardDiscarded?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
|
||||
};
|
||||
|
||||
export type CombatTriggerRegistry = Record<string, BuffTriggerBehavior>;
|
||||
|
||||
export function createCombatTriggerRegistry(): CombatTriggerRegistry {
|
||||
return {
|
||||
spike: {
|
||||
onAttacked(ctx, attackerKey, _defenderKey, damage, stacks) {
|
||||
const { state } = ctx;
|
||||
applyDamage(state, attackerKey, stacks, _defenderKey);
|
||||
return damage;
|
||||
},
|
||||
},
|
||||
aim: {
|
||||
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
|
||||
if (stacks > 0) return damage * 2;
|
||||
return damage;
|
||||
},
|
||||
onDamage(ctx, targetKey, damage, stacks) {
|
||||
const { state } = ctx;
|
||||
const entity = targetKey === "player" ? null : state.enemies[targetKey];
|
||||
if (entity) {
|
||||
const loss = Math.min(stacks, damage);
|
||||
removeBuff(entity.buffs, "aim", loss);
|
||||
}
|
||||
},
|
||||
},
|
||||
charge: {
|
||||
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
|
||||
if (stacks > 0) {
|
||||
return damage * 2;
|
||||
}
|
||||
return damage;
|
||||
},
|
||||
modifyIncomingDamage(_ctx, _targetKey, damage, stacks) {
|
||||
if (stacks > 0) {
|
||||
return damage * 2;
|
||||
}
|
||||
return damage;
|
||||
},
|
||||
onDamage(ctx, targetKey, damage, stacks) {
|
||||
const { state } = ctx;
|
||||
const entity = targetKey === "player"
|
||||
? { buffs: state.player.buffs } as { buffs: BuffTable }
|
||||
: state.enemies[targetKey];
|
||||
if (entity) {
|
||||
const loss = Math.min(stacks, damage);
|
||||
removeBuff(entity.buffs, "charge", loss);
|
||||
}
|
||||
},
|
||||
},
|
||||
roll: {
|
||||
modifyOutgoingDamage(ctx, sourceKey, damage, stacks) {
|
||||
if (stacks >= 10) {
|
||||
const { state } = ctx;
|
||||
const entity = sourceKey === "player"
|
||||
? { buffs: state.player.buffs } as { buffs: BuffTable }
|
||||
: state.enemies[sourceKey];
|
||||
if (entity) {
|
||||
const spendable = Math.floor(stacks / 10) * 10;
|
||||
const bonusDamage = Math.floor(spendable / 10);
|
||||
removeBuff(entity.buffs, "roll", spendable);
|
||||
return damage + bonusDamage;
|
||||
}
|
||||
}
|
||||
return damage;
|
||||
},
|
||||
},
|
||||
tailSting: {
|
||||
onTurnEnd(ctx, entityKey, stacks) {
|
||||
const { state } = ctx;
|
||||
if (entityKey !== "player" && state.enemies[entityKey]?.isAlive) {
|
||||
applyDamage(state, "player", stacks, entityKey);
|
||||
}
|
||||
},
|
||||
},
|
||||
energyDrain: {
|
||||
onDamage(ctx, targetKey, _damage, _stacks) {
|
||||
const { state } = ctx;
|
||||
if (targetKey === "player" && state.player.damagedThisTurn === false) {
|
||||
// This is the first damage; mark it.
|
||||
// actual energy drain happens in onTurnStart check
|
||||
}
|
||||
},
|
||||
onTurnStart(ctx, entityKey, _stacks) {
|
||||
// energyDrain: first damage each turn loses 1 energy
|
||||
// We just mark that the enemy has this; actual drain is in onDamage
|
||||
},
|
||||
},
|
||||
molt: {
|
||||
onDamage(ctx, targetKey, _damage, _stacks) {
|
||||
const { state } = ctx;
|
||||
if (targetKey !== "player") {
|
||||
const enemy = state.enemies[targetKey];
|
||||
if (enemy && enemy.isAlive) {
|
||||
const moltStacks = enemy.buffs["molt"] ?? 0;
|
||||
if (moltStacks >= enemy.maxHp) {
|
||||
enemy.isAlive = false;
|
||||
state.result = "fled";
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
storm: {
|
||||
onAttacked(ctx, attackerKey, defenderKey, damage, stacks) {
|
||||
const { state } = ctx;
|
||||
if (defenderKey !== "player" && state.enemies[defenderKey]?.isAlive) {
|
||||
addStatusCardToHand(state, "static", 1);
|
||||
}
|
||||
return damage;
|
||||
},
|
||||
},
|
||||
vultureEye: {
|
||||
onDamage(ctx, targetKey, damage, stacks) {
|
||||
const { state } = ctx;
|
||||
if (targetKey === "player" && damage > 0) {
|
||||
const vultureEnemies = state.enemyOrder.filter(
|
||||
id => state.enemies[id].isAlive && state.enemies[id].buffs["vultureEye"] && state.enemies[id].templateId === "秃鹫"
|
||||
);
|
||||
if (vultureEnemies.length > 0) {
|
||||
for (const vultureId of vultureEnemies) {
|
||||
const vulture = state.enemies[vultureId];
|
||||
const intent = vulture.intentData["attack"];
|
||||
if (intent) {
|
||||
const effects = intent.effects as unknown as CombatEffectEntry[];
|
||||
for (const entry of effects) {
|
||||
if (entry[0] === "player" && entry[1].id === "attack") {
|
||||
applyDamage(state, "player", entry[2], vultureId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
venom: {
|
||||
onCardDiscarded(ctx, cardId, stacks) {
|
||||
const { state } = ctx;
|
||||
state.player.cardsDiscardedThisTurn++;
|
||||
const venomCards = state.player.deck.hand.filter(id => {
|
||||
const card = state.player.deck.cards[id];
|
||||
return card && card.itemData === null && card.displayName === "蛇毒";
|
||||
});
|
||||
if (state.player.cardsDiscardedThisTurn > 1 && venomCards.length > 0) {
|
||||
applyDamage(state, "player", 6, undefined);
|
||||
}
|
||||
},
|
||||
},
|
||||
static: {
|
||||
modifyIncomingDamage(_ctx, targetKey, damage, stacks) {
|
||||
if (targetKey === "player") {
|
||||
return damage + stacks;
|
||||
}
|
||||
return damage;
|
||||
},
|
||||
},
|
||||
discard: {
|
||||
onShuffle(ctx, stacks) {
|
||||
// Bandit: shuffle discards random item cards
|
||||
// Simplified: mark the effect for the procedure to handle
|
||||
},
|
||||
},
|
||||
curse: {
|
||||
onDamage(ctx, targetKey, damage, stacks) {
|
||||
// Curse: when attacked, item attack -1 until card from that item is discarded
|
||||
// This is handled via itemBuffs in effects
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
|
||||
const cardDef = statusCardDesertData.find(c => c.id === effectId);
|
||||
if (!cardDef) return;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const cardId = `status-${effectId}-${Date.now()}-${i}`;
|
||||
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
|
||||
state.player.deck.cards[card.id] = card;
|
||||
state.player.deck.hand.push(card.id);
|
||||
}
|
||||
}
|
||||
|
||||
export type TriggerEvent =
|
||||
| "onTurnStart"
|
||||
| "onTurnEnd"
|
||||
| "onAttacked"
|
||||
| "onDamage"
|
||||
| "modifyOutgoingDamage"
|
||||
| "modifyIncomingDamage"
|
||||
| "onShuffle"
|
||||
| "onCardPlayed"
|
||||
| "onCardDiscarded";
|
||||
|
||||
export function dispatchTrigger(
|
||||
ctx: TriggerContext,
|
||||
event: "onTurnStart" | "onTurnEnd",
|
||||
entityKey: "player" | string,
|
||||
registry: CombatTriggerRegistry,
|
||||
): void {
|
||||
const buffs = entityKey === "player"
|
||||
? ctx.state.player.buffs
|
||||
: ctx.state.enemies[entityKey]?.buffs;
|
||||
if (!buffs) return;
|
||||
|
||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior) continue;
|
||||
const handler = behavior[event];
|
||||
if (handler) {
|
||||
handler(ctx, entityKey, stacks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchAttackedTrigger(
|
||||
ctx: TriggerContext,
|
||||
attackerKey: "player" | string,
|
||||
defenderKey: "player" | string,
|
||||
damage: number,
|
||||
registry: CombatTriggerRegistry,
|
||||
): number {
|
||||
const buffs = defenderKey === "player"
|
||||
? ctx.state.player.buffs
|
||||
: ctx.state.enemies[defenderKey]?.buffs;
|
||||
if (!buffs) return damage;
|
||||
|
||||
let modifiedDamage = damage;
|
||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior?.onAttacked) continue;
|
||||
modifiedDamage = behavior.onAttacked(ctx, attackerKey, defenderKey, modifiedDamage, stacks);
|
||||
}
|
||||
return modifiedDamage;
|
||||
}
|
||||
|
||||
export function dispatchDamageTrigger(
|
||||
ctx: TriggerContext,
|
||||
targetKey: "player" | string,
|
||||
damage: number,
|
||||
registry: CombatTriggerRegistry,
|
||||
): void {
|
||||
const buffs = targetKey === "player"
|
||||
? ctx.state.player.buffs
|
||||
: ctx.state.enemies[targetKey]?.buffs;
|
||||
if (!buffs) return;
|
||||
|
||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior?.onDamage) continue;
|
||||
behavior.onDamage(ctx, targetKey, damage, stacks);
|
||||
}
|
||||
}
|
||||
|
||||
export function dispatchOutgoingDamageTrigger(
|
||||
ctx: TriggerContext,
|
||||
sourceKey: "player" | string,
|
||||
damage: number,
|
||||
registry: CombatTriggerRegistry,
|
||||
): number {
|
||||
const buffs = sourceKey === "player"
|
||||
? ctx.state.player.buffs
|
||||
: ctx.state.enemies[sourceKey]?.buffs;
|
||||
if (!buffs) return damage;
|
||||
|
||||
let modifiedDamage = damage;
|
||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior?.modifyOutgoingDamage) continue;
|
||||
modifiedDamage = behavior.modifyOutgoingDamage(ctx, sourceKey, modifiedDamage, stacks);
|
||||
}
|
||||
return modifiedDamage;
|
||||
}
|
||||
|
||||
export function dispatchIncomingDamageTrigger(
|
||||
ctx: TriggerContext,
|
||||
targetKey: "player" | string,
|
||||
damage: number,
|
||||
registry: CombatTriggerRegistry,
|
||||
): number {
|
||||
const buffs = targetKey === "player"
|
||||
? ctx.state.player.buffs
|
||||
: ctx.state.enemies[targetKey]?.buffs;
|
||||
if (!buffs) return damage;
|
||||
|
||||
let modifiedDamage = damage;
|
||||
for (const [buffId, stacks] of Object.entries(buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior?.modifyIncomingDamage) continue;
|
||||
modifiedDamage = behavior.modifyIncomingDamage(ctx, targetKey, modifiedDamage, stacks);
|
||||
}
|
||||
return modifiedDamage;
|
||||
}
|
||||
|
||||
export function dispatchShuffleTrigger(
|
||||
ctx: TriggerContext,
|
||||
registry: CombatTriggerRegistry,
|
||||
): void {
|
||||
for (const enemyId of ctx.state.enemyOrder) {
|
||||
const enemy = ctx.state.enemies[enemyId];
|
||||
if (!enemy.isAlive) continue;
|
||||
for (const [buffId, stacks] of Object.entries(enemy.buffs)) {
|
||||
const behavior = registry[buffId];
|
||||
if (!behavior?.onShuffle) continue;
|
||||
behavior.onShuffle(ctx, stacks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
import type { EnemyDesert } from "../data/enemyDesert.csv";
|
||||
import type { EnemyIntentDesert } from "../data/enemyIntentDesert.csv";
|
||||
import type { EffectDesert } from "../data/effectDesert.csv";
|
||||
import type { PlayerDeck, GameCard } from "../deck/types";
|
||||
import type { PlayerState } from "../progress/types";
|
||||
|
||||
export type BuffTable = Record<string, number>;
|
||||
|
||||
export type EffectTiming = EffectDesert["timing"];
|
||||
|
||||
export type EffectTarget = "self" | "target" | "all" | "random" | "player" | "team";
|
||||
|
||||
export type ItemBuff = {
|
||||
effectId: string;
|
||||
stacks: number;
|
||||
timing: EffectTiming;
|
||||
sourceItemId: string;
|
||||
targetItemId: string;
|
||||
};
|
||||
|
||||
export type EnemyState = {
|
||||
id: string;
|
||||
templateId: string;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
buffs: BuffTable;
|
||||
currentIntentId: string;
|
||||
intentData: Record<string, EnemyIntentDesert>;
|
||||
isAlive: boolean;
|
||||
hadDefendBroken: boolean;
|
||||
};
|
||||
|
||||
export type PlayerCombatState = {
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
energy: number;
|
||||
maxEnergy: number;
|
||||
buffs: BuffTable;
|
||||
deck: PlayerDeck;
|
||||
damageTakenThisTurn: number;
|
||||
damagedThisTurn: boolean;
|
||||
cardsDiscardedThisTurn: number;
|
||||
};
|
||||
|
||||
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
|
||||
|
||||
export type CombatResult = "victory" | "defeat" | "fled";
|
||||
|
||||
export type LootEntry = {
|
||||
type: "gold" | "item" | "relic";
|
||||
amount?: number;
|
||||
itemId?: string;
|
||||
};
|
||||
|
||||
export type CombatState = {
|
||||
enemies: Record<string, EnemyState>;
|
||||
enemyOrder: string[];
|
||||
player: PlayerCombatState;
|
||||
phase: CombatPhase;
|
||||
turnNumber: number;
|
||||
result: CombatResult | null;
|
||||
loot: LootEntry[];
|
||||
itemBuffs: ItemBuff[];
|
||||
fatigueAddedCount: number;
|
||||
enemyTemplateData: Record<string, EnemyDesert>;
|
||||
};
|
||||
|
||||
export type CombatEffectEntry = [EffectTarget, EffectDesert, number];
|
||||
|
||||
export type CombatEntity = {
|
||||
buffs: BuffTable;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
isAlive: boolean;
|
||||
};
|
||||
|
||||
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>;
|
||||
|
|
@ -8,3 +8,4 @@ wound, 伤口, 无效果,占用手牌和牌堆, true,
|
|||
venom, 蛇毒, 弃掉超过1张蛇毒时受到6伤害, true, [self; venom; 1]
|
||||
curse, 诅咒, 受攻击时物品攻击-1,直到弃掉一张该物品的牌, true, [self; curse; 1]
|
||||
static, 静电, 在手里时受电击伤害+1, true, [self; static; 1]
|
||||
fatigue, 疲劳, 1费/消耗,占用手牌, true,
|
||||
|
|
|
|||
|
|
|
@ -82,3 +82,66 @@ export {
|
|||
flipXTransform,
|
||||
flipYTransform,
|
||||
} from './utils/shape-collision';
|
||||
|
||||
// Combat
|
||||
export type {
|
||||
BuffTable,
|
||||
CombatEffectEntry,
|
||||
CombatEntity,
|
||||
CombatGameContext,
|
||||
CombatPhase,
|
||||
CombatResult,
|
||||
CombatState,
|
||||
EffectTarget,
|
||||
EffectTiming,
|
||||
EnemyState,
|
||||
ItemBuff,
|
||||
LootEntry,
|
||||
PlayerCombatState,
|
||||
} from './combat';
|
||||
|
||||
export type {
|
||||
TriggerContext,
|
||||
BuffTriggerBehavior,
|
||||
CombatTriggerRegistry,
|
||||
TriggerEvent,
|
||||
} from './combat';
|
||||
|
||||
export {
|
||||
createCombatState,
|
||||
createEnemyInstance,
|
||||
createPlayerCombatState,
|
||||
drawCardsToHand,
|
||||
addFatigueCards,
|
||||
discardHand,
|
||||
discardCard,
|
||||
exhaustCard,
|
||||
getEnemyCurrentIntent,
|
||||
advanceEnemyIntent,
|
||||
getEffectTiming,
|
||||
getEffectData,
|
||||
INITIAL_HAND_SIZE,
|
||||
DEFAULT_MAX_ENERGY,
|
||||
FATIGUE_CARDS_PER_SHUFFLE,
|
||||
applyDamage,
|
||||
applyDefend,
|
||||
applyBuff,
|
||||
removeBuff,
|
||||
updateBuffs,
|
||||
resolveEffect,
|
||||
resolveCardEffects,
|
||||
getModifiedAttackDamage,
|
||||
getModifiedDefendAmount,
|
||||
canPlayCard,
|
||||
playCard,
|
||||
areAllEnemiesDead,
|
||||
isPlayerDead,
|
||||
createCombatTriggerRegistry,
|
||||
dispatchTrigger,
|
||||
dispatchAttackedTrigger,
|
||||
dispatchDamageTrigger,
|
||||
dispatchOutgoingDamageTrigger,
|
||||
dispatchIncomingDamageTrigger,
|
||||
dispatchShuffleTrigger,
|
||||
runCombat,
|
||||
} from './combat';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,344 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
applyDamage,
|
||||
applyDefend,
|
||||
applyBuff,
|
||||
removeBuff,
|
||||
updateBuffs,
|
||||
canPlayCard,
|
||||
playCard,
|
||||
areAllEnemiesDead,
|
||||
isPlayerDead,
|
||||
getModifiedAttackDamage,
|
||||
getModifiedDefendAmount,
|
||||
} from '@/samples/slay-the-spire-like/combat/effects';
|
||||
import type { CombatState, PlayerCombatState, EnemyState } from '@/samples/slay-the-spire-like/combat/types';
|
||||
import {
|
||||
createCombatState,
|
||||
createEnemyInstance,
|
||||
createPlayerCombatState,
|
||||
drawCardsToHand,
|
||||
} from '@/samples/slay-the-spire-like/combat/state';
|
||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
|
||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
||||
import { enemyDesertData, encounterDesertData } from '@/samples/slay-the-spire-like/data';
|
||||
import { Mulberry32RNG } from '@/utils/rng';
|
||||
|
||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
||||
const shape = parseShapeString(shapeStr);
|
||||
return {
|
||||
itemData: {
|
||||
type: 'weapon',
|
||||
name,
|
||||
shape: shapeStr,
|
||||
costType: 'energy',
|
||||
costCount: 1,
|
||||
targetType: 'single',
|
||||
price: 10,
|
||||
desc: '测试',
|
||||
},
|
||||
shape,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
||||
const meta1 = createTestMeta('短刀', 'oe');
|
||||
const item1: InventoryItem<GameItemMeta> = {
|
||||
id: 'item-1',
|
||||
shape: meta1.shape,
|
||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||
meta: meta1,
|
||||
};
|
||||
placeItem(inv, item1);
|
||||
return inv;
|
||||
}
|
||||
|
||||
function createTestCombatState(): CombatState {
|
||||
const inv = createTestInventory();
|
||||
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
|
||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
||||
return createCombatState(playerState, inv, encounter);
|
||||
}
|
||||
|
||||
function createSimpleRng() {
|
||||
return new Mulberry32RNG(42);
|
||||
}
|
||||
|
||||
describe('combat/effects', () => {
|
||||
describe('applyDamage', () => {
|
||||
it('should deal damage to player', () => {
|
||||
const state = createTestCombatState();
|
||||
applyDamage(state, 'player', 10);
|
||||
|
||||
expect(state.player.hp).toBe(40);
|
||||
expect(state.player.damageTakenThisTurn).toBe(10);
|
||||
expect(state.player.damagedThisTurn).toBe(true);
|
||||
});
|
||||
|
||||
it('should deal damage to enemy', () => {
|
||||
const state = createTestCombatState();
|
||||
const enemyId = state.enemyOrder[0];
|
||||
const enemy = state.enemies[enemyId];
|
||||
const initialHp = enemy.hp;
|
||||
|
||||
applyDamage(state, enemyId, 5);
|
||||
|
||||
expect(enemy.hp).toBe(initialHp - 5);
|
||||
});
|
||||
|
||||
it('should be absorbed by defend buff on player', () => {
|
||||
const state = createTestCombatState();
|
||||
state.player.buffs['defend'] = 3;
|
||||
|
||||
const result = applyDamage(state, 'player', 5);
|
||||
|
||||
expect(result.blockedByDefend).toBe(3);
|
||||
expect(result.damageDealt).toBe(2);
|
||||
expect(state.player.hp).toBe(48);
|
||||
});
|
||||
|
||||
it('should be fully absorbed by defend buff', () => {
|
||||
const state = createTestCombatState();
|
||||
state.player.buffs['defend'] = 10;
|
||||
|
||||
applyDamage(state, 'player', 5);
|
||||
|
||||
expect(state.player.hp).toBe(50);
|
||||
expect(state.player.buffs['defend']).toBe(5);
|
||||
});
|
||||
|
||||
it('should be absorbed by defend buff on enemy', () => {
|
||||
const state = createTestCombatState();
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['defend'] = 4;
|
||||
|
||||
const result = applyDamage(state, enemyId, 6);
|
||||
|
||||
expect(result.blockedByDefend).toBe(4);
|
||||
expect(result.damageDealt).toBe(2);
|
||||
expect(state.enemies[enemyId].hp).toBe(state.enemies[enemyId].maxHp - 2);
|
||||
});
|
||||
|
||||
it('should mark defend broken when defend fully consumed', () => {
|
||||
const state = createTestCombatState();
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['defend'] = 3;
|
||||
|
||||
applyDamage(state, enemyId, 5);
|
||||
|
||||
expect(state.enemies[enemyId].hadDefendBroken).toBe(true);
|
||||
});
|
||||
|
||||
it('should kill enemy when HP reaches 0', () => {
|
||||
const state = createTestCombatState();
|
||||
const enemyId = state.enemyOrder[0];
|
||||
|
||||
applyDamage(state, enemyId, state.enemies[enemyId].maxHp);
|
||||
|
||||
expect(state.enemies[enemyId].isAlive).toBe(false);
|
||||
expect(state.enemies[enemyId].hp).toBe(0);
|
||||
});
|
||||
|
||||
it('should not deal negative damage', () => {
|
||||
const state = createTestCombatState();
|
||||
|
||||
const result = applyDamage(state, 'player', -5);
|
||||
|
||||
expect(result.damageDealt).toBe(0);
|
||||
expect(state.player.hp).toBe(50);
|
||||
});
|
||||
|
||||
it('should apply damageReduce buff', () => {
|
||||
const state = createTestCombatState();
|
||||
state.player.buffs['damageReduce'] = 3;
|
||||
|
||||
applyDamage(state, 'player', 5);
|
||||
|
||||
expect(state.player.hp).toBe(48);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDefend', () => {
|
||||
it('should add defend stacks', () => {
|
||||
const buffs: Record<string, number> = {};
|
||||
applyDefend(buffs, 5);
|
||||
|
||||
expect(buffs['defend']).toBe(5);
|
||||
});
|
||||
|
||||
it('should stack with existing defend', () => {
|
||||
const buffs: Record<string, number> = { defend: 3 };
|
||||
applyDefend(buffs, 4);
|
||||
|
||||
expect(buffs['defend']).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyBuff / removeBuff', () => {
|
||||
it('should apply buff stacks', () => {
|
||||
const buffs: Record<string, number> = {};
|
||||
applyBuff(buffs, 'aim', 'lingering', 3);
|
||||
|
||||
expect(buffs['aim']).toBe(3);
|
||||
});
|
||||
|
||||
it('should stack existing buffs', () => {
|
||||
const buffs: Record<string, number> = { aim: 2 };
|
||||
applyBuff(buffs, 'aim', 'lingering', 3);
|
||||
|
||||
expect(buffs['aim']).toBe(5);
|
||||
});
|
||||
|
||||
it('should remove buff partially', () => {
|
||||
const buffs: Record<string, number> = { aim: 5 };
|
||||
const removed = removeBuff(buffs, 'aim', 3);
|
||||
|
||||
expect(removed).toBe(3);
|
||||
expect(buffs['aim']).toBe(2);
|
||||
});
|
||||
|
||||
it('should remove buff fully when stacks exceed current', () => {
|
||||
const buffs: Record<string, number> = { aim: 2 };
|
||||
const removed = removeBuff(buffs, 'aim', 10);
|
||||
|
||||
expect(removed).toBe(2);
|
||||
expect(buffs['aim']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBuffs', () => {
|
||||
it('should clear temporary buffs', () => {
|
||||
const buffs: Record<string, number> = { damageReduce: 3, defendNext: 2 };
|
||||
updateBuffs(buffs);
|
||||
|
||||
expect(buffs['damageReduce']).toBeUndefined();
|
||||
expect(buffs['defendNext']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should decrement lingering buffs', () => {
|
||||
const buffs: Record<string, number> = { spike: 3, aim: 1 };
|
||||
updateBuffs(buffs);
|
||||
|
||||
expect(buffs['spike']).toBe(2);
|
||||
expect(buffs['aim']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not affect permanent or posture buffs', () => {
|
||||
const buffs: Record<string, number> = { defend: 5, energyDrain: 1 };
|
||||
updateBuffs(buffs);
|
||||
|
||||
expect(buffs['defend']).toBe(5);
|
||||
expect(buffs['energyDrain']).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canPlayCard', () => {
|
||||
it('should allow playing card with enough energy', () => {
|
||||
const state = createTestCombatState();
|
||||
const cardId = state.player.deck.hand[0];
|
||||
|
||||
const result = canPlayCard(state, cardId);
|
||||
expect(result.canPlay).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject card not in hand', () => {
|
||||
const state = createTestCombatState();
|
||||
|
||||
const result = canPlayCard(state, 'nonexistent-card');
|
||||
expect(result.canPlay).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject card with insufficient energy', () => {
|
||||
const state = createTestCombatState();
|
||||
state.player.energy = 0;
|
||||
const cardId = state.player.deck.hand[0];
|
||||
|
||||
const card = state.player.deck.cards[cardId];
|
||||
if (card?.itemData?.costType === 'energy' && card.itemData.costCount > 0) {
|
||||
const result = canPlayCard(state, cardId);
|
||||
expect(result.canPlay).toBe(false);
|
||||
expect(result.reason).toBe('能量不足');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('playCard', () => {
|
||||
it('should deduct energy cost when playing card', () => {
|
||||
const state = createTestCombatState();
|
||||
const cardId = state.player.deck.hand[0];
|
||||
const card = state.player.deck.cards[cardId];
|
||||
const initialEnergy = state.player.energy;
|
||||
|
||||
if (card?.itemData?.costType === 'energy') {
|
||||
const ctx = { state, rng: createSimpleRng() };
|
||||
const result = playCard(ctx, cardId);
|
||||
|
||||
if (result.success) {
|
||||
expect(state.player.energy).toBe(initialEnergy - card.itemData.costCount);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should move card to discard pile after playing', () => {
|
||||
const state = createTestCombatState();
|
||||
const cardId = state.player.deck.hand[0];
|
||||
const ctx = { state, rng: createSimpleRng() };
|
||||
|
||||
const result = playCard(ctx, cardId);
|
||||
|
||||
if (result.success) {
|
||||
expect(state.player.deck.hand.includes(cardId)).toBe(false);
|
||||
expect(state.player.deck.discardPile.includes(cardId) || state.player.deck.exhaustPile.includes(cardId)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('areAllEnemiesDead / isPlayerDead', () => {
|
||||
it('should detect all enemies dead', () => {
|
||||
const state = createTestCombatState();
|
||||
expect(areAllEnemiesDead(state)).toBe(false);
|
||||
|
||||
for (const enemyId of state.enemyOrder) {
|
||||
state.enemies[enemyId].isAlive = false;
|
||||
}
|
||||
expect(areAllEnemiesDead(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect player death', () => {
|
||||
const state = createTestCombatState();
|
||||
expect(isPlayerDead(state)).toBe(false);
|
||||
|
||||
state.player.hp = 0;
|
||||
expect(isPlayerDead(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModifiedAttackDamage / getModifiedDefendAmount', () => {
|
||||
it('should return base damage with no item buffs', () => {
|
||||
const state = createTestCombatState();
|
||||
expect(getModifiedAttackDamage(state, 'some-card', 5)).toBe(5);
|
||||
});
|
||||
|
||||
it('should return base defend with no item buffs', () => {
|
||||
const state = createTestCombatState();
|
||||
expect(getModifiedDefendAmount(state, 'some-card', 4)).toBe(4);
|
||||
});
|
||||
|
||||
it('should add item buff attack damage', () => {
|
||||
const state = createTestCombatState();
|
||||
state.itemBuffs.push({
|
||||
effectId: 'attackBuff',
|
||||
stacks: 3,
|
||||
timing: 'itemUntilPlayed',
|
||||
sourceItemId: 'item-1',
|
||||
targetItemId: 'item-1',
|
||||
});
|
||||
|
||||
expect(getModifiedAttackDamage(state, 'some-card', 5)).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,261 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { createGameHost, GameHost } from '@/core/game-host';
|
||||
import { createGameContext, createGameCommandRegistry } from '@/core/game';
|
||||
import type { CombatState, CombatGameContext } from '@/samples/slay-the-spire-like/combat/types';
|
||||
import { createCombatState } from '@/samples/slay-the-spire-like/combat/state';
|
||||
import { runCombat } from '@/samples/slay-the-spire-like/combat/procedure';
|
||||
import { prompts } from '@/samples/slay-the-spire-like/combat/prompts';
|
||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
|
||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
||||
import { encounterDesertData, enemyDesertData } from '@/samples/slay-the-spire-like/data';
|
||||
|
||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
||||
const shape = parseShapeString(shapeStr);
|
||||
return {
|
||||
itemData: {
|
||||
type: 'weapon',
|
||||
name,
|
||||
shape: shapeStr,
|
||||
costType: 'energy',
|
||||
costCount: 1,
|
||||
targetType: 'single',
|
||||
price: 10,
|
||||
desc: '测试',
|
||||
},
|
||||
shape,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
||||
const meta1 = createTestMeta('短刀', 'oe');
|
||||
const item1: InventoryItem<GameItemMeta> = {
|
||||
id: 'item-1',
|
||||
shape: meta1.shape,
|
||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||
meta: meta1,
|
||||
};
|
||||
placeItem(inv, item1);
|
||||
return inv;
|
||||
}
|
||||
|
||||
function createTestCombatState(): CombatState {
|
||||
const inv = createTestInventory();
|
||||
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
|
||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
||||
return createCombatState(playerState, inv, encounter);
|
||||
}
|
||||
|
||||
function waitForPrompt(host: GameHost<CombatState>): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (host.activePromptSchema.value !== null) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 10);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
describe('combat/procedure', () => {
|
||||
describe('runCombat with GameHost', () => {
|
||||
it('should start combat and prompt for player action', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const initialState = createTestCombatState();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
|
||||
await waitForPrompt(host);
|
||||
expect(host.activePromptSchema.value).not.toBeNull();
|
||||
expect(host.activePromptSchema.value?.name).toBe('play-card');
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
|
||||
it('should accept play-card input', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
const state = host.state.value;
|
||||
const cardId = state.player.deck.hand[0];
|
||||
|
||||
const error = host.tryAnswerPrompt(prompts.playCard, cardId, state.enemyOrder[0]);
|
||||
expect(error).toBeNull();
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
|
||||
it('should reject invalid card play', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
const error = host.tryAnswerPrompt(prompts.playCard, 'nonexistent-card');
|
||||
expect(error).not.toBeNull();
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
|
||||
it('should transition to end-turn after playing cards', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
const state = host.state.value;
|
||||
const cardId = state.player.deck.hand[0];
|
||||
|
||||
host.tryAnswerPrompt(prompts.playCard, cardId, state.enemyOrder[0]);
|
||||
await waitForPrompt(host);
|
||||
|
||||
expect(host.activePromptSchema.value).not.toBeNull();
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
|
||||
it('should accept end-turn', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
const error = host.tryAnswerPrompt(prompts.endTurn);
|
||||
expect(error).toBeNull();
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
});
|
||||
|
||||
describe('combat outcome', () => {
|
||||
it('should return victory when all enemies are dead', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => {
|
||||
const state = createTestCombatState();
|
||||
for (const enemyId of state.enemyOrder) {
|
||||
state.enemies[enemyId].hp = 1;
|
||||
}
|
||||
return state;
|
||||
},
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
let iterations = 0;
|
||||
while (host.status.value === 'running' && iterations < 100) {
|
||||
const state = host.state.value;
|
||||
if (host.activePromptSchema.value?.name === 'play-card') {
|
||||
const cardId = state.player.deck.hand[0];
|
||||
if (cardId) {
|
||||
const targetId = state.enemyOrder.find(id => state.enemies[id].isAlive);
|
||||
host.tryAnswerPrompt(prompts.playCard, cardId, targetId);
|
||||
}
|
||||
} else if (host.activePromptSchema.value?.name === 'end-turn') {
|
||||
host.tryAnswerPrompt(prompts.endTurn);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
iterations++;
|
||||
}
|
||||
|
||||
if (host.status.value === 'running') {
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('combat state transitions', () => {
|
||||
it('should track turn number across turns', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
host.tryAnswerPrompt(prompts.endTurn);
|
||||
await waitForPrompt(host);
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
|
||||
it('should reset energy at start of player turn', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
const state = host.state.value;
|
||||
expect(state.player.energy).toBe(state.player.maxEnergy);
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,303 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createCombatState,
|
||||
createEnemyInstance,
|
||||
createPlayerCombatState,
|
||||
drawCardsToHand,
|
||||
addFatigueCards,
|
||||
discardHand,
|
||||
discardCard,
|
||||
exhaustCard,
|
||||
getEnemyCurrentIntent,
|
||||
advanceEnemyIntent,
|
||||
getEffectTiming,
|
||||
getEffectData,
|
||||
INITIAL_HAND_SIZE,
|
||||
DEFAULT_MAX_ENERGY,
|
||||
FATIGUE_CARDS_PER_SHUFFLE,
|
||||
} from '@/samples/slay-the-spire-like/combat/state';
|
||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
|
||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
||||
import { encounterDesertData, enemyDesertData, effectDesertData } from '@/samples/slay-the-spire-like/data';
|
||||
|
||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
||||
const shape = parseShapeString(shapeStr);
|
||||
return {
|
||||
itemData: {
|
||||
type: 'weapon',
|
||||
name,
|
||||
shape: shapeStr,
|
||||
costType: 'energy',
|
||||
costCount: 1,
|
||||
targetType: 'single',
|
||||
price: 10,
|
||||
desc: '测试物品',
|
||||
},
|
||||
shape,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
||||
const meta1 = createTestMeta('短刀', 'oe');
|
||||
const item1: InventoryItem<GameItemMeta> = {
|
||||
id: 'item-1',
|
||||
shape: meta1.shape,
|
||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||
meta: meta1,
|
||||
};
|
||||
placeItem(inv, item1);
|
||||
return inv;
|
||||
}
|
||||
|
||||
function createTestPlayerState(): PlayerState {
|
||||
return { maxHp: 50, currentHp: 50, gold: 0 };
|
||||
}
|
||||
|
||||
describe('combat/state', () => {
|
||||
describe('constants', () => {
|
||||
it('should have correct default values', () => {
|
||||
expect(INITIAL_HAND_SIZE).toBe(5);
|
||||
expect(DEFAULT_MAX_ENERGY).toBe(3);
|
||||
expect(FATIGUE_CARDS_PER_SHUFFLE).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEnemyInstance', () => {
|
||||
it('should create enemy from desert data', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
|
||||
expect(enemy.templateId).toBe('仙人掌怪');
|
||||
expect(enemy.hp).toBe(cactusData.initHp);
|
||||
expect(enemy.maxHp).toBe(cactusData.initHp);
|
||||
expect(enemy.isAlive).toBe(true);
|
||||
expect(enemy.hadDefendBroken).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply bonus HP', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 5, { value: 0 });
|
||||
|
||||
expect(enemy.hp).toBe(cactusData.initHp + 5);
|
||||
expect(enemy.maxHp).toBe(cactusData.initHp + 5);
|
||||
});
|
||||
|
||||
it('should initialize buffs from template', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
|
||||
expect(enemy.buffs['spike']).toBe(1);
|
||||
});
|
||||
|
||||
it('should set initial intent', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
|
||||
expect(enemy.currentIntentId).toBe(cactusData.initialIntent);
|
||||
});
|
||||
|
||||
it('should generate unique IDs', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const e1 = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
const e2 = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
|
||||
expect(e1.id).not.toBe(e2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPlayerCombatState', () => {
|
||||
it('should create player state from run state and inventory', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
|
||||
expect(combatPlayer.hp).toBe(50);
|
||||
expect(combatPlayer.maxHp).toBe(50);
|
||||
expect(combatPlayer.energy).toBe(DEFAULT_MAX_ENERGY);
|
||||
expect(combatPlayer.maxEnergy).toBe(DEFAULT_MAX_ENERGY);
|
||||
expect(Object.keys(combatPlayer.buffs).length).toBe(0);
|
||||
expect(combatPlayer.damagedThisTurn).toBe(false);
|
||||
expect(combatPlayer.cardsDiscardedThisTurn).toBe(0);
|
||||
});
|
||||
|
||||
it('should generate deck from inventory', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
|
||||
expect(Object.keys(combatPlayer.deck.cards).length).toBeGreaterThan(0);
|
||||
expect(combatPlayer.deck.drawPile.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCombatState', () => {
|
||||
it('should create full combat state from encounter', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
||||
|
||||
const combat = createCombatState(playerState, inv, encounter);
|
||||
|
||||
expect(combat.phase).toBe('playerTurn');
|
||||
expect(combat.turnNumber).toBe(1);
|
||||
expect(combat.result).toBeNull();
|
||||
expect(combat.loot).toEqual([]);
|
||||
expect(combat.fatigueAddedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should create enemies from encounter data', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
||||
|
||||
const combat = createCombatState(playerState, inv, encounter);
|
||||
|
||||
expect(combat.enemyOrder.length).toBeGreaterThan(0);
|
||||
for (const enemyId of combat.enemyOrder) {
|
||||
expect(combat.enemies[enemyId].isAlive).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should draw initial hand', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
||||
|
||||
const combat = createCombatState(playerState, inv, encounter);
|
||||
|
||||
expect(combat.player.deck.hand.length).toBe(INITIAL_HAND_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawCardsToHand', () => {
|
||||
it('should draw cards from draw pile to hand', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
|
||||
const initialDrawPile = combatPlayer.deck.drawPile.length;
|
||||
const initialHand = combatPlayer.deck.hand.length;
|
||||
drawCardsToHand(combatPlayer.deck, 3);
|
||||
|
||||
expect(combatPlayer.deck.hand.length).toBe(initialHand + 3);
|
||||
expect(combatPlayer.deck.drawPile.length).toBe(initialDrawPile - 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addFatigueCards', () => {
|
||||
it('should add fatigue cards to draw pile', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
|
||||
const initialCount = Object.keys(combatPlayer.deck.cards).length;
|
||||
const fatigueCounter = { value: 0 };
|
||||
addFatigueCards(combatPlayer.deck, FATIGUE_CARDS_PER_SHUFFLE, fatigueCounter);
|
||||
|
||||
expect(Object.keys(combatPlayer.deck.cards).length).toBe(initialCount + FATIGUE_CARDS_PER_SHUFFLE);
|
||||
expect(fatigueCounter.value).toBe(FATIGUE_CARDS_PER_SHUFFLE);
|
||||
});
|
||||
|
||||
it('should create fatigue cards with correct properties', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
|
||||
addFatigueCards(combatPlayer.deck, 1, { value: 0 });
|
||||
const fatigueCard = combatPlayer.deck.cards['fatigue-1'];
|
||||
|
||||
expect(fatigueCard).toBeDefined();
|
||||
expect(fatigueCard.displayName).toBe('疲劳');
|
||||
expect(fatigueCard.sourceItemId).toBeNull();
|
||||
expect(fatigueCard.itemData).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('discardHand', () => {
|
||||
it('should move all hand cards to discard pile', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
drawCardsToHand(combatPlayer.deck, 3);
|
||||
|
||||
const handCount = combatPlayer.deck.hand.length;
|
||||
discardHand(combatPlayer.deck);
|
||||
|
||||
expect(combatPlayer.deck.hand).toEqual([]);
|
||||
expect(combatPlayer.deck.discardPile.length).toBe(handCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discardCard / exhaustCard', () => {
|
||||
it('should move a card from hand to discard pile', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
drawCardsToHand(combatPlayer.deck, 3);
|
||||
|
||||
const cardId = combatPlayer.deck.hand[0];
|
||||
discardCard(combatPlayer.deck, cardId);
|
||||
|
||||
expect(combatPlayer.deck.hand.includes(cardId)).toBe(false);
|
||||
expect(combatPlayer.deck.discardPile.includes(cardId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should move a card from hand to exhaust pile', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
drawCardsToHand(combatPlayer.deck, 3);
|
||||
|
||||
const cardId = combatPlayer.deck.hand[0];
|
||||
exhaustCard(combatPlayer.deck, cardId);
|
||||
|
||||
expect(combatPlayer.deck.hand.includes(cardId)).toBe(false);
|
||||
expect(combatPlayer.deck.exhaustPile.includes(cardId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enemy intent', () => {
|
||||
it('should get current intent', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
|
||||
const intent = getEnemyCurrentIntent(enemy);
|
||||
expect(intent).toBeDefined();
|
||||
expect(intent!.id).toBe('boost');
|
||||
});
|
||||
|
||||
it('should advance intent after action', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
|
||||
const originalIntent = enemy.currentIntentId;
|
||||
advanceEnemyIntent(enemy);
|
||||
|
||||
expect(enemy.currentIntentId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEffectTiming / getEffectData', () => {
|
||||
it('should return timing for known effects', () => {
|
||||
expect(getEffectTiming('attack')).toBe('instant');
|
||||
expect(getEffectTiming('defend')).toBe('posture');
|
||||
expect(getEffectTiming('spike')).toBe('lingering');
|
||||
expect(getEffectTiming('energyDrain')).toBe('permanent');
|
||||
});
|
||||
|
||||
it('should return undefined for unknown effects', () => {
|
||||
expect(getEffectTiming('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return effect data for known effects', () => {
|
||||
const data = getEffectData('attack');
|
||||
expect(data).toBeDefined();
|
||||
expect(data!.id).toBe('attack');
|
||||
expect(data!.name).toBe('攻击');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,254 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createCombatTriggerRegistry,
|
||||
dispatchTrigger,
|
||||
dispatchAttackedTrigger,
|
||||
dispatchDamageTrigger,
|
||||
dispatchOutgoingDamageTrigger,
|
||||
dispatchIncomingDamageTrigger,
|
||||
} from '@/samples/slay-the-spire-like/combat/triggers';
|
||||
import type { TriggerContext, CombatTriggerRegistry } from '@/samples/slay-the-spire-like/combat/triggers';
|
||||
import type { CombatState } from '@/samples/slay-the-spire-like/combat/types';
|
||||
import { createCombatState } from '@/samples/slay-the-spire-like/combat/state';
|
||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
|
||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
||||
import { encounterDesertData, enemyDesertData } from '@/samples/slay-the-spire-like/data';
|
||||
import { Mulberry32RNG } from '@/utils/rng';
|
||||
|
||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
||||
const shape = parseShapeString(shapeStr);
|
||||
return {
|
||||
itemData: {
|
||||
type: 'weapon',
|
||||
name,
|
||||
shape: shapeStr,
|
||||
costType: 'energy',
|
||||
costCount: 1,
|
||||
targetType: 'single',
|
||||
price: 10,
|
||||
desc: '测试',
|
||||
},
|
||||
shape,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
||||
const meta1 = createTestMeta('短刀', 'oe');
|
||||
const item1: InventoryItem<GameItemMeta> = {
|
||||
id: 'item-1',
|
||||
shape: meta1.shape,
|
||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||
meta: meta1,
|
||||
};
|
||||
placeItem(inv, item1);
|
||||
return inv;
|
||||
}
|
||||
|
||||
function createTestCombatState(): CombatState {
|
||||
const inv = createTestInventory();
|
||||
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
|
||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
||||
return createCombatState(playerState, inv, encounter);
|
||||
}
|
||||
|
||||
function createTestTriggerCtx(state: CombatState): TriggerContext {
|
||||
return { state, rng: new Mulberry32RNG(42) };
|
||||
}
|
||||
|
||||
describe('combat/triggers', () => {
|
||||
describe('createCombatTriggerRegistry', () => {
|
||||
it('should create registry with desert zone triggers', () => {
|
||||
const registry = createCombatTriggerRegistry();
|
||||
|
||||
expect(registry['spike']).toBeDefined();
|
||||
expect(registry['aim']).toBeDefined();
|
||||
expect(registry['charge']).toBeDefined();
|
||||
expect(registry['roll']).toBeDefined();
|
||||
expect(registry['tailSting']).toBeDefined();
|
||||
expect(registry['energyDrain']).toBeDefined();
|
||||
expect(registry['molt']).toBeDefined();
|
||||
expect(registry['storm']).toBeDefined();
|
||||
expect(registry['vultureEye']).toBeDefined();
|
||||
expect(registry['venom']).toBeDefined();
|
||||
expect(registry['static']).toBeDefined();
|
||||
expect(registry['curse']).toBeDefined();
|
||||
expect(registry['discard']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spike trigger', () => {
|
||||
it('should deal damage to attacker when enemy is attacked', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['spike'] = 2;
|
||||
|
||||
const initialPlayerHp = state.player.hp;
|
||||
dispatchAttackedTrigger(ctx, 'player', enemyId, 5, registry);
|
||||
|
||||
expect(state.player.hp).toBeLessThan(initialPlayerHp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aim trigger', () => {
|
||||
it('should double outgoing damage with aim stacks', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['aim'] = 3;
|
||||
|
||||
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
|
||||
expect(modified).toBe(10);
|
||||
});
|
||||
|
||||
it('should not double damage with 0 aim stacks', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['aim'] = 0;
|
||||
|
||||
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
|
||||
expect(modified).toBe(5);
|
||||
});
|
||||
|
||||
it('should lose aim stacks on damage', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['aim'] = 5;
|
||||
|
||||
dispatchDamageTrigger(ctx, enemyId, 3, registry);
|
||||
|
||||
expect(state.enemies[enemyId].buffs['aim']).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('charge trigger', () => {
|
||||
it('should double outgoing and incoming damage', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['charge'] = 2;
|
||||
|
||||
const outDmg = dispatchOutgoingDamageTrigger(ctx, enemyId, 6, registry);
|
||||
expect(outDmg).toBe(12);
|
||||
|
||||
const inDmg = dispatchIncomingDamageTrigger(ctx, enemyId, 6, registry);
|
||||
expect(inDmg).toBe(12);
|
||||
});
|
||||
|
||||
it('should lose charge stacks on damage', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['charge'] = 5;
|
||||
|
||||
dispatchDamageTrigger(ctx, enemyId, 3, registry);
|
||||
|
||||
expect(state.enemies[enemyId].buffs['charge']).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roll trigger', () => {
|
||||
it('should increase damage when roll >= 10', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['roll'] = 20;
|
||||
|
||||
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
|
||||
expect(modified).toBe(7);
|
||||
expect(state.enemies[enemyId].buffs['roll']).toBe(0);
|
||||
});
|
||||
|
||||
it('should not modify damage when roll < 10', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['roll'] = 5;
|
||||
|
||||
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
|
||||
expect(modified).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailSting trigger', () => {
|
||||
it('should deal damage to player at turn end', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['tailSting'] = 3;
|
||||
|
||||
const initialHp = state.player.hp;
|
||||
dispatchTrigger(ctx, 'onTurnEnd', enemyId, registry);
|
||||
|
||||
expect(state.player.hp).toBeLessThan(initialHp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('static trigger', () => {
|
||||
it('should increase incoming damage to player', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
state.player.buffs['static'] = 2;
|
||||
|
||||
const modified = dispatchIncomingDamageTrigger(ctx, 'player', 5, registry);
|
||||
expect(modified).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('molt trigger', () => {
|
||||
it('should cause enemy to flee when molt stacks >= maxHp', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['molt'] = state.enemies[enemyId].maxHp;
|
||||
|
||||
dispatchDamageTrigger(ctx, enemyId, 1, registry);
|
||||
|
||||
expect(state.enemies[enemyId].isAlive).toBe(false);
|
||||
expect(state.result).toBe('fled');
|
||||
});
|
||||
|
||||
it('should not cause flee when molt stacks < maxHp', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['molt'] = 1;
|
||||
|
||||
dispatchDamageTrigger(ctx, enemyId, 1, registry);
|
||||
|
||||
expect(state.enemies[enemyId].isAlive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatchTrigger with missing handler', () => {
|
||||
it('should be a no-op for unknown buff', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['nonexistentBuff'] = 5;
|
||||
|
||||
expect(() => {
|
||||
dispatchTrigger(ctx, 'onTurnStart', enemyId, registry);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue