refactor(slay-the-spire-like): use IRunContext in combat system

Update the combat system to use `IRunContext` instead of passing
raw inventory objects. This provides a cleaner abstraction for
accessing item data and managing consumed uses.

- Update `canPlayCard` and `payCardCost` to use `IRunContext`
- Pass `IRunContext` through the trigger registration chain
- Fix logic in `canPlayCard` for "uses" cost validation
- Update combat effect tests to use a mock `IRunContext`
This commit is contained in:
hypercross 2026-04-20 11:25:52 +08:00
parent 5019bc6324
commit 5e172c61bb
4 changed files with 77 additions and 27 deletions

View File

@ -1,12 +1,13 @@
import { IRunContext } from "@/samples/slay-the-spire-like/system/combat/types";
import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers"; import { Triggers } from "@/samples/slay-the-spire-like/system/combat/triggers";
import { addInstantEffectTriggers } from "./instant"; import { addInstantEffectTriggers } from "./instant";
import { addDamageTriggers } from "./damage"; import { addDamageTriggers } from "./damage";
import { addTurnStartTriggers } from "./turn-start"; import { addTurnStartTriggers } from "./turn-start";
import { addCardEventTriggers } from "./card-events"; import { addCardEventTriggers } from "./card-events";
export function addDesertTriggers(triggers: Triggers) { export function addDesertTriggers(triggers: Triggers, run: IRunContext) {
addInstantEffectTriggers(triggers); addInstantEffectTriggers(triggers);
addDamageTriggers(triggers); addDamageTriggers(triggers);
addTurnStartTriggers(triggers); addTurnStartTriggers(triggers);
addCardEventTriggers(triggers); addCardEventTriggers(triggers, run);
} }

View File

@ -145,9 +145,9 @@ export function canPlayCard(
if (costType === "uses") { if (costType === "uses") {
const item = run.getItemData(itemId); const item = run.getItemData(itemId);
if (!item) return false; if (!item) return false;
const maxUses = item?.card.costType === "energy" ? item.card.costCount : 0; const maxUses = item?.card.costType === "uses" ? item.card.costCount : 0;
const consumed = run.getConsumedUses(itemId); const consumed = run.getConsumedUses(itemId);
return consumed + costCount <= maxUses; return consumed < maxUses;
} }
return true; return true;
} }

View File

@ -17,10 +17,14 @@ import type {
CombatEntity, CombatEntity,
CombatState, CombatState,
EffectTable, EffectTable,
IRunContext,
PlayerEntity, PlayerEntity,
EnemyEntity, EnemyEntity,
} from "@/samples/slay-the-spire-like/system/combat/types"; } from "@/samples/slay-the-spire-like/system/combat/types";
import type { EffectData } from "@/samples/slay-the-spire-like/system/types"; import type {
EffectData,
ItemData,
} from "@/samples/slay-the-spire-like/system/types";
import type { import type {
CellKey, CellKey,
GridInventory, GridInventory,
@ -30,6 +34,31 @@ import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/progress
import type { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape"; import type { ParsedShape } from "@/samples/slay-the-spire-like/system/utils/parse-shape";
import type { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision"; import type { Transform2D } from "@/samples/slay-the-spire-like/system/utils/shape-collision";
function createRunContext(
items: Map<string, InventoryItem<GameItemMeta>>,
): IRunContext {
return {
getItemData(id: string): ItemData | null {
const item = items.get(id);
return item?.meta.itemData ?? null;
},
getNeighborItems(_id: string): Iterable<string> {
return [];
},
getConsumedUses(id: string): number {
const item = items.get(id);
return item?.meta.consumedUses ?? 0;
},
setConsumedUsesAsync(id: string, uses: number): Promise<void> {
const item = items.get(id);
if (item?.meta) {
item.meta.consumedUses = uses;
}
return Promise.resolve();
},
};
}
function createEffect( function createEffect(
id: string, id: string,
lifecycle: EffectData["lifecycle"], lifecycle: EffectData["lifecycle"],
@ -517,8 +546,9 @@ describe("combat/effects", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 3; player.energy = 3;
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
const result = canPlayCard(player, "energy", 2, "any", inventory); const result = canPlayCard(player, "energy", 2, "any", run);
expect(result).toBe(true); expect(result).toBe(true);
}); });
@ -527,8 +557,9 @@ describe("combat/effects", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 1; player.energy = 1;
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
const result = canPlayCard(player, "energy", 2, "any", inventory); const result = canPlayCard(player, "energy", 2, "any", run);
expect(result).toBe(false); expect(result).toBe(false);
}); });
@ -537,8 +568,9 @@ describe("combat/effects", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
const item = createItem("potion-1", "potion-card", "uses", 3, 1); const item = createItem("potion-1", "potion-card", "uses", 3, 1);
const inventory = createInventory([item]); const inventory = createInventory([item]);
const run = createRunContext(inventory.items);
const result = canPlayCard(player, "uses", 3, "potion-1", inventory); const result = canPlayCard(player, "uses", 3, "potion-1", run);
expect(result).toBe(true); expect(result).toBe(true);
}); });
@ -547,8 +579,9 @@ describe("combat/effects", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
const item = createItem("potion-1", "potion-card", "uses", 3, 3); const item = createItem("potion-1", "potion-card", "uses", 3, 3);
const inventory = createInventory([item]); const inventory = createInventory([item]);
const run = createRunContext(inventory.items);
const result = canPlayCard(player, "uses", 3, "potion-1", inventory); const result = canPlayCard(player, "uses", 3, "potion-1", run);
expect(result).toBe(false); expect(result).toBe(false);
}); });
@ -556,8 +589,9 @@ describe("combat/effects", () => {
it("should reject playing uses card when item not in inventory", () => { it("should reject playing uses card when item not in inventory", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
const result = canPlayCard(player, "uses", 1, "missing", inventory); const result = canPlayCard(player, "uses", 1, "missing", run);
expect(result).toBe(false); expect(result).toBe(false);
}); });
@ -566,51 +600,56 @@ describe("combat/effects", () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 0; player.energy = 0;
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
const result = canPlayCard(player, "none", 0, "any", inventory); const result = canPlayCard(player, "none", 0, "any", run);
expect(result).toBe(true); expect(result).toBe(true);
}); });
}); });
describe("payCardCost", () => { describe("payCardCost", () => {
it("should deduct energy for energy cost card", () => { it("should deduct energy for energy cost card", async () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 3; player.energy = 3;
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
payCardCost(player, "energy", 2, "any", inventory); await payCardCost(player, "energy", 2, "any", run);
expect(player.energy).toBe(1); expect(player.energy).toBe(1);
}); });
it("should increment depletion for uses cost card", () => { it("should increment depletion for uses cost card", async () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
const item = createItem("potion-1", "potion-card", "uses", 3, 1); const item = createItem("potion-1", "potion-card", "uses", 3, 1);
const inventory = createInventory([item]); const inventory = createInventory([item]);
const run = createRunContext(inventory.items);
payCardCost(player, "uses", 3, "potion-1", inventory); await payCardCost(player, "uses", 3, "potion-1", run);
expect(item.meta?.consumedUses).toBe(4); expect(item.meta?.consumedUses).toBe(4);
}); });
it("should do nothing for none cost card", () => { it("should do nothing for none cost card", async () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
player.energy = 3; player.energy = 3;
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
payCardCost(player, "none", 0, "any", inventory); await payCardCost(player, "none", 0, "any", run);
expect(player.energy).toBe(3); expect(player.energy).toBe(3);
}); });
it("should handle missing item gracefully for uses cost", () => { it("should handle missing item gracefully for uses cost", async () => {
const player = createPlayerEntity(); const player = createPlayerEntity();
const inventory = createInventory([]); const inventory = createInventory([]);
const run = createRunContext(inventory.items);
expect(() => await expect(
payCardCost(player, "uses", 1, "missing", inventory), payCardCost(player, "uses", 1, "missing", run),
).not.toThrow(); ).resolves.not.toThrow();
}); });
}); });
}); });

View File

@ -14,6 +14,7 @@ import { addTriggers } from "@/samples/slay-the-spire-like/data/desert/triggers"
import { import {
CombatState, CombatState,
EnemyEntity, EnemyEntity,
IRunContext,
} from "@/samples/slay-the-spire-like/system/combat/types"; } from "@/samples/slay-the-spire-like/system/combat/types";
import { EffectData } from "@/samples/slay-the-spire-like/system/types"; import { EffectData } from "@/samples/slay-the-spire-like/system/types";
import { import {
@ -138,7 +139,6 @@ function createCombatState(overrides: Partial<CombatState> = {}): CombatState {
itemEffects: {}, itemEffects: {},
}, },
enemies: [], enemies: [],
inventory: createInventory([]),
phase: "playerTurn", phase: "playerTurn",
turnNumber: 1, turnNumber: 1,
result: null, result: null,
@ -155,8 +155,18 @@ function createTestContext(state?: CombatState): IGameContext<CombatState> {
} }
function getTriggers(): Triggers { function getTriggers(): Triggers {
const triggers = createTriggers(); const run: IRunContext = {
addTriggers(triggers); getConsumedUses() {
return 0;
},
getItemData() {
return null;
},
*getNeighborItems() {},
async setConsumedUsesAsync() {},
};
const triggers = createTriggers(run);
addTriggers(triggers, run);
return triggers; return triggers;
} }