import { describe, it, expect } from "vitest"; import { addEffect, addEntityEffect, addItemEffect, onEntityEffectUpkeep, onEntityPostureDamage, onPlayerItemEffectUpkeep, onItemPlay, onItemDiscard, getAliveEnemies, getCombatEntity, canPlayCard, payCardCost, } from "@/samples/slay-the-spire-like/system/combat/effects"; import type { CombatEntity, CombatState, EffectTable, IRunContext, PlayerEntity, EnemyEntity, } from "@/samples/slay-the-spire-like/system/combat/types"; import type { EffectData, ItemData, } from "@/samples/slay-the-spire-like/system/types"; import type { CellKey, GridInventory, InventoryItem, } from "@/samples/slay-the-spire-like/system/grid-inventory/types"; import type { GameItemMeta } from "@/samples/slay-the-spire-like/system/grid-inventory/types"; 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 { CardEffect } from "@/samples/slay-the-spire-like/data/desert"; function createRunContext( items: Map>, ): IRunContext { return { getItemData(id: string): ItemData | null { const item = items.get(id); return item?.meta?.itemData ?? null; }, getAdjacentItems(_id: string): Iterable { return []; }, getConsumedUses(id: string): number { const item = items.get(id); return item?.meta?.consumedUses ?? 0; }, setConsumedUsesAsync(id: string, uses: number): Promise { const item = items.get(id); if (item?.meta) { item.meta.consumedUses = uses; } return Promise.resolve(); }, }; } function createEffect( id: string, lifecycle: EffectData["lifecycle"], ): EffectData { return { id, name: id, description: "", lifecycle, emoji: "" }; } function createCard( id: string, costType: "energy" | "uses" | "none", costCount: number, ) { return { id, name: id, desc: "", type: "item" as const, costType, costCount, targetType: "player" as const, effects: [] as CardEffect[], }; } function createItem( itemId: string, cardId: string, costType: "energy" | "uses" | "none", costCount: number, depletion = 0, ): InventoryItem { return { id: itemId, shape: { id: "1x1", cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape, transform: { offset: { x: 0, y: 0 }, flipX: false, flipY: false, } as unknown as Transform2D, meta: { itemData: { id: itemId, type: "weapon", name: itemId, shape: "1x1", card: createCard(cardId, costType, costCount), price: 0, description: "", }, shape: { id: "1x1", cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape, consumedUses: costType === "uses" ? depletion : undefined, }, }; } function createInventory( items: InventoryItem[], ): GridInventory { const map = new Map>(); const occupied = new Set(); for (const item of items) { map.set(item.id, item); occupied.add(`${item.transform.offset.x},${item.transform.offset.y}`); } return { width: 6, height: 4, items: map, occupiedCells: occupied }; } function createCombatEntity(hp = 10, maxHp = 10): CombatEntity { return { id: "", effects: {}, hp, maxHp, isAlive: hp > 0, }; } function createPlayerEntity(hp = 30, maxHp = 30): PlayerEntity { return { ...createCombatEntity(hp, maxHp), energy: 3, maxEnergy: 3, deck: { cards: {}, regions: { drawPile: { id: "drawPile", axes: [], childIds: [], partMap: {} }, hand: { id: "hand", axes: [], childIds: [], partMap: {} }, discardPile: { id: "discardPile", axes: [], childIds: [], partMap: {} }, exhaustPile: { id: "exhaustPile", axes: [], childIds: [], partMap: {} }, }, }, itemEffects: {}, }; } function createEnemyEntity(id: string, hp = 10, maxHp = 10): EnemyEntity { return { ...createCombatEntity(hp, maxHp), id, enemy: { id, name: id, description: "", intents: null! }, intents: {}, currentIntent: null!, }; } function createCombatState( playerHp = 30, enemies: EnemyEntity[] = [], ): CombatState { return { player: createPlayerEntity(playerHp), enemies, phase: "playerTurn", turnNumber: 1, result: null, loot: [], }; } describe("combat/effects", () => { describe("addEffect", () => { it("should add a new effect to an empty table", () => { const table: EffectTable = {}; const effect = createEffect("strength", "temporary"); addEffect(table, effect, 3); expect(table["strength"]).toBeDefined(); expect(table["strength"].data).toBe(effect); expect(table["strength"].stacks).toBe(3); }); it("should stack with existing effect of same id", () => { const table: EffectTable = {}; const effect = createEffect("strength", "lingering"); addEffect(table, effect, 2); addEffect(table, effect, 3); expect(table["strength"].stacks).toBe(5); }); it("should remove effect when stacks reach 0", () => { const table: EffectTable = {}; const effect = createEffect("strength", "temporary"); addEffect(table, effect, 3); addEffect(table, effect, -3); expect(table["strength"]).toBeUndefined(); }); it("should not add effect when stacks is 0", () => { const table: EffectTable = {}; const effect = createEffect("strength", "temporary"); addEffect(table, effect, 0); expect(table["strength"]).toBeUndefined(); }); it("should handle negative stacks", () => { const table: EffectTable = {}; const effect = createEffect("weak", "temporary"); addEffect(table, effect, -2); expect(table["weak"].stacks).toBe(-2); }); }); describe("addEntityEffect", () => { it("should add effect to entity.effects", () => { const entity = createCombatEntity(); const effect = createEffect("vulnerable", "lingering"); addEntityEffect(entity, effect, 2); expect(entity.effects["vulnerable"].stacks).toBe(2); }); }); describe("addItemEffect", () => { it("should add effect to player.itemEffects[itemKey]", () => { const player = createPlayerEntity(); const effect = createEffect("adjacent-buff", "itemTemporary"); addItemEffect(player, "sword-1", effect, 3); expect(player.itemEffects["sword-1"]["adjacent-buff"].stacks).toBe(3); }); it("should initialize itemEffects entry if not present", () => { const player = createPlayerEntity(); const effect = createEffect("adjacent-buff", "itemTemporary"); addItemEffect(player, "new-item", effect, 1); expect(player.itemEffects["new-item"]).toBeDefined(); }); it("should stack with existing item effect", () => { const player = createPlayerEntity(); const effect = createEffect("adjacent-buff", "itemTemporary"); addItemEffect(player, "sword-1", effect, 2); addItemEffect(player, "sword-1", effect, 3); expect(player.itemEffects["sword-1"]["adjacent-buff"].stacks).toBe(5); }); }); describe("onEntityEffectUpkeep", () => { it("should remove temporary effects", () => { const entity = createCombatEntity(); const tempEffect = createEffect("temp-shield", "temporary"); addEntityEffect(entity, tempEffect, 5); onEntityEffectUpkeep(entity); expect(entity.effects["temp-shield"]).toBeUndefined(); }); it("should decrement lingering effects by 1", () => { const entity = createCombatEntity(); const lingeringEffect = createEffect("poison", "lingering"); addEntityEffect(entity, lingeringEffect, 3); onEntityEffectUpkeep(entity); expect(entity.effects["poison"].stacks).toBe(2); }); it("should remove lingering effects when stacks reach 0", () => { const entity = createCombatEntity(); const lingeringEffect = createEffect("poison", "lingering"); addEntityEffect(entity, lingeringEffect, 1); onEntityEffectUpkeep(entity); expect(entity.effects["poison"]).toBeUndefined(); }); it("should not affect permanent effects", () => { const entity = createCombatEntity(); const permEffect = createEffect("max-hp-up", "permanent"); addEntityEffect(entity, permEffect, 5); onEntityEffectUpkeep(entity); expect(entity.effects["max-hp-up"].stacks).toBe(5); }); it("should not affect instant effects", () => { const entity = createCombatEntity(); const instantEffect = createEffect("instant-damage", "instant"); addEntityEffect(entity, instantEffect, 10); onEntityEffectUpkeep(entity); expect(entity.effects["instant-damage"].stacks).toBe(10); }); it("should increment lingering effects with negative stacks", () => { const entity = createCombatEntity(); const lingeringEffect = createEffect("regen", "lingering"); addEntityEffect(entity, lingeringEffect, -3); onEntityEffectUpkeep(entity); expect(entity.effects["regen"].stacks).toBe(-2); }); }); describe("onEntityPostureDamage", () => { it("should reduce posture effects by damage amount", () => { const entity = createCombatEntity(); const postureEffect = createEffect("block", "posture"); addEntityEffect(entity, postureEffect, 10); onEntityPostureDamage(entity, 4); expect(entity.effects["block"].stacks).toBe(6); }); it("should not reduce posture effects below 0", () => { const entity = createCombatEntity(); const postureEffect = createEffect("block", "posture"); addEntityEffect(entity, postureEffect, 3); onEntityPostureDamage(entity, 10); expect(entity.effects["block"]).toBeUndefined(); }); it("should not affect non-posture effects", () => { const entity = createCombatEntity(); const postureEffect = createEffect("block", "posture"); const permEffect = createEffect("strength", "permanent"); addEntityEffect(entity, postureEffect, 5); addEntityEffect(entity, permEffect, 3); onEntityPostureDamage(entity, 2); expect(entity.effects["block"].stacks).toBe(3); expect(entity.effects["strength"].stacks).toBe(3); }); it("should handle zero damage", () => { const entity = createCombatEntity(); const postureEffect = createEffect("block", "posture"); addEntityEffect(entity, postureEffect, 5); onEntityPostureDamage(entity, 0); expect(entity.effects["block"].stacks).toBe(5); }); }); describe("onPlayerItemEffectUpkeep", () => { it("should remove itemTemporary effects", () => { const player = createPlayerEntity(); const effect = createEffect("adjacent-buff", "itemTemporary"); addItemEffect(player, "sword-1", effect, 5); onPlayerItemEffectUpkeep(player); expect(player.itemEffects["sword-1"]["adjacent-buff"]).toBeUndefined(); }); it("should not affect itemPermanent effects", () => { const player = createPlayerEntity(); const effect = createEffect("adjacent-buff", "itemPermanent"); addItemEffect(player, "sword-1", effect, 5); onPlayerItemEffectUpkeep(player); expect(player.itemEffects["sword-1"]["adjacent-buff"].stacks).toBe(5); }); it("should not affect itemUntilPlay effects", () => { const player = createPlayerEntity(); const effect = createEffect("charged", "itemUntilPlay"); addItemEffect(player, "sword-1", effect, 3); onPlayerItemEffectUpkeep(player); expect(player.itemEffects["sword-1"]["charged"].stacks).toBe(3); }); }); describe("onItemPlay", () => { it("should remove itemUntilPlay effects", () => { const player = createPlayerEntity(); const effect = createEffect("charged", "itemUntilPlay"); addItemEffect(player, "sword-1", effect, 3); onItemPlay(player, "sword-1"); expect(player.itemEffects["sword-1"]["charged"]).toBeUndefined(); }); it("should not affect other lifecycle effects", () => { const player = createPlayerEntity(); const permEffect = createEffect("passive", "itemPermanent"); const playEffect = createEffect("charged", "itemUntilPlay"); addItemEffect(player, "sword-1", permEffect, 5); addItemEffect(player, "sword-1", playEffect, 3); onItemPlay(player, "sword-1"); expect(player.itemEffects["sword-1"]["passive"].stacks).toBe(5); expect(player.itemEffects["sword-1"]["charged"]).toBeUndefined(); }); it("should do nothing for item with no effects", () => { const player = createPlayerEntity(); expect(() => onItemPlay(player, "nonexistent")).not.toThrow(); }); }); describe("onItemDiscard", () => { it("should remove itemUntilDiscard effects", () => { const player = createPlayerEntity(); const effect = createEffect("discard-buff", "itemUntilDiscard"); addItemEffect(player, "sword-1", effect, 3); onItemDiscard(player, "sword-1"); expect(player.itemEffects["sword-1"]["discard-buff"]).toBeUndefined(); }); it("should not affect other lifecycle effects", () => { const player = createPlayerEntity(); const permEffect = createEffect("passive", "itemPermanent"); const discardEffect = createEffect("discard-buff", "itemUntilDiscard"); addItemEffect(player, "sword-1", permEffect, 5); addItemEffect(player, "sword-1", discardEffect, 3); onItemDiscard(player, "sword-1"); expect(player.itemEffects["sword-1"]["passive"].stacks).toBe(5); expect(player.itemEffects["sword-1"]["discard-buff"]).toBeUndefined(); }); it("should do nothing for item with no effects", () => { const player = createPlayerEntity(); expect(() => onItemDiscard(player, "nonexistent")).not.toThrow(); }); }); describe("getAliveEnemies", () => { it("should yield only alive enemies", () => { const state = createCombatState(30, [ createEnemyEntity("slime-1", 10, 10), createEnemyEntity("slime-2", 0, 10), createEnemyEntity("slime-3", 5, 10), ]); const alive = [...getAliveEnemies(state)]; expect(alive.length).toBe(2); expect(alive[0].id).toBe("slime-1"); expect(alive[1].id).toBe("slime-3"); }); it("should return empty for no enemies", () => { const state = createCombatState(30, []); const alive = [...getAliveEnemies(state)]; expect(alive.length).toBe(0); }); it("should return empty when all enemies are dead", () => { const state = createCombatState(30, [ createEnemyEntity("slime-1", 0, 10), createEnemyEntity("slime-2", 0, 10), ]); const alive = [...getAliveEnemies(state)]; expect(alive.length).toBe(0); }); }); describe("getCombatEntity", () => { it('should return player for "player" key', () => { const state = createCombatState(30); const entity = getCombatEntity(state, "player"); expect(entity).toBe(state.player); }); it("should return enemy by id", () => { const enemy = createEnemyEntity("boss-1", 50, 50); const state = createCombatState(30, [enemy]); const entity = getCombatEntity(state, "boss-1"); expect(entity).toBe(enemy); }); it("should return undefined for non-existent enemy", () => { const state = createCombatState(30, [createEnemyEntity("slime-1")]); const entity = getCombatEntity(state, "nonexistent"); expect(entity).toBeUndefined(); }); }); describe("canPlayCard", () => { it("should allow playing energy card when player has enough energy", () => { const player = createPlayerEntity(); player.energy = 3; const inventory = createInventory([]); const run = createRunContext(inventory.items); const result = canPlayCard(player, "energy", 2, "any", run); expect(result).toBe(true); }); it("should reject playing energy card when player lacks energy", () => { const player = createPlayerEntity(); player.energy = 1; const inventory = createInventory([]); const run = createRunContext(inventory.items); const result = canPlayCard(player, "energy", 2, "any", run); expect(result).toBe(false); }); it("should allow playing uses card when item has remaining uses", () => { 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", run); expect(result).toBe(true); }); it("should reject playing uses card when item is depleted", () => { 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", run); expect(result).toBe(false); }); 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", run); expect(result).toBe(false); }); it("should always allow playing none cost card", () => { const player = createPlayerEntity(); player.energy = 0; const inventory = createInventory([]); const run = createRunContext(inventory.items); const result = canPlayCard(player, "none", 0, "any", run); expect(result).toBe(true); }); }); describe("payCardCost", () => { it("should deduct energy for energy cost card", async () => { const player = createPlayerEntity(); player.energy = 3; const inventory = createInventory([]); const run = createRunContext(inventory.items); await payCardCost(player, "energy", 2, "any", run); expect(player.energy).toBe(1); }); 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); await payCardCost(player, "uses", 3, "potion-1", run); expect(item.meta?.consumedUses).toBe(4); }); it("should do nothing for none cost card", async () => { const player = createPlayerEntity(); player.energy = 3; const inventory = createInventory([]); const run = createRunContext(inventory.items); await payCardCost(player, "none", 0, "any", run); expect(player.energy).toBe(3); }); it("should handle missing item gracefully for uses cost", async () => { const player = createPlayerEntity(); const inventory = createInventory([]); const run = createRunContext(inventory.items); await expect( payCardCost(player, "uses", 1, "missing", run), ).resolves.not.toThrow(); }); }); });