import { describe, it, expect } from 'vitest'; import { addEffect, addEntityEffect, addItemEffect, onEntityEffectUpkeep, onEntityPostureDamage, onPlayerItemEffectUpkeep, onItemPlay, onItemDiscard, getAliveEnemies, getCombatEntity, } from '@/samples/slay-the-spire-like/system/combat/effects'; import type { CombatEntity, CombatState, EffectTable, PlayerEntity, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types'; import type { EffectData } from '@/samples/slay-the-spire-like/system/types'; function createEffect(id: string, lifecycle: EffectData['lifecycle']): EffectData { return { id, name: id, description: '', lifecycle }; } function createCombatEntity(hp = 10, maxHp = 10): CombatEntity { return { 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: {}, currentIntentId: '', }; } function createCombatState(playerHp = 30, enemies: EnemyEntity[] = []): CombatState { return { player: createPlayerEntity(playerHp), enemies, inventory: { width: 6, height: 4, items: new Map(), occupiedCells: new Set() }, 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(); }); }); });