chore: remove old tests
This commit is contained in:
parent
1c238aec3a
commit
4deebf67c3
|
|
@ -1,344 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
applyDamage,
|
||||
applyDefend,
|
||||
applyBuff,
|
||||
removeBuff,
|
||||
updateBuffs,
|
||||
canPlayCard,
|
||||
playCard,
|
||||
areAllEnemiesDead,
|
||||
isPlayerDead,
|
||||
getModifiedAttackDamage,
|
||||
getModifiedDefendAmount,
|
||||
} from '@/samples/slay-the-spire-like/combat/effects';
|
||||
import type { CombatState, PlayerCombatState, EnemyState } from '@/samples/slay-the-spire-like/combat/types';
|
||||
import {
|
||||
createCombatState,
|
||||
createEnemyInstance,
|
||||
createPlayerCombatState,
|
||||
drawCardsToHand,
|
||||
} from '@/samples/slay-the-spire-like/combat/state';
|
||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
|
||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
||||
import { enemyDesertData, encounterDesertData } from '@/samples/slay-the-spire-like/data';
|
||||
import { Mulberry32RNG } from '@/utils/rng';
|
||||
|
||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
||||
const shape = parseShapeString(shapeStr);
|
||||
return {
|
||||
itemData: {
|
||||
type: 'weapon',
|
||||
name,
|
||||
shape: shapeStr,
|
||||
costType: 'energy',
|
||||
costCount: 1,
|
||||
targetType: 'single',
|
||||
price: 10,
|
||||
desc: '测试',
|
||||
},
|
||||
shape,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
||||
const meta1 = createTestMeta('短刀', 'oe');
|
||||
const item1: InventoryItem<GameItemMeta> = {
|
||||
id: 'item-1',
|
||||
shape: meta1.shape,
|
||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||
meta: meta1,
|
||||
};
|
||||
placeItem(inv, item1);
|
||||
return inv;
|
||||
}
|
||||
|
||||
function createTestCombatState(): CombatState {
|
||||
const inv = createTestInventory();
|
||||
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
|
||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
||||
return createCombatState(playerState, inv, encounter);
|
||||
}
|
||||
|
||||
function createSimpleRng() {
|
||||
return new Mulberry32RNG(42);
|
||||
}
|
||||
|
||||
describe('combat/effects', () => {
|
||||
describe('applyDamage', () => {
|
||||
it('should deal damage to player', () => {
|
||||
const state = createTestCombatState();
|
||||
applyDamage(state, 'player', 10);
|
||||
|
||||
expect(state.player.hp).toBe(40);
|
||||
expect(state.player.damageTakenThisTurn).toBe(10);
|
||||
expect(state.player.damagedThisTurn).toBe(true);
|
||||
});
|
||||
|
||||
it('should deal damage to enemy', () => {
|
||||
const state = createTestCombatState();
|
||||
const enemyId = state.enemyOrder[0];
|
||||
const enemy = state.enemies[enemyId];
|
||||
const initialHp = enemy.hp;
|
||||
|
||||
applyDamage(state, enemyId, 5);
|
||||
|
||||
expect(enemy.hp).toBe(initialHp - 5);
|
||||
});
|
||||
|
||||
it('should be absorbed by defend buff on player', () => {
|
||||
const state = createTestCombatState();
|
||||
state.player.buffs['defend'] = 3;
|
||||
|
||||
const result = applyDamage(state, 'player', 5);
|
||||
|
||||
expect(result.blockedByDefend).toBe(3);
|
||||
expect(result.damageDealt).toBe(2);
|
||||
expect(state.player.hp).toBe(48);
|
||||
});
|
||||
|
||||
it('should be fully absorbed by defend buff', () => {
|
||||
const state = createTestCombatState();
|
||||
state.player.buffs['defend'] = 10;
|
||||
|
||||
applyDamage(state, 'player', 5);
|
||||
|
||||
expect(state.player.hp).toBe(50);
|
||||
expect(state.player.buffs['defend']).toBe(5);
|
||||
});
|
||||
|
||||
it('should be absorbed by defend buff on enemy', () => {
|
||||
const state = createTestCombatState();
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['defend'] = 4;
|
||||
|
||||
const result = applyDamage(state, enemyId, 6);
|
||||
|
||||
expect(result.blockedByDefend).toBe(4);
|
||||
expect(result.damageDealt).toBe(2);
|
||||
expect(state.enemies[enemyId].hp).toBe(state.enemies[enemyId].maxHp - 2);
|
||||
});
|
||||
|
||||
it('should mark defend broken when defend fully consumed', () => {
|
||||
const state = createTestCombatState();
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['defend'] = 3;
|
||||
|
||||
applyDamage(state, enemyId, 5);
|
||||
|
||||
expect(state.enemies[enemyId].hadDefendBroken).toBe(true);
|
||||
});
|
||||
|
||||
it('should kill enemy when HP reaches 0', () => {
|
||||
const state = createTestCombatState();
|
||||
const enemyId = state.enemyOrder[0];
|
||||
|
||||
applyDamage(state, enemyId, state.enemies[enemyId].maxHp);
|
||||
|
||||
expect(state.enemies[enemyId].isAlive).toBe(false);
|
||||
expect(state.enemies[enemyId].hp).toBe(0);
|
||||
});
|
||||
|
||||
it('should not deal negative damage', () => {
|
||||
const state = createTestCombatState();
|
||||
|
||||
const result = applyDamage(state, 'player', -5);
|
||||
|
||||
expect(result.damageDealt).toBe(0);
|
||||
expect(state.player.hp).toBe(50);
|
||||
});
|
||||
|
||||
it('should apply damageReduce buff', () => {
|
||||
const state = createTestCombatState();
|
||||
state.player.buffs['damageReduce'] = 3;
|
||||
|
||||
applyDamage(state, 'player', 5);
|
||||
|
||||
expect(state.player.hp).toBe(48);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyDefend', () => {
|
||||
it('should add defend stacks', () => {
|
||||
const buffs: Record<string, number> = {};
|
||||
applyDefend(buffs, 5);
|
||||
|
||||
expect(buffs['defend']).toBe(5);
|
||||
});
|
||||
|
||||
it('should stack with existing defend', () => {
|
||||
const buffs: Record<string, number> = { defend: 3 };
|
||||
applyDefend(buffs, 4);
|
||||
|
||||
expect(buffs['defend']).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyBuff / removeBuff', () => {
|
||||
it('should apply buff stacks', () => {
|
||||
const buffs: Record<string, number> = {};
|
||||
applyBuff(buffs, 'aim', 'lingering', 3);
|
||||
|
||||
expect(buffs['aim']).toBe(3);
|
||||
});
|
||||
|
||||
it('should stack existing buffs', () => {
|
||||
const buffs: Record<string, number> = { aim: 2 };
|
||||
applyBuff(buffs, 'aim', 'lingering', 3);
|
||||
|
||||
expect(buffs['aim']).toBe(5);
|
||||
});
|
||||
|
||||
it('should remove buff partially', () => {
|
||||
const buffs: Record<string, number> = { aim: 5 };
|
||||
const removed = removeBuff(buffs, 'aim', 3);
|
||||
|
||||
expect(removed).toBe(3);
|
||||
expect(buffs['aim']).toBe(2);
|
||||
});
|
||||
|
||||
it('should remove buff fully when stacks exceed current', () => {
|
||||
const buffs: Record<string, number> = { aim: 2 };
|
||||
const removed = removeBuff(buffs, 'aim', 10);
|
||||
|
||||
expect(removed).toBe(2);
|
||||
expect(buffs['aim']).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateBuffs', () => {
|
||||
it('should clear temporary buffs', () => {
|
||||
const buffs: Record<string, number> = { damageReduce: 3, defendNext: 2 };
|
||||
updateBuffs(buffs);
|
||||
|
||||
expect(buffs['damageReduce']).toBeUndefined();
|
||||
expect(buffs['defendNext']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should decrement lingering buffs', () => {
|
||||
const buffs: Record<string, number> = { curse: 3, energyDrain: 1 };
|
||||
updateBuffs(buffs);
|
||||
|
||||
expect(buffs['curse']).toBe(2);
|
||||
expect(buffs['energyDrain']).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should not affect permanent or posture buffs', () => {
|
||||
const buffs: Record<string, number> = { defend: 5, spike: 1 };
|
||||
updateBuffs(buffs);
|
||||
|
||||
expect(buffs['defend']).toBe(5);
|
||||
expect(buffs['spike']).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canPlayCard', () => {
|
||||
it('should allow playing card with enough energy', () => {
|
||||
const state = createTestCombatState();
|
||||
const cardId = state.player.deck.hand[0];
|
||||
|
||||
const result = canPlayCard(state, cardId);
|
||||
expect(result.canPlay).toBe(true);
|
||||
});
|
||||
|
||||
it('should reject card not in hand', () => {
|
||||
const state = createTestCombatState();
|
||||
|
||||
const result = canPlayCard(state, 'nonexistent-card');
|
||||
expect(result.canPlay).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject card with insufficient energy', () => {
|
||||
const state = createTestCombatState();
|
||||
state.player.energy = 0;
|
||||
const cardId = state.player.deck.hand[0];
|
||||
|
||||
const card = state.player.deck.cards[cardId];
|
||||
if (card?.itemData?.costType === 'energy' && card.itemData.costCount > 0) {
|
||||
const result = canPlayCard(state, cardId);
|
||||
expect(result.canPlay).toBe(false);
|
||||
expect(result.reason).toBe('能量不足');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('playCard', () => {
|
||||
it('should deduct energy cost when playing card', () => {
|
||||
const state = createTestCombatState();
|
||||
const cardId = state.player.deck.hand[0];
|
||||
const card = state.player.deck.cards[cardId];
|
||||
const initialEnergy = state.player.energy;
|
||||
|
||||
if (card?.itemData?.costType === 'energy') {
|
||||
const ctx = { state, rng: createSimpleRng() };
|
||||
const result = playCard(ctx, cardId);
|
||||
|
||||
if (result.success) {
|
||||
expect(state.player.energy).toBe(initialEnergy - card.itemData.costCount);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should move card to discard pile after playing', () => {
|
||||
const state = createTestCombatState();
|
||||
const cardId = state.player.deck.hand[0];
|
||||
const ctx = { state, rng: createSimpleRng() };
|
||||
|
||||
const result = playCard(ctx, cardId);
|
||||
|
||||
if (result.success) {
|
||||
expect(state.player.deck.hand.includes(cardId)).toBe(false);
|
||||
expect(state.player.deck.discardPile.includes(cardId) || state.player.deck.exhaustPile.includes(cardId)).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('areAllEnemiesDead / isPlayerDead', () => {
|
||||
it('should detect all enemies dead', () => {
|
||||
const state = createTestCombatState();
|
||||
expect(areAllEnemiesDead(state)).toBe(false);
|
||||
|
||||
for (const enemyId of state.enemyOrder) {
|
||||
state.enemies[enemyId].isAlive = false;
|
||||
}
|
||||
expect(areAllEnemiesDead(state)).toBe(true);
|
||||
});
|
||||
|
||||
it('should detect player death', () => {
|
||||
const state = createTestCombatState();
|
||||
expect(isPlayerDead(state)).toBe(false);
|
||||
|
||||
state.player.hp = 0;
|
||||
expect(isPlayerDead(state)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModifiedAttackDamage / getModifiedDefendAmount', () => {
|
||||
it('should return base damage with no item buffs', () => {
|
||||
const state = createTestCombatState();
|
||||
expect(getModifiedAttackDamage(state, 'some-card', 5)).toBe(5);
|
||||
});
|
||||
|
||||
it('should return base defend with no item buffs', () => {
|
||||
const state = createTestCombatState();
|
||||
expect(getModifiedDefendAmount(state, 'some-card', 4)).toBe(4);
|
||||
});
|
||||
|
||||
it('should add item buff attack damage', () => {
|
||||
const state = createTestCombatState();
|
||||
state.itemBuffs.push({
|
||||
effectId: 'attackBuff',
|
||||
stacks: 3,
|
||||
timing: 'itemUntilPlayed',
|
||||
sourceItemId: 'item-1',
|
||||
targetItemId: 'item-1',
|
||||
});
|
||||
|
||||
expect(getModifiedAttackDamage(state, 'some-card', 5)).toBe(5);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,261 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import { createGameHost, GameHost } from '@/core/game-host';
|
||||
import { createGameContext, createGameCommandRegistry } from '@/core/game';
|
||||
import type { CombatState, CombatGameContext } from '@/samples/slay-the-spire-like/combat/types';
|
||||
import { createCombatState } from '@/samples/slay-the-spire-like/combat/state';
|
||||
import { runCombat } from '@/samples/slay-the-spire-like/combat/procedure';
|
||||
import { prompts } from '@/samples/slay-the-spire-like/combat/prompts';
|
||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
|
||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
||||
import { encounterDesertData, enemyDesertData } from '@/samples/slay-the-spire-like/data';
|
||||
|
||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
||||
const shape = parseShapeString(shapeStr);
|
||||
return {
|
||||
itemData: {
|
||||
type: 'weapon',
|
||||
name,
|
||||
shape: shapeStr,
|
||||
costType: 'energy',
|
||||
costCount: 1,
|
||||
targetType: 'single',
|
||||
price: 10,
|
||||
desc: '测试',
|
||||
},
|
||||
shape,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
||||
const meta1 = createTestMeta('短刀', 'oe');
|
||||
const item1: InventoryItem<GameItemMeta> = {
|
||||
id: 'item-1',
|
||||
shape: meta1.shape,
|
||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||
meta: meta1,
|
||||
};
|
||||
placeItem(inv, item1);
|
||||
return inv;
|
||||
}
|
||||
|
||||
function createTestCombatState(): CombatState {
|
||||
const inv = createTestInventory();
|
||||
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
|
||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
||||
return createCombatState(playerState, inv, encounter);
|
||||
}
|
||||
|
||||
function waitForPrompt(host: GameHost<CombatState>): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
const check = () => {
|
||||
if (host.activePromptSchema.value !== null) {
|
||||
resolve();
|
||||
} else {
|
||||
setTimeout(check, 10);
|
||||
}
|
||||
};
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
describe('combat/procedure', () => {
|
||||
describe('runCombat with GameHost', () => {
|
||||
it('should start combat and prompt for player action', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const initialState = createTestCombatState();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
|
||||
await waitForPrompt(host);
|
||||
expect(host.activePromptSchema.value).not.toBeNull();
|
||||
expect(host.activePromptSchema.value?.name).toBe('play-card');
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
|
||||
it('should accept play-card input', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
const state = host.state.value;
|
||||
const cardId = state.player.deck.hand[0];
|
||||
|
||||
const error = host.tryAnswerPrompt(prompts.playCard, cardId, state.enemyOrder[0]);
|
||||
expect(error).toBeNull();
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
|
||||
it('should reject invalid card play', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
const error = host.tryAnswerPrompt(prompts.playCard, 'nonexistent-card');
|
||||
expect(error).not.toBeNull();
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
|
||||
it('should transition to end-turn after playing cards', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
const state = host.state.value;
|
||||
const cardId = state.player.deck.hand[0];
|
||||
|
||||
host.tryAnswerPrompt(prompts.playCard, cardId, state.enemyOrder[0]);
|
||||
await waitForPrompt(host);
|
||||
|
||||
expect(host.activePromptSchema.value).not.toBeNull();
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
|
||||
it('should accept end-turn', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
const error = host.tryAnswerPrompt(prompts.endTurn);
|
||||
expect(error).toBeNull();
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
});
|
||||
|
||||
describe('combat outcome', () => {
|
||||
it('should return victory when all enemies are dead', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => {
|
||||
const state = createTestCombatState();
|
||||
for (const enemyId of state.enemyOrder) {
|
||||
state.enemies[enemyId].hp = 1;
|
||||
}
|
||||
return state;
|
||||
},
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
let iterations = 0;
|
||||
while (host.status.value === 'running' && iterations < 100) {
|
||||
const state = host.state.value;
|
||||
if (host.activePromptSchema.value?.name === 'play-card') {
|
||||
const cardId = state.player.deck.hand[0];
|
||||
if (cardId) {
|
||||
const targetId = state.enemyOrder.find(id => state.enemies[id].isAlive);
|
||||
host.tryAnswerPrompt(prompts.playCard, cardId, targetId);
|
||||
}
|
||||
} else if (host.activePromptSchema.value?.name === 'end-turn') {
|
||||
host.tryAnswerPrompt(prompts.endTurn);
|
||||
}
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
iterations++;
|
||||
}
|
||||
|
||||
if (host.status.value === 'running') {
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('combat state transitions', () => {
|
||||
it('should track turn number across turns', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
host.tryAnswerPrompt(prompts.endTurn);
|
||||
await waitForPrompt(host);
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
|
||||
it('should reset energy at start of player turn', async () => {
|
||||
const registry = createGameCommandRegistry<CombatState>();
|
||||
const host = new GameHost(
|
||||
registry,
|
||||
() => createTestCombatState(),
|
||||
async (ctx) => {
|
||||
return await runCombat(ctx);
|
||||
},
|
||||
);
|
||||
|
||||
const combatPromise = host.start(42);
|
||||
await waitForPrompt(host);
|
||||
|
||||
const state = host.state.value;
|
||||
expect(state.player.energy).toBe(state.player.maxEnergy);
|
||||
|
||||
host._context._commands._cancel();
|
||||
try { await combatPromise; } catch {}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,303 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createCombatState,
|
||||
createEnemyInstance,
|
||||
createPlayerCombatState,
|
||||
drawCardsToHand,
|
||||
addFatigueCards,
|
||||
discardHand,
|
||||
discardCard,
|
||||
exhaustCard,
|
||||
getEnemyCurrentIntent,
|
||||
advanceEnemyIntent,
|
||||
getEffectTiming,
|
||||
getEffectData,
|
||||
INITIAL_HAND_SIZE,
|
||||
DEFAULT_MAX_ENERGY,
|
||||
FATIGUE_CARDS_PER_SHUFFLE,
|
||||
} from '@/samples/slay-the-spire-like/combat/state';
|
||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
|
||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
||||
import { encounterDesertData, enemyDesertData, effectDesertData } from '@/samples/slay-the-spire-like/data';
|
||||
|
||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
||||
const shape = parseShapeString(shapeStr);
|
||||
return {
|
||||
itemData: {
|
||||
type: 'weapon',
|
||||
name,
|
||||
shape: shapeStr,
|
||||
costType: 'energy',
|
||||
costCount: 1,
|
||||
targetType: 'single',
|
||||
price: 10,
|
||||
desc: '测试物品',
|
||||
},
|
||||
shape,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
||||
const meta1 = createTestMeta('短刀', 'oe');
|
||||
const item1: InventoryItem<GameItemMeta> = {
|
||||
id: 'item-1',
|
||||
shape: meta1.shape,
|
||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||
meta: meta1,
|
||||
};
|
||||
placeItem(inv, item1);
|
||||
return inv;
|
||||
}
|
||||
|
||||
function createTestPlayerState(): PlayerState {
|
||||
return { maxHp: 50, currentHp: 50, gold: 0 };
|
||||
}
|
||||
|
||||
describe('combat/state', () => {
|
||||
describe('constants', () => {
|
||||
it('should have correct default values', () => {
|
||||
expect(INITIAL_HAND_SIZE).toBe(5);
|
||||
expect(DEFAULT_MAX_ENERGY).toBe(3);
|
||||
expect(FATIGUE_CARDS_PER_SHUFFLE).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createEnemyInstance', () => {
|
||||
it('should create enemy from desert data', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
|
||||
expect(enemy.templateId).toBe('仙人掌怪');
|
||||
expect(enemy.hp).toBe(cactusData.initHp);
|
||||
expect(enemy.maxHp).toBe(cactusData.initHp);
|
||||
expect(enemy.isAlive).toBe(true);
|
||||
expect(enemy.hadDefendBroken).toBe(false);
|
||||
});
|
||||
|
||||
it('should apply bonus HP', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 5, { value: 0 });
|
||||
|
||||
expect(enemy.hp).toBe(cactusData.initHp + 5);
|
||||
expect(enemy.maxHp).toBe(cactusData.initHp + 5);
|
||||
});
|
||||
|
||||
it('should initialize buffs from template', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
|
||||
expect(enemy.buffs['spike']).toBe(1);
|
||||
});
|
||||
|
||||
it('should set initial intent', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
|
||||
expect(enemy.currentIntentId).toBe(cactusData.initialIntent);
|
||||
});
|
||||
|
||||
it('should generate unique IDs', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const e1 = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
const e2 = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
|
||||
expect(e1.id).not.toBe(e2.id);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createPlayerCombatState', () => {
|
||||
it('should create player state from run state and inventory', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
|
||||
expect(combatPlayer.hp).toBe(50);
|
||||
expect(combatPlayer.maxHp).toBe(50);
|
||||
expect(combatPlayer.energy).toBe(DEFAULT_MAX_ENERGY);
|
||||
expect(combatPlayer.maxEnergy).toBe(DEFAULT_MAX_ENERGY);
|
||||
expect(Object.keys(combatPlayer.buffs).length).toBe(0);
|
||||
expect(combatPlayer.damagedThisTurn).toBe(false);
|
||||
expect(combatPlayer.cardsDiscardedThisTurn).toBe(0);
|
||||
});
|
||||
|
||||
it('should generate deck from inventory', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
|
||||
expect(Object.keys(combatPlayer.deck.cards).length).toBeGreaterThan(0);
|
||||
expect(combatPlayer.deck.drawPile.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCombatState', () => {
|
||||
it('should create full combat state from encounter', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
||||
|
||||
const combat = createCombatState(playerState, inv, encounter);
|
||||
|
||||
expect(combat.phase).toBe('playerTurn');
|
||||
expect(combat.turnNumber).toBe(1);
|
||||
expect(combat.result).toBeNull();
|
||||
expect(combat.loot).toEqual([]);
|
||||
expect(combat.fatigueAddedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('should create enemies from encounter data', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
||||
|
||||
const combat = createCombatState(playerState, inv, encounter);
|
||||
|
||||
expect(combat.enemyOrder.length).toBeGreaterThan(0);
|
||||
for (const enemyId of combat.enemyOrder) {
|
||||
expect(combat.enemies[enemyId].isAlive).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should draw initial hand', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
||||
|
||||
const combat = createCombatState(playerState, inv, encounter);
|
||||
|
||||
expect(combat.player.deck.hand.length).toBe(INITIAL_HAND_SIZE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('drawCardsToHand', () => {
|
||||
it('should draw cards from draw pile to hand', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
|
||||
const initialDrawPile = combatPlayer.deck.drawPile.length;
|
||||
const initialHand = combatPlayer.deck.hand.length;
|
||||
drawCardsToHand(combatPlayer.deck, 3);
|
||||
|
||||
expect(combatPlayer.deck.hand.length).toBe(initialHand + 3);
|
||||
expect(combatPlayer.deck.drawPile.length).toBe(initialDrawPile - 3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addFatigueCards', () => {
|
||||
it('should add fatigue cards to draw pile', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
|
||||
const initialCount = Object.keys(combatPlayer.deck.cards).length;
|
||||
const fatigueCounter = { value: 0 };
|
||||
addFatigueCards(combatPlayer.deck, FATIGUE_CARDS_PER_SHUFFLE, fatigueCounter);
|
||||
|
||||
expect(Object.keys(combatPlayer.deck.cards).length).toBe(initialCount + FATIGUE_CARDS_PER_SHUFFLE);
|
||||
expect(fatigueCounter.value).toBe(FATIGUE_CARDS_PER_SHUFFLE);
|
||||
});
|
||||
|
||||
it('should create fatigue cards with correct properties', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
|
||||
addFatigueCards(combatPlayer.deck, 1, { value: 0 });
|
||||
const fatigueCard = combatPlayer.deck.cards['fatigue-1'];
|
||||
|
||||
expect(fatigueCard).toBeDefined();
|
||||
expect(fatigueCard.displayName).toBe('疲劳');
|
||||
expect(fatigueCard.sourceItemId).toBeNull();
|
||||
expect(fatigueCard.itemData).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('discardHand', () => {
|
||||
it('should move all hand cards to discard pile', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
drawCardsToHand(combatPlayer.deck, 3);
|
||||
|
||||
const handCount = combatPlayer.deck.hand.length;
|
||||
discardHand(combatPlayer.deck);
|
||||
|
||||
expect(combatPlayer.deck.hand).toEqual([]);
|
||||
expect(combatPlayer.deck.discardPile.length).toBe(handCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('discardCard / exhaustCard', () => {
|
||||
it('should move a card from hand to discard pile', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
drawCardsToHand(combatPlayer.deck, 3);
|
||||
|
||||
const cardId = combatPlayer.deck.hand[0];
|
||||
discardCard(combatPlayer.deck, cardId);
|
||||
|
||||
expect(combatPlayer.deck.hand.includes(cardId)).toBe(false);
|
||||
expect(combatPlayer.deck.discardPile.includes(cardId)).toBe(true);
|
||||
});
|
||||
|
||||
it('should move a card from hand to exhaust pile', () => {
|
||||
const inv = createTestInventory();
|
||||
const playerState = createTestPlayerState();
|
||||
const combatPlayer = createPlayerCombatState(playerState, inv);
|
||||
drawCardsToHand(combatPlayer.deck, 3);
|
||||
|
||||
const cardId = combatPlayer.deck.hand[0];
|
||||
exhaustCard(combatPlayer.deck, cardId);
|
||||
|
||||
expect(combatPlayer.deck.hand.includes(cardId)).toBe(false);
|
||||
expect(combatPlayer.deck.exhaustPile.includes(cardId)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enemy intent', () => {
|
||||
it('should get current intent', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
|
||||
const intent = getEnemyCurrentIntent(enemy);
|
||||
expect(intent).toBeDefined();
|
||||
expect(intent!.id).toBe('boost');
|
||||
});
|
||||
|
||||
it('should advance intent after action', () => {
|
||||
const cactusData = enemyDesertData.find(e => e.id === '仙人掌怪')!;
|
||||
const enemy = createEnemyInstance('仙人掌怪', cactusData, 0, { value: 0 });
|
||||
|
||||
const originalIntent = enemy.currentIntentId;
|
||||
advanceEnemyIntent(enemy);
|
||||
|
||||
expect(enemy.currentIntentId).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEffectTiming / getEffectData', () => {
|
||||
it('should return timing for known effects', () => {
|
||||
expect(getEffectTiming('attack')).toBe('instant');
|
||||
expect(getEffectTiming('defend')).toBe('posture');
|
||||
expect(getEffectTiming('spike')).toBe('permanent');
|
||||
expect(getEffectTiming('energyDrain')).toBe('lingering');
|
||||
});
|
||||
|
||||
it('should return undefined for unknown effects', () => {
|
||||
expect(getEffectTiming('nonexistent')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return effect data for known effects', () => {
|
||||
const data = getEffectData('attack');
|
||||
expect(data).toBeDefined();
|
||||
expect(data!.id).toBe('attack');
|
||||
expect(data!.name).toBe('攻击');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
createCombatTriggerRegistry,
|
||||
dispatchTrigger,
|
||||
dispatchAttackedTrigger,
|
||||
dispatchDamageTrigger,
|
||||
dispatchOutgoingDamageTrigger,
|
||||
dispatchIncomingDamageTrigger,
|
||||
} from '@/samples/slay-the-spire-like/combat/triggers';
|
||||
import type { TriggerContext, CombatTriggerRegistry } from '@/samples/slay-the-spire-like/combat/triggers';
|
||||
import type { CombatState } from '@/samples/slay-the-spire-like/combat/types';
|
||||
import { createCombatState } from '@/samples/slay-the-spire-like/combat/state';
|
||||
import { createGridInventory, placeItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GridInventory, InventoryItem } from '@/samples/slay-the-spire-like/grid-inventory';
|
||||
import type { GameItemMeta, PlayerState } from '@/samples/slay-the-spire-like/progress/types';
|
||||
import { IDENTITY_TRANSFORM } from '@/samples/slay-the-spire-like/utils/shape-collision';
|
||||
import { parseShapeString } from '@/samples/slay-the-spire-like/utils/parse-shape';
|
||||
import { encounterDesertData, enemyDesertData } from '@/samples/slay-the-spire-like/data';
|
||||
import { Mulberry32RNG } from '@/utils/rng';
|
||||
|
||||
function createTestMeta(name: string, shapeStr: string): GameItemMeta {
|
||||
const shape = parseShapeString(shapeStr);
|
||||
return {
|
||||
itemData: {
|
||||
type: 'weapon',
|
||||
name,
|
||||
shape: shapeStr,
|
||||
costType: 'energy',
|
||||
costCount: 1,
|
||||
targetType: 'single',
|
||||
price: 10,
|
||||
desc: '测试',
|
||||
},
|
||||
shape,
|
||||
};
|
||||
}
|
||||
|
||||
function createTestInventory(): GridInventory<GameItemMeta> {
|
||||
const inv = createGridInventory<GameItemMeta>(6, 4);
|
||||
const meta1 = createTestMeta('短刀', 'oe');
|
||||
const item1: InventoryItem<GameItemMeta> = {
|
||||
id: 'item-1',
|
||||
shape: meta1.shape,
|
||||
transform: { ...IDENTITY_TRANSFORM, offset: { x: 0, y: 0 } },
|
||||
meta: meta1,
|
||||
};
|
||||
placeItem(inv, item1);
|
||||
return inv;
|
||||
}
|
||||
|
||||
function createTestCombatState(): CombatState {
|
||||
const inv = createTestInventory();
|
||||
const playerState: PlayerState = { maxHp: 50, currentHp: 50, gold: 0 };
|
||||
const encounter = encounterDesertData.find(e => e.name === '仙人掌怪')!;
|
||||
return createCombatState(playerState, inv, encounter);
|
||||
}
|
||||
|
||||
function createTestTriggerCtx(state: CombatState): TriggerContext {
|
||||
return { state, rng: new Mulberry32RNG(42) };
|
||||
}
|
||||
|
||||
describe('combat/triggers', () => {
|
||||
describe('createCombatTriggerRegistry', () => {
|
||||
it('should create registry with desert zone triggers', () => {
|
||||
const registry = createCombatTriggerRegistry();
|
||||
|
||||
expect(registry['spike']).toBeDefined();
|
||||
expect(registry['aim']).toBeDefined();
|
||||
expect(registry['charge']).toBeDefined();
|
||||
expect(registry['roll']).toBeDefined();
|
||||
expect(registry['tailSting']).toBeDefined();
|
||||
expect(registry['energyDrain']).toBeDefined();
|
||||
expect(registry['molt']).toBeDefined();
|
||||
expect(registry['storm']).toBeDefined();
|
||||
expect(registry['vultureEye']).toBeDefined();
|
||||
expect(registry['venom']).toBeDefined();
|
||||
expect(registry['static']).toBeDefined();
|
||||
expect(registry['curse']).toBeDefined();
|
||||
expect(registry['discard']).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('spike trigger', () => {
|
||||
it('should deal damage to attacker when enemy is attacked', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['spike'] = 2;
|
||||
|
||||
const initialPlayerHp = state.player.hp;
|
||||
dispatchAttackedTrigger(ctx, 'player', enemyId, 5, registry);
|
||||
|
||||
expect(state.player.hp).toBeLessThan(initialPlayerHp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aim trigger', () => {
|
||||
it('should double outgoing damage with aim stacks', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['aim'] = 3;
|
||||
|
||||
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
|
||||
expect(modified).toBe(10);
|
||||
});
|
||||
|
||||
it('should not double damage with 0 aim stacks', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['aim'] = 0;
|
||||
|
||||
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
|
||||
expect(modified).toBe(5);
|
||||
});
|
||||
|
||||
it('should lose aim stacks on damage', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['aim'] = 5;
|
||||
|
||||
dispatchDamageTrigger(ctx, enemyId, 3, registry);
|
||||
|
||||
expect(state.enemies[enemyId].buffs['aim']).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('charge trigger', () => {
|
||||
it('should double outgoing and incoming damage', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['charge'] = 2;
|
||||
|
||||
const outDmg = dispatchOutgoingDamageTrigger(ctx, enemyId, 6, registry);
|
||||
expect(outDmg).toBe(12);
|
||||
|
||||
const inDmg = dispatchIncomingDamageTrigger(ctx, enemyId, 6, registry);
|
||||
expect(inDmg).toBe(12);
|
||||
});
|
||||
|
||||
it('should lose charge stacks on damage', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['charge'] = 5;
|
||||
|
||||
dispatchDamageTrigger(ctx, enemyId, 3, registry);
|
||||
|
||||
expect(state.enemies[enemyId].buffs['charge']).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('roll trigger', () => {
|
||||
it('should increase damage when roll >= 10', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['roll'] = 20;
|
||||
|
||||
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
|
||||
expect(modified).toBe(7);
|
||||
expect(state.enemies[enemyId].buffs['roll']).toBe(0);
|
||||
});
|
||||
|
||||
it('should not modify damage when roll < 10', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['roll'] = 5;
|
||||
|
||||
const modified = dispatchOutgoingDamageTrigger(ctx, enemyId, 5, registry);
|
||||
expect(modified).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('tailSting trigger', () => {
|
||||
it('should deal damage to player at turn end', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['tailSting'] = 3;
|
||||
|
||||
const initialHp = state.player.hp;
|
||||
dispatchTrigger(ctx, 'onTurnEnd', enemyId, registry);
|
||||
|
||||
expect(state.player.hp).toBeLessThan(initialHp);
|
||||
});
|
||||
});
|
||||
|
||||
describe('static trigger', () => {
|
||||
it('should increase incoming damage to player', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
state.player.buffs['static'] = 2;
|
||||
|
||||
const modified = dispatchIncomingDamageTrigger(ctx, 'player', 5, registry);
|
||||
expect(modified).toBe(7);
|
||||
});
|
||||
});
|
||||
|
||||
describe('molt trigger', () => {
|
||||
it('should cause enemy to flee when molt stacks >= maxHp', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['molt'] = state.enemies[enemyId].maxHp;
|
||||
|
||||
dispatchDamageTrigger(ctx, enemyId, 1, registry);
|
||||
|
||||
expect(state.enemies[enemyId].isAlive).toBe(false);
|
||||
expect(state.result).toBe('fled');
|
||||
});
|
||||
|
||||
it('should not cause flee when molt stacks < maxHp', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['molt'] = 1;
|
||||
|
||||
dispatchDamageTrigger(ctx, enemyId, 1, registry);
|
||||
|
||||
expect(state.enemies[enemyId].isAlive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dispatchTrigger with missing handler', () => {
|
||||
it('should be a no-op for unknown buff', () => {
|
||||
const state = createTestCombatState();
|
||||
const registry = createCombatTriggerRegistry();
|
||||
const ctx = createTestTriggerCtx(state);
|
||||
const enemyId = state.enemyOrder[0];
|
||||
state.enemies[enemyId].buffs['nonexistentBuff'] = 5;
|
||||
|
||||
expect(() => {
|
||||
dispatchTrigger(ctx, 'onTurnStart', enemyId, registry);
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,311 +1,8 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
heroItemFighter1Data,
|
||||
encounterDesertData,
|
||||
enemyDesertData,
|
||||
enemyIntentDesertData,
|
||||
effectDesertData,
|
||||
statusCardDesertData,
|
||||
} from '@/samples/slay-the-spire-like/data';
|
||||
import data from '@/samples/slay-the-spire-like/data';
|
||||
|
||||
describe('heroItemFighter1.csv import', () => {
|
||||
it('should import data as an array', () => {
|
||||
expect(Array.isArray(heroItemFighter1Data)).toBe(true);
|
||||
expect(heroItemFighter1Data.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have expected number of items', () => {
|
||||
expect(heroItemFighter1Data.length).toBe(24);
|
||||
});
|
||||
|
||||
it('should have correct fields for each item', () => {
|
||||
for (const item of heroItemFighter1Data) {
|
||||
expect(item).toHaveProperty('type');
|
||||
expect(item).toHaveProperty('name');
|
||||
expect(item).toHaveProperty('shape');
|
||||
expect(item).toHaveProperty('costType');
|
||||
expect(item).toHaveProperty('costCount');
|
||||
expect(item).toHaveProperty('targetType');
|
||||
expect(item).toHaveProperty('desc');
|
||||
expect(item).toHaveProperty('effects');
|
||||
}
|
||||
});
|
||||
|
||||
it('should parse costCount as number', () => {
|
||||
for (const item of heroItemFighter1Data) {
|
||||
expect(typeof item.costCount).toBe('number');
|
||||
}
|
||||
});
|
||||
|
||||
it('should contain expected items by name', () => {
|
||||
const names = heroItemFighter1Data.map(item => item.name);
|
||||
expect(names).toContain('剑');
|
||||
expect(names).toContain('盾');
|
||||
expect(names).toContain('绷带');
|
||||
expect(names).toContain('火把');
|
||||
});
|
||||
|
||||
it('should have valid type values', () => {
|
||||
const validTypes = ['weapon', 'armor', 'consumable', 'tool'];
|
||||
for (const item of heroItemFighter1Data) {
|
||||
expect(validTypes).toContain(item.type);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid costType values', () => {
|
||||
const validCostTypes = ['energy', 'uses'];
|
||||
for (const item of heroItemFighter1Data) {
|
||||
expect(validCostTypes).toContain(item.costType);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid targetType values', () => {
|
||||
const validTargetTypes = ['single', 'none'];
|
||||
for (const item of heroItemFighter1Data) {
|
||||
expect(validTargetTypes).toContain(item.targetType);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have correct item counts by type', () => {
|
||||
const typeCounts = heroItemFighter1Data.reduce((acc, item) => {
|
||||
acc[item.type] = (acc[item.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
expect(typeCounts['weapon']).toBe(6);
|
||||
expect(typeCounts['armor']).toBe(6);
|
||||
expect(typeCounts['consumable']).toBe(6);
|
||||
expect(typeCounts['tool']).toBe(6);
|
||||
});
|
||||
|
||||
it('should have effects with target, effect ref, and value', () => {
|
||||
for (const item of heroItemFighter1Data) {
|
||||
expect(Array.isArray(item.effects)).toBe(true);
|
||||
for (const [target, effect, value] of item.effects) {
|
||||
expect(target === 'self' || target === 'target' || target === 'all' || target === 'random').toBe(true);
|
||||
expect(typeof effect === 'string' || typeof effect === 'object').toBe(true);
|
||||
expect(typeof value).toBe('number');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('encounterDesert.csv import', () => {
|
||||
it('should import data as an array', () => {
|
||||
expect(Array.isArray(encounterDesertData)).toBe(true);
|
||||
expect(encounterDesertData.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have correct encounter type counts', () => {
|
||||
const typeCounts = encounterDesertData.reduce((acc, e) => {
|
||||
acc[e.type] = (acc[e.type] || 0) + 1;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
|
||||
expect(typeCounts['minion']).toBe(10);
|
||||
expect(typeCounts['elite']).toBe(4);
|
||||
expect(typeCounts['shop']).toBe(2);
|
||||
expect(typeCounts['camp']).toBe(2);
|
||||
expect(typeCounts['curio']).toBe(8);
|
||||
expect(typeCounts['event']).toBe(1);
|
||||
});
|
||||
|
||||
it('should have enemies for combat encounters', () => {
|
||||
for (const e of encounterDesertData) {
|
||||
if (e.type === 'minion' || e.type === 'elite') {
|
||||
expect(Array.isArray(e.enemies)).toBe(true);
|
||||
expect(e.enemies.length).toBeGreaterThan(0);
|
||||
for (const [enemy, bonusHp] of e.enemies) {
|
||||
expect(typeof enemy === 'string' || typeof enemy === 'object').toBe(true);
|
||||
expect(typeof bonusHp).toBe('number');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have empty enemies for non-combat encounters', () => {
|
||||
for (const e of encounterDesertData) {
|
||||
if (e.type === 'shop' || e.type === 'camp') {
|
||||
expect(e.enemies.length).toBe(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have dialogue for curio and event encounters', () => {
|
||||
for (const e of encounterDesertData) {
|
||||
if (e.type === 'curio' || e.type === 'event') {
|
||||
expect(e.dialogue).toBeTruthy();
|
||||
expect(e.dialogue.startsWith('desert_')).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('effectDesert.csv import', () => {
|
||||
it('should import data as an array', () => {
|
||||
expect(Array.isArray(effectDesertData)).toBe(true);
|
||||
expect(effectDesertData.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have expected number of effects', () => {
|
||||
expect(effectDesertData.length).toBe(35);
|
||||
});
|
||||
|
||||
it('should have correct fields for each effect', () => {
|
||||
for (const effect of effectDesertData) {
|
||||
expect(effect).toHaveProperty('id');
|
||||
expect(effect).toHaveProperty('name');
|
||||
expect(effect).toHaveProperty('description');
|
||||
expect(effect).toHaveProperty('timing');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid timing values', () => {
|
||||
const validTimings = ['instant', 'temporary', 'lingering', 'permanent', 'posture', 'card', 'cardDraw', 'cardHand', 'item', 'itemUntilPlayed'];
|
||||
for (const effect of effectDesertData) {
|
||||
expect(validTimings).toContain(effect.timing);
|
||||
}
|
||||
});
|
||||
|
||||
it('should contain core effect types', () => {
|
||||
const ids = effectDesertData.map(e => e.id);
|
||||
expect(ids).toContain('attack');
|
||||
expect(ids).toContain('defend');
|
||||
expect(ids).toContain('spike');
|
||||
expect(ids).toContain('venom');
|
||||
expect(ids).toContain('draw');
|
||||
expect(ids).toContain('removeWound');
|
||||
expect(ids).toContain('gainEnergy');
|
||||
});
|
||||
});
|
||||
|
||||
describe('enemyDesert.csv import', () => {
|
||||
it('should import data as an array', () => {
|
||||
expect(Array.isArray(enemyDesertData)).toBe(true);
|
||||
expect(enemyDesertData.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have expected number of enemies', () => {
|
||||
expect(enemyDesertData.length).toBe(14);
|
||||
});
|
||||
|
||||
it('should have correct fields for each enemy', () => {
|
||||
for (const enemy of enemyDesertData) {
|
||||
expect(enemy).toHaveProperty('id');
|
||||
expect(enemy).toHaveProperty('initHp');
|
||||
expect(enemy).toHaveProperty('initBuffs');
|
||||
expect(enemy).toHaveProperty('initialIntent');
|
||||
expect(typeof enemy.initHp).toBe('number');
|
||||
expect(typeof enemy.initialIntent).toBe('string');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have valid HP ranges', () => {
|
||||
for (const enemy of enemyDesertData) {
|
||||
expect(enemy.initHp).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have minions with lower HP than elites', () => {
|
||||
const minionIds = ['仙人掌怪', '蛇', '木乃伊', '枪手', '风卷草', '秃鹫', '沙蝎', '幼沙虫', '蜥蜴', '沙匪'];
|
||||
const eliteIds = ['风暴之灵', '骑马枪手', '沙虫王', '沙漠守卫'];
|
||||
const byId = Object.fromEntries(enemyDesertData.map(e => [e.id, e]));
|
||||
|
||||
for (const id of minionIds) {
|
||||
expect(byId[id].initHp).toBeLessThan(40);
|
||||
}
|
||||
for (const id of eliteIds) {
|
||||
expect(byId[id].initHp).toBeGreaterThanOrEqual(40);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('enemyIntentDesert.csv import', () => {
|
||||
it('should import data as an array', () => {
|
||||
expect(Array.isArray(enemyIntentDesertData)).toBe(true);
|
||||
expect(enemyIntentDesertData.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have expected number of intent rows', () => {
|
||||
expect(enemyIntentDesertData.length).toBe(41);
|
||||
});
|
||||
|
||||
it('should have correct fields for each intent', () => {
|
||||
for (const intent of enemyIntentDesertData) {
|
||||
expect(intent).toHaveProperty('enemy');
|
||||
expect(intent).toHaveProperty('id');
|
||||
expect(intent).toHaveProperty('nextIntents');
|
||||
expect(intent).toHaveProperty('brokenIntent');
|
||||
expect(intent).toHaveProperty('effects');
|
||||
expect(intent.enemy).toHaveProperty('id');
|
||||
expect(Array.isArray(intent.nextIntents)).toBe(true);
|
||||
expect(Array.isArray(intent.brokenIntent)).toBe(true);
|
||||
expect(Array.isArray(intent.effects)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have effects with target, effect ref, and value', () => {
|
||||
for (const intent of enemyIntentDesertData) {
|
||||
for (const [target, effect, value] of intent.effects) {
|
||||
expect(target === 'self' || target === 'player' || target === 'team').toBe(true);
|
||||
expect(typeof effect === 'string' || typeof effect === 'object').toBe(true);
|
||||
expect(typeof value).toBe('number');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should cover all 14 enemies', () => {
|
||||
const enemyIds = new Set(enemyIntentDesertData.map(i => typeof i.enemy === 'string' ? i.enemy : i.enemy.id));
|
||||
expect(enemyIds.size).toBe(14);
|
||||
});
|
||||
});
|
||||
|
||||
describe('statusCardDesert.csv import', () => {
|
||||
it('should import data as an array', () => {
|
||||
expect(Array.isArray(statusCardDesertData)).toBe(true);
|
||||
expect(statusCardDesertData.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should have expected number of status cards', () => {
|
||||
expect(statusCardDesertData.length).toBe(6);
|
||||
});
|
||||
|
||||
it('should have correct fields for each status card', () => {
|
||||
for (const card of statusCardDesertData) {
|
||||
expect(card).toHaveProperty('id');
|
||||
expect(card).toHaveProperty('name');
|
||||
expect(card).toHaveProperty('desc');
|
||||
expect(card).toHaveProperty('unplayable');
|
||||
expect(card).toHaveProperty('effects');
|
||||
expect(typeof card.id).toBe('string');
|
||||
expect(typeof card.name).toBe('string');
|
||||
expect(typeof card.desc).toBe('string');
|
||||
expect(typeof card.unplayable).toBe('boolean');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have all cards unplayable', () => {
|
||||
for (const card of statusCardDesertData) {
|
||||
expect(card.unplayable).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have effects with target, effect ref, and value', () => {
|
||||
for (const card of statusCardDesertData) {
|
||||
expect(Array.isArray(card.effects)).toBe(true);
|
||||
for (const [target, effect, value] of card.effects) {
|
||||
expect(target).toBe('self');
|
||||
expect(typeof effect === 'string' || typeof effect === 'object').toBe(true);
|
||||
expect(typeof value).toBe('number');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should contain expected status cards by id', () => {
|
||||
const ids = statusCardDesertData.map(c => c.id);
|
||||
expect(ids).toContain('wound');
|
||||
expect(ids).toContain('venom');
|
||||
expect(ids).toContain('curse');
|
||||
expect(ids).toContain('static');
|
||||
describe('data import', () => {
|
||||
it('should import properly', () => {
|
||||
expect(data.desert.effects).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
import encounters from '@/samples/slay-the-spire-like/dialogue/encounters/encounters.yarnproject';
|
||||
|
||||
describe('encounters.yarnproject import', () => {
|
||||
it('should load the yarnproject with expected project metadata', () => {
|
||||
expect(encounters.project.projectName).toBe('encounters');
|
||||
expect(encounters.project.baseLanguage).toBe('en');
|
||||
expect(encounters.project.authorName).toContain('hyper');
|
||||
expect(encounters.project.projectFileVersion).toBe(4);
|
||||
});
|
||||
|
||||
it('should have sourceFiles configured', () => {
|
||||
expect(encounters.project.sourceFiles).toContain('**/*.yarn');
|
||||
});
|
||||
|
||||
it('should have a valid baseDir', () => {
|
||||
expect(typeof encounters.baseDir).toBe('string');
|
||||
expect(encounters.baseDir.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should compile nodes from .yarn files', () => {
|
||||
const nodeTitles = Object.keys(encounters.program.nodes);
|
||||
expect(nodeTitles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should contain expected nodes from story.yarn', () => {
|
||||
const nodeTitles = Object.keys(encounters.program.nodes);
|
||||
expect(nodeTitles).toContain('Start');
|
||||
expect(nodeTitles).toContain('Scene1');
|
||||
expect(nodeTitles).toContain('Scene2');
|
||||
expect(nodeTitles).toContain('Scene3');
|
||||
expect(nodeTitles).toContain('Scene4');
|
||||
});
|
||||
|
||||
it('should have instructions in each node', () => {
|
||||
for (const [title, node] of Object.entries(encounters.program.nodes)) {
|
||||
if ('instructions' in node && Array.isArray((node as any).instructions)) {
|
||||
expect((node as any).instructions.length).toBeGreaterThan(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should have Start node with jump instruction to Scene1', () => {
|
||||
const startNode = encounters.program.nodes['Start'];
|
||||
if ('instructions' in startNode) {
|
||||
const jumpInstruction = startNode.instructions.find(
|
||||
(instr) => instr.op === 'jump',
|
||||
);
|
||||
expect(jumpInstruction).toBeDefined();
|
||||
expect(jumpInstruction!.target).toBe('Scene1');
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue