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 { addInstantEffectTriggers } from "./instant";
import { addDamageTriggers } from "./damage";
import { addTurnStartTriggers } from "./turn-start";
import { addCardEventTriggers } from "./card-events";
export function addDesertTriggers(triggers: Triggers) {
export function addDesertTriggers(triggers: Triggers, run: IRunContext) {
addInstantEffectTriggers(triggers);
addDamageTriggers(triggers);
addTurnStartTriggers(triggers);
addCardEventTriggers(triggers);
addCardEventTriggers(triggers, run);
}

View File

@ -145,9 +145,9 @@ export function canPlayCard(
if (costType === "uses") {
const item = run.getItemData(itemId);
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);
return consumed + costCount <= maxUses;
return consumed < maxUses;
}
return true;
}

View File

@ -17,10 +17,14 @@ import type {
CombatEntity,
CombatState,
EffectTable,
IRunContext,
PlayerEntity,
EnemyEntity,
} 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 {
CellKey,
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 { 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(
id: string,
lifecycle: EffectData["lifecycle"],
@ -517,8 +546,9 @@ describe("combat/effects", () => {
const player = createPlayerEntity();
player.energy = 3;
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);
});
@ -527,8 +557,9 @@ describe("combat/effects", () => {
const player = createPlayerEntity();
player.energy = 1;
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);
});
@ -537,8 +568,9 @@ describe("combat/effects", () => {
const player = createPlayerEntity();
const item = createItem("potion-1", "potion-card", "uses", 3, 1);
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);
});
@ -547,8 +579,9 @@ describe("combat/effects", () => {
const player = createPlayerEntity();
const item = createItem("potion-1", "potion-card", "uses", 3, 3);
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);
});
@ -556,8 +589,9 @@ describe("combat/effects", () => {
it("should reject playing uses card when item not in inventory", () => {
const player = createPlayerEntity();
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);
});
@ -566,51 +600,56 @@ describe("combat/effects", () => {
const player = createPlayerEntity();
player.energy = 0;
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);
});
});
describe("payCardCost", () => {
it("should deduct energy for energy cost card", () => {
it("should deduct energy for energy cost card", async () => {
const player = createPlayerEntity();
player.energy = 3;
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);
});
it("should increment depletion for uses cost card", () => {
it("should increment depletion for uses cost card", async () => {
const player = createPlayerEntity();
const item = createItem("potion-1", "potion-card", "uses", 3, 1);
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);
});
it("should do nothing for none cost card", () => {
it("should do nothing for none cost card", async () => {
const player = createPlayerEntity();
player.energy = 3;
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);
});
it("should handle missing item gracefully for uses cost", () => {
it("should handle missing item gracefully for uses cost", async () => {
const player = createPlayerEntity();
const inventory = createInventory([]);
const run = createRunContext(inventory.items);
expect(() =>
payCardCost(player, "uses", 1, "missing", inventory),
).not.toThrow();
await expect(
payCardCost(player, "uses", 1, "missing", run),
).resolves.not.toThrow();
});
});
});

View File

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