a2cdf6d21c
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m19s
- Add ActiveGolem interface and activeGolems to GolemancyState - Add maxRoomDuration to all 12 golem definitions - Create golem-combat-actions.ts with pure golem combat logic (summoning, maintenance, attacks, room-duration) - Create golem-combat.ts pipeline for golem combat setup - Wire golem maintenance and attacks into processCombatTick - Wire golem summoning into advanceRoomOrFloor on room entry - Wire golem room-duration countdown into onFloorCleared callback - Update combat-actions tests for new processCombatTick signature - All 921 tests pass, all files under 400-line limit Closes #259
358 lines
13 KiB
TypeScript
358 lines
13 KiB
TypeScript
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { useGameStore } from '../stores/gameStore';
|
|
import { useManaStore, makeInitialElements } from '../stores/manaStore';
|
|
import { useCombatStore } from '../stores/combatStore';
|
|
import { usePrestigeStore } from '../stores/prestigeStore';
|
|
import { useUIStore } from '../stores/uiStore';
|
|
import { useDisciplineStore } from '../stores/discipline-slice';
|
|
import { HOURS_PER_TICK, MAX_DAY, INCURSION_START_DAY } from '../constants';
|
|
import { getFloorMaxHP } from '../utils';
|
|
|
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
|
|
function resetAllStores() {
|
|
useUIStore.setState({
|
|
paused: false,
|
|
gameOver: false,
|
|
victory: false,
|
|
logs: [],
|
|
});
|
|
|
|
useGameStore.setState({
|
|
day: 1,
|
|
hour: 0,
|
|
incursionStrength: 0,
|
|
containmentWards: 0,
|
|
initialized: true,
|
|
});
|
|
|
|
useManaStore.setState({
|
|
rawMana: 100,
|
|
meditateTicks: 0,
|
|
totalManaGathered: 0,
|
|
elements: makeInitialElements(50, {}),
|
|
});
|
|
|
|
useCombatStore.setState({
|
|
currentFloor: 1,
|
|
floorHP: getFloorMaxHP(1),
|
|
floorMaxHP: getFloorMaxHP(1),
|
|
maxFloorReached: 1,
|
|
activeSpell: 'manaBolt',
|
|
currentAction: 'meditate',
|
|
castProgress: 0,
|
|
spireMode: false,
|
|
currentRoom: { roomType: 'combat', enemies: [] },
|
|
clearedFloors: {},
|
|
climbDirection: null,
|
|
isDescending: false,
|
|
golemancy: { enabledGolems: [], summonedGolems: [], activeGolems: [], lastSummonFloor: 0 },
|
|
equipmentSpellStates: [],
|
|
comboHitCount: 0,
|
|
floorHitCount: 0,
|
|
spells: { manaBolt: { learned: true, level: 1, studyProgress: 0 } },
|
|
activityLog: [],
|
|
achievements: { unlocked: [], progress: {} },
|
|
totalSpellsCast: 0,
|
|
totalDamageDealt: 0,
|
|
totalCraftsCompleted: 0,
|
|
});
|
|
|
|
usePrestigeStore.setState({
|
|
loopCount: 0,
|
|
insight: 0,
|
|
totalInsight: 0,
|
|
loopInsight: 0,
|
|
prestigeUpgrades: {},
|
|
pactSlots: 1,
|
|
defeatedGuardians: [],
|
|
signedPacts: [],
|
|
signedPactDetails: {},
|
|
pactRitualFloor: null,
|
|
pactRitualProgress: 0,
|
|
});
|
|
|
|
useDisciplineStore.setState({
|
|
disciplines: {},
|
|
activeIds: [],
|
|
concurrentLimit: 1,
|
|
totalXP: 0,
|
|
});
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
// TICK INTEGRATION TESTS
|
|
// ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
describe('Tick Integration', () => {
|
|
beforeEach(resetAllStores);
|
|
|
|
describe('time progression', () => {
|
|
it('should advance hour by HOURS_PER_TICK', () => {
|
|
useGameStore.getState().tick();
|
|
expect(useGameStore.getState().hour).toBeCloseTo(HOURS_PER_TICK, 5);
|
|
});
|
|
|
|
it('should advance day when hour wraps past 24', () => {
|
|
// Set hour close to 24
|
|
useGameStore.setState({ hour: 23.99 });
|
|
useGameStore.getState().tick();
|
|
expect(useGameStore.getState().day).toBe(2);
|
|
expect(useGameStore.getState().hour).toBeCloseTo(23.99 + HOURS_PER_TICK - 24, 5);
|
|
});
|
|
|
|
it('should advance multiple hours over many ticks', () => {
|
|
for (let i = 0; i < 100; i++) {
|
|
useGameStore.getState().tick();
|
|
}
|
|
const expectedHour = (100 * HOURS_PER_TICK) % 24;
|
|
const expectedDay = 1 + Math.floor((100 * HOURS_PER_TICK) / 24);
|
|
expect(useGameStore.getState().day).toBe(expectedDay);
|
|
expect(useGameStore.getState().hour).toBeCloseTo(expectedHour, 5);
|
|
});
|
|
});
|
|
|
|
describe('mana regeneration', () => {
|
|
it('should increase raw mana on tick (base regen)', () => {
|
|
useManaStore.setState({ rawMana: 50 });
|
|
useGameStore.getState().tick();
|
|
expect(useManaStore.getState().rawMana).toBeGreaterThan(50);
|
|
});
|
|
|
|
it('should cap raw mana at max', () => {
|
|
useManaStore.setState({ rawMana: 9999 });
|
|
useGameStore.getState().tick();
|
|
// Max mana with no skills/upgrades is 100
|
|
expect(useManaStore.getState().rawMana).toBeLessThanOrEqual(100);
|
|
});
|
|
|
|
it('should increase totalManaGathered from meditation regen', () => {
|
|
// Passive meditation regen should count toward totalManaGathered
|
|
useManaStore.setState({ rawMana: 50, totalManaGathered: 5 });
|
|
useGameStore.getState().tick();
|
|
expect(useManaStore.getState().totalManaGathered).toBeGreaterThan(5);
|
|
});
|
|
|
|
it('should not count regen toward totalManaGathered when at cap', () => {
|
|
// Regen that would exceed max mana should not count toward totalManaGathered
|
|
useManaStore.setState({ rawMana: 100, totalManaGathered: 50 });
|
|
useGameStore.getState().tick();
|
|
// When rawMana equals or exceeds maxMana, no regen is added to totalManaGathered
|
|
// Use toBeCloseTo with tolerance for floating point drift from base regen calculations
|
|
expect(useManaStore.getState().totalManaGathered).toBeLessThanOrEqual(50.05);
|
|
});
|
|
});
|
|
|
|
describe('incursion penalty', () => {
|
|
it('should have no incursion before INCURSION_START_DAY', () => {
|
|
useGameStore.setState({ day: INCURSION_START_DAY - 1, hour: 23 });
|
|
useGameStore.getState().tick();
|
|
expect(useGameStore.getState().incursionStrength).toBe(0);
|
|
});
|
|
|
|
it('should apply incursion after INCURSION_START_DAY', () => {
|
|
useGameStore.setState({ day: INCURSION_START_DAY, hour: 1 });
|
|
useGameStore.getState().tick();
|
|
expect(useGameStore.getState().incursionStrength).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should reduce mana regen during incursion', () => {
|
|
// No incursion: day 1
|
|
resetAllStores();
|
|
useGameStore.setState({ day: 1, hour: 0 });
|
|
useManaStore.setState({ rawMana: 50 });
|
|
useGameStore.getState().tick();
|
|
const regenNoIncursion = useManaStore.getState().rawMana - 50;
|
|
|
|
// With incursion: day 25
|
|
resetAllStores();
|
|
useGameStore.setState({ day: 25, hour: 12 });
|
|
useManaStore.setState({ rawMana: 50 });
|
|
useGameStore.getState().tick();
|
|
const regenWithIncursion = useManaStore.getState().rawMana - 50;
|
|
|
|
expect(regenWithIncursion).toBeLessThan(regenNoIncursion);
|
|
});
|
|
});
|
|
|
|
describe('meditation', () => {
|
|
it('should increment meditateTicks when action is meditate', () => {
|
|
useCombatStore.setState({ currentAction: 'meditate' });
|
|
useGameStore.getState().tick();
|
|
expect(useManaStore.getState().meditateTicks).toBe(1);
|
|
});
|
|
|
|
it('should reset meditateTicks when action changes', () => {
|
|
useCombatStore.setState({ currentAction: 'meditate' });
|
|
useGameStore.getState().tick();
|
|
useGameStore.getState().tick();
|
|
expect(useManaStore.getState().meditateTicks).toBe(2);
|
|
|
|
useCombatStore.setState({ currentAction: 'climb' });
|
|
useGameStore.getState().tick();
|
|
expect(useManaStore.getState().meditateTicks).toBe(0);
|
|
});
|
|
|
|
it('should boost regen with meditation', () => {
|
|
// Without meditation
|
|
resetAllStores();
|
|
useCombatStore.setState({ currentAction: 'climb' });
|
|
useManaStore.setState({ rawMana: 50, meditateTicks: 100 });
|
|
useGameStore.getState().tick();
|
|
const regenNoMeditate = useManaStore.getState().rawMana - 50;
|
|
|
|
// With meditation (same ticks)
|
|
resetAllStores();
|
|
useCombatStore.setState({ currentAction: 'meditate' });
|
|
useManaStore.setState({ rawMana: 50, meditateTicks: 100 });
|
|
useGameStore.getState().tick();
|
|
const regenMeditate = useManaStore.getState().rawMana - 50;
|
|
|
|
expect(regenMeditate).toBeGreaterThan(regenNoMeditate);
|
|
});
|
|
});
|
|
|
|
describe('paused / game over', () => {
|
|
it('should not advance time when paused', () => {
|
|
useUIStore.setState({ paused: true });
|
|
const before = useGameStore.getState().hour;
|
|
useGameStore.getState().tick();
|
|
expect(useGameStore.getState().hour).toBe(before);
|
|
});
|
|
|
|
it('should not advance time when game over', () => {
|
|
useUIStore.setState({ gameOver: true });
|
|
const before = useGameStore.getState().hour;
|
|
useGameStore.getState().tick();
|
|
expect(useGameStore.getState().hour).toBe(before);
|
|
});
|
|
|
|
it('should not regenerate mana when paused', () => {
|
|
useUIStore.setState({ paused: true });
|
|
useManaStore.setState({ rawMana: 50 });
|
|
useGameStore.getState().tick();
|
|
expect(useManaStore.getState().rawMana).toBe(50);
|
|
});
|
|
});
|
|
|
|
describe('loop end', () => {
|
|
it('should trigger game over when day > MAX_DAY', () => {
|
|
useGameStore.setState({ day: MAX_DAY, hour: 23.99 });
|
|
useGameStore.getState().tick();
|
|
expect(useUIStore.getState().gameOver).toBe(true);
|
|
});
|
|
|
|
it('should set loopInsight when loop ends', () => {
|
|
useGameStore.setState({ day: MAX_DAY, hour: 23.99 });
|
|
useGameStore.getState().tick();
|
|
expect(usePrestigeStore.getState().loopInsight).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should not be victory when loop ends normally', () => {
|
|
useGameStore.setState({ day: MAX_DAY, hour: 23.99 });
|
|
useGameStore.getState().tick();
|
|
expect(useUIStore.getState().victory).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('victory condition', () => {
|
|
it('should trigger victory when floor 100 reached with signed pact', () => {
|
|
useCombatStore.setState({ maxFloorReached: 100 });
|
|
usePrestigeStore.setState({ signedPacts: [100] });
|
|
useGameStore.getState().tick();
|
|
expect(useUIStore.getState().gameOver).toBe(true);
|
|
expect(useUIStore.getState().victory).toBe(true);
|
|
});
|
|
|
|
it('should not trigger victory without signed pact', () => {
|
|
useCombatStore.setState({ maxFloorReached: 100 });
|
|
usePrestigeStore.setState({ signedPacts: [] });
|
|
useGameStore.getState().tick();
|
|
expect(useUIStore.getState().victory).toBe(false);
|
|
});
|
|
|
|
it('should not trigger victory without floor 100', () => {
|
|
useCombatStore.setState({ maxFloorReached: 99 });
|
|
usePrestigeStore.setState({ signedPacts: [100] });
|
|
useGameStore.getState().tick();
|
|
expect(useUIStore.getState().victory).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('pact ritual progress', () => {
|
|
it('should advance pact ritual progress on tick', () => {
|
|
usePrestigeStore.setState({ pactRitualFloor: 10, pactRitualProgress: 0 });
|
|
useGameStore.getState().tick();
|
|
expect(usePrestigeStore.getState().pactRitualProgress).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should not advance pact ritual when not active', () => {
|
|
usePrestigeStore.setState({ pactRitualFloor: null });
|
|
useGameStore.getState().tick();
|
|
expect(usePrestigeStore.getState().pactRitualProgress).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe('multiple ticks', () => {
|
|
it('should accumulate mana over multiple ticks', () => {
|
|
useManaStore.setState({ rawMana: 10 });
|
|
for (let i = 0; i < 50; i++) {
|
|
useGameStore.getState().tick();
|
|
}
|
|
expect(useManaStore.getState().rawMana).toBeGreaterThan(10);
|
|
});
|
|
|
|
it('should advance time correctly over many ticks', () => {
|
|
const numTicks = 625; // 625 * 0.04 = 25 hours = 1 day + 1 hour
|
|
for (let i = 0; i < numTicks; i++) {
|
|
useGameStore.getState().tick();
|
|
}
|
|
expect(useGameStore.getState().day).toBe(2);
|
|
expect(useGameStore.getState().hour).toBeCloseTo(1, 5);
|
|
});
|
|
|
|
it('should accumulate totalManaGathered from meditation over ticks', () => {
|
|
useManaStore.setState({ rawMana: 10, totalManaGathered: 42 });
|
|
for (let i = 0; i < 10; i++) {
|
|
useGameStore.getState().tick();
|
|
}
|
|
// Passive meditation regen should now count toward totalManaGathered
|
|
expect(useManaStore.getState().totalManaGathered).toBeGreaterThan(42);
|
|
});
|
|
});
|
|
|
|
describe('incursion strength progression', () => {
|
|
it('should be near 0 at start of incursion day', () => {
|
|
// At INCURSION_START_DAY hour 0, incursion is 0, but tick advances hour first
|
|
// so after tick, hour=0.04 and incursion is very small but > 0
|
|
useGameStore.setState({ day: INCURSION_START_DAY, hour: 0 });
|
|
useGameStore.getState().tick();
|
|
// After tick, hour advanced by HOURS_PER_TICK, so incursion is small
|
|
expect(useGameStore.getState().incursionStrength).toBeGreaterThanOrEqual(0);
|
|
expect(useGameStore.getState().incursionStrength).toBeLessThan(0.01);
|
|
});
|
|
|
|
it('should increase over the course of the incursion', () => {
|
|
// Early incursion
|
|
resetAllStores();
|
|
useGameStore.setState({ day: INCURSION_START_DAY, hour: 1 });
|
|
useGameStore.getState().tick();
|
|
const early = useGameStore.getState().incursionStrength;
|
|
|
|
// Late incursion
|
|
resetAllStores();
|
|
useGameStore.setState({ day: MAX_DAY, hour: 23 });
|
|
useGameStore.getState().tick();
|
|
const late = useGameStore.getState().incursionStrength;
|
|
|
|
expect(late).toBeGreaterThan(early);
|
|
});
|
|
|
|
it('should cap at 0.95', () => {
|
|
useGameStore.setState({ day: MAX_DAY, hour: 23.99 });
|
|
useGameStore.getState().tick();
|
|
expect(useGameStore.getState().incursionStrength).toBeLessThanOrEqual(0.95);
|
|
});
|
|
});
|
|
});
|