551 lines
19 KiB
TypeScript
551 lines
19 KiB
TypeScript
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, PlayerEntity, EnemyEntity } from '@/samples/slay-the-spire-like/system/combat/types';
|
|
import type { EffectData } from '@/samples/slay-the-spire-like/system/types';
|
|
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/system/grid-inventory/types';
|
|
import type { GameItemMeta } from '@/samples/slay-the-spire-like/system/progress/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';
|
|
|
|
function createEffect(id: string, lifecycle: EffectData['lifecycle']): EffectData {
|
|
return { id, name: id, description: '', lifecycle };
|
|
}
|
|
|
|
function createCard(id: string, costType: 'energy' | 'uses' | 'none', costCount: number) {
|
|
return { id, name: id, desc: '', type: 'item' as const, costType, costCount, targetType: 'none' as const, effects: [] as const };
|
|
}
|
|
|
|
function createItem(itemId: string, cardId: string, costType: 'energy' | 'uses' | 'none', costCount: number, depletion = 0): InventoryItem<GameItemMeta> {
|
|
return {
|
|
id: itemId,
|
|
shape: { id: '1x1', cells: [{ x: 0, y: 0 }] } as unknown as ParsedShape,
|
|
transform: { x: 0, y: 0, rotation: 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,
|
|
depletion: costType === 'uses' ? depletion : undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
function createInventory(items: InventoryItem<GameItemMeta>[]): GridInventory<GameItemMeta> {
|
|
const map = new Map<string, InventoryItem<GameItemMeta>>();
|
|
const occupied = new Set<string>();
|
|
for (const item of items) {
|
|
map.set(item.id, item);
|
|
occupied.add(`${item.transform.x},${item.transform.y}`);
|
|
}
|
|
return { width: 6, height: 4, items: map, occupiedCells: occupied };
|
|
}
|
|
|
|
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();
|
|
});
|
|
});
|
|
|
|
describe('canPlayCard', () => {
|
|
it('should allow playing energy card when player has enough energy', () => {
|
|
const player = createPlayerEntity();
|
|
player.energy = 3;
|
|
const inventory = createInventory([]);
|
|
|
|
const result = canPlayCard(player, 'energy', 2, 'any', inventory);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
|
|
it('should reject playing energy card when player lacks energy', () => {
|
|
const player = createPlayerEntity();
|
|
player.energy = 1;
|
|
const inventory = createInventory([]);
|
|
|
|
const result = canPlayCard(player, 'energy', 2, 'any', inventory);
|
|
|
|
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 result = canPlayCard(player, 'uses', 3, 'potion-1', inventory);
|
|
|
|
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 result = canPlayCard(player, 'uses', 3, 'potion-1', inventory);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should reject playing uses card when item not in inventory', () => {
|
|
const player = createPlayerEntity();
|
|
const inventory = createInventory([]);
|
|
|
|
const result = canPlayCard(player, 'uses', 1, 'missing', inventory);
|
|
|
|
expect(result).toBe(false);
|
|
});
|
|
|
|
it('should always allow playing none cost card', () => {
|
|
const player = createPlayerEntity();
|
|
player.energy = 0;
|
|
const inventory = createInventory([]);
|
|
|
|
const result = canPlayCard(player, 'none', 0, 'any', inventory);
|
|
|
|
expect(result).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('payCardCost', () => {
|
|
it('should deduct energy for energy cost card', () => {
|
|
const player = createPlayerEntity();
|
|
player.energy = 3;
|
|
const inventory = createInventory([]);
|
|
|
|
payCardCost(player, 'energy', 2, 'any', inventory);
|
|
|
|
expect(player.energy).toBe(1);
|
|
});
|
|
|
|
it('should increment depletion for uses cost card', () => {
|
|
const player = createPlayerEntity();
|
|
const item = createItem('potion-1', 'potion-card', 'uses', 3, 1);
|
|
const inventory = createInventory([item]);
|
|
|
|
payCardCost(player, 'uses', 3, 'potion-1', inventory);
|
|
|
|
expect(item.meta?.depletion).toBe(4);
|
|
});
|
|
|
|
it('should do nothing for none cost card', () => {
|
|
const player = createPlayerEntity();
|
|
player.energy = 3;
|
|
const inventory = createInventory([]);
|
|
|
|
payCardCost(player, 'none', 0, 'any', inventory);
|
|
|
|
expect(player.energy).toBe(3);
|
|
});
|
|
|
|
it('should handle missing item gracefully for uses cost', () => {
|
|
const player = createPlayerEntity();
|
|
const inventory = createInventory([]);
|
|
|
|
expect(() => payCardCost(player, 'uses', 1, 'missing', inventory)).not.toThrow();
|
|
});
|
|
});
|
|
});
|