diff --git a/src/samples/slay-the-spire-like/combat/effects.ts b/src/samples/slay-the-spire-like/combat/effects.ts new file mode 100644 index 0000000..7021b42 --- /dev/null +++ b/src/samples/slay-the-spire-like/combat/effects.ts @@ -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; +} diff --git a/src/samples/slay-the-spire-like/combat/index.ts b/src/samples/slay-the-spire-like/combat/index.ts new file mode 100644 index 0000000..cfdb10e --- /dev/null +++ b/src/samples/slay-the-spire-like/combat/index.ts @@ -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"; diff --git a/src/samples/slay-the-spire-like/combat/procedure.ts b/src/samples/slay-the-spire-like/combat/procedure.ts new file mode 100644 index 0000000..d65f86a --- /dev/null +++ b/src/samples/slay-the-spire-like/combat/procedure.ts @@ -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 { + 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 { + 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 { + 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; +} diff --git a/src/samples/slay-the-spire-like/combat/prompts.ts b/src/samples/slay-the-spire-like/combat/prompts.ts new file mode 100644 index 0000000..4d08927 --- /dev/null +++ b/src/samples/slay-the-spire-like/combat/prompts.ts @@ -0,0 +1,12 @@ +import { createPromptDef } from "@/core/game"; + +export const prompts = { + playCard: createPromptDef<[string, string?]>( + "play-card [targetId:string]", + "选择卡牌并指定目标" + ), + endTurn: createPromptDef<[]>( + "end-turn", + "结束回合" + ), +}; diff --git a/src/samples/slay-the-spire-like/combat/state.ts b/src/samples/slay-the-spire-like/combat/state.ts new file mode 100644 index 0000000..782d9b2 --- /dev/null +++ b/src/samples/slay-the-spire-like/combat/state.ts @@ -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 { + const lookup: Record = {}; + for (const intent of enemyIntentDesertData) { + if (intent.enemy.id === enemyTemplateId) { + lookup[intent.id] = intent; + } + } + return lookup; +} + +export function createPlayerCombatState( + playerState: PlayerState, + inventory: GridInventory, +): 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, + encounter: EncounterDesert, +): CombatState { + const idCounter = { value: 0 }; + const player = createPlayerCombatState(playerState, inventory); + + const enemies: Record = {}; + const enemyOrder: string[] = []; + const enemyTemplateData: Record = {}; + + 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 }; diff --git a/src/samples/slay-the-spire-like/combat/triggers.ts b/src/samples/slay-the-spire-like/combat/triggers.ts new file mode 100644 index 0000000..fb28942 --- /dev/null +++ b/src/samples/slay-the-spire-like/combat/triggers.ts @@ -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; + +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); + } + } +} diff --git a/src/samples/slay-the-spire-like/combat/types.ts b/src/samples/slay-the-spire-like/combat/types.ts new file mode 100644 index 0000000..9fdbb47 --- /dev/null +++ b/src/samples/slay-the-spire-like/combat/types.ts @@ -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; + +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; + 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; + enemyOrder: string[]; + player: PlayerCombatState; + phase: CombatPhase; + turnNumber: number; + result: CombatResult | null; + loot: LootEntry[]; + itemBuffs: ItemBuff[]; + fatigueAddedCount: number; + enemyTemplateData: Record; +}; + +export type CombatEffectEntry = [EffectTarget, EffectDesert, number]; + +export type CombatEntity = { + buffs: BuffTable; + hp: number; + maxHp: number; + isAlive: boolean; +}; + +export type CombatGameContext = import("@/core/game").IGameContext; diff --git a/src/samples/slay-the-spire-like/data/statusCardDesert.csv b/src/samples/slay-the-spire-like/data/statusCardDesert.csv index c03a258..8c87886 100644 --- a/src/samples/slay-the-spire-like/data/statusCardDesert.csv +++ b/src/samples/slay-the-spire-like/data/statusCardDesert.csv @@ -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, diff --git a/src/samples/slay-the-spire-like/index.ts b/src/samples/slay-the-spire-like/index.ts index d5d5eb4..60df7b1 100644 --- a/src/samples/slay-the-spire-like/index.ts +++ b/src/samples/slay-the-spire-like/index.ts @@ -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'; diff --git a/tests/samples/slay-the-spire-like/combat/effects.test.ts b/tests/samples/slay-the-spire-like/combat/effects.test.ts new file mode 100644 index 0000000..3d2bf51 --- /dev/null +++ b/tests/samples/slay-the-spire-like/combat/effects.test.ts @@ -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 { + const inv = createGridInventory(6, 4); + const meta1 = createTestMeta('短刀', 'oe'); + const item1: InventoryItem = { + 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 = {}; + applyDefend(buffs, 5); + + expect(buffs['defend']).toBe(5); + }); + + it('should stack with existing defend', () => { + const buffs: Record = { defend: 3 }; + applyDefend(buffs, 4); + + expect(buffs['defend']).toBe(7); + }); + }); + + describe('applyBuff / removeBuff', () => { + it('should apply buff stacks', () => { + const buffs: Record = {}; + applyBuff(buffs, 'aim', 'lingering', 3); + + expect(buffs['aim']).toBe(3); + }); + + it('should stack existing buffs', () => { + const buffs: Record = { aim: 2 }; + applyBuff(buffs, 'aim', 'lingering', 3); + + expect(buffs['aim']).toBe(5); + }); + + it('should remove buff partially', () => { + const buffs: Record = { 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 = { 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 = { damageReduce: 3, defendNext: 2 }; + updateBuffs(buffs); + + expect(buffs['damageReduce']).toBeUndefined(); + expect(buffs['defendNext']).toBeUndefined(); + }); + + it('should decrement lingering buffs', () => { + const buffs: Record = { 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 = { 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); + }); + }); +}); diff --git a/tests/samples/slay-the-spire-like/combat/procedure.test.ts b/tests/samples/slay-the-spire-like/combat/procedure.test.ts new file mode 100644 index 0000000..cd20b31 --- /dev/null +++ b/tests/samples/slay-the-spire-like/combat/procedure.test.ts @@ -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 { + const inv = createGridInventory(6, 4); + const meta1 = createTestMeta('短刀', 'oe'); + const item1: InventoryItem = { + 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): Promise { + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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 {} + }); + }); +}); diff --git a/tests/samples/slay-the-spire-like/combat/state.test.ts b/tests/samples/slay-the-spire-like/combat/state.test.ts new file mode 100644 index 0000000..2327595 --- /dev/null +++ b/tests/samples/slay-the-spire-like/combat/state.test.ts @@ -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 { + const inv = createGridInventory(6, 4); + const meta1 = createTestMeta('短刀', 'oe'); + const item1: InventoryItem = { + 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('攻击'); + }); + }); +}); diff --git a/tests/samples/slay-the-spire-like/combat/triggers.test.ts b/tests/samples/slay-the-spire-like/combat/triggers.test.ts new file mode 100644 index 0000000..9cab337 --- /dev/null +++ b/tests/samples/slay-the-spire-like/combat/triggers.test.ts @@ -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 { + const inv = createGridInventory(6, 4); + const meta1 = createTestMeta('短刀', 'oe'); + const item1: InventoryItem = { + 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(); + }); + }); +});