refactor: middle ware triggers

This commit is contained in:
hypercross 2026-04-17 08:33:02 +08:00
parent 3dc566c2fd
commit 7d8684a16f
4 changed files with 75 additions and 408 deletions

View File

@ -1,346 +1,29 @@
import { cardDesertData } from "../data";
import { createStatusCard } from "../deck/factory";
import type { BuffTable, CombatEffectEntry, CombatState } from "./types";
import { applyDamage, removeBuff } from "./effects";
import {createMiddlewareChain} from "../utils/middleware";
import {CombatGameContext} from "./types";
export type TriggerContext = {
state: CombatState;
rng: { nextInt: (n: number) => number };
};
export type Triggers = {
onTurnStart: { entityKey: "player" | string, },
onTurnEnd: { entityKey: "player" | string, },
onShuffle: { entityKey: "player" | string, },
onCardPlayed: { cardId: string, },
onCardDiscarded: { cardId: string, },
onCardDrawn: { cardId: string, },
onEffectApplied: { effectId: string, entityKey: "player" | string, stacks: 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;
onCardDrawn?: (ctx: TriggerContext, cardId: string, stacks: number) => void;
};
export type TriggerEvent =
| "onTurnStart"
| "onTurnEnd"
| "onAttacked"
| "onDamage"
| "modifyOutgoingDamage"
| "modifyIncomingDamage"
| "onShuffle"
| "onCardPlayed"
| "onCardDiscarded"
| "onCardDrawn";
export type CombatTriggerRegistry = Record<string, BuffTriggerBehavior>;
export function createCombatTriggerRegistry(): CombatTriggerRegistry {
export function createTriggers(){
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;
}
}
}
},
},
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 = cardDesertData.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);
onTurnStart: createTrigger("onTurnStart"),
onTurnEnd: createTrigger("onTurnEnd"),
onShuffle: createTrigger("onShuffle"),
onCardPlayed: createTrigger("onCardPlayed"),
onCardDiscarded: createTrigger("onCardDiscarded"),
onCardDrawn: createTrigger("onCardDrawn"),
onEffectApplied: createTrigger("onEffectApplied"),
}
}
export function dispatchTrigger(
ctx: TriggerContext,
event: TriggerEvent,
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);
}
}
}
export function dispatchCardDrawnTrigger(
ctx: TriggerContext,
cardId: string,
registry: CombatTriggerRegistry,
): void {
const buffs = ctx.state.player.buffs;
if (!buffs) return;
for (const [buffId, stacks] of Object.entries(buffs)) {
const behavior = registry[buffId];
if (!behavior?.onCardDrawn) continue;
behavior.onCardDrawn(ctx, cardId, stacks);
}
export function createTrigger<TKey extends keyof Triggers>(event: TKey) {
type Ctx = Triggers[TKey] & { event: TKey, game: CombatGameContext };
return createMiddlewareChain<Ctx>();
}

View File

@ -1,71 +1,30 @@
import type { PlayerDeck, GameCard } from "../deck/types";
import type { PlayerState } from "../progress/types";
import type { PlayerDeck } from "../deck/types";
import {EnemyData, IntentData} from "@/samples/slay-the-spire-like/system/type";
export type BuffTable = Record<string, number>;
export type EffectTable = Record<string, number>;
export type EffectTiming = "instant" | "temporary" | "lingering" | "permanent" | "posture" | "card" | "cardDraw" | "cardHand" | "item" | "itemUntilPlayed";
export type EffectData = {
readonly id: string;
readonly timing: EffectTiming;
};
export type EffectTarget = "self" | "target" | "all" | "random" | "player" | "team";
export type EnemyIntentData = {
readonly enemy: string;
readonly intentId: string;
readonly initialIntent: boolean;
readonly nextIntents: readonly string[];
readonly brokenIntent: readonly string[];
readonly initBuffs: readonly [EffectData, number];
readonly effects: readonly ["self" | "player" | "team", EffectData, number];
};
export type EncounterData = {
readonly type: "minion" | "elite" | "event" | "shop" | "camp" | "curio";
readonly name: string;
readonly description: string;
readonly enemies: readonly [string, number, number];
readonly dialogue: string;
};
export type ItemBuff = {
effectId: string;
stacks: number;
timing: EffectTiming;
sourceItemId: string;
targetItemId: string;
};
export type EnemyState = {
id: string;
templateId: string;
export type CombatEntity = {
effects: EffectTable;
hp: number;
maxHp: number;
buffs: BuffTable;
currentIntentId: string;
intentData: Record<string, EnemyIntentData>;
isAlive: boolean;
hadDefendBroken: boolean;
};
export type PlayerCombatState = {
hp: number;
maxHp: number;
export type PlayerEntity = CombatEntity & {
energy: number;
maxEnergy: number;
buffs: BuffTable;
deck: PlayerDeck;
damageTakenThisTurn: number;
damagedThisTurn: boolean;
cardsDiscardedThisTurn: number;
itemBuffs: ItemBuff[];
fatigueAddedCount: number;
itemEffects: Record<string, EffectTable>;
}
export type EnemyEntity = CombatEntity & {
id: string;
enemy: EnemyData;
intents: Record<string, IntentData>;
currentIntentId: string;
};
export type CombatPhase = "playerTurn" | "enemyTurn" | "combatEnd";
export type CombatResult = "victory" | "defeat";
export type LootEntry = {
@ -75,23 +34,14 @@ export type LootEntry = {
};
export type CombatState = {
enemies: Record<string, EnemyState>;
enemyOrder: string[];
player: PlayerCombatState;
enemies: EnemyEntity[];
player: PlayerEntity;
phase: CombatPhase;
turnNumber: number;
result: CombatResult | null;
loot: LootEntry[];
enemyTemplateData: Record<string, EnemyIntentData>;
};
export type CombatEffectEntry = [EffectTarget, EffectData, number];
export type CombatEntity = {
buffs: BuffTable;
hp: number;
maxHp: number;
isAlive: boolean;
};
export type CombatGameContext = import("@/core/game").IGameContext<CombatState>;

View File

@ -0,0 +1,34 @@
type Middleware<TContext, TReturn> = (
context: TContext,
next: () => Promise<TReturn>
) => Promise<TReturn>;
export type MiddlewareChain<TContext, TReturn> = {
use: (middleware: Middleware<TContext, TReturn>) => void;
execute: (context: TContext) => Promise<TReturn>;
};
export function createMiddlewareChain<TContext extends object, TReturn=TContext>(
fallback?: (context: TContext) => Promise<TReturn>
): MiddlewareChain<TContext, TReturn> {
const middlewares: Middleware<TContext, TReturn>[] = [];
return {
use(middleware: Middleware<TContext, TReturn>) {
middlewares.push(middleware);
},
async execute(context: TContext) {
let index = 0;
async function dispatch(ctx: TContext): Promise<TReturn> {
if (index >= middlewares.length) {
return fallback ? fallback(ctx) : ctx as unknown as TReturn;
}
const current = middlewares[index++];
return current(ctx, () => dispatch(ctx));
}
return dispatch(context);
},
};
}