656 lines
19 KiB
TypeScript
656 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,
|
|
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/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 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"],
|
|
): 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,
|
|
consumedUses: costType === "uses" ? depletion : undefined,
|
|
},
|
|
};
|
|
}
|
|
|
|
function createInventory(
|
|
items: InventoryItem<GameItemMeta>[],
|
|
): GridInventory<GameItemMeta> {
|
|
const map = new Map<string, InventoryItem<GameItemMeta>>();
|
|
const occupied = new Set<CellKey>();
|
|
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 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();
|
|
});
|
|
});
|
|
});
|