diff --git a/tests/samples/slay-the-spire-like/combat/effects.test.ts b/tests/samples/slay-the-spire-like/combat/effects.test.ts new file mode 100644 index 0000000..da8ac69 --- /dev/null +++ b/tests/samples/slay-the-spire-like/combat/effects.test.ts @@ -0,0 +1,417 @@ +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(); + }); + }); +}); diff --git a/tests/utils/middleware.test.ts b/tests/utils/middleware.test.ts new file mode 100644 index 0000000..d775d80 --- /dev/null +++ b/tests/utils/middleware.test.ts @@ -0,0 +1,436 @@ +import { describe, it, expect } from 'vitest'; +import { createMiddlewareChain, type MiddlewareChain } from '@/utils/middleware'; + +describe('createMiddlewareChain', () => { + describe('basic execution', () => { + it('should return context when no middlewares and no fallback', async () => { + const chain = createMiddlewareChain<{ value: number }>(); + const result = await chain.execute({ value: 42 }); + + expect(result).toEqual({ value: 42 }); + }); + + it('should call fallback when no middlewares', async () => { + const chain = createMiddlewareChain<{ value: number }, string>( + async ctx => `value is ${ctx.value}` + ); + const result = await chain.execute({ value: 42 }); + + expect(result).toBe('value is 42'); + }); + + it('should pass context to fallback', async () => { + const chain = createMiddlewareChain<{ a: number; b: number }, number>( + async ctx => ctx.a + ctx.b + ); + const result = await chain.execute({ a: 3, b: 7 }); + + expect(result).toBe(10); + }); + }); + + describe('single middleware', () => { + it('should execute a single middleware', async () => { + const chain = createMiddlewareChain<{ count: number }>(); + chain.use(async (ctx, next) => { + ctx.count *= 2; + return next(); + }); + + const result = await chain.execute({ count: 5 }); + + expect(result.count).toBe(10); + }); + + it('should allow middleware to modify return value', async () => { + const chain = createMiddlewareChain<{ value: number }, number>( + async ctx => ctx.value + ); + chain.use(async (ctx, next) => { + const result = await next(); + return result * 2; + }); + + const result = await chain.execute({ value: 21 }); + + expect(result).toBe(42); + }); + + it('should allow middleware to short-circuit without calling next', async () => { + const chain = createMiddlewareChain<{ value: number }>(); + chain.use(async (_ctx, _next) => { + return { value: 999 }; + }); + + const result = await chain.execute({ value: 1 }); + + expect(result.value).toBe(999); + }); + }); + + describe('multiple middlewares', () => { + it('should execute middlewares in order', async () => { + const order: number[] = []; + const chain = createMiddlewareChain<{ value: number }>(); + + chain.use(async (_ctx, next) => { + order.push(1); + const result = await next(); + order.push(4); + return result; + }); + chain.use(async (_ctx, next) => { + order.push(2); + const result = await next(); + order.push(3); + return result; + }); + + await chain.execute({ value: 0 }); + + expect(order).toEqual([1, 2, 3, 4]); + }); + + it('should accumulate modifications through the chain', async () => { + const chain = createMiddlewareChain<{ value: number }>(); + + chain.use(async (ctx, next) => { + ctx.value += 1; + return next(); + }); + chain.use(async (ctx, next) => { + ctx.value *= 2; + return next(); + }); + chain.use(async (ctx, next) => { + ctx.value += 3; + return next(); + }); + + const result = await chain.execute({ value: 0 }); + + expect(result.value).toBe(5); + }); + + it('should allow middleware to modify result on the way back', async () => { + const chain = createMiddlewareChain<{ base: number }, number>( + async ctx => ctx.base + ); + + chain.use(async (_ctx, next) => { + const result = await next(); + return result + 10; + }); + chain.use(async (_ctx, next) => { + const result = await next(); + return result * 2; + }); + + const result = await chain.execute({ base: 5 }); + + expect(result).toBe(20); + }); + + it('should allow middleware to short-circuit in the middle', async () => { + const executed: number[] = []; + const chain = createMiddlewareChain<{ value: number }>(); + + chain.use(async (_ctx, next) => { + executed.push(1); + return next(); + }); + chain.use(async () => { + executed.push(2); + return { value: -1 }; + }); + chain.use(async (_ctx, next) => { + executed.push(3); + return next(); + }); + + const result = await chain.execute({ value: 100 }); + + expect(result.value).toBe(-1); + expect(executed).toEqual([1, 2]); + }); + }); + + describe('nested next calls', () => { + it('should advance index on each next call, skipping remaining middlewares', async () => { + const chain = createMiddlewareChain<{ counter: number }>(); + + chain.use(async (_ctx, next) => { + await next(); + await next(); + }); + + const result = await chain.execute({ counter: 0 }); + + expect(result).toBeUndefined(); + }); + + it('should allow middleware to call next conditionally', async () => { + const chain = createMiddlewareChain<{ skip: boolean; value: number }>(); + + chain.use(async (ctx, next) => { + if (ctx.skip) { + return { value: -1 }; + } + return next(); + }); + chain.use(async (ctx, next) => { + ctx.value += 10; + return next(); + }); + + const resultA = await chain.execute({ skip: true, value: 0 }); + const resultB = await chain.execute({ skip: false, value: 0 }); + + expect(resultA.value).toBe(-1); + expect(resultB.value).toBe(10); + }); + + it('should handle middleware that awaits next multiple times with a fallback', async () => { + const log: string[] = []; + const chain = createMiddlewareChain<{ value: number }, string[]>( + async _ctx => log + ); + + chain.use(async (_ctx, next) => { + log.push('before'); + await next(); + log.push('after-first'); + await next(); + log.push('after-second'); + return log; + }); + chain.use(async (_ctx, next) => { + log.push('mw2'); + return next(); + }); + + const result = await chain.execute({ value: 0 }); + + expect(result).toEqual(['before', 'mw2', 'after-first', 'after-second']); + }); + + it('should return fallback result on second next call when no more middlewares remain', async () => { + const chain = createMiddlewareChain<{ value: number }, number>( + async ctx => ctx.value * 10 + ); + + chain.use(async (_ctx, next) => { + await next(); + return await next(); + }); + chain.use(async (_ctx, next) => { + return next(); + }); + + const result = await chain.execute({ value: 5 }); + + expect(result).toBe(50); + }); + + it('should return fallback result on second next call when no more middlewares remain', async () => { + const chain = createMiddlewareChain<{ value: number }, number>( + async ctx => ctx.value * 10 + ); + + chain.use(async (_ctx, next) => { + await next(); + return await next(); + }); + chain.use(async (_ctx, next) => { + return next(); + }); + + const result = await chain.execute({ value: 5 }); + + expect(result).toBe(50); + }); + }); + + describe('async behavior', () => { + it('should handle async middlewares', async () => { + const chain = createMiddlewareChain<{ value: number }>(); + + chain.use(async (ctx, next) => { + await new Promise(resolve => setTimeout(resolve, 10)); + ctx.value += 1; + return next(); + }); + chain.use(async (ctx, next) => { + await new Promise(resolve => setTimeout(resolve, 10)); + ctx.value += 2; + return next(); + }); + + const result = await chain.execute({ value: 0 }); + + expect(result.value).toBe(3); + }); + + it('should handle async fallback', async () => { + const chain = createMiddlewareChain<{ value: number }, number>( + async ctx => { + await new Promise(resolve => setTimeout(resolve, 10)); + return ctx.value * 10; + } + ); + + const result = await chain.execute({ value: 5 }); + + expect(result).toBe(50); + }); + }); + + describe('error handling', () => { + it('should propagate errors from middleware', async () => { + const chain = createMiddlewareChain<{ value: number }>(); + + chain.use(async () => { + throw new Error('middleware error'); + }); + + await expect(chain.execute({ value: 1 })).rejects.toThrow('middleware error'); + }); + + it('should propagate errors from fallback', async () => { + const chain = createMiddlewareChain<{ value: number }, number>( + async () => { + throw new Error('fallback error'); + } + ); + + await expect(chain.execute({ value: 1 })).rejects.toThrow('fallback error'); + }); + + it('should allow middleware to catch errors from downstream', async () => { + const chain = createMiddlewareChain<{ value: number }>(); + + chain.use(async (_ctx, next) => { + try { + return await next(); + } catch { + return { value: -1 }; + } + }); + chain.use(async () => { + throw new Error('downstream error'); + }); + + const result = await chain.execute({ value: 1 }); + + expect(result.value).toBe(-1); + }); + }); + + describe('return type override', () => { + it('should support different TReturn type than TContext', async () => { + const chain = createMiddlewareChain<{ name: string }, string>( + async ctx => `Hello, ${ctx.name}!` + ); + + const result = await chain.execute({ name: 'World' }); + + expect(result).toBe('Hello, World!'); + }); + + it('should allow middleware to transform return type', async () => { + const chain = createMiddlewareChain<{ items: number[] }, number>( + async ctx => ctx.items.reduce((a, b) => a + b, 0) + ); + + chain.use(async (_ctx, next) => { + const sum = await next(); + return sum * 2; + }); + + const result = await chain.execute({ items: [1, 2, 3] }); + + expect(result).toBe(12); + }); + }); + + describe('reusability', () => { + it('should reset index on each execute call', async () => { + const chain = createMiddlewareChain<{ count: number }>(); + + chain.use(async (ctx, next) => { + ctx.count += 1; + return next(); + }); + + const resultA = await chain.execute({ count: 0 }); + const resultB = await chain.execute({ count: 0 }); + + expect(resultA.count).toBe(1); + expect(resultB.count).toBe(1); + }); + + it('should share middlewares across execute calls', async () => { + const chain = createMiddlewareChain<{ log: string[] }>(); + + chain.use(async (ctx, next) => { + ctx.log.push('always'); + return next(); + }); + + await chain.execute({ log: [] }); + await chain.execute({ log: [] }); + + expect(chain).toBeDefined(); + }); + }); + + describe('edge cases', () => { + it('should handle empty context object', async () => { + const chain = createMiddlewareChain>(); + const result = await chain.execute({}); + + expect(result).toEqual({}); + }); + + it('should handle middleware that returns a completely different object', async () => { + const chain = createMiddlewareChain<{ x: number }, { y: string }>( + async () => ({ y: 'default' }) + ); + + chain.use(async (_ctx, next) => { + return next(); + }); + + const result = await chain.execute({ x: 42 }); + + expect(result).toEqual({ y: 'default' }); + }); + + it('should handle middleware that mutates context without returning', async () => { + const chain = createMiddlewareChain<{ value: number }>( + async ctx => ctx + ); + + chain.use(async (ctx, next) => { + ctx.value = 100; + return next(); + }); + + const result = await chain.execute({ value: 0 }); + + expect(result.value).toBe(100); + }); + + it('should return undefined when middleware does not call next or return', async () => { + const chain = createMiddlewareChain<{ value: number }>(); + + chain.use(async (ctx) => { + ctx.value = 100; + }); + + const result = await chain.execute({ value: 0 }); + + expect(result).toBeUndefined(); + }); + }); +});