test: add cross-module integration tests for tick pipeline
Build and Publish Mana Loop Docker Image / build-and-publish (push) Successful in 1m22s

Add 38 integration tests split across 4 files (all under 400 lines):
- cross-module-helpers.ts: shared resetAllStores() and tickN() utilities
- cross-module-combat-meditation.test.ts (12 tests): combat floor
  clearing, meditation regen flow, incursion effects, convert action
- cross-module-prestige-discipline.test.ts (15 tests): prestige loop
  reset, discipline mana drain/XP, pact ritual completion
- cross-module-lifecycle-consistency.test.ts (11 tests): full loop
  lifecycle, store consistency invariants, pause/gameOver blocking

All 38 new + 112 existing tests pass.
This commit is contained in:
2026-05-25 18:26:32 +02:00
parent fdf3984e75
commit 4aa12a10f0
7 changed files with 617 additions and 3 deletions
+2 -2
View File
@@ -1,8 +1,8 @@
# Circular Dependencies # Circular Dependencies
Generated: 2026-05-25T13:20:07.523Z Generated: 2026-05-25T15:37:17.998Z
Found: 6 circular chain(s) — these MUST be fixed before modifying involved files. Found: 6 circular chain(s) — these MUST be fixed before modifying involved files.
1. Processed 135 files (1.5s) (2 warnings) 1. Processed 135 files (1.7s) (2 warnings)
2. 1) utils/floor-utils.ts > utils/room-utils.ts > utils/enemy-utils.ts 2. 1) utils/floor-utils.ts > utils/room-utils.ts > utils/enemy-utils.ts
3. 2) utils/floor-utils.ts > utils/room-utils.ts 3. 2) utils/floor-utils.ts > utils/room-utils.ts
4. 3) stores/gameStore.ts > stores/gameActions.ts 4. 3) stores/gameStore.ts > stores/gameActions.ts
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"_meta": { "_meta": {
"generated": "2026-05-25T13:20:05.764Z", "generated": "2026-05-25T15:37:16.139Z",
"description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.", "description": "Import dependency graph for src/lib/game. Keys are files, values are arrays of files they import.",
"usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry." "usage": "To find what a file affects, search for its path in the VALUES. To find what a file depends on, look at its KEY entry."
}, },
+4
View File
@@ -199,6 +199,10 @@ Mana-Loop/
│ │ │ ├── crafting-utils-equipment.test.ts │ │ │ ├── crafting-utils-equipment.test.ts
│ │ │ ├── crafting-utils-recipe.test.ts │ │ │ ├── crafting-utils-recipe.test.ts
│ │ │ ├── crafting-utils-time.test.ts │ │ │ ├── crafting-utils-time.test.ts
│ │ │ ├── cross-module-combat-meditation.test.ts
│ │ │ ├── cross-module-helpers.ts
│ │ │ ├── cross-module-lifecycle-consistency.test.ts
│ │ │ ├── cross-module-prestige-discipline.test.ts
│ │ │ ├── discipline-math.test.ts │ │ │ ├── discipline-math.test.ts
│ │ │ ├── discipline-prerequisites.test.ts │ │ │ ├── discipline-prerequisites.test.ts
│ │ │ ├── enemy-barrier-utils.test.ts │ │ │ ├── enemy-barrier-utils.test.ts
@@ -0,0 +1,182 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useGameStore } from '../stores/gameStore';
import { useManaStore } from '../stores/manaStore';
import { useCombatStore } from '../stores/combatStore';
import { useUIStore } from '../stores/uiStore';
import { MAX_DAY } from '../constants';
import { getFloorMaxHP } from '../utils';
import { resetAllStores, tickN } from './cross-module-helpers';
describe('Cross-Module: Combat & Meditation', () => {
beforeEach(resetAllStores);
describe('combat floor clearing via tick', () => {
it('should advance floor when climb action deals enough damage', () => {
useManaStore.setState({ rawMana: 9999 });
useCombatStore.setState({
currentAction: 'climb',
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
activeSpell: 'manaBolt',
});
tickN(500);
expect(useCombatStore.getState().currentFloor).toBeGreaterThan(1);
const { floorHP, floorMaxHP } = useCombatStore.getState();
expect(floorHP).toBeGreaterThanOrEqual(0);
expect(floorHP).toBeLessThanOrEqual(floorMaxHP);
});
it('should not advance floor when action is meditate', () => {
useCombatStore.setState({
currentAction: 'meditate',
currentFloor: 1,
floorHP: getFloorMaxHP(1),
activeSpell: 'manaBolt',
});
tickN(50);
expect(useCombatStore.getState().currentFloor).toBe(1);
expect(useCombatStore.getState().maxFloorReached).toBe(1);
});
it('should track maxFloorReached across multiple floors', () => {
useCombatStore.setState({
currentAction: 'climb',
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
activeSpell: 'manaBolt',
});
tickN(500);
const combat = useCombatStore.getState();
expect(combat.maxFloorReached).toBeGreaterThanOrEqual(combat.currentFloor);
expect(combat.maxFloorReached).toBeGreaterThan(1);
});
it('should cap maxFloorReached at 100', () => {
useCombatStore.setState({
currentAction: 'climb',
currentFloor: 100,
floorHP: getFloorMaxHP(100),
floorMaxHP: getFloorMaxHP(100),
maxFloorReached: 100,
activeSpell: 'manaBolt',
});
tickN(100);
expect(useCombatStore.getState().currentFloor).toBe(100);
expect(useCombatStore.getState().maxFloorReached).toBe(100);
});
it('should update maxFloorReached and reduce mana after climbing', () => {
useManaStore.setState({ rawMana: 9999 });
useCombatStore.setState({
currentAction: 'climb',
currentFloor: 1,
floorHP: getFloorMaxHP(1),
floorMaxHP: getFloorMaxHP(1),
activeSpell: 'manaBolt',
});
tickN(500);
expect(useCombatStore.getState().maxFloorReached).toBeGreaterThan(1);
expect(useManaStore.getState().rawMana).toBeLessThan(9999);
expect(useGameStore.getState().day).toBeGreaterThanOrEqual(1);
});
});
describe('meditation mana regen flow', () => {
it('should increase raw mana over time while meditating', () => {
useCombatStore.setState({ currentAction: 'meditate' });
useManaStore.setState({ rawMana: 10 });
tickN(20);
expect(useManaStore.getState().rawMana).toBeGreaterThan(10);
});
it('should track meditateTicks in mana store during meditation', () => {
useCombatStore.setState({ currentAction: 'meditate' });
tickN(5);
expect(useManaStore.getState().meditateTicks).toBe(5);
});
it('should reset meditateTicks when action changes from meditate', () => {
useCombatStore.setState({ currentAction: 'meditate' });
tickN(5);
expect(useManaStore.getState().meditateTicks).toBe(5);
useCombatStore.setState({ currentAction: 'climb' });
tickN(1);
expect(useManaStore.getState().meditateTicks).toBe(0);
});
it('should boost mana regen with higher meditateTicks', () => {
resetAllStores();
useCombatStore.setState({ currentAction: 'meditate' });
useManaStore.setState({ rawMana: 10 });
tickN(5);
const after5 = useManaStore.getState().rawMana;
resetAllStores();
useCombatStore.setState({ currentAction: 'meditate' });
useManaStore.setState({ rawMana: 10 });
tickN(50);
const after50 = useManaStore.getState().rawMana;
expect(after50 - 10).toBeGreaterThan(after5 - 10);
});
});
describe('incursion strength affecting mana regen', () => {
it('should reduce mana regen during high incursion', () => {
resetAllStores();
useGameStore.setState({ day: 1, hour: 0 });
useCombatStore.setState({ currentAction: 'meditate' });
useManaStore.setState({ rawMana: 10 });
tickN(10);
const lowIncursionMana = useManaStore.getState().rawMana;
resetAllStores();
useGameStore.setState({ day: MAX_DAY, hour: 23 });
useCombatStore.setState({ currentAction: 'meditate' });
useManaStore.setState({ rawMana: 10 });
tickN(10);
const highIncursionMana = useManaStore.getState().rawMana;
expect(highIncursionMana).toBeLessThan(lowIncursionMana);
});
});
describe('convert action via tick', () => {
it('should convert raw mana to elements when action is convert', () => {
useManaStore.getState().unlockElement('fire', 0);
useManaStore.setState({ rawMana: 500 });
useCombatStore.setState({ currentAction: 'convert' });
tickN(10);
expect(useManaStore.getState().elements.fire.current).toBeGreaterThan(0);
});
it('should increase element mana when converting', () => {
useManaStore.getState().unlockElement('fire', 0);
useManaStore.setState({ rawMana: 500 });
useCombatStore.setState({ currentAction: 'convert' });
tickN(10);
expect(useManaStore.getState().elements.fire.current).toBeGreaterThan(0);
});
});
});
@@ -0,0 +1,114 @@
import { useGameStore } from '../stores/gameStore';
import { useManaStore, makeInitialElements } from '../stores/manaStore';
import { useCombatStore, makeInitialSpells } from '../stores/combatStore';
import { usePrestigeStore } from '../stores/prestigeStore';
import { useUIStore } from '../stores/uiStore';
import { useDisciplineStore } from '../stores/discipline-slice';
import { useAttunementStore } from '../stores/attunementStore';
import { useCraftingStore } from '../stores/craftingStore';
import { getFloorMaxHP } from '../utils';
export 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: [], lastSummonFloor: 0 },
equipmentSpellStates: [],
comboHitCount: 0,
floorHitCount: 0,
spells: makeInitialSpells(),
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,
processedPerks: [],
});
useAttunementStore.setState({
attunements: {},
});
useCraftingStore.setState({
designProgress: null,
designProgress2: null,
preparationProgress: null,
applicationProgress: null,
equipmentCraftingProgress: null,
enchantmentDesigns: [],
unlockedEffects: [],
equippedInstances: {},
equipmentInstances: {},
lootInventory: {
materials: {},
blueprints: [],
},
enchantmentSelection: {
selectedEquipmentType: null,
selectedEffects: [],
designName: '',
selectedDesign: null,
selectedEquipmentInstance: null,
},
lastError: null,
});
}
export function tickN(n: number) {
for (let i = 0; i < n; i++) {
useGameStore.getState().tick();
}
}
@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useGameStore } from '../stores/gameStore';
import { useManaStore } from '../stores/manaStore';
import { useCombatStore } from '../stores/combatStore';
import { usePrestigeStore } from '../stores/prestigeStore';
import { useUIStore } from '../stores/uiStore';
import { MAX_DAY } from '../constants';
import { resetAllStores, tickN } from './cross-module-helpers';
describe('Cross-Module: Lifecycle & Consistency', () => {
beforeEach(resetAllStores);
describe('full loop lifecycle', () => {
it('should progress through multiple days without errors', () => {
expect(() => {
tickN(1000);
}).not.toThrow();
});
it('should end the loop when day exceeds MAX_DAY', () => {
useGameStore.setState({ day: MAX_DAY, hour: 23.9 });
tickN(5);
expect(useUIStore.getState().gameOver).toBe(true);
expect(usePrestigeStore.getState().loopInsight).toBeGreaterThan(0);
});
it('should generate log messages during the loop', () => {
usePrestigeStore.setState({ defeatedGuardians: [10], insight: 1000 });
useCombatStore.setState({ currentAction: 'climb' });
tickN(100);
const logs = useUIStore.getState().logs;
expect(Array.isArray(logs)).toBe(true);
});
});
describe('store consistency after many ticks', () => {
it('should keep rawMana within [0, maxMana] after many ticks', () => {
useManaStore.setState({ rawMana: 50 });
tickN(500);
const mana = useManaStore.getState().rawMana;
expect(mana).toBeGreaterThanOrEqual(0);
expect(mana).toBeLessThanOrEqual(200);
});
it('should keep floorHP within [0, floorMaxHP] after many ticks', () => {
useCombatStore.setState({ currentAction: 'climb' });
tickN(200);
const { floorHP, floorMaxHP } = useCombatStore.getState();
expect(floorHP).toBeGreaterThanOrEqual(0);
expect(floorHP).toBeLessThanOrEqual(floorMaxHP);
});
it('should keep currentFloor within [1, 100] after many ticks', () => {
useCombatStore.setState({ currentAction: 'climb' });
tickN(1000);
const { currentFloor } = useCombatStore.getState();
expect(currentFloor).toBeGreaterThanOrEqual(1);
expect(currentFloor).toBeLessThanOrEqual(100);
});
it('should keep day within [1, MAX_DAY + 1] during normal play', () => {
tickN(100);
const { day } = useGameStore.getState();
expect(day).toBeGreaterThanOrEqual(1);
expect(day).toBeLessThanOrEqual(MAX_DAY + 1);
});
it('should keep hour within [0, 24) after any number of ticks', () => {
tickN(999);
const { hour } = useGameStore.getState();
expect(hour).toBeGreaterThanOrEqual(0);
expect(hour).toBeLessThan(24);
});
it('should keep incursionStrength within [0, 0.95]', () => {
tickN(2000);
const { incursionStrength } = useGameStore.getState();
expect(incursionStrength).toBeGreaterThanOrEqual(0);
expect(incursionStrength).toBeLessThanOrEqual(0.95);
});
});
describe('pause and gameOver consistency', () => {
it('should not change any store state when paused', () => {
useUIStore.setState({ paused: true });
useManaStore.setState({ rawMana: 50 });
useCombatStore.setState({ currentFloor: 5, currentAction: 'climb' });
const manaBefore = useManaStore.getState().rawMana;
const floorBefore = useCombatStore.getState().currentFloor;
const dayBefore = useGameStore.getState().day;
tickN(10);
expect(useManaStore.getState().rawMana).toBe(manaBefore);
expect(useCombatStore.getState().currentFloor).toBe(floorBefore);
expect(useGameStore.getState().day).toBe(dayBefore);
});
it('should not change any store state when gameOver', () => {
useUIStore.setState({ gameOver: true });
useManaStore.setState({ rawMana: 50 });
const manaBefore = useManaStore.getState().rawMana;
const dayBefore = useGameStore.getState().day;
tickN(10);
expect(useManaStore.getState().rawMana).toBe(manaBefore);
expect(useGameStore.getState().day).toBe(dayBefore);
});
});
});
@@ -0,0 +1,188 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useGameStore } from '../stores/gameStore';
import { useManaStore } 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 { resetAllStores, tickN } from './cross-module-helpers';
describe('Cross-Module: Prestige & Discipline', () => {
beforeEach(resetAllStores);
describe('prestige loop reset', () => {
it('should reset game day/hour on startNewLoop', () => {
useGameStore.setState({ day: 30, hour: 12, incursionStrength: 0.5 });
usePrestigeStore.setState({ loopInsight: 100, insight: 50 });
useGameStore.getState().startNewLoop();
expect(useGameStore.getState().day).toBe(1);
expect(useGameStore.getState().hour).toBe(0);
expect(useGameStore.getState().incursionStrength).toBe(0);
});
it('should preserve insight across loop reset', () => {
usePrestigeStore.setState({ insight: 200, loopInsight: 50 });
useGameStore.getState().startNewLoop();
expect(usePrestigeStore.getState().insight).toBeGreaterThanOrEqual(250);
});
it('should increment loop count on startNewLoop', () => {
usePrestigeStore.setState({ loopCount: 2, insight: 500 });
useGameStore.getState().startNewLoop();
expect(usePrestigeStore.getState().loopCount).toBe(3);
});
it('should clear defeatedGuardians and signedPacts on new loop', () => {
usePrestigeStore.setState({
defeatedGuardians: [10, 20, 30],
signedPacts: [10],
insight: 1000,
});
useGameStore.getState().startNewLoop();
expect(usePrestigeStore.getState().defeatedGuardians).toEqual([]);
expect(usePrestigeStore.getState().signedPacts).toEqual([]);
});
it('should clear pact ritual state on new loop', () => {
usePrestigeStore.setState({
pactRitualFloor: 50,
pactRitualProgress: 10,
insight: 1000,
});
useGameStore.getState().startNewLoop();
expect(usePrestigeStore.getState().pactRitualFloor).toBeNull();
expect(usePrestigeStore.getState().pactRitualProgress).toBe(0);
});
it('should reset combat floor on new loop', () => {
usePrestigeStore.setState({ insight: 1000 });
useCombatStore.setState({ currentFloor: 50, maxFloorReached: 50 });
useGameStore.getState().startNewLoop();
expect(useCombatStore.getState().currentFloor).toBe(1);
expect(useCombatStore.getState().maxFloorReached).toBe(1);
});
it('should clear gameOver and victory flags on new loop', () => {
useUIStore.setState({ gameOver: true, victory: false });
usePrestigeStore.setState({ insight: 1000 });
useGameStore.getState().startNewLoop();
expect(useUIStore.getState().gameOver).toBe(false);
expect(useUIStore.getState().victory).toBe(false);
});
it('should reset mana on new loop', () => {
usePrestigeStore.setState({ insight: 1000 });
useManaStore.setState({
rawMana: 5,
totalManaGathered: 9999,
});
useGameStore.getState().startNewLoop();
expect(useManaStore.getState().rawMana).toBeGreaterThan(5);
expect(useManaStore.getState().totalManaGathered).toBe(0);
});
it('should preserve prestige upgrades across loop reset', () => {
usePrestigeStore.setState({
insight: 1000,
prestigeUpgrades: { manaWell: 3, spireKey: 1 },
});
useGameStore.getState().startNewLoop();
expect(usePrestigeStore.getState().prestigeUpgrades.manaWell).toBe(3);
expect(usePrestigeStore.getState().prestigeUpgrades.spireKey).toBe(1);
});
});
describe('discipline mana drain and XP via tick', () => {
it('should accrue discipline XP when enough mana', () => {
useDisciplineStore.getState().activate('raw-mastery');
useManaStore.setState({ rawMana: 99999 });
tickN(10);
expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0);
});
it('should drain raw mana for raw-type disciplines', () => {
useDisciplineStore.getState().activate('raw-mastery');
useManaStore.setState({ rawMana: 99999 });
tickN(10);
expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0);
const disc = useDisciplineStore.getState().disciplines['raw-mastery'];
expect(disc).toBeDefined();
expect(disc!.xp).toBeGreaterThan(0);
});
it('should pause discipline when insufficient mana', () => {
useDisciplineStore.getState().activate('raw-mastery');
useManaStore.setState({ rawMana: 0 });
tickN(5);
const disc = useDisciplineStore.getState().disciplines['raw-mastery'];
if (disc) {
expect(disc.paused).toBe(true);
} else {
expect(useDisciplineStore.getState().totalXP).toBe(0);
}
});
it('should respect concurrent limit', () => {
useDisciplineStore.getState().activate('raw-mastery');
useManaStore.setState({ rawMana: 99999 });
tickN(10);
expect(useDisciplineStore.getState().totalXP).toBeGreaterThan(0);
const activeIds = useDisciplineStore.getState().activeIds;
expect(activeIds.length).toBeLessThanOrEqual(
useDisciplineStore.getState().concurrentLimit,
);
});
});
describe('pact ritual completion via tick', () => {
it('should complete pact ritual after enough ticks', () => {
usePrestigeStore.setState({
defeatedGuardians: [10],
pactRitualFloor: 10,
pactRitualProgress: 0,
signedPacts: [],
pactSlots: 2,
});
tickN(500);
expect(usePrestigeStore.getState().signedPacts).toContain(10);
expect(usePrestigeStore.getState().pactRitualFloor).toBeNull();
expect(usePrestigeStore.getState().pactRitualProgress).toBe(0);
});
it('should not advance pact ritual when not active', () => {
usePrestigeStore.setState({ pactRitualFloor: null });
tickN(50);
expect(usePrestigeStore.getState().pactRitualProgress).toBe(0);
});
});
});