2026-04-16 14:00:49 +08:00
|
|
|
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 };
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-16 19:14:46 +08:00
|
|
|
// TODO add an onCardDrawn trigger
|
|
|
|
|
// TODO refactor this to NOT implicitly correspond to an effect type, but generic event handler
|
|
|
|
|
// TODO also, refactor this to be async to support prompts and produceAsync
|
2026-04-16 14:00:49 +08:00
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-04-16 19:14:46 +08:00
|
|
|
// TODO refactor this to be keyed by event type
|
2026-04-16 14:00:49 +08:00
|
|
|
export type CombatTriggerRegistry = Record<string, BuffTriggerBehavior>;
|
|
|
|
|
|
|
|
|
|
export function createCombatTriggerRegistry(): CombatTriggerRegistry {
|
|
|
|
|
return {
|
|
|
|
|
spike: {
|
|
|
|
|
onAttacked(ctx, attackerKey, _defenderKey, damage, stacks) {
|
|
|
|
|
const { state } = ctx;
|
|
|
|
|
applyDamage(state, attackerKey, stacks, _defenderKey);
|
|
|
|
|
return damage;
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
aim: {
|
|
|
|
|
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
|
|
|
|
|
if (stacks > 0) return damage * 2;
|
|
|
|
|
return damage;
|
|
|
|
|
},
|
|
|
|
|
onDamage(ctx, targetKey, damage, stacks) {
|
|
|
|
|
const { state } = ctx;
|
|
|
|
|
const entity = targetKey === "player" ? null : state.enemies[targetKey];
|
|
|
|
|
if (entity) {
|
|
|
|
|
const loss = Math.min(stacks, damage);
|
|
|
|
|
removeBuff(entity.buffs, "aim", loss);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
charge: {
|
|
|
|
|
modifyOutgoingDamage(_ctx, _sourceKey, damage, stacks) {
|
|
|
|
|
if (stacks > 0) {
|
|
|
|
|
return damage * 2;
|
|
|
|
|
}
|
|
|
|
|
return damage;
|
|
|
|
|
},
|
|
|
|
|
modifyIncomingDamage(_ctx, _targetKey, damage, stacks) {
|
|
|
|
|
if (stacks > 0) {
|
|
|
|
|
return damage * 2;
|
|
|
|
|
}
|
|
|
|
|
return damage;
|
|
|
|
|
},
|
|
|
|
|
onDamage(ctx, targetKey, damage, stacks) {
|
|
|
|
|
const { state } = ctx;
|
|
|
|
|
const entity = targetKey === "player"
|
|
|
|
|
? { buffs: state.player.buffs } as { buffs: BuffTable }
|
|
|
|
|
: state.enemies[targetKey];
|
|
|
|
|
if (entity) {
|
|
|
|
|
const loss = Math.min(stacks, damage);
|
|
|
|
|
removeBuff(entity.buffs, "charge", loss);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
roll: {
|
|
|
|
|
modifyOutgoingDamage(ctx, sourceKey, damage, stacks) {
|
|
|
|
|
if (stacks >= 10) {
|
|
|
|
|
const { state } = ctx;
|
|
|
|
|
const entity = sourceKey === "player"
|
|
|
|
|
? { buffs: state.player.buffs } as { buffs: BuffTable }
|
|
|
|
|
: state.enemies[sourceKey];
|
|
|
|
|
if (entity) {
|
|
|
|
|
const spendable = Math.floor(stacks / 10) * 10;
|
|
|
|
|
const bonusDamage = Math.floor(spendable / 10);
|
|
|
|
|
removeBuff(entity.buffs, "roll", spendable);
|
|
|
|
|
return damage + bonusDamage;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return damage;
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
tailSting: {
|
|
|
|
|
onTurnEnd(ctx, entityKey, stacks) {
|
|
|
|
|
const { state } = ctx;
|
|
|
|
|
if (entityKey !== "player" && state.enemies[entityKey]?.isAlive) {
|
|
|
|
|
applyDamage(state, "player", stacks, entityKey);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
energyDrain: {
|
|
|
|
|
onDamage(ctx, targetKey, _damage, _stacks) {
|
|
|
|
|
const { state } = ctx;
|
|
|
|
|
if (targetKey === "player" && state.player.damagedThisTurn === false) {
|
|
|
|
|
// This is the first damage; mark it.
|
|
|
|
|
// actual energy drain happens in onTurnStart check
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
onTurnStart(ctx, entityKey, _stacks) {
|
|
|
|
|
// energyDrain: first damage each turn loses 1 energy
|
|
|
|
|
// We just mark that the enemy has this; actual drain is in onDamage
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
molt: {
|
|
|
|
|
onDamage(ctx, targetKey, _damage, _stacks) {
|
|
|
|
|
const { state } = ctx;
|
|
|
|
|
if (targetKey !== "player") {
|
|
|
|
|
const enemy = state.enemies[targetKey];
|
|
|
|
|
if (enemy && enemy.isAlive) {
|
|
|
|
|
const moltStacks = enemy.buffs["molt"] ?? 0;
|
|
|
|
|
if (moltStacks >= enemy.maxHp) {
|
|
|
|
|
enemy.isAlive = false;
|
|
|
|
|
state.result = "fled";
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
storm: {
|
|
|
|
|
onAttacked(ctx, attackerKey, defenderKey, damage, stacks) {
|
|
|
|
|
const { state } = ctx;
|
|
|
|
|
if (defenderKey !== "player" && state.enemies[defenderKey]?.isAlive) {
|
|
|
|
|
addStatusCardToHand(state, "static", 1);
|
|
|
|
|
}
|
|
|
|
|
return damage;
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
vultureEye: {
|
|
|
|
|
onDamage(ctx, targetKey, damage, stacks) {
|
|
|
|
|
const { state } = ctx;
|
|
|
|
|
if (targetKey === "player" && damage > 0) {
|
|
|
|
|
const vultureEnemies = state.enemyOrder.filter(
|
|
|
|
|
id => state.enemies[id].isAlive && state.enemies[id].buffs["vultureEye"] && state.enemies[id].templateId === "秃鹫"
|
|
|
|
|
);
|
|
|
|
|
if (vultureEnemies.length > 0) {
|
|
|
|
|
for (const vultureId of vultureEnemies) {
|
|
|
|
|
const vulture = state.enemies[vultureId];
|
|
|
|
|
const intent = vulture.intentData["attack"];
|
|
|
|
|
if (intent) {
|
|
|
|
|
const effects = intent.effects as unknown as CombatEffectEntry[];
|
|
|
|
|
for (const entry of effects) {
|
|
|
|
|
if (entry[0] === "player" && entry[1].id === "attack") {
|
|
|
|
|
applyDamage(state, "player", entry[2], vultureId);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
venom: {
|
|
|
|
|
onCardDiscarded(ctx, cardId, stacks) {
|
|
|
|
|
const { state } = ctx;
|
|
|
|
|
state.player.cardsDiscardedThisTurn++;
|
|
|
|
|
const venomCards = state.player.deck.hand.filter(id => {
|
|
|
|
|
const card = state.player.deck.cards[id];
|
|
|
|
|
return card && card.itemData === null && card.displayName === "蛇毒";
|
|
|
|
|
});
|
|
|
|
|
if (state.player.cardsDiscardedThisTurn > 1 && venomCards.length > 0) {
|
|
|
|
|
applyDamage(state, "player", 6, undefined);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
static: {
|
|
|
|
|
modifyIncomingDamage(_ctx, targetKey, damage, stacks) {
|
|
|
|
|
if (targetKey === "player") {
|
|
|
|
|
return damage + stacks;
|
|
|
|
|
}
|
|
|
|
|
return damage;
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
discard: {
|
|
|
|
|
onShuffle(ctx, stacks) {
|
|
|
|
|
// Bandit: shuffle discards random item cards
|
|
|
|
|
// Simplified: mark the effect for the procedure to handle
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
curse: {
|
|
|
|
|
onDamage(ctx, targetKey, damage, stacks) {
|
|
|
|
|
// Curse: when attacked, item attack -1 until card from that item is discarded
|
|
|
|
|
// This is handled via itemBuffs in effects
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addStatusCardToHand(state: CombatState, effectId: string, count: number): void {
|
|
|
|
|
const cardDef = statusCardDesertData.find(c => c.id === effectId);
|
|
|
|
|
if (!cardDef) return;
|
|
|
|
|
|
|
|
|
|
for (let i = 0; i < count; i++) {
|
|
|
|
|
const cardId = `status-${effectId}-${Date.now()}-${i}`;
|
|
|
|
|
const card = createStatusCard(cardId, cardDef.name, cardDef.desc);
|
|
|
|
|
state.player.deck.cards[card.id] = card;
|
|
|
|
|
state.player.deck.hand.push(card.id);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type TriggerEvent =
|
|
|
|
|
| "onTurnStart"
|
|
|
|
|
| "onTurnEnd"
|
|
|
|
|
| "onAttacked"
|
|
|
|
|
| "onDamage"
|
|
|
|
|
| "modifyOutgoingDamage"
|
|
|
|
|
| "modifyIncomingDamage"
|
|
|
|
|
| "onShuffle"
|
|
|
|
|
| "onCardPlayed"
|
|
|
|
|
| "onCardDiscarded";
|
|
|
|
|
|
|
|
|
|
export function dispatchTrigger(
|
|
|
|
|
ctx: TriggerContext,
|
|
|
|
|
event: "onTurnStart" | "onTurnEnd",
|
|
|
|
|
entityKey: "player" | string,
|
|
|
|
|
registry: CombatTriggerRegistry,
|
|
|
|
|
): void {
|
|
|
|
|
const buffs = entityKey === "player"
|
|
|
|
|
? ctx.state.player.buffs
|
|
|
|
|
: ctx.state.enemies[entityKey]?.buffs;
|
|
|
|
|
if (!buffs) return;
|
|
|
|
|
|
|
|
|
|
for (const [buffId, stacks] of Object.entries(buffs)) {
|
|
|
|
|
const behavior = registry[buffId];
|
|
|
|
|
if (!behavior) continue;
|
|
|
|
|
const handler = behavior[event];
|
|
|
|
|
if (handler) {
|
|
|
|
|
handler(ctx, entityKey, stacks);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function dispatchAttackedTrigger(
|
|
|
|
|
ctx: TriggerContext,
|
|
|
|
|
attackerKey: "player" | string,
|
|
|
|
|
defenderKey: "player" | string,
|
|
|
|
|
damage: number,
|
|
|
|
|
registry: CombatTriggerRegistry,
|
|
|
|
|
): number {
|
|
|
|
|
const buffs = defenderKey === "player"
|
|
|
|
|
? ctx.state.player.buffs
|
|
|
|
|
: ctx.state.enemies[defenderKey]?.buffs;
|
|
|
|
|
if (!buffs) return damage;
|
|
|
|
|
|
|
|
|
|
let modifiedDamage = damage;
|
|
|
|
|
for (const [buffId, stacks] of Object.entries(buffs)) {
|
|
|
|
|
const behavior = registry[buffId];
|
|
|
|
|
if (!behavior?.onAttacked) continue;
|
|
|
|
|
modifiedDamage = behavior.onAttacked(ctx, attackerKey, defenderKey, modifiedDamage, stacks);
|
|
|
|
|
}
|
|
|
|
|
return modifiedDamage;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function dispatchDamageTrigger(
|
|
|
|
|
ctx: TriggerContext,
|
|
|
|
|
targetKey: "player" | string,
|
|
|
|
|
damage: number,
|
|
|
|
|
registry: CombatTriggerRegistry,
|
|
|
|
|
): void {
|
|
|
|
|
const buffs = targetKey === "player"
|
|
|
|
|
? ctx.state.player.buffs
|
|
|
|
|
: ctx.state.enemies[targetKey]?.buffs;
|
|
|
|
|
if (!buffs) return;
|
|
|
|
|
|
|
|
|
|
for (const [buffId, stacks] of Object.entries(buffs)) {
|
|
|
|
|
const behavior = registry[buffId];
|
|
|
|
|
if (!behavior?.onDamage) continue;
|
|
|
|
|
behavior.onDamage(ctx, targetKey, damage, stacks);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function dispatchOutgoingDamageTrigger(
|
|
|
|
|
ctx: TriggerContext,
|
|
|
|
|
sourceKey: "player" | string,
|
|
|
|
|
damage: number,
|
|
|
|
|
registry: CombatTriggerRegistry,
|
|
|
|
|
): number {
|
|
|
|
|
const buffs = sourceKey === "player"
|
|
|
|
|
? ctx.state.player.buffs
|
|
|
|
|
: ctx.state.enemies[sourceKey]?.buffs;
|
|
|
|
|
if (!buffs) return damage;
|
|
|
|
|
|
|
|
|
|
let modifiedDamage = damage;
|
|
|
|
|
for (const [buffId, stacks] of Object.entries(buffs)) {
|
|
|
|
|
const behavior = registry[buffId];
|
|
|
|
|
if (!behavior?.modifyOutgoingDamage) continue;
|
|
|
|
|
modifiedDamage = behavior.modifyOutgoingDamage(ctx, sourceKey, modifiedDamage, stacks);
|
|
|
|
|
}
|
|
|
|
|
return modifiedDamage;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function dispatchIncomingDamageTrigger(
|
|
|
|
|
ctx: TriggerContext,
|
|
|
|
|
targetKey: "player" | string,
|
|
|
|
|
damage: number,
|
|
|
|
|
registry: CombatTriggerRegistry,
|
|
|
|
|
): number {
|
|
|
|
|
const buffs = targetKey === "player"
|
|
|
|
|
? ctx.state.player.buffs
|
|
|
|
|
: ctx.state.enemies[targetKey]?.buffs;
|
|
|
|
|
if (!buffs) return damage;
|
|
|
|
|
|
|
|
|
|
let modifiedDamage = damage;
|
|
|
|
|
for (const [buffId, stacks] of Object.entries(buffs)) {
|
|
|
|
|
const behavior = registry[buffId];
|
|
|
|
|
if (!behavior?.modifyIncomingDamage) continue;
|
|
|
|
|
modifiedDamage = behavior.modifyIncomingDamage(ctx, targetKey, modifiedDamage, stacks);
|
|
|
|
|
}
|
|
|
|
|
return modifiedDamage;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function dispatchShuffleTrigger(
|
|
|
|
|
ctx: TriggerContext,
|
|
|
|
|
registry: CombatTriggerRegistry,
|
|
|
|
|
): void {
|
|
|
|
|
for (const enemyId of ctx.state.enemyOrder) {
|
|
|
|
|
const enemy = ctx.state.enemies[enemyId];
|
|
|
|
|
if (!enemy.isAlive) continue;
|
|
|
|
|
for (const [buffId, stacks] of Object.entries(enemy.buffs)) {
|
|
|
|
|
const behavior = registry[buffId];
|
|
|
|
|
if (!behavior?.onShuffle) continue;
|
|
|
|
|
behavior.onShuffle(ctx, stacks);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|