浏览代码

Merge pull request #5901 from IvanSavenko/adventure_spells

(somewhat) configurable adventure map spells
Ivan Savenko 3 月之前
父节点
当前提交
3b36d74882
共有 68 个文件被更改,包括 1995 次插入1097 次删除
  1. 10 9
      AI/Nullkiller/Analyzers/HeroManager.cpp
  2. 5 2
      AI/Nullkiller/Goals/AdventureSpellCast.cpp
  3. 33 25
      AI/Nullkiller/Pathfinding/AINodeStorage.cpp
  4. 4 4
      AI/Nullkiller/Pathfinding/Actions/AdventureSpellCastMovementActions.cpp
  5. 4 2
      AI/Nullkiller/Pathfinding/Actions/AdventureSpellCastMovementActions.h
  6. 2 4
      AI/Nullkiller/Pathfinding/Actions/BoatActions.cpp
  7. 6 0
      AI/Nullkiller/Pathfinding/Actions/BoatActions.h
  8. 1 1
      AI/Nullkiller/Pathfinding/Actions/TownPortalAction.cpp
  9. 3 1
      AI/Nullkiller/Pathfinding/Actions/TownPortalAction.h
  10. 28 15
      AI/Nullkiller/Pathfinding/Rules/AILayerTransitionRule.cpp
  11. 9 3
      AI/VCAI/Goals/AdventureSpellCast.cpp
  12. 18 14
      AI/VCAI/Pathfinding/AINodeStorage.cpp
  13. 2 4
      AI/VCAI/Pathfinding/Actions/BoatActions.cpp
  14. 3 1
      AI/VCAI/Pathfinding/Actions/BoatActions.h
  15. 1 1
      AI/VCAI/Pathfinding/Actions/TownPortalAction.cpp
  16. 3 1
      AI/VCAI/Pathfinding/Actions/TownPortalAction.h
  17. 17 5
      AI/VCAI/Pathfinding/Rules/AILayerTransitionRule.cpp
  18. 4 8
      client/CPlayerInterface.cpp
  19. 10 0
      client/PlayerLocalState.cpp
  20. 7 0
      client/PlayerLocalState.h
  21. 11 31
      client/adventureMap/AdventureMapInterface.cpp
  22. 1 21
      client/battle/BattleFieldController.cpp
  23. 0 3
      client/battle/BattleFieldController.h
  24. 134 137
      client/gui/CursorHandler.cpp
  25. 29 47
      client/gui/CursorHandler.h
  26. 9 8
      client/mapView/MapRendererContext.cpp
  27. 0 1
      client/mapView/MapRendererContext.h
  28. 0 1
      client/mapView/MapViewController.cpp
  29. 5 4
      client/windows/CSpellWindow.cpp
  30. 73 0
      config/cursors.json
  31. 333 1
      config/schemas/spell.json
  32. 256 91
      config/spells/adventure.json
  33. 181 2
      docs/modders/Entities_Format/Spell_Format.md
  34. 12 10
      lib/CMakeLists.txt
  35. 0 4
      lib/GameSettings.cpp
  36. 0 4
      lib/IGameSettings.h
  37. 4 0
      lib/callback/CDynLibHandler.cpp
  38. 1 1
      lib/callback/CGameInfoCallback.h
  39. 5 0
      lib/callback/EditorCallback.cpp
  40. 1 0
      lib/callback/EditorCallback.h
  41. 2 0
      lib/callback/IGameInfoCallback.h
  42. 10 10
      lib/constants/EntityIdentifiers.h
  43. 17 5
      lib/pathfinder/CPathfinder.cpp
  44. 2 6
      lib/spells/CSpellHandler.cpp
  45. 1 2
      lib/spells/CSpellHandler.h
  46. 4 27
      lib/spells/ISpellMechanics.cpp
  47. 11 1
      lib/spells/ISpellMechanics.h
  48. 46 0
      lib/spells/adventure/AdventureSpellEffect.cpp
  49. 60 0
      lib/spells/adventure/AdventureSpellEffect.h
  50. 104 54
      lib/spells/adventure/AdventureSpellMechanics.cpp
  51. 23 20
      lib/spells/adventure/AdventureSpellMechanics.h
  52. 141 0
      lib/spells/adventure/DimensionDoorEffect.cpp
  53. 37 0
      lib/spells/adventure/DimensionDoorEffect.h
  54. 0 166
      lib/spells/adventure/DimensionDoorMechanics.cpp
  55. 0 30
      lib/spells/adventure/DimensionDoorMechanics.h
  56. 84 0
      lib/spells/adventure/RemoveObjectEffect.cpp
  57. 33 0
      lib/spells/adventure/RemoveObjectEffect.h
  58. 0 78
      lib/spells/adventure/ScuttleBoatMechanics.cpp
  59. 0 28
      lib/spells/adventure/ScuttleBoatMechanics.h
  60. 43 20
      lib/spells/adventure/SummonBoatEffect.cpp
  61. 34 0
      lib/spells/adventure/SummonBoatEffect.h
  62. 27 30
      lib/spells/adventure/TownPortalEffect.cpp
  63. 15 9
      lib/spells/adventure/TownPortalEffect.h
  64. 68 0
      lib/spells/adventure/ViewWorldEffect.cpp
  65. 7 7
      lib/spells/adventure/ViewWorldEffect.h
  66. 0 90
      lib/spells/adventure/ViewWorldMechanics.cpp
  67. 0 48
      lib/spells/adventure/ViewWorldMechanics.h
  68. 1 0
      test/mock/mock_IGameInfoCallback.h

+ 10 - 9
AI/Nullkiller/Analyzers/HeroManager.cpp

@@ -12,6 +12,8 @@
 #include "../Engine/Nullkiller.h"
 #include "../../../lib/mapObjects/MapObjects.h"
 #include "../../../lib/IGameSettings.h"
+#include "../../../lib/spells/ISpellMechanics.h"
+#include "../../../lib/spells/adventure/TownPortalEffect.h"
 
 namespace NKAI
 {
@@ -210,32 +212,31 @@ float HeroManager::getFightingStrengthCached(const CGHeroInstance * hero) const
 
 float HeroManager::getMagicStrength(const CGHeroInstance * hero) const
 {
-	auto hasFly = hero->spellbookContainsSpell(SpellID::FLY);
-	auto hasTownPortal = hero->spellbookContainsSpell(SpellID::TOWN_PORTAL);
 	auto manaLimit = hero->manaLimit();
 	auto spellPower = hero->getPrimSkillLevel(PrimarySkill::SPELL_POWER);
-	auto hasEarth = hero->getSpellSchoolLevel(SpellID(SpellID::TOWN_PORTAL).toSpell()) > 0;
 
 	auto score = 0.0f;
 
+	// FIXME: this will not cover spells give by scrolls / tomes. Intended?
 	for(auto spellId : hero->getSpellsInSpellbook())
 	{
 		auto spell = spellId.toSpell();
 		auto schoolLevel = hero->getSpellSchoolLevel(spell);
+		auto townPortalEffect = spell->getAdventureMechanics().getEffectAs<TownPortalEffect>(hero);
 
 		score += (spell->getLevel() + 1) * (schoolLevel + 1) * 0.05f;
+
+		if (spell->getAdventureMechanics().givesBonus(hero, BonusType::FLYING_MOVEMENT))
+			score += 0.3;
+
+		if(townPortalEffect != nullptr && schoolLevel != 0)
+			score += 0.6f;
 	}
 
 	vstd::amin(score, 1);
 
 	score *= std::min(1.0f, spellPower / 10.0f);
 
-	if(hasFly)
-		score += 0.3f;
-
-	if(hasTownPortal && hasEarth)
-		score += 0.6f;
-
 	vstd::amin(score, 1);
 
 	score *= std::min(1.0f, manaLimit / 100.0f);

+ 5 - 2
AI/Nullkiller/Goals/AdventureSpellCast.cpp

@@ -10,6 +10,8 @@
 #include "StdInc.h"
 #include "AdventureSpellCast.h"
 #include "../AIGateway.h"
+#include "../../../lib/spells/ISpellMechanics.h"
+#include "../../../lib/spells/adventure/TownPortalEffect.h"
 
 namespace NKAI
 {
@@ -39,8 +41,9 @@ void AdventureSpellCast::accept(AIGateway * ai)
 	if(hero->mana < hero->getSpellCost(spell))
 		throw cannotFulfillGoalException("Hero has not enough mana to cast " + spell->getNameTranslated());
 
+	auto townPortalEffect = spell->getAdventureMechanics().getEffectAs<TownPortalEffect>(hero);
 
-	if(town && spellID == SpellID::TOWN_PORTAL)
+	if(town && townPortalEffect)
 	{
 		ai->selectedObject = town->id;
 
@@ -61,7 +64,7 @@ void AdventureSpellCast::accept(AIGateway * ai)
 	cb->waitTillRealize = true;
 	cb->castSpell(hero, spellID, tile);
 
-	if(town && spellID == SpellID::TOWN_PORTAL)
+	if(town && townPortalEffect)
 	{
 		// visit town
 		ai->moveHeroToTile(town->visitablePos(), hero);

+ 33 - 25
AI/Nullkiller/Pathfinding/AINodeStorage.cpp

@@ -17,6 +17,8 @@
 #include "../../../lib/pathfinder/CPathfinder.h"
 #include "../../../lib/pathfinder/PathfinderUtil.h"
 #include "../../../lib/pathfinder/PathfinderOptions.h"
+#include "../../../lib/spells/ISpellMechanics.h"
+#include "../../../lib/spells/adventure/TownPortalEffect.h"
 #include "../../../lib/IGameSettings.h"
 #include "../../../lib/CPlayerState.h"
 
@@ -1059,32 +1061,27 @@ std::vector<CGPathNode *> AINodeStorage::calculateTeleportations(
 struct TownPortalFinder
 {
 	const std::vector<CGPathNode *> & initialNodes;
-	MasteryLevel::Type townPortalSkillLevel;
-	uint64_t movementNeeded;
 	const ChainActor * actor;
 	const CGHeroInstance * hero;
 	std::vector<const CGTownInstance *> targetTowns;
 	AINodeStorage * nodeStorage;
-
-	SpellID spellID;
 	const CSpell * townPortal;
-
-	TownPortalFinder(
-		const ChainActor * actor,
-		const std::vector<CGPathNode *> & initialNodes,
-		std::vector<const CGTownInstance *> targetTowns,
-		AINodeStorage * nodeStorage)
-		:actor(actor), initialNodes(initialNodes), hero(actor->hero),
-		targetTowns(targetTowns), nodeStorage(nodeStorage)
+	uint64_t movementNeeded;
+	SpellID spellID;
+	bool townSelectionAllowed;
+
+	TownPortalFinder(const ChainActor * actor, const std::vector<CGPathNode *> & initialNodes, const std::vector<const CGTownInstance *> & targetTowns, AINodeStorage * nodeStorage, SpellID spellID)
+		: initialNodes(initialNodes)
+		, actor(actor)
+		, hero(actor->hero)
+		, targetTowns(targetTowns)
+		, nodeStorage(nodeStorage)
+		, townPortal(spellID.toSpell())
+		, spellID(spellID)
 	{
-		spellID = SpellID::TOWN_PORTAL;
-		townPortal = spellID.toSpell();
-
-		// TODO: Copy/Paste from TownPortalMechanics
-		townPortalSkillLevel = MasteryLevel::Type(hero->getSpellSchoolLevel(townPortal));
-
-		int baseCost = hero->cb->getSettings().getInteger(EGameSettings::HEROES_MOVEMENT_COST_BASE);
-		movementNeeded = baseCost * (townPortalSkillLevel >= MasteryLevel::EXPERT ? 2 : 3);
+		auto townPortalEffect = townPortal->getAdventureMechanics().getEffectAs<TownPortalEffect>(hero);
+		movementNeeded = townPortalEffect->getMovementPointsRequired();
+		townSelectionAllowed = townPortalEffect->townSelectionAllowed();
 	}
 
 	bool actorCanCastTownPortal()
@@ -1105,7 +1102,7 @@ struct TownPortalFinder
 				continue;
 			}
 
-			if(townPortalSkillLevel < MasteryLevel::ADVANCED)
+			if(!townSelectionAllowed)
 			{
 				const CGTownInstance * nearestTown = *vstd::minElementByFun(targetTowns, [&](const CGTownInstance * t) -> int
 				{
@@ -1151,7 +1148,7 @@ struct TownPortalFinder
 				DO_NOT_SAVE_TO_COMMITTED_TILES);
 
 			node->theNodeBefore = bestNode;
-			node->addSpecialAction(std::make_shared<AIPathfinding::TownPortalAction>(targetTown));
+			node->addSpecialAction(std::make_shared<AIPathfinding::TownPortalAction>(targetTown, spellID));
 		}
 
 		return nodeOptional;
@@ -1177,10 +1174,21 @@ void AINodeStorage::calculateTownPortal(
 		return; // no towns no need to run loop further
 	}
 
-	TownPortalFinder townPortalFinder(actor, initialNodes, towns, this);
-
-	if(townPortalFinder.actorCanCastTownPortal())
+	for (const auto & spell : LIBRARY->spellh->objects)
 	{
+		if (!spell || !spell->isAdventure())
+			continue;
+
+		auto townPortalEffect = spell->getAdventureMechanics().getEffectAs<TownPortalEffect>(actor->hero);
+
+		if (!townPortalEffect)
+			continue;
+
+		TownPortalFinder townPortalFinder(actor, initialNodes, towns, this, spell->id);
+
+		if(!townPortalFinder.actorCanCastTownPortal())
+			continue;
+
 		for(const CGTownInstance * targetTown : towns)
 		{
 			if(targetTown->getVisitingHero()

+ 4 - 4
AI/Nullkiller/Pathfinding/Actions/AdventureSpellCastMovementActions.cpp

@@ -28,12 +28,12 @@ namespace AIPathfinding
 		manaCost = hero->getSpellCost(spellToCast.toSpell());
 	}
 
-	WaterWalkingAction::WaterWalkingAction(const CGHeroInstance * hero)
-		:AdventureCastAction(SpellID::WATER_WALK, hero, DayFlags::WATER_WALK_CAST)
+	WaterWalkingAction::WaterWalkingAction(const CGHeroInstance * hero, SpellID spellToCast)
+		:AdventureCastAction(spellToCast, hero, DayFlags::WATER_WALK_CAST)
 	{ }
 
-	AirWalkingAction::AirWalkingAction(const CGHeroInstance * hero)
-		: AdventureCastAction(SpellID::FLY, hero, DayFlags::FLY_CAST)
+	AirWalkingAction::AirWalkingAction(const CGHeroInstance * hero, SpellID spellToCast)
+		: AdventureCastAction(spellToCast, hero, DayFlags::FLY_CAST)
 	{
 	}
 

+ 4 - 2
AI/Nullkiller/Pathfinding/Actions/AdventureSpellCastMovementActions.h

@@ -45,14 +45,16 @@ namespace AIPathfinding
 
 	class WaterWalkingAction : public AdventureCastAction
 	{
+		SpellID spellToCast;
 	public:
-		WaterWalkingAction(const CGHeroInstance * hero);
+		WaterWalkingAction(const CGHeroInstance * hero, SpellID spellToCast);
 	};
 
 	class AirWalkingAction : public AdventureCastAction
 	{
+		SpellID spellToCast;
 	public:
-		AirWalkingAction(const CGHeroInstance * hero);
+		AirWalkingAction(const CGHeroInstance * hero, SpellID spellToCast);
 	};
 }
 

+ 2 - 4
AI/Nullkiller/Pathfinding/Actions/BoatActions.cpp

@@ -92,7 +92,7 @@ namespace AIPathfinding
 
 	void SummonBoatAction::execute(AIGateway * ai, const CGHeroInstance * hero) const
 	{
-		Goals::AdventureSpellCast(hero, SpellID::SUMMON_BOAT).accept(ai);
+		Goals::AdventureSpellCast(hero, usedSpell).accept(ai);
 	}
 
 	const ChainActor * SummonBoatAction::getActor(const ChainActor * sourceActor) const
@@ -139,10 +139,8 @@ namespace AIPathfinding
 
 	int32_t SummonBoatAction::getManaCost(const CGHeroInstance * hero) const
 	{
-		SpellID summonBoat = SpellID::SUMMON_BOAT;
-
 		// FIXME: this should be hero->getSpellCost, however currently queries to bonus system are too slow
-		return summonBoat.toSpell()->getCost(0);
+		return usedSpell.toSpell()->getCost(0);
 	}
 }
 

+ 6 - 0
AI/Nullkiller/Pathfinding/Actions/BoatActions.h

@@ -24,7 +24,13 @@ namespace AIPathfinding
 	
 	class SummonBoatAction : public VirtualBoatAction
 	{
+		SpellID usedSpell;
 	public:
+		SummonBoatAction(SpellID usedSpell)
+			: usedSpell(usedSpell)
+		{
+		}
+
 		void execute(AIGateway * ai, const CGHeroInstance * hero) const override;
 
 		virtual void applyOnDestination(

+ 1 - 1
AI/Nullkiller/Pathfinding/Actions/TownPortalAction.cpp

@@ -20,7 +20,7 @@ using namespace AIPathfinding;
 
 void TownPortalAction::execute(AIGateway * ai, const CGHeroInstance * hero) const
 {
-	auto goal = Goals::AdventureSpellCast(hero, SpellID::TOWN_PORTAL);
+	auto goal = Goals::AdventureSpellCast(hero, usedSpell);
 	
 	goal.town = target;
 	goal.tile = target->visitablePos();

+ 3 - 1
AI/Nullkiller/Pathfinding/Actions/TownPortalAction.h

@@ -22,10 +22,12 @@ namespace AIPathfinding
 	{
 	private:
 		const CGTownInstance * target;
+		SpellID usedSpell;
 
 	public:
-		TownPortalAction(const CGTownInstance * target)
+		TownPortalAction(const CGTownInstance * target, SpellID usedSpell)
 			:target(target)
+			,usedSpell(usedSpell)
 		{
 		}
 

+ 28 - 15
AI/Nullkiller/Pathfinding/Rules/AILayerTransitionRule.cpp

@@ -12,6 +12,8 @@
 #include "../../Engine/Nullkiller.h"
 #include "../../../../lib/pathfinder/CPathfinder.h"
 #include "../../../../lib/pathfinder/TurnInfo.h"
+#include "../../../../lib/spells/ISpellMechanics.h"
+#include "../../../../lib/spells/adventure/SummonBoatEffect.h"
 
 namespace NKAI
 {
@@ -109,19 +111,22 @@ namespace AIPathfinding
 
 	void AILayerTransitionRule::setup()
 	{
-		SpellID waterWalk = SpellID::WATER_WALK;
-		SpellID airWalk = SpellID::FLY;
-
 		for(const CGHeroInstance * hero : nodeStorage->getAllHeroes())
 		{
-			if(hero->canCastThisSpell(waterWalk.toSpell()) && hero->mana >= hero->getSpellCost(waterWalk.toSpell()))
+			for (const auto & spell : LIBRARY->spellh->objects)
 			{
-				waterWalkingActions[hero] = std::make_shared<WaterWalkingAction>(hero);
-			}
+				if (!spell || !spell->isAdventure())
+					continue;
 
-			if(hero->canCastThisSpell(airWalk.toSpell()) && hero->mana >= hero->getSpellCost(airWalk.toSpell()))
-			{
-				airWalkingActions[hero] = std::make_shared<AirWalkingAction>(hero);
+				if(spell->getAdventureMechanics().givesBonus(hero, BonusType::WATER_WALKING) && hero->canCastThisSpell(spell.get()) && hero->mana >= hero->getSpellCost(spell.get()))
+				{
+					waterWalkingActions[hero] = std::make_shared<WaterWalkingAction>(hero, spell->id);
+				}
+
+				if(spell->getAdventureMechanics().givesBonus(hero, BonusType::FLYING_MOVEMENT) && hero->canCastThisSpell(spell.get()) && hero->mana >= hero->getSpellCost(spell.get()))
+				{
+					airWalkingActions[hero] = std::make_shared<AirWalkingAction>(hero, spell->id);
+				}
 			}
 		}
 
@@ -159,13 +164,21 @@ namespace AIPathfinding
 
 		for(const CGHeroInstance * hero : nodeStorage->getAllHeroes())
 		{
-			auto summonBoatSpell = SpellID(SpellID::SUMMON_BOAT).toSpell();
-
-			if(hero->canCastThisSpell(summonBoatSpell)
-				&& hero->getSpellSchoolLevel(summonBoatSpell) >= MasteryLevel::ADVANCED)
+			for (const auto & spell : LIBRARY->spellh->objects)
 			{
-				// TODO: For lower school level we might need to check the existence of some boat
-				summonableVirtualBoats[hero] = std::make_shared<SummonBoatAction>();
+				if (!spell || !spell->isAdventure())
+					continue;
+
+				auto effect = spell->getAdventureMechanics().getEffectAs<SummonBoatEffect>(hero);
+
+				if (!effect || !hero->canCastThisSpell(spell.get()))
+					continue;
+
+				if (effect->canCreateNewBoat() && effect->getSuccessChance(hero) == 100)
+				{
+					// TODO: For lower school level we might need to check the existence of some boat
+					summonableVirtualBoats[hero] = std::make_shared<SummonBoatAction>(spell->id);
+				}
 			}
 		}
 	}

+ 9 - 3
AI/VCAI/Goals/AdventureSpellCast.cpp

@@ -13,6 +13,8 @@
 #include "../FuzzyHelper.h"
 #include "../AIhelper.h"
 #include "../../../lib/mapObjects/CGTownInstance.h"
+#include "../../../lib/spells/ISpellMechanics.h"
+#include "../../../lib/spells/adventure/TownPortalEffect.h"
 
 using namespace Goals;
 
@@ -39,7 +41,9 @@ TSubgoal AdventureSpellCast::whatToDoToAchieve()
 	if(hero->mana < hero->getSpellCost(spell))
 		throw cannotFulfillGoalException("Hero has not enough mana to cast " + spell->getNameTranslated());
 
-	if(spellID == SpellID::TOWN_PORTAL && town && town->getVisitingHero())
+	auto townPortalEffect = spell->getAdventureMechanics().getEffectAs<TownPortalEffect>(hero.h);
+
+	if(townPortalEffect && town && town->getVisitingHero())
 		throw cannotFulfillGoalException("The town is already occupied by " + town->getVisitingHero()->getNameTranslated());
 
 	return iAmElementar();
@@ -47,7 +51,9 @@ TSubgoal AdventureSpellCast::whatToDoToAchieve()
 
 void AdventureSpellCast::accept(VCAI * ai)
 {
-	if(town && spellID == SpellID::TOWN_PORTAL)
+	auto townPortalEffect = spellID.toSpell()->getAdventureMechanics().getEffectAs<TownPortalEffect>(hero.h);
+
+	if(town && townPortalEffect)
 	{
 		ai->selectedObject = town->id;
 	}
@@ -57,7 +63,7 @@ void AdventureSpellCast::accept(VCAI * ai)
 	cb->waitTillRealize = true;
 	cb->castSpell(hero.h, spellID, tile);
 
-	if(town && spellID == SpellID::TOWN_PORTAL)
+	if(town && townPortalEffect)
 	{
 		// visit town
 		ai->moveHeroToTile(town->visitablePos(), hero);

+ 18 - 14
AI/VCAI/Pathfinding/AINodeStorage.cpp

@@ -15,6 +15,8 @@
 #include "../../../lib/pathfinder/CPathfinder.h"
 #include "../../../lib/pathfinder/PathfinderOptions.h"
 #include "../../../lib/pathfinder/PathfinderUtil.h"
+#include "../../../lib/spells/ISpellMechanics.h"
+#include "../../../lib/spells/adventure/TownPortalEffect.h"
 #include "../../../lib/IGameSettings.h"
 #include "../../../lib/CPlayerState.h"
 
@@ -225,12 +227,21 @@ void AINodeStorage::calculateTownPortalTeleportations(
 	const PathNodeInfo & source,
 	std::vector<CGPathNode *> & neighbours)
 {
-	SpellID spellID = SpellID::TOWN_PORTAL;
-	const CSpell * townPortal = spellID.toSpell();
 	auto srcNode = getAINode(source.node);
 
-	if(hero->canCastThisSpell(townPortal) && hero->mana >= hero->getSpellCost(townPortal))
+	for (const auto & spell : LIBRARY->spellh->objects)
 	{
+		if (!spell || !spell->isAdventure())
+			continue;
+
+		auto townPortalEffect = spell->getAdventureMechanics().getEffectAs<TownPortalEffect>(hero);
+
+		if (!townPortalEffect)
+			continue;
+
+		if(!hero->canCastThisSpell(spell.get()) || hero->mana < hero->getSpellCost(spell.get()))
+			continue;
+
 		auto towns = cb->getTownsInfo(false);
 
 		vstd::erase_if(towns, [&](const CGTownInstance * t) -> bool
@@ -238,22 +249,15 @@ void AINodeStorage::calculateTownPortalTeleportations(
 			return cb->getPlayerRelations(hero->tempOwner, t->tempOwner) == PlayerRelations::ENEMIES;
 		});
 
-		if(!towns.size())
-		{
+		if(towns.empty())
 			return;
-		}
-
-		// TODO: Copy/Paste from TownPortalMechanics
-		auto skillLevel = hero->getSpellSchoolLevel(townPortal);
-		int baseCost = hero->cb->getSettings().getInteger(EGameSettings::HEROES_MOVEMENT_COST_BASE);
-		auto movementCost = baseCost * (skillLevel >= 3 ? 2 : 3);
 
-		if(hero->movementPointsRemaining() < movementCost)
+		if(hero->movementPointsRemaining() < townPortalEffect->getMovementPointsRequired())
 		{
 			return;
 		}
 
-		if(skillLevel < MasteryLevel::ADVANCED)
+		if(!townPortalEffect->townSelectionAllowed())
 		{
 			const CGTownInstance * nearestTown = *vstd::minElementByFun(towns, [&](const CGTownInstance * t) -> int
 			{
@@ -279,7 +283,7 @@ void AINodeStorage::calculateTownPortalTeleportations(
 				AIPathNode * node = nodeOptional.value();
 
 				node->theNodeBefore = source.node;
-				node->specialAction.reset(new AIPathfinding::TownPortalAction(targetTown));
+				node->specialAction.reset(new AIPathfinding::TownPortalAction(targetTown, spell->id));
 				node->moveRemains = source.node->moveRemains;
 				
 				neighbours.push_back(node);

+ 2 - 4
AI/VCAI/Pathfinding/Actions/BoatActions.cpp

@@ -23,7 +23,7 @@ namespace AIPathfinding
 
 	Goals::TSubgoal SummonBoatAction::whatToDo(const HeroPtr & hero) const
 	{
-		return Goals::sptr(Goals::AdventureSpellCast(hero, SpellID::SUMMON_BOAT));
+		return Goals::sptr(Goals::AdventureSpellCast(hero, usedSpell));
 	}
 
 	void SummonBoatAction::applyOnDestination(
@@ -53,8 +53,6 @@ namespace AIPathfinding
 
 	uint32_t SummonBoatAction::getManaCost(const CGHeroInstance * hero) const
 	{
-		SpellID summonBoat = SpellID::SUMMON_BOAT;
-
-		return hero->getSpellCost(summonBoat.toSpell());
+		return hero->getSpellCost(usedSpell.toSpell());
 	}
 }

+ 3 - 1
AI/VCAI/Pathfinding/Actions/BoatActions.h

@@ -34,9 +34,11 @@ namespace AIPathfinding
 	
 	class SummonBoatAction : public VirtualBoatAction
 	{
+		SpellID usedSpell;
 	public:
-		SummonBoatAction()
+		SummonBoatAction(SpellID usedSpell)
 			:VirtualBoatAction(AINodeStorage::CAST_CHAIN)
+			,usedSpell(usedSpell)
 		{
 		}
 

+ 1 - 1
AI/VCAI/Pathfinding/Actions/TownPortalAction.cpp

@@ -19,5 +19,5 @@ Goals::TSubgoal TownPortalAction::whatToDo(const HeroPtr & hero) const
 {
 	const CGTownInstance * targetTown = target; // const pointer is not allowed in settown
 
-	return Goals::sptr(Goals::AdventureSpellCast(hero, SpellID::TOWN_PORTAL).settown(targetTown).settile(targetTown->visitablePos()));
+	return Goals::sptr(Goals::AdventureSpellCast(hero, spellToUse).settown(targetTown).settile(targetTown->visitablePos()));
 }

+ 3 - 1
AI/VCAI/Pathfinding/Actions/TownPortalAction.h

@@ -20,10 +20,12 @@ namespace AIPathfinding
 	{
 	private:
 		const CGTownInstance * target;
+		SpellID spellToUse;
 
 	public:
-		TownPortalAction(const CGTownInstance * target)
+		TownPortalAction(const CGTownInstance * target, SpellID spellToUse)
 			:target(target)
+			,spellToUse(spellToUse)
 		{
 		}
 

+ 17 - 5
AI/VCAI/Pathfinding/Rules/AILayerTransitionRule.cpp

@@ -10,6 +10,9 @@
 #include "StdInc.h"
 #include "AILayerTransitionRule.h"
 
+#include "../../../../lib/spells/ISpellMechanics.h"
+#include "../../../../lib/spells/adventure/SummonBoatEffect.h"
+
 namespace AIPathfinding
 {
 	AILayerTransitionRule::AILayerTransitionRule(CPlayerSpecificInfoCallback * cb, VCAI * ai, std::shared_ptr<AINodeStorage> nodeStorage)
@@ -74,13 +77,22 @@ namespace AIPathfinding
 		}
 
 		auto hero = nodeStorage->getHero();
-		auto summonBoatSpell = SpellID(SpellID::SUMMON_BOAT).toSpell();
 
-		if(hero->canCastThisSpell(summonBoatSpell)
-			&& hero->getSpellSchoolLevel(summonBoatSpell) >= MasteryLevel::ADVANCED)
+		for (const auto & spell : LIBRARY->spellh->objects)
 		{
-			// TODO: For lower school level we might need to check the existence of some boat
-			summonableVirtualBoat.reset(new SummonBoatAction());
+			if (!spell || !spell->isAdventure())
+				continue;
+
+			auto effect = spell->getAdventureMechanics().getEffectAs<SummonBoatEffect>(hero);
+
+			if (!effect || !hero->canCastThisSpell(spell.get()))
+				continue;
+
+			if (effect->canCreateNewBoat() && effect->getSuccessChance(hero) == 100)
+			{
+				// TODO: For lower school level we might need to check the existence of some boat
+				summonableVirtualBoat.reset(new SummonBoatAction(spell->id));
+			}
 		}
 	}
 

+ 4 - 8
client/CPlayerInterface.cpp

@@ -1234,11 +1234,10 @@ void CPlayerInterface::heroBonusChanged( const CGHeroInstance *hero, const Bonus
 		return;
 
 	adventureInt->onHeroChanged(hero);
-	if ((bonus.type == BonusType::FLYING_MOVEMENT || bonus.type == BonusType::WATER_WALKING) && !gain)
-	{
-		//recalculate paths because hero has lost bonus influencing pathfinding
-		localState->erasePath(hero);
-	}
+
+	//recalculate paths because hero has lost or gained bonus influencing pathfinding
+	if (bonus.type == BonusType::FLYING_MOVEMENT || bonus.type == BonusType::WATER_WALKING || bonus.type == BonusType::ROUGH_TERRAIN_DISCOUNT || bonus.type == BonusType::NO_TERRAIN_PENALTY)
+		localState->verifyPath(hero);
 }
 
 void CPlayerInterface::moveHero( const CGHeroInstance *h, const CGPath& path )
@@ -1602,9 +1601,6 @@ void CPlayerInterface::advmapSpellCast(const CGHeroInstance * caster, SpellID sp
 	if(ENGINE->windows().topWindow<CSpellWindow>())
 		ENGINE->windows().popWindows(1);
 
-	if(spellID == SpellID::FLY || spellID == SpellID::WATER_WALK)
-		localState->erasePath(caster);
-
 	auto castSoundPath = spellID.toSpell()->getCastSound();
 	if(!castSoundPath.empty())
 		ENGINE->sound().playSound(castSoundPath);

+ 10 - 0
client/PlayerLocalState.cpp

@@ -91,6 +91,16 @@ void PlayerLocalState::verifyPath(const CGHeroInstance * h)
 	setPath(h, getPath(h).endPos());
 }
 
+SpellID PlayerLocalState::getCurrentSpell() const
+{
+	return currentSpell;
+}
+
+void PlayerLocalState::setCurrentSpell(SpellID castedSpell)
+{
+	currentSpell = castedSpell;
+}
+
 const CGHeroInstance * PlayerLocalState::getCurrentHero() const
 {
 	if(currentSelection && currentSelection->ID == Obj::HERO)

+ 7 - 0
client/PlayerLocalState.h

@@ -49,6 +49,8 @@ class PlayerLocalState
 
 	PlayerSpellbookSetting spellbookSettings;
 
+	SpellID currentSpell;
+
 	void syncronizeState();
 public:
 
@@ -89,6 +91,11 @@ public:
 	const CGTownInstance * getCurrentTown() const;
 	const CArmedInstance * getCurrentArmy() const;
 
+	// returns currently cast spell, if any
+	SpellID getCurrentSpell() const;
+
+	void setCurrentSpell(SpellID castedSpell);
+
 	void serialize(JsonNode & dest) const;
 	void deserialize(const JsonNode & source);
 

+ 11 - 31
client/adventureMap/AdventureMapInterface.cpp

@@ -47,6 +47,7 @@
 #include "../../lib/mapObjects/CGTownInstance.h"
 #include "../../lib/pathfinder/CGPathNode.h"
 #include "../../lib/pathfinder/TurnInfo.h"
+#include "../../lib/spells/adventure/AdventureSpellEffect.h"
 #include "../../lib/spells/ISpellMechanics.h"
 #include "../../lib/spells/Problem.h"
 
@@ -515,7 +516,6 @@ void AdventureMapInterface::onTileLeftClicked(const int3 &targetPosition)
 	if(spellBeingCasted)
 	{
 		assert(shortcuts->optionSpellcasting());
-		assert(spellBeingCasted->id == SpellID::SCUTTLE_BOAT || spellBeingCasted->id == SpellID::DIMENSION_DOOR);
 
 		if(isValidAdventureSpellTarget(targetPosition))
 			performSpellcasting(targetPosition);
@@ -613,31 +613,16 @@ void AdventureMapInterface::onTileHovered(const int3 &targetPosition)
 
 	if(spellBeingCasted)
 	{
-		switch(spellBeingCasted->id.toEnum())
-		{
-		case SpellID::SCUTTLE_BOAT:
-			if(isValidAdventureSpellTarget(targetPosition))
-				ENGINE->cursor().set(Cursor::Map::SCUTTLE_BOAT);
-			else
-				ENGINE->cursor().set(Cursor::Map::POINTER);
-			return;
+		const auto * hero = GAME->interface()->localState->getCurrentHero();
+		const auto * spellEffect = spellBeingCasted->getAdventureMechanics().getEffectAs<AdventureSpellRangedEffect>(hero);
+		spells::detail::ProblemImpl problem;
 
-		case SpellID::DIMENSION_DOOR:
-			if(isValidAdventureSpellTarget(targetPosition))
-			{
-				if(GAME->interface()->cb->getSettings().getBoolean(EGameSettings::DIMENSION_DOOR_TRIGGERS_GUARDS) && GAME->interface()->cb->isTileGuardedUnchecked(targetPosition))
-					ENGINE->cursor().set(Cursor::Map::T1_ATTACK);
-				else
-					ENGINE->cursor().set(Cursor::Map::TELEPORT);
-				return;
-			}
-			else
-				ENGINE->cursor().set(Cursor::Map::POINTER);
-			return;
-		default:
+		if(spellEffect && spellEffect->canBeCastAtImpl(problem, GAME->interface()->cb.get(), hero, targetPosition))
+			ENGINE->cursor().set(spellEffect->getCursorForTarget(GAME->interface()->cb.get(), hero, targetPosition));
+		else
 			ENGINE->cursor().set(Cursor::Map::POINTER);
-			return;
-		}
+
+		return;
 	}
 
 	if(!isTargetPositionVisible)
@@ -839,11 +824,8 @@ void AdventureMapInterface::onTileRightClicked(const int3 &mapPos)
 
 void AdventureMapInterface::enterCastingMode(const CSpell * sp)
 {
-	assert(sp->id == SpellID::SCUTTLE_BOAT || sp->id == SpellID::DIMENSION_DOOR);
 	spellBeingCasted = sp;
-	Settings config = settings.write["session"]["showSpellRange"];
-	config->Bool() = true;
-
+	GAME->interface()->localState->setCurrentSpell(sp->id);
 	setState(EAdventureState::CASTING_SPELL);
 }
 
@@ -852,9 +834,7 @@ void AdventureMapInterface::exitCastingMode()
 	assert(spellBeingCasted);
 	spellBeingCasted = nullptr;
 	setState(EAdventureState::MAKING_TURN);
-
-	Settings config = settings.write["session"]["showSpellRange"];
-	config->Bool() = false;
+	GAME->interface()->localState->setCurrentSpell(SpellID::NONE);
 }
 
 void AdventureMapInterface::hotkeyAbortCastingMode()

+ 1 - 21
client/battle/BattleFieldController.cpp

@@ -117,9 +117,6 @@ BattleFieldController::BattleFieldController(BattleInterface & owner):
 	cellUnitMovementHighlight = ENGINE->renderHandler().loadImage(ImagePath::builtin("UnitMovementHighlight.PNG"), EImageBlitMode::COLORKEY);
 	cellUnitMaxMovementHighlight = ENGINE->renderHandler().loadImage(ImagePath::builtin("UnitMaxMovementHighlight.PNG"), EImageBlitMode::COLORKEY);
 
-	attackCursors = ENGINE->renderHandler().loadAnimation(AnimationPath::builtin("CRCOMBAT"), EImageBlitMode::COLORKEY);
-	spellCursors = ENGINE->renderHandler().loadAnimation(AnimationPath::builtin("CRSPELL"), EImageBlitMode::COLORKEY);
-
 	rangedFullDamageLimitImages = ENGINE->renderHandler().loadAnimation(AnimationPath::builtin("battle/rangeHighlights/rangeHighlightsGreen.json"), EImageBlitMode::COLORKEY);
 	shootingRangeLimitImages = ENGINE->renderHandler().loadAnimation(AnimationPath::builtin("battle/rangeHighlights/rangeHighlightsRed.json"), EImageBlitMode::COLORKEY);
 
@@ -861,24 +858,7 @@ void BattleFieldController::show(Canvas & to)
 	renderBattlefield(to);
 
 	if (isActive() && isGesturing() && getHoveredHex() != BattleHex::INVALID)
-	{
-		auto combatCursorIndex = ENGINE->cursor().get<Cursor::Combat>();
-		if (combatCursorIndex)
-		{
-			auto combatImageIndex = static_cast<size_t>(*combatCursorIndex);
-			to.draw(attackCursors->getImage(combatImageIndex), hexPositionAbsolute(getHoveredHex()).center() - ENGINE->cursor().getPivotOffsetCombat(combatImageIndex));
-			return;
-		}
-
-		auto spellCursorIndex = ENGINE->cursor().get<Cursor::Spellcast>();
-		if (spellCursorIndex)
-		{
-			auto spellImageIndex = static_cast<size_t>(*spellCursorIndex);
-			to.draw(spellCursors->getImage(spellImageIndex), hexPositionAbsolute(getHoveredHex()).center() - ENGINE->cursor().getPivotOffsetSpellcast());
-			return;
-		}
-
-	}
+		to.draw(ENGINE->cursor().getCurrentImage(), hexPositionAbsolute(getHoveredHex()).center() - ENGINE->cursor().getPivotOffset());
 }
 
 bool BattleFieldController::receiveEvent(const Point & position, int eventType) const

+ 0 - 3
client/battle/BattleFieldController.h

@@ -37,9 +37,6 @@ class BattleFieldController : public CIntObject
 	std::shared_ptr<CAnimation> rangedFullDamageLimitImages;
 	std::shared_ptr<CAnimation> shootingRangeLimitImages;
 
-	std::shared_ptr<CAnimation> attackCursors;
-	std::shared_ptr<CAnimation> spellCursors;
-
 	/// Canvas that contains background, hex grid (if enabled), absolute obstacles and movement range of active stack
 	std::unique_ptr<Canvas> backgroundWithHexes;
 

+ 134 - 137
client/gui/CursorHandler.cpp

@@ -21,6 +21,7 @@
 #include "../render/IRenderHandler.h"
 
 #include "../../lib/CConfigHandler.h"
+#include "../../lib/json/JsonUtils.h"
 
 std::unique_ptr<ICursor> CursorHandler::createCursor()
 {
@@ -42,7 +43,6 @@ CursorHandler::CursorHandler()
 	, showing(false)
 	, pos(0,0)
 	, dndObject(nullptr)
-	, type(Cursor::Type::DEFAULT)
 {
 	showType = dynamic_cast<CursorSoftware *>(cursor.get()) ? Cursor::ShowType::SOFTWARE : Cursor::ShowType::HARDWARE;
 }
@@ -51,49 +51,152 @@ CursorHandler::~CursorHandler() = default;
 
 void CursorHandler::init()
 {
-	cursors =
+	JsonNode cursorConfig = JsonUtils::assembleFromFiles("config/cursors.json");
+	std::vector<AnimationPath> animations;
+
+	for (const auto & cursorEntry : cursorConfig.Struct())
 	{
-			ENGINE->renderHandler().loadAnimation(AnimationPath::builtin("CRADVNTR"), EImageBlitMode::COLORKEY),
-			ENGINE->renderHandler().loadAnimation(AnimationPath::builtin("CRCOMBAT"), EImageBlitMode::COLORKEY),
-			ENGINE->renderHandler().loadAnimation(AnimationPath::builtin("CRDEFLT"), EImageBlitMode::COLORKEY),
-			ENGINE->renderHandler().loadAnimation(AnimationPath::builtin("CRSPELL"), EImageBlitMode::COLORKEY)
-	};
+		CursorParameters parameters;
+		parameters.cursorID = cursorEntry.first;
+		parameters.image = ImagePath::fromJson(cursorEntry.second["image"]);
+		parameters.animation = AnimationPath::fromJson(cursorEntry.second["animation"]);
+		parameters.animationFrameIndex = cursorEntry.second["frame"].Integer();
+		parameters.isAnimated = cursorEntry.second["animated"].Bool();
+		parameters.pivot.x = cursorEntry.second["pivotX"].Integer();
+		parameters.pivot.y = cursorEntry.second["pivotY"].Integer();
+
+		cursors.push_back(parameters);
+	}
 
 	set(Cursor::Map::POINTER);
 }
 
-void CursorHandler::changeGraphic(Cursor::Type type, size_t index)
+void CursorHandler::set(const std::string & index)
 {
 	assert(dndObject == nullptr);
 
-	if (type == this->type && index == this->frame)
+	if (index == currentCursorID)
 		return;
 
-	this->type = type;
-	this->frame = index;
+	currentCursorID = index;
+	currentCursorIndex = 0;
+	frameTime = 0;
+	for (size_t i = 0; i < cursors.size(); ++i)
+	{
+		if (cursors[i].cursorID == index)
+		{
+			currentCursorIndex = i;
+			break;
+		}
+	}
 
-	cursor->setImage(getCurrentImage(), getPivotOffset());
-}
+	const auto & currentCursor = cursors.at(currentCursorIndex);
 
-void CursorHandler::set(Cursor::Default index)
-{
-	changeGraphic(Cursor::Type::DEFAULT, static_cast<size_t>(index));
+	if (currentCursor.image.empty())
+	{
+		if (!loadedAnimations.count(currentCursor.animation))
+			loadedAnimations[currentCursor.animation] = ENGINE->renderHandler().loadAnimation(currentCursor.animation, EImageBlitMode::COLORKEY);
+
+		if (currentCursor.isAnimated)
+			cursorImage = loadedAnimations[currentCursor.animation]->getImage(0);
+		else
+			cursorImage = loadedAnimations[currentCursor.animation]->getImage(currentCursor.animationFrameIndex);
+	}
+	else
+	{
+		if (!loadedImages.count(currentCursor.image))
+			loadedImages[currentCursor.image] = ENGINE->renderHandler().loadImage(currentCursor.image, EImageBlitMode::COLORKEY);
+		cursorImage = loadedImages[currentCursor.image];
+	}
+
+	cursor->setImage(getCurrentImage(), getPivotOffset());
 }
 
 void CursorHandler::set(Cursor::Map index)
 {
-	changeGraphic(Cursor::Type::ADVENTURE, static_cast<size_t>(index));
+	constexpr std::array mapCursorNames =
+	{
+		"mapPointer",
+		"mapHourglass",
+		"mapHero",
+		"mapTown",
+		"mapTurn1Move",
+		"mapTurn1Attack",
+		"mapTurn1Sail",
+		"mapTurn1Disembark",
+		"mapTurn1Exchange",
+		"mapTurn1Visit",
+		"mapTurn2Move",
+		"mapTurn2Attack",
+		"mapTurn2Sail",
+		"mapTurn2Disembark",
+		"mapTurn2Exchange",
+		"mapTurn2Visit",
+		"mapTurn3Move",
+		"mapTurn3Attack",
+		"mapTurn3Sail",
+		"mapTurn3Disembark",
+		"mapTurn3Exchange",
+		"mapTurn3Visit",
+		"mapTurn4Move",
+		"mapTurn4Attack",
+		"mapTurn4Sail",
+		"mapTurn4Disembark",
+		"mapTurn4Exchange",
+		"mapTurn4Visit",
+		"mapTurn1SailVisit",
+		"mapTurn2SailVisit",
+		"mapTurn3SailVisit",
+		"mapTurn4SailVisit",
+		"mapScrollNorth",
+		"mapScrollNorthEast",
+		"mapScrollEast",
+		"mapScrollSouthEast",
+		"mapScrollSouth",
+		"mapScrollSouthWest",
+		"mapScrollWest",
+		"mapScrollNorthWest",
+		"UNUSED",
+		"mapDimensionDoor",
+		"mapScuttleBoat"
+	};
+
+	set(mapCursorNames.at(static_cast<int>(index)));
 }
 
 void CursorHandler::set(Cursor::Combat index)
 {
-	changeGraphic(Cursor::Type::COMBAT, static_cast<size_t>(index));
+	constexpr std::array combatCursorNames =
+	{
+		"combatBlocked",
+		"combatMove",
+		"combatFly",
+		"combatShoot",
+		"combatHero",
+		"combatQuery",
+		"combatPointer",
+		"combatHitNorthEast",
+		"combatHitEast",
+		"combatHitSouthEast",
+		"combatHitSouthWest",
+		"combatHitWest",
+		"combatHitNorthWest",
+		"combatHitNorth",
+		"combatHitSouth",
+		"combatShootPenalty",
+		"combatShootCatapult",
+		"combatHeal",
+		"combatSacrifice",
+		"combatTeleport"
+	};
+
+	set(combatCursorNames.at(static_cast<int>(index)));
 }
 
 void CursorHandler::set(Cursor::Spellcast index)
 {
-	//Note: this is animated cursor, ignore specified frame and only change type
-	changeGraphic(Cursor::Type::SPELLBOOK, frame);
+	//Note: this is animated cursor, ignore requested frame and only change type
+	set("castSpell");
 }
 
 void CursorHandler::dragAndDropCursor(std::shared_ptr<IImage> image)
@@ -116,120 +219,12 @@ void CursorHandler::cursorMove(const int & x, const int & y)
 	cursor->setCursorPosition(pos);
 }
 
-Point CursorHandler::getPivotOffsetDefault(size_t index)
-{
-	return {0, 0};
-}
-
-Point CursorHandler::getPivotOffsetMap(size_t index)
-{
-	static const std::array<Point, 43> offsets = {{
-		{  0,  0}, // POINTER          =  0,
-		{  0,  0}, // HOURGLASS        =  1,
-		{ 12, 10}, // HERO             =  2,
-		{ 12, 12}, // TOWN             =  3,
-
-		{ 15, 13}, // T1_MOVE          =  4,
-		{ 13, 13}, // T1_ATTACK        =  5,
-		{ 16, 32}, // T1_SAIL          =  6,
-		{ 13, 20}, // T1_DISEMBARK     =  7,
-		{  8,  9}, // T1_EXCHANGE      =  8,
-		{ 14, 16}, // T1_VISIT         =  9,
-
-		{ 15, 13}, // T2_MOVE          = 10,
-		{ 13, 13}, // T2_ATTACK        = 11,
-		{ 16, 32}, // T2_SAIL          = 12,
-		{ 13, 20}, // T2_DISEMBARK     = 13,
-		{  8,  9}, // T2_EXCHANGE      = 14,
-		{ 14, 16}, // T2_VISIT         = 15,
-
-		{ 15, 13}, // T3_MOVE          = 16,
-		{ 13, 13}, // T3_ATTACK        = 17,
-		{ 16, 32}, // T3_SAIL          = 18,
-		{ 13, 20}, // T3_DISEMBARK     = 19,
-		{  8,  9}, // T3_EXCHANGE      = 20,
-		{ 14, 16}, // T3_VISIT         = 21,
-
-		{ 15, 13}, // T4_MOVE          = 22,
-		{ 13, 13}, // T4_ATTACK        = 23,
-		{ 16, 32}, // T4_SAIL          = 24,
-		{ 13, 20}, // T4_DISEMBARK     = 25,
-		{  8,  9}, // T4_EXCHANGE      = 26,
-		{ 14, 16}, // T4_VISIT         = 27,
-
-		{ 16, 32}, // T1_SAIL_VISIT    = 28,
-		{ 16, 32}, // T2_SAIL_VISIT    = 29,
-		{ 16, 32}, // T3_SAIL_VISIT    = 30,
-		{ 16, 32}, // T4_SAIL_VISIT    = 31,
-
-		{  6,  1}, // SCROLL_NORTH     = 32,
-		{ 16,  2}, // SCROLL_NORTHEAST = 33,
-		{ 21,  6}, // SCROLL_EAST      = 34,
-		{ 16, 16}, // SCROLL_SOUTHEAST = 35,
-		{  6, 21}, // SCROLL_SOUTH     = 36,
-		{  1, 16}, // SCROLL_SOUTHWEST = 37,
-		{  1,  5}, // SCROLL_WEST      = 38,
-		{  2,  1}, // SCROLL_NORTHWEST = 39,
-
-		{  0,  0}, // POINTER_COPY     = 40,
-		{ 14, 16}, // TELEPORT         = 41,
-		{ 20, 20}, // SCUTTLE_BOAT     = 42
-	}};
-
-	assert(offsets.size() == size_t(Cursor::Map::COUNT)); //Invalid number of pivot offsets for cursor
-	assert(index < offsets.size());
-	return offsets[index] * ENGINE->screenHandler().getScalingFactor();
-}
-
-Point CursorHandler::getPivotOffsetCombat(size_t index)
-{
-	static const std::array<Point, 20> offsets = {{
-		{ 12, 12 }, // BLOCKED        = 0,
-		{ 10, 14 }, // MOVE           = 1,
-		{ 14, 14 }, // FLY            = 2,
-		{ 12, 12 }, // SHOOT          = 3,
-		{ 12, 12 }, // HERO           = 4,
-		{  8, 12 }, // QUERY          = 5,
-		{  0,  0 }, // POINTER        = 6,
-		{ 21,  0 }, // HIT_NORTHEAST  = 7,
-		{ 31,  5 }, // HIT_EAST       = 8,
-		{ 21, 21 }, // HIT_SOUTHEAST  = 9,
-		{  0, 21 }, // HIT_SOUTHWEST  = 10,
-		{  0,  5 }, // HIT_WEST       = 11,
-		{  0,  0 }, // HIT_NORTHWEST  = 12,
-		{  6,  0 }, // HIT_NORTH      = 13,
-		{  6, 31 }, // HIT_SOUTH      = 14,
-		{ 14,  0 }, // SHOOT_PENALTY  = 15,
-		{ 12, 12 }, // SHOOT_CATAPULT = 16,
-		{ 12, 12 }, // HEAL           = 17,
-		{ 12, 12 }, // SACRIFICE      = 18,
-		{ 14, 20 }, // TELEPORT       = 19
-	}};
-
-	assert(offsets.size() == size_t(Cursor::Combat::COUNT)); //Invalid number of pivot offsets for cursor
-	assert(index < offsets.size());
-	return offsets[index] * ENGINE->screenHandler().getScalingFactor();
-}
-
-Point CursorHandler::getPivotOffsetSpellcast()
-{
-	return Point(18, 28) * ENGINE->screenHandler().getScalingFactor();
-}
-
 Point CursorHandler::getPivotOffset()
 {
 	if (dndObject)
 		return dndObject->dimensions() / 2;
 
-	switch (type) {
-	case Cursor::Type::ADVENTURE: return getPivotOffsetMap(frame);
-	case Cursor::Type::COMBAT:    return getPivotOffsetCombat(frame);
-	case Cursor::Type::DEFAULT:   return getPivotOffsetDefault(frame);
-	case Cursor::Type::SPELLBOOK: return getPivotOffsetSpellcast();
-	};
-
-	assert(0);
-	return {0, 0};
+	return cursors.at(currentCursorIndex).pivot;
 }
 
 std::shared_ptr<IImage> CursorHandler::getCurrentImage()
@@ -237,15 +232,17 @@ std::shared_ptr<IImage> CursorHandler::getCurrentImage()
 	if (dndObject)
 		return dndObject;
 
-	return cursors[static_cast<size_t>(type)]->getImage(frame);
+	return cursorImage;
 }
 
-void CursorHandler::updateSpellcastCursor()
+void CursorHandler::updateAnimatedCursor()
 {
 	static const float frameDisplayDuration = 0.1f; // H3 uses 100 ms per frame
 
 	frameTime += ENGINE->framerate().getElapsedMilliseconds() / 1000.f;
-	size_t newFrame = frame;
+	int32_t newFrame = currentFrame;
+	const auto & animationName = cursors.at(currentCursorIndex).animation;
+	const auto & animation = loadedAnimations.at(animationName);
 
 	while (frameTime >= frameDisplayDuration)
 	{
@@ -253,12 +250,12 @@ void CursorHandler::updateSpellcastCursor()
 		newFrame++;
 	}
 
-	auto & animation = cursors.at(static_cast<size_t>(type));
-
 	while (newFrame >= animation->size())
 		newFrame -= animation->size();
 
-	changeGraphic(Cursor::Type::SPELLBOOK, newFrame);
+	currentFrame = newFrame;
+	cursorImage = animation->getImage(currentFrame);
+	cursor->setImage(getCurrentImage(), getPivotOffset());
 }
 
 void CursorHandler::render()
@@ -271,8 +268,8 @@ void CursorHandler::update()
 	if(!showing)
 		return;
 
-	if (type == Cursor::Type::SPELLBOOK)
-		updateSpellcastCursor();
+	if (cursors.at(currentCursorIndex).isAnimated)
+		updateAnimatedCursor();
 
 	cursor->update();
 }

+ 29 - 47
client/gui/CursorHandler.h

@@ -18,25 +18,18 @@ class CAnimation;
 
 namespace Cursor
 {
-	enum class Type {
+	enum class Type : int8_t {
 		ADVENTURE, // set of various cursors for adventure map
 		COMBAT,    // set of various cursors for combat
-		DEFAULT,   // default arrow and hourglass cursors
 		SPELLBOOK  // animated cursor for spellcasting
 	};
 
-	enum class ShowType {
+	enum class ShowType : int8_t {
 		SOFTWARE,
 		HARDWARE
 	};
 
-	enum class Default {
-		POINTER      = 0,
-		//ARROW_COPY = 1, // probably unused
-		HOURGLASS  = 2,
-	};
-
-	enum class Combat {
+	enum class Combat : int8_t {
 		BLOCKED        = 0,
 		MOVE           = 1,
 		FLY            = 2,
@@ -61,7 +54,7 @@ namespace Cursor
 		COUNT
 	};
 
-	enum class Map {
+	enum class Map : int8_t {
 		POINTER          =  0,
 		HOURGLASS        =  1,
 		HERO             =  2,
@@ -109,7 +102,7 @@ namespace Cursor
 		COUNT
 	};
 
-	enum class Spellcast {
+	enum class Spellcast : int8_t {
 		SPELL = 0,
 	};
 }
@@ -117,26 +110,33 @@ namespace Cursor
 /// handles mouse cursor
 class CursorHandler final
 {
-	std::shared_ptr<IImage> dndObject; //if set, overrides currentCursor
+	struct CursorParameters
+	{
+		std::string cursorID;
+		ImagePath image;
+		AnimationPath animation;
+		Point pivot;
+		int animationFrameIndex;
+		bool isAnimated;
+	};
 
-	std::array<std::shared_ptr<CAnimation>, 4> cursors;
+	std::vector<CursorParameters> cursors;
+	std::map<AnimationPath, std::shared_ptr<CAnimation>> loadedAnimations;
+	std::map<ImagePath, std::shared_ptr<IImage>> loadedImages;
 
-	bool showing;
+	std::shared_ptr<IImage> dndObject; //if set, overrides currentCursor
+	std::shared_ptr<IImage> cursorImage; //if set, overrides currentCursor
 
 	/// Current cursor
-	Cursor::Type type;
-	Cursor::ShowType showType;
-	size_t frame;
-	float frameTime;
+	std::string currentCursorID;
 	Point pos;
+	float frameTime;
+	int32_t currentCursorIndex;
+	int32_t currentFrame;
+	Cursor::ShowType showType;
+	bool showing;
 
-	void changeGraphic(Cursor::Type type, size_t index);
-
-	Point getPivotOffset();
-
-	void updateSpellcastCursor();
-
-	std::shared_ptr<IImage> getCurrentImage();
+	void updateAnimatedCursor();
 
 	std::unique_ptr<ICursor> cursor;
 
@@ -154,31 +154,13 @@ public:
 	void dragAndDropCursor(const AnimationPath & path, size_t index);
 
 	/// Changes cursor to specified index
-	void set(Cursor::Default index);
 	void set(Cursor::Map index);
 	void set(Cursor::Combat index);
 	void set(Cursor::Spellcast index);
+	void set(const std::string & index);
 
-	/// Returns current index of cursor
-	template<typename Index>
-	std::optional<Index> get()
-	{
-		bool typeValid = true;
-
-		typeValid &= (std::is_same<Index, Cursor::Default>::value   )|| type != Cursor::Type::DEFAULT;
-		typeValid &= (std::is_same<Index, Cursor::Map>::value       )|| type != Cursor::Type::ADVENTURE;
-		typeValid &= (std::is_same<Index, Cursor::Combat>::value    )|| type != Cursor::Type::COMBAT;
-		typeValid &= (std::is_same<Index, Cursor::Spellcast>::value )|| type != Cursor::Type::SPELLBOOK;
-
-		if (typeValid)
-			return static_cast<Index>(frame);
-		return std::nullopt;
-	}
-
-	Point getPivotOffsetSpellcast();
-	Point getPivotOffsetDefault(size_t index);
-	Point getPivotOffsetMap(size_t index);
-	Point getPivotOffsetCombat(size_t index);
+	std::shared_ptr<IImage> getCurrentImage();
+	Point getPivotOffset();
 
 	void render();
 	void update();

+ 9 - 8
client/mapView/MapRendererContext.cpp

@@ -19,14 +19,16 @@
 #include "../GameInstance.h"
 
 #include "../../lib/Point.h"
+#include "../../lib/battle/CPlayerBattleCallback.h"
+#include "../../lib/battle/IBattleState.h"
 #include "../../lib/callback/CCallback.h"
 #include "../../lib/mapObjects/CGHeroInstance.h"
 #include "../../lib/mapObjects/MiscObjects.h"
-#include "../../lib/spells/CSpellHandler.h"
 #include "../../lib/mapping/CMap.h"
 #include "../../lib/pathfinder/CGPathNode.h"
-#include "../../lib/battle/CPlayerBattleCallback.h"
-#include "../../lib/battle/IBattleState.h"
+#include "../../lib/spells/CSpellHandler.h"
+#include "../../lib/spells/ISpellMechanics.h"
+#include "../../lib/spells/adventure/AdventureSpellEffect.h"
 
 MapRendererBaseContext::MapRendererBaseContext(const MapRendererContextState & viewState)
 	: viewState(viewState)
@@ -367,15 +369,14 @@ bool MapRendererAdventureContext::showTextOverlay() const
 
 bool MapRendererAdventureContext::showSpellRange(const int3 & position) const
 {
-	if (!settingSpellRange)
-		return false;
-
 	auto hero = GAME->interface()->localState->getCurrentHero();
+	auto spell = GAME->interface()->localState->getCurrentSpell();
 
-	if (!hero)
+	if (!hero || !spell.hasValue())
 		return false;
 
-	return !isInScreenRange(hero->getSightCenter(), position);
+	const auto * spellEffect = spell.toSpell()->getAdventureMechanics().getEffectAs<AdventureSpellRangedEffect>(hero);
+	return !spellEffect->isTargetInRange(GAME->interface()->cb.get(), hero, position);
 }
 
 MapRendererAdventureTransitionContext::MapRendererAdventureTransitionContext(const MapRendererContextState & viewState)

+ 0 - 1
client/mapView/MapRendererContext.h

@@ -72,7 +72,6 @@ public:
 	bool settingShowGrid = false;
 	bool settingShowVisitable = false;
 	bool settingShowBlocked = false;
-	bool settingSpellRange= false;
 	bool settingTextOverlay = false;
 	bool settingsAdventureObjectAnimation = true;
 	bool settingsAdventureTerrainAnimation = true;

+ 0 - 1
client/mapView/MapViewController.cpp

@@ -233,7 +233,6 @@ void MapViewController::updateState()
 		adventureContext->settingShowGrid = settings["gameTweaks"]["showGrid"].Bool();
 		adventureContext->settingShowVisitable = settings["session"]["showVisitable"].Bool();
 		adventureContext->settingShowBlocked = settings["session"]["showBlocked"].Bool();
-		adventureContext->settingSpellRange = settings["session"]["showSpellRange"].Bool();
 		adventureContext->settingTextOverlay = (ENGINE->isKeyboardAltDown() || ENGINE->input().getNumTouchFingers() == 2) && settings["general"]["enableOverlay"].Bool();
 	}
 }

+ 5 - 4
client/windows/CSpellWindow.cpp

@@ -42,6 +42,7 @@
 #include "../../lib/callback/CCallback.h"
 #include "../../lib/spells/CSpellHandler.h"
 #include "../../lib/spells/ISpellMechanics.h"
+#include "../../lib/spells/adventure/AdventureSpellEffect.h"
 #include "../../lib/spells/Problem.h"
 #include "../../lib/spells/SpellSchoolHandler.h"
 #include "../../lib/texts/CGeneralTextHandler.h"
@@ -752,12 +753,12 @@ void CSpellWindow::SpellArea::clickPressed(const Point & cursorPosition)
 			spells::detail::ProblemImpl problem;
 			if (mySpell->getAdventureMechanics().canBeCast(problem, GAME->interface()->cb.get(), owner->myHero))
 			{
-				if(mySpell->getTargetType() == spells::AimType::LOCATION)
+				const auto * rangeEffect = mySpell->getAdventureMechanics().getEffectAs<AdventureSpellRangedEffect>(owner->myHero);
+
+				if(rangeEffect != nullptr)
 					adventureInt->enterCastingMode(mySpell);
-				else if(mySpell->getTargetType() == spells::AimType::NO_TARGET)
-					owner->myInt->cb->castSpell(h, mySpell->id);
 				else
-					logGlobal->error("Invalid spell target type");
+					owner->myInt->cb->castSpell(h, mySpell->id);
 			}
 			else
 			{

+ 73 - 0
config/cursors.json

@@ -0,0 +1,73 @@
+/// This file can be modified via mods by creating file config/cursors.json
+/// File in mod only needs to contain new or modified entries
+/// Format:
+/// - "image" - path to image (e.g. png file) with image for this cursor. Overrides "animation" key.
+/// - "animation" - path to animation (.def or .json) with image for this cursor. Also requires "frame" or "animated" key
+/// - "frame" - index of frame inside animation file that should be used for this cursor
+/// - "animated" - if set to true, cursor will be animated using entire animation file
+/// - "pivotX" and "pivotY" - position inside image that should act as pointer tip. Mouse clicks would generate click event on pixel below this point
+{
+	"mapPointer"          : { "pivotX" :  0, "pivotY" :  0, "animation" : "CRADVNTR", "frame" :  0 },
+	"mapHourglass"        : { "pivotX" :  0, "pivotY" :  0, "animation" : "CRADVNTR", "frame" :  1 },
+	"mapHero"             : { "pivotX" : 12, "pivotY" : 10, "animation" : "CRADVNTR", "frame" :  2 },
+	"mapTown"             : { "pivotX" : 12, "pivotY" : 12, "animation" : "CRADVNTR", "frame" :  3 },
+	"mapTurn1Move"        : { "pivotX" : 15, "pivotY" : 13, "animation" : "CRADVNTR", "frame" :  4 },
+	"mapTurn1Attack"      : { "pivotX" : 13, "pivotY" : 13, "animation" : "CRADVNTR", "frame" :  5 },
+	"mapTurn1Sail"        : { "pivotX" : 16, "pivotY" : 32, "animation" : "CRADVNTR", "frame" :  6 },
+	"mapTurn1Disembark"   : { "pivotX" : 13, "pivotY" : 20, "animation" : "CRADVNTR", "frame" :  7 },
+	"mapTurn1Exchange"    : { "pivotX" :  8, "pivotY" :  9, "animation" : "CRADVNTR", "frame" :  8 },
+	"mapTurn1Visit"       : { "pivotX" : 14, "pivotY" : 16, "animation" : "CRADVNTR", "frame" :  9 },
+	"mapTurn2Move"        : { "pivotX" : 15, "pivotY" : 13, "animation" : "CRADVNTR", "frame" : 10 },
+	"mapTurn2Attack"      : { "pivotX" : 13, "pivotY" : 13, "animation" : "CRADVNTR", "frame" : 11 },
+	"mapTurn2Sail"        : { "pivotX" : 16, "pivotY" : 32, "animation" : "CRADVNTR", "frame" : 12 },
+	"mapTurn2Disembark"   : { "pivotX" : 13, "pivotY" : 20, "animation" : "CRADVNTR", "frame" : 13 },
+	"mapTurn2Exchange"    : { "pivotX" :  8, "pivotY" :  9, "animation" : "CRADVNTR", "frame" : 14 },
+	"mapTurn2Visit"       : { "pivotX" : 14, "pivotY" : 16, "animation" : "CRADVNTR", "frame" : 15 },
+	"mapTurn3Move"        : { "pivotX" : 15, "pivotY" : 13, "animation" : "CRADVNTR", "frame" : 16 },
+	"mapTurn3Attack"      : { "pivotX" : 13, "pivotY" : 13, "animation" : "CRADVNTR", "frame" : 17 },
+	"mapTurn3Sail"        : { "pivotX" : 16, "pivotY" : 32, "animation" : "CRADVNTR", "frame" : 18 },
+	"mapTurn3Disembark"   : { "pivotX" : 13, "pivotY" : 20, "animation" : "CRADVNTR", "frame" : 19 },
+	"mapTurn3Exchange"    : { "pivotX" :  8, "pivotY" :  9, "animation" : "CRADVNTR", "frame" : 20 },
+	"mapTurn3Visit"       : { "pivotX" : 14, "pivotY" : 16, "animation" : "CRADVNTR", "frame" : 21 },
+	"mapTurn4Move"        : { "pivotX" : 15, "pivotY" : 13, "animation" : "CRADVNTR", "frame" : 22 },
+	"mapTurn4Attack"      : { "pivotX" : 13, "pivotY" : 13, "animation" : "CRADVNTR", "frame" : 23 },
+	"mapTurn4Sail"        : { "pivotX" : 16, "pivotY" : 32, "animation" : "CRADVNTR", "frame" : 24 },
+	"mapTurn4Disembark"   : { "pivotX" : 13, "pivotY" : 20, "animation" : "CRADVNTR", "frame" : 25 },
+	"mapTurn4Exchange"    : { "pivotX" :  8, "pivotY" :  9, "animation" : "CRADVNTR", "frame" : 26 },
+	"mapTurn4Visit"       : { "pivotX" : 14, "pivotY" : 16, "animation" : "CRADVNTR", "frame" : 27 },
+	"mapTurn1SailVisit"   : { "pivotX" : 16, "pivotY" : 32, "animation" : "CRADVNTR", "frame" : 28 },
+	"mapTurn2SailVisit"   : { "pivotX" : 16, "pivotY" : 32, "animation" : "CRADVNTR", "frame" : 29 },
+	"mapTurn3SailVisit"   : { "pivotX" : 16, "pivotY" : 32, "animation" : "CRADVNTR", "frame" : 30 },
+	"mapTurn4SailVisit"   : { "pivotX" : 16, "pivotY" : 32, "animation" : "CRADVNTR", "frame" : 31 },
+	"mapScrollNorth"      : { "pivotX" :  6, "pivotY" :  1, "animation" : "CRADVNTR", "frame" : 32 },
+	"mapScrollNorthEast"  : { "pivotX" : 16, "pivotY" :  2, "animation" : "CRADVNTR", "frame" : 33 },
+	"mapScrollEast"       : { "pivotX" : 21, "pivotY" :  6, "animation" : "CRADVNTR", "frame" : 34 },
+	"mapScrollSouthEast"  : { "pivotX" : 16, "pivotY" : 16, "animation" : "CRADVNTR", "frame" : 35 },
+	"mapScrollSouth"      : { "pivotX" :  6, "pivotY" : 21, "animation" : "CRADVNTR", "frame" : 36 },
+	"mapScrollSouthWest"  : { "pivotX" :  1, "pivotY" : 16, "animation" : "CRADVNTR", "frame" : 37 },
+	"mapScrollWest"       : { "pivotX" :  1, "pivotY" :  5, "animation" : "CRADVNTR", "frame" : 38 },
+	"mapScrollNorthWest"  : { "pivotX" :  2, "pivotY" :  1, "animation" : "CRADVNTR", "frame" : 39 },
+	"mapDimensionDoor"    : { "pivotX" : 14, "pivotY" : 16, "animation" : "CRADVNTR", "frame" : 41 },
+	"mapScuttleBoat"      : { "pivotX" : 20, "pivotY" : 20, "animation" : "CRADVNTR", "frame" : 42 },
+	"combatBlocked"       : { "pivotX" : 12, "pivotY" : 12, "animation" : "CRCOMBAT", "frame" :  0},
+	"combatMove"          : { "pivotX" : 10, "pivotY" : 14, "animation" : "CRCOMBAT", "frame" :  1},
+	"combatFly"           : { "pivotX" : 14, "pivotY" : 14, "animation" : "CRCOMBAT", "frame" :  2},
+	"combatShoot"         : { "pivotX" : 12, "pivotY" : 12, "animation" : "CRCOMBAT", "frame" :  3},
+	"combatHero"          : { "pivotX" : 12, "pivotY" : 12, "animation" : "CRCOMBAT", "frame" :  4},
+	"combatQuery"         : { "pivotX" :  8, "pivotY" : 12, "animation" : "CRCOMBAT", "frame" :  5},
+	"combatPointer"       : { "pivotX" :  0, "pivotY" :  0, "animation" : "CRCOMBAT", "frame" :  6},
+	"combatHitNorthEast"  : { "pivotX" : 21, "pivotY" :  0, "animation" : "CRCOMBAT", "frame" :  7},
+	"combatHitEast"       : { "pivotX" : 31, "pivotY" :  5, "animation" : "CRCOMBAT", "frame" :  8},
+	"combatHitSouthEast"  : { "pivotX" : 21, "pivotY" : 21, "animation" : "CRCOMBAT", "frame" :  9},
+	"combatHitSouthWest"  : { "pivotX" :  0, "pivotY" : 21, "animation" : "CRCOMBAT", "frame" : 10},
+	"combatHitWest"       : { "pivotX" :  0, "pivotY" :  5, "animation" : "CRCOMBAT", "frame" : 11},
+	"combatHitNorthWest"  : { "pivotX" :  0, "pivotY" :  0, "animation" : "CRCOMBAT", "frame" : 12},
+	"combatHitNorth"      : { "pivotX" :  6, "pivotY" :  0, "animation" : "CRCOMBAT", "frame" : 13},
+	"combatHitSouth"      : { "pivotX" :  6, "pivotY" : 31, "animation" : "CRCOMBAT", "frame" : 14},
+	"combatShootPenalty"  : { "pivotX" : 14, "pivotY" :  0, "animation" : "CRCOMBAT", "frame" : 15},
+	"combatShootCatapult" : { "pivotX" : 12, "pivotY" : 12, "animation" : "CRCOMBAT", "frame" : 16},
+	"combatHeal"          : { "pivotX" : 12, "pivotY" : 12, "animation" : "CRCOMBAT", "frame" : 17},
+	"combatSacrifice"     : { "pivotX" : 12, "pivotY" : 12, "animation" : "CRCOMBAT", "frame" : 18},
+	"combatTeleport"      : { "pivotX" : 14, "pivotY" : 20, "animation" : "CRCOMBAT", "frame" : 19},
+	"castSpell"           : { "pivotX" : 18, "pivotY" : 28, "animation" : "CRSPELL", "animated" : true }
+}

+ 333 - 1
config/schemas/spell.json

@@ -50,6 +50,335 @@
 				}
 			}
 		},
+		"adventureEffect" : {
+			"type" : "object",
+			"required" : [ "type" ],
+			"properties" : {
+				"type" : {
+					"type" : "string",
+					"enum" : [ "generic", "dimensionDoor", "removeObject", "summonBoat", "townPortal", "viewWorld" ]
+				},
+				"castsPerDay" : { "type" : "number" },
+				"castsPerDayXL" : { "type" : "number" },
+				"bonuses" : { "additionalProperties" : { "$ref" : "bonusInstance.json" }},
+			},
+			"allOf" : [
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "dimensionDoor" } } },
+					"then" : {
+						"properties" : {
+							"castsPerDay" : {},
+							"castsPerDayXL" : {},
+							"bonuses" : {},
+							"type" : {},
+							"rangeX" : { "type" : "number" },
+							"rangeY" : { "type" : "number" },
+							"ignoreFow" : { "type" : "boolean" },
+							"cursor" : { "type" : "string" },
+							"cursorGuarded" : { "type" : "string" },
+							"movementPointsRequired" : { "type" : "number" },
+							"movementPointsTaken" : { "type" : "number" },
+							"waterLandFailureTakesPoints" : { "type" : "boolean" },
+							"exposeFow" : { "type" : "boolean" }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "removeObject" } } },
+					"then" : {
+						"properties" : {
+							"castsPerDay" : {},
+							"castsPerDayXL" : {},
+							"bonuses" : {},
+							"type" : {},
+							"rangeX" : { "type" : "number" },
+							"rangeY" : { "type" : "number" },
+							"ignoreFow" : { "type" : "boolean" },
+							"cursor" : { "type" : "string" },
+							"objects" : { "additionalProperties" : { "type" : "boolean" } }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "summonBoat" } } },
+					"then" : {
+						"properties" : {
+							"castsPerDay" : {},
+							"castsPerDayXL" : {},
+							"bonuses" : {},
+							"type" : {},
+							"useExistingBoat" : { "type" : "boolean" },
+							"createdBoat" : { "type" : "string" }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "townPortal" } } },
+					"then" : {
+						"properties" : {
+							"castsPerDay" : {},
+							"castsPerDayXL" : {},
+							"bonuses" : {},
+							"type" : {},
+							"movementPointsRequired" : { "type" : "number" },
+							"movementPointsTaken" : { "type" : "number" },
+							"allowTownSelection" : { "type" : "boolean" },
+							"skipOccupiedTowns" : { "type" : "boolean" }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "viewWorld" } } },
+					"then" : {
+						"properties" : {
+							"castsPerDay" : {},
+							"castsPerDayXL" : {},
+							"bonuses" : {},
+							"type" : {},
+							"showTerrain" : { "type" : "boolean" },
+							"objects" : { "additionalProperties" : { "type" : "boolean" } }
+						},
+						"additionalProperties" : false
+					}
+				}
+			]
+		},
+		"battleEffect" : {
+			"type" : "object",
+			"required" : [ "type" ],
+			"properties" : {
+				"type" : {
+					"type" : "string",
+					"enum" : [ 
+						"core:catapult",
+						"core:clone",
+						"core:damage",
+						"core:demonSummon",
+						"core:dispel",
+						"core:heal",
+						"core:moat",
+						"core:obstacle",
+						"core:removeObstacle",
+						"core:sacrifice",
+						"core:summon",
+						"core:teleport",
+						"core:timed"
+					]
+				},
+				"indirect" : { "type" : "boolean" },
+				"optional" : { "type" : "boolean" }
+			},
+			"allOf" : [
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "core:catapult" } } },
+					"then" : {
+						"properties" : {
+							"type" : {},
+							"indirect" : {},
+							"optional" : {},
+							"targetsToAttack" : { "type" : "number" },
+							"chanceToHitKeep" : { "type" : "number" },
+							"chanceToHitGate" : { "type" : "number" },
+							"chanceToHitTower" : { "type" : "number" },
+							"chanceToHitWall" : { "type" : "number" },
+							"chanceToNormalHit" : { "type" : "number" },
+							"chanceToCrit" : { "type" : "number" }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "core:clone" } } },
+					"then" : {
+						"properties" : {
+							"type" : {},
+							"indirect" : {},
+							"optional" : {},
+							"ignoreImmunity" : { "type" : "boolean" },
+							"chainLength" : { "type" : "number" },
+							"chainFactor" : { "type" : "number" },
+							"maxTier" : { "type" : "number" }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "core:damage" } } },
+					"then" : {
+						"properties" : {
+							"type" : {},
+							"indirect" : {},
+							"optional" : {},
+							"ignoreImmunity" : { "type" : "boolean" },
+							"chainLength" : { "type" : "number" },
+							"chainFactor" : { "type" : "number" },
+							"killByPercentage" : { "type" : "boolean" },
+							"killByCount" : { "type" : "boolean" }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "core:demonSummon" } } },
+					"then" : {
+						"properties" : {
+							"type" : {},
+							"indirect" : {},
+							"optional" : {},
+							"ignoreImmunity" : { "type" : "boolean" },
+							"chainLength" : { "type" : "number" },
+							"chainFactor" : { "type" : "number" },
+							"id" : { "type" : "string" },
+							"permanent" : { "type" : "boolean" }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "core:dispel" } } },
+					"then" : {
+						"properties" : {
+							"type" : {},
+							"indirect" : {},
+							"optional" : {},
+							"ignoreImmunity" : { "type" : "boolean" },
+							"chainLength" : { "type" : "number" },
+							"chainFactor" : { "type" : "number" },
+							"dispelPositive" : { "type" : "boolean" },
+							"dispelNegative" : { "type" : "boolean" },
+							"dispelNeutral" : { "type" : "boolean" }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "enum" : [ "core:heal", "core:sacrifice" ] } } },
+					"then" : {
+						"properties" : {
+							"type" : {},
+							"indirect" : {},
+							"optional" : {},
+							"ignoreImmunity" : { "type" : "boolean" },
+							"chainLength" : { "type" : "number" },
+							"chainFactor" : { "type" : "number" },
+							"healLevel" : { "type" : "string" },
+							"healPower" : { "type" : "string" },
+							"minFullUnits" : { "type" : "number" }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "core:moat" } } },
+					"then" : {
+						"properties" : {
+							"type" : {},
+							"indirect" : {},
+							"optional" : {},
+							"hidden" : { "type" : "boolean" },
+							"trap" : { "type" : "boolean" },
+							"removeOnTrigger" : { "type" : "boolean" },
+							"dispellable" : { "type" : "boolean" },
+							"moatDamage" : { "type" : "number" },
+							"moatHexes" : {  },
+							"triggerAbility" : { "type" : "string" },
+							"defender" : {  },
+							"bonus" : { "additionalProperties" : { "$ref" : "bonusInstance.json" } }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "core:obstacle" } } },
+					"then" : {
+						"properties" : {
+							"type" : {},
+							"indirect" : {},
+							"optional" : {},
+							"hidden" : { "type" : "boolean" },
+							"passable" : { "type" : "boolean" },
+							"trap" : { "type" : "boolean" },
+							"removeOnTrigger" : { "type" : "boolean" },
+							"hideNative" : { "type" : "boolean" },
+							"patchCount" : { "type" : "number" },
+							"turnsRemaining" : { "type" : "number" },
+							"triggerAbility" : { "type" : "string" },
+							"attacker" : {  },
+							"defender" : {  },
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "core:removeObstacle" } } },
+					"then" : {
+						"properties" : {
+							"type" : {},
+							"indirect" : {},
+							"optional" : {},
+							"removeAbsolute" : { "type" : "boolean" },
+							"removeUsual" : { "type" : "boolean" },
+							"removeAllSpells" : { "type" : "boolean" },
+							"removeSpells" : {  }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "core:summon" } } },
+					"then" : {
+						"properties" : {
+							"type" : {},
+							"indirect" : {},
+							"optional" : {},
+							"id" : { "type" : "string" },
+							"permanent" : { "type" : "boolean" },
+							"exclusive" : { "type" : "boolean" },
+							"summonByHealth" : { "type" : "boolean" },
+							"summonSameUnit" : { "type" : "boolean" },
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "core:teleport" } } },
+					"then" : {
+						"properties" : {
+							"type" : {},
+							"indirect" : {},
+							"optional" : {},
+							"ignoreImmunity" : { "type" : "boolean" },
+							"chainLength" : { "type" : "number" },
+							"chainFactor" : { "type" : "number" },
+							"triggerObstacles" : { "type" : "boolean" },
+							"isWallPassable" : { "type" : "boolean" },
+							"isMoatPassable" : { "type" : "boolean" }
+						},
+						"additionalProperties" : false
+					}
+				},
+				{ 
+					"if" : { "properties" : { "type" : { "const" : "core:timed" } } },
+					"then" : {
+						"properties" : {
+							"type" : {},
+							"indirect" : {},
+							"optional" : {},
+							"ignoreImmunity" : { "type" : "boolean" },
+							"chainLength" : { "type" : "number" },
+							"chainFactor" : { "type" : "number" },
+							"cumulative" : { "type" : "boolean" },
+							"bonus" : { "additionalProperties" : { "$ref" : "bonusInstance.json" } }
+						},
+						"additionalProperties" : false
+					}
+				},
+			]
+		},
 		"flags" : {
 			"type" : "object",
 			"additionalProperties" : {
@@ -94,9 +423,12 @@
 				"battleEffects" : {
 					"type" : "object",
 					"additionalProperties" : {
-						"type" : "object"
+						"$ref" : "#/definitions/battleEffect"
 					}
 				},
+				"adventureEffect" : {
+					"$ref" : "#/definitions/adventureEffect"
+				},
 				"targetModifier" : {
 					"type" : "object",
 					"additionalProperties" : false,

+ 256 - 91
config/spells/adventure.json

@@ -6,9 +6,24 @@
 		"sounds": {
 			"cast": "SUMMBOAT"
 		},
-		"levels" :{
+		"levels" : {
 			"base":{
-				"range" : "X"
+				"range" : "X",
+				"adventureEffect" : {
+					"type" : "summonBoat",
+					"castsPerDay" : 0,
+					"useExistingBoat" : true
+				}
+			},
+			"advanced":{
+				"adventureEffect" : {
+					"createdBoat" : "boatNecropolis"
+				}
+			},
+			"expert":{
+				"adventureEffect" : {
+					"createdBoat" : "boatNecropolis"
+				}
 			}
 		},
 		"flags" : {
@@ -25,7 +40,26 @@
 		},
 		"levels" : {
 			"base":{
-				"range" : "X"
+				"range" : "X",
+				"adventureEffect" : {
+					"type" : "removeObject",
+					"castsPerDay" : 0,
+					"cursor" : "mapScuttleBoat", // defined in config/cursors.json
+					"rangeX" : 9,
+					"rangeY" : 8,
+					"ignoreFow" : false,
+					"objects" : {
+						"boat" : true
+					}
+				}
+			},
+			"advanced":{
+				"adventureEffect" : {
+				}
+			},
+			"expert":{
+				"adventureEffect" : {
+				}
 			}
 		},
 		"flags" : {
@@ -43,51 +77,58 @@
 		"levels" : {
 			"base":{
 				"range" : "0",
-				"effects" : {
-					"visionsMonsters" : {
-						"type" : "VISIONS",
-						"subtype" : "visionsMonsters",
-						"duration" : "ONE_DAY",
-						"val" : 1,
-						"valueType" : "INDEPENDENT_MAX"
+				"adventureEffect" : {
+					"type" : "generic",
+					"castsPerDay" : 0,
+					"bonuses" : {
+						"visionsMonsters" : {
+							"type" : "VISIONS",
+							"subtype" : "visionsMonsters",
+							"duration" : "ONE_DAY",
+							"val" : 1,
+							"valueType" : "INDEPENDENT_MAX"
+						}
 					}
-				}				
+				}
 			},
 			"advanced":{
-				"effects" : {
-					"visionsMonsters" : {
-						"val" : 2
-					},
-					"visionsHeroes" :{
-						"type" : "VISIONS",
-						"subtype" : "visionsHeroes",
-						"duration" : "ONE_DAY",
-						"val" : 2,
-						"valueType" : "INDEPENDENT_MAX"
+				"adventureEffect" : {
+					"bonuses" : {
+						"visionsMonsters" : {
+							"val" : 2
+						},
+						"visionsHeroes" :{
+							"type" : "VISIONS",
+							"subtype" : "visionsHeroes",
+							"duration" : "ONE_DAY",
+							"val" : 2,
+							"valueType" : "INDEPENDENT_MAX"
+						}
 					}
-					
-				}			
+				}
 			},
 			"expert":{
-				"effects" : {
-					"visionsMonsters" : {
-						"val" : 3
-					},
-					"visionsHeroes" :{
-						"type" : "VISIONS",
-						"subtype" : "visionsHeroes",
-						"duration" : "ONE_DAY",
-						"val" : 3,
-						"valueType" : "INDEPENDENT_MAX"
-					},
-					"visionsTowns" :{
-						"type" : "VISIONS",
-						"subtype" : "visionsTowns",
-						"duration" : "ONE_DAY",
-						"val" : 3,
-						"valueType" : "INDEPENDENT_MAX"
+				"adventureEffect" : {
+					"bonuses" : {
+						"visionsMonsters" : {
+							"val" : 3
+						},
+						"visionsHeroes" :{
+							"type" : "VISIONS",
+							"subtype" : "visionsHeroes",
+							"duration" : "ONE_DAY",
+							"val" : 3,
+							"valueType" : "INDEPENDENT_MAX"
+						},
+						"visionsTowns" :{
+							"type" : "VISIONS",
+							"subtype" : "visionsTowns",
+							"duration" : "ONE_DAY",
+							"val" : 3,
+							"valueType" : "INDEPENDENT_MAX"
+						}
 					}
-				}			
+				}
 			}
 		},
 		"flags" : {
@@ -103,7 +144,33 @@
 		},
 		"levels" : {
 			"base":{
-				"range" : "X"
+				"range" : "X",
+				"adventureEffect" : {
+					"type" : "viewWorld",
+					"castsPerDay" : 0,
+					"objects" : {
+						"resource" : true
+					}
+				}
+			},
+			"advanced":{
+				"adventureEffect" : {
+					"objects" : {
+						"resource" : true,
+						"mine" : true,
+						"abandonedMine" : true
+					}
+				}
+			},
+			"expert":{
+				"adventureEffect" : {
+					"objects" : {
+						"resource" : true,
+						"mine" : true,
+						"abandonedMine" : true
+					},
+					"showTerrain" : true
+				}
 			}
 		},
 		"flags" : {
@@ -120,28 +187,36 @@
 		"levels" : {
 			"base":{
 				"range" : "0",
-				"effects" : {
-					"stealth" : {
-						"type" : "DISGUISED",
-						"duration" : "ONE_DAY",
-						"val" : 1,
-						"valueType" : "INDEPENDENT_MAX"
+				"adventureEffect" : {
+					"type" : "generic",
+					"castsPerDay" : 0,
+					"bonuses" : {
+						"stealth" : {
+							"type" : "DISGUISED",
+							"duration" : "ONE_DAY",
+							"val" : 1,
+							"valueType" : "INDEPENDENT_MAX"
+						}
 					}
-				}				
+				}
 			},
 			"advanced":{
-				"effects" : {
-					"stealth" : {
-						"val" : 2
-					}					
-				}			
+				"adventureEffect" : {
+					"bonuses" : {
+						"stealth" : {
+							"val" : 2
+						}
+					}
+				}
 			},
 			"expert":{
-				"effects" : {
-					"stealth" : {
-						"val" : 3
+				"adventureEffect" : {
+					"bonuses" : {
+						"stealth" : {
+							"val" : 3
+						}
 					}
-				}			
+				}
 			}
 		},
 		"flags" : {
@@ -157,7 +232,31 @@
 		},
 		"levels" : {
 			"base":{
-				"range" : "X"
+				"range" : "X",
+				"adventureEffect" : {
+					"type" : "viewWorld",
+					"castsPerDay" : 0,
+					"objects" : {
+						"artifact" : true
+					}
+				}
+			},
+			"advanced":{
+				"adventureEffect" : {
+					"objects" : {
+						"artifact" : true,
+						"hero" : true
+					}
+				}
+			},
+			"expert":{
+				"adventureEffect" : {
+					"objects" : {
+						"artifact" : true,
+						"hero" : true,
+						"town" : true
+					}
+				}
 			}
 		},
 		"flags" : {
@@ -174,28 +273,38 @@
 		"levels" : {
 			"base":{
 				"range" : "0",
-				"effects" : {
-					"fly" : {
-						"type" : "FLYING_MOVEMENT",
-						"duration" : "ONE_DAY",
-						"val" : 40,
-						"valueType" : "INDEPENDENT_MIN"
+				"adventureEffect" : {
+					"type" : "generic",
+					"castsPerDay" : 0,
+					"bonuses" : {
+						"fly" : {
+							"type" : "FLYING_MOVEMENT",
+							"duration" : "ONE_DAY",
+							"val" : 40,
+							"valueType" : "INDEPENDENT_MIN"
+						}
 					}
-				}				
+				}
 			},
 			"advanced":{
-				"effects" : {
-					"fly" : {
-						"val" : 20
+				"adventureEffect" : {
+					"type" : "generic",
+					"bonuses" : {
+						"fly" : {
+							"val" : 20
+						}
 					}
-				}			
+				}
 			},
 			"expert":{
-				"effects" : {
-					"fly" : {
-						"val" : 0
+				"adventureEffect" : {
+					"type" : "generic",
+					"bonuses" : {
+						"fly" : {
+							"val" : 0
+						}
 					}
-				}			
+				}
 			}
 		},
 		"flags" : {
@@ -212,28 +321,38 @@
 		"levels" : {
 			"base":{
 				"range" : "0",
-				"effects" : {
-					"waterWalk" : {
-						"type" : "WATER_WALKING",
-						"duration" : "ONE_DAY",
-						"val" : 40,
-						"valueType" : "INDEPENDENT_MIN"
+				"adventureEffect" : {
+					"type" : "generic",
+					"castsPerDay" : 0,
+					"bonuses" : {
+						"waterWalk" : {
+							"type" : "WATER_WALKING",
+							"duration" : "ONE_DAY",
+							"val" : 40,
+							"valueType" : "INDEPENDENT_MIN"
+						}
 					}
-				}				
+				}
 			},
 			"advanced":{
-				"effects" : {
-					"waterWalk" : {
-						"val" : 20
+				"adventureEffect" : {
+					"type" : "generic",
+					"bonuses" : {
+						"waterWalk" : {
+							"val" : 20
+						}
 					}
-				}			
+				}
 			},
 			"expert":{
-				"effects" : {
-					"waterWalk" : {
-						"val" : 0
+				"adventureEffect" : {
+					"type" : "generic",
+					"bonuses" : {
+						"waterWalk" : {
+							"val" : 0
+						}
 					}
-				}			
+				}
 			}
 		},
 		"flags" : {
@@ -250,7 +369,31 @@
 		},
 		"levels" : {
 			"base":{
-				"range" : "X"
+				"range" : "X",
+				"adventureEffect" : {
+					"type" : "dimensionDoor",
+					"movementPointsRequired" : 0,
+					"movementPointsTaken" : 300,
+					"waterLandFailureTakesPoints" : true,
+					"cursor" : "mapDimensionDoor", // defined in config/cursors.json
+					"cursorGuarded" : "mapTurn1Attack", // defined in config/cursors.json
+					"castsPerDay" : 2,
+					"rangeX" : 9,
+					"rangeY" : 8,
+					"ignoreFow" : true,
+					"exposeFow" : true
+				}
+			},
+			"advanced":{
+				"adventureEffect" : {
+					"castsPerDay" : 3
+				}
+			},
+			"expert":{
+				"adventureEffect" : {
+					"castsPerDay" : 4,
+					"movementPointsTaken" : 200
+				}
 			}
 		},
 		"flags" : {
@@ -266,7 +409,29 @@
 		},
 		"levels" : {
 			"base":{
-				"range" : "X"
+				"range" : "X",
+				"adventureEffect" : {
+					"type" : "townPortal",
+					"castsPerDay" : 2,
+					"allowTownSelection" : false,
+					"skipOccupiedTowns" : false,
+					"movementPointsRequired" : 300,
+					"movementPointsTaken" : 300
+				}
+			},
+			"advanced":{
+				"adventureEffect" : {
+					"allowTownSelection" : true,
+					"movementPointsRequired" : 200,
+					"movementPointsTaken" : 200
+				}
+			},
+			"expert":{
+				"adventureEffect" : {
+					"allowTownSelection" : true,
+					"movementPointsRequired" : 200,
+					"movementPointsTaken" : 200
+				}
 			}
 		},
 		"flags" : {

+ 181 - 2
docs/modders/Entities_Format/Spell_Format.md

@@ -241,8 +241,6 @@ TODO
 		"firstEffect": {[bonus format]},
 		"secondEffect": {[bonus format]}
 		//...
-
-	
 	},
 
 	// DEPRECATED, please use "battleEffects" with timed effect and "cumulative" set to true instead
@@ -260,6 +258,11 @@ TODO
 		"mod:secondEffect": {[effect format]}
 		//...
 	}
+	
+	/// See Configurable adventure map effects section below for detailed description
+	"adventureEffect" : {
+		[effect format]
+	}
 }
 ```
 
@@ -672,3 +675,179 @@ Value of all bonuses can be affected by following bonuses:
 
 - range 0: any single obstacle
 - range X: all obstacles
+
+## Configurable adventure map effects
+
+Currently, VCMI does not allow completely new spell effects for adventure maps. However, it is possible to:
+
+- modify the parameters of all H3 spells.
+- create spells with similar effects to H3 spells
+- create a spell that gives bonuses to the hero who cast the spell.
+
+Unlike combat effects, adventure map spells can only have one special effect, such as the Dimension Door or Town Portal effect. The number of bonuses granted by an adventure map spell is unlimited.
+
+The AI has a limited understanding of adventure map spells and may use the following spells:
+
+- Spells that give `WATER_WALKING` or `FLYING_MOVEMENT` bonuses
+- Spells with the Summon Boat effect, provided the spell can create new boats with a 100% success chance.
+- Any spells with the Town Portal effect.
+
+### Common format
+
+All properties in this section can be used for all non-generic adventure map spell effects.
+
+Parameters:
+
+- `type` - the type of spell effect used for this spell, or `generic` if a custom mechanic is not used.
+- `castsPerDay` - Optional. Defines how many times a hero can cast this spell per day; set to zero or omit for unlimited use.
+- `castsPerDayXL` - Optional. An alternative cast-per-day limit that is only active on maps that are at least XL+U in size. If this value is not set or is set to zero, the game will use the value of the `castsPerDay` variable.
+- `bonuses` - A list of bonuses that will be given to the hero when this spell is cast successfully. When used with effects that can fail (e.g. Summon Boat), the bonuses will only apply to a successful cast.
+
+Example:
+
+```json
+"adventureEffect" : {
+	"type" : "generic",
+	"castsPerDay" : 0,
+	"castsPerDayXL" : 0,
+	"bonuses" : {
+		"fly" : {
+			"type" : "FLYING_MOVEMENT",
+			"duration" : "ONE_DAY",
+			"val" : 40,
+			"valueType" : "INDEPENDENT_MIN"
+		}
+	}
+}
+```
+
+### Dimension Door
+
+The effect instantly teleports the hero to the selected location.
+
+Parameters:
+
+- `movementPointsRequired` - The amount of movement points the hero must have to cast this spell.
+- `movementPointsTaken` - The amount of movement points that will be taken if the spell is cast successfully. If the hero does not have enough movement points, they will be reduced to zero after casting.
+- `waterLandFailureTakesPoints` - If set to true, mana and movement points will be spent on an attempt to teleport to an inaccessible location (e.g. teleporting to land while in a boat).
+- `cursor` - Identifier of the cursor that will be shown when hovering over a valid destination tile. See `config/cursors.json` for more details.
+- `cursorGuarded` - alternative cursor that appears if using the teleport spell on a target would result in combat. This is only used if the game rule 'dimensionDoorTriggersGuards' is active.
+- `exposeFow` - If this is set to true, using this spell will reveal information behind fog of war, such as whether teleportation is possible or if the location is guarded.
+- `ignoreFow` - If this is set to true, it is possible to use the spell to teleport into terra incognita.
+- `rangeX` - maximum distance to teleport in the X dimension (left-right axis).
+- `rangeY` - maximum distance to teleport in the Y dimension (top-bottom axis).
+
+Example:
+
+```json
+"adventureEffect" : {
+	"type" : "dimensionDoor",
+	"movementPointsRequired" : 0,
+	"movementPointsTaken" : 300,
+	"waterLandFailureTakesPoints" : true,
+	"cursor" : "mapDimensionDoor",
+	"cursorGuarded" : "mapTurn1Attack",
+	"castsPerDay" : 2,
+	"rangeX" : 9,
+	"rangeY" : 8,
+	"ignoreFow" : true,
+	"exposeFow" : true
+}
+```
+
+### Remove Object
+
+The effect completely removes the targeted object from the map. The Scuttle Boat spell is an example of this effect. The success chance is defined as [spell effect power](#spell-power).
+
+Parameters:
+
+- `objects` - a list of map objects that can be removed by this spell.
+- `cursor` - identifier of the cursor that will be displayed when hovering over a valid target object.  See `config/cursors.json` for more details.
+- `ignoreFow` - If set to true, it is possible to use this spell to remove objects behind terra incognita.
+- `rangeX` - maximum distance to remove objects in the X dimension (left-right axis).
+- `rangeY` - maximum distance to remove objects in the Y dimension (top-bottom axis).
+
+Example:
+
+```json
+"adventureEffect" : {
+	"type" : "removeObject",
+	"castsPerDay" : 0,
+	"cursor" : "mapScuttleBoat",
+	"rangeX" : 9,
+	"rangeY" : 8,
+	"ignoreFow" : false,
+	"objects" : {
+		"boat" : true
+	}
+}
+```
+
+### Summon Boat
+
+The effect moves or creates a boat next to the hero who cast the spell. The success chance is defined as [spell effect power](#spell-power).
+
+Parameters:
+
+- `useExistingBoat` - If this is set to true, the spell can move existing boats to the hero's location.
+- `createdBoat` - Optional identifier of the boat type that can be created by this spell. If this is not set, the spell cannot create new boats.
+
+Note that if the spell can both create new boats and use existing ones, it would prefer to move existing boats and only create new ones if there are no suitable ones to move.
+
+Example:
+
+```json
+"adventureEffect" : {
+	"type" : "summonBoat",
+	"castsPerDay" : 0,
+	"useExistingBoat" : true,
+	"createdBoat" : "boatNecropolis"
+}
+```
+
+### Town Portal
+
+Effect moves hero to a location of owned or allied town.
+
+Parameters:
+
+- `movementPointsRequired` - amount of movement points that hero must have to cast this spell
+- `movementPointsTaken` - amount of movement points that will be taken on sucessful cast of the spell. If hero does not have enough movement points, they will be reduced to zero after cast
+- `allowTownSelection` - if set to true, player will be able to select town to teleport to among all friendly non-occupied towns.
+- `skipOccupiedTowns` - if set to true, hero will teleport to nearest non-occupied town, ignoring any closer towns that are occupied by a visiting hero. No effect if `allowTownSelection` is set.
+
+Example:
+
+```json
+"adventureEffect" : {
+	"type" : "townPortal",
+	"castsPerDay" : 2,
+	"allowTownSelection" : false,
+	"skipOccupiedTowns" : false,
+	"movementPointsRequired" : 300,
+	"movementPointsTaken" : 300
+}
+```
+
+### View World
+
+Effect shows World View menu with specified objects behind FoW revealed to the player
+
+Parameters:
+
+- `objects` - list of object types that will be revealed on World View. Note that only following objects have assotiated icon, any objects not from this list will not be visible: `resource`, `mine`, `abandonedMine`, `artifact`, `hero`, `town`.
+- `showTerrain` - if set to true, terrain of the entire map (but not objects on it) will be revealed to the player.
+
+Example:
+
+```json
+"adventureEffect" : {
+	"type" : "viewWorld",
+	"objects" : {
+		"resource" : true,
+		"mine" : true,
+		"abandonedMine" : true
+	},
+	"showTerrain" : true
+}
+```

+ 12 - 10
lib/CMakeLists.txt

@@ -268,12 +268,13 @@ set(lib_MAIN_SRCS
 	spells/TargetCondition.cpp
 	spells/ViewSpellInt.cpp
 
+	spells/adventure/AdventureSpellEffect.cpp
 	spells/adventure/AdventureSpellMechanics.cpp
-	spells/adventure/DimensionDoorMechanics.cpp
-	spells/adventure/ScuttleBoatMechanics.cpp
-	spells/adventure/SummonBoatMechanics.cpp
-	spells/adventure/TownPortalMechanics.cpp
-	spells/adventure/ViewWorldMechanics.cpp
+	spells/adventure/DimensionDoorEffect.cpp
+	spells/adventure/RemoveObjectEffect.cpp
+	spells/adventure/SummonBoatEffect.cpp
+	spells/adventure/TownPortalEffect.cpp
+	spells/adventure/ViewWorldEffect.cpp
 
 	spells/effects/Catapult.cpp
 	spells/effects/Clone.cpp
@@ -731,11 +732,12 @@ set(lib_MAIN_HEADERS
 	spells/ViewSpellInt.h
 
 	spells/adventure/AdventureSpellMechanics.h
-	spells/adventure/DimensionDoorMechanics.h
-	spells/adventure/ScuttleBoatMechanics.h
-	spells/adventure/SummonBoatMechanics.h
-	spells/adventure/TownPortalMechanics.h
-	spells/adventure/ViewWorldMechanics.h
+	spells/adventure/AdventureSpellEffect.h
+	spells/adventure/DimensionDoorEffect.h
+	spells/adventure/RemoveObjectEffect.h
+	spells/adventure/SummonBoatEffect.h
+	spells/adventure/TownPortalEffect.h
+	spells/adventure/ViewWorldEffect.h
 
 	spells/effects/Catapult.h
 	spells/effects/Clone.h

+ 0 - 4
lib/GameSettings.cpp

@@ -68,10 +68,6 @@ const std::vector<GameSettings::SettingOption> GameSettings::settingProperties =
 		{EGameSettings::CREATURES_JOINING_PERCENTAGE,                     "creatures", "joiningPercentage"                    },
 		{EGameSettings::CREATURES_WEEKLY_GROWTH_CAP,                      "creatures", "weeklyGrowthCap"                      },
 		{EGameSettings::CREATURES_WEEKLY_GROWTH_PERCENT,                  "creatures", "weeklyGrowthPercent"                  },
-		{EGameSettings::DIMENSION_DOOR_EXPOSES_TERRAIN_TYPE,              "spells",    "dimensionDoorExposesTerrainType"      },
-		{EGameSettings::DIMENSION_DOOR_FAILURE_SPENDS_POINTS,             "spells",    "dimensionDoorFailureSpendsPoints"     },
-		{EGameSettings::DIMENSION_DOOR_ONLY_TO_UNCOVERED_TILES,           "spells",    "dimensionDoorOnlyToUncoveredTiles"    },
-		{EGameSettings::DIMENSION_DOOR_TOURNAMENT_RULES_LIMIT,            "spells",    "dimensionDoorTournamentRulesLimit"    },
 		{EGameSettings::DIMENSION_DOOR_TRIGGERS_GUARDS,                   "spells",    "dimensionDoorTriggersGuards"          },
 		{EGameSettings::DWELLINGS_ACCUMULATE_WHEN_NEUTRAL,                "dwellings", "accumulateWhenNeutral"                },
 		{EGameSettings::DWELLINGS_ACCUMULATE_WHEN_OWNED,                  "dwellings", "accumulateWhenOwned"                  },

+ 0 - 4
lib/IGameSettings.h

@@ -41,10 +41,6 @@ enum class EGameSettings
 	CREATURES_JOINING_PERCENTAGE,
 	CREATURES_WEEKLY_GROWTH_CAP,
 	CREATURES_WEEKLY_GROWTH_PERCENT,
-	DIMENSION_DOOR_EXPOSES_TERRAIN_TYPE,
-	DIMENSION_DOOR_FAILURE_SPENDS_POINTS,
-	DIMENSION_DOOR_ONLY_TO_UNCOVERED_TILES,
-	DIMENSION_DOOR_TOURNAMENT_RULES_LIMIT,
 	DIMENSION_DOOR_TRIGGERS_GUARDS,
 	DWELLINGS_ACCUMULATE_WHEN_NEUTRAL,
 	DWELLINGS_ACCUMULATE_WHEN_OWNED,

+ 4 - 0
lib/callback/CDynLibHandler.cpp

@@ -67,6 +67,10 @@ VCMI_LIB_NAMESPACE_BEGIN
 		getName = reinterpret_cast<TGetNameFun>(dlsym(dll, "GetAiName"));
 		getAI = reinterpret_cast<TGetAIFun>(dlsym(dll, methodName.c_str()));
 	}
+	else
+	{
+		logGlobal->error("Cannot open dynamic library '%s'. Reason: %s", libpath.string(), dlerror());
+	}
 #endif // VCMI_WINDOWS
 
 	if (!dll)

+ 1 - 1
lib/callback/CGameInfoCallback.h

@@ -72,7 +72,7 @@ public:
 	//map
 	int3 guardingCreaturePosition (int3 pos) const override;
 	std::vector<const CGObjectInstance*> getGuardingCreatures (int3 pos) const override;
-	bool isTileGuardedUnchecked(int3 tile) const;
+	bool isTileGuardedUnchecked(int3 tile) const override;
 	const TerrainTile * getTile(int3 tile, bool verbose = true) const override;
 	const TerrainTile * getTileUnchecked(int3 tile) const override;
 	void getVisibleTilesInRange(std::unordered_set<int3> &tiles, int3 pos, int radious, int3::EDistanceFormula distanceFormula = int3::DIST_2D) const;

+ 5 - 0
lib/callback/EditorCallback.cpp

@@ -62,6 +62,11 @@ const TerrainTile * EditorCallback::getTileUnchecked(int3) const
 	THROW_EDITOR_UNSUPPORTED;
 }
 
+bool EditorCallback::isTileGuardedUnchecked(int3 tile) const
+{
+	THROW_EDITOR_UNSUPPORTED;
+}
+
 const CGObjectInstance * EditorCallback::getTopObj(int3) const
 {
 	THROW_EDITOR_UNSUPPORTED;

+ 1 - 0
lib/callback/EditorCallback.h

@@ -32,6 +32,7 @@ public:
 
 	const TerrainTile * getTile(int3 tile, bool verbose) const override;
 	const TerrainTile * getTileUnchecked(int3 tile) const override;
+	bool isTileGuardedUnchecked(int3 tile) const override;
 	const CGObjectInstance * getTopObj(int3 pos) const override;
 	EDiggingStatus getTileDigStatus(int3 tile, bool verbose) const override;
 	void calculatePaths(const std::shared_ptr<PathfinderConfig> & config) const override;

+ 2 - 0
lib/callback/IGameInfoCallback.h

@@ -147,6 +147,8 @@ public:
 	virtual bool checkForVisitableDir(const int3 & src, const int3 & dst) const = 0;
 	/// Returns all wandering monsters that guard specified tile
 	virtual std::vector<const CGObjectInstance *> getGuardingCreatures (int3 pos) const = 0;
+	/// Returns if tile is guarded by wandering monsters without checking whether player has access to the tile. AVOID USAGE.
+	virtual bool isTileGuardedUnchecked(int3 tile) const = 0;
 
 	/// Returns all tiles within specified range with specific tile visibility mode
 	virtual void getTilesInRange(std::unordered_set<int3> & tiles, const int3 & pos, int radius, ETileVisibility mode, std::optional<PlayerColor> player = std::optional<PlayerColor>(), int3::EDistanceFormula formula = int3::DIST_2D) const = 0;

+ 10 - 10
lib/constants/EntityIdentifiers.h

@@ -856,16 +856,16 @@ public:
 		NONE = -1,
 
 		// Adventure map spells
-		SUMMON_BOAT = 0,
-		SCUTTLE_BOAT = 1,
-		VISIONS = 2,
-		VIEW_EARTH = 3,
-		DISGUISE = 4,
-		VIEW_AIR = 5,
-		FLY = 6,
-		WATER_WALK = 7,
-		DIMENSION_DOOR = 8,
-		TOWN_PORTAL = 9,
+		SUMMON_BOAT [[deprecated("check for spell mechanics instead of spell ID")]] = 0,
+		SCUTTLE_BOAT [[deprecated("check for spell mechanics instead of spell ID")]] = 1,
+		VISIONS [[deprecated("check for spell mechanics instead of spell ID")]] = 2,
+		VIEW_EARTH [[deprecated("check for spell mechanics instead of spell ID")]] = 3,
+		DISGUISE [[deprecated("check for spell mechanics instead of spell ID")]] = 4,
+		VIEW_AIR [[deprecated("check for spell mechanics instead of spell ID")]] = 5,
+		FLY [[deprecated("check for spell mechanics instead of spell ID")]] = 6,
+		WATER_WALK [[deprecated("check for spell mechanics instead of spell ID")]] = 7,
+		DIMENSION_DOOR [[deprecated("check for spell mechanics instead of spell ID")]] = 8,
+		TOWN_PORTAL [[deprecated("check for spell mechanics instead of spell ID")]] = 9,
 
 		// Combat spells
 		QUICKSAND = 10,

+ 17 - 5
lib/pathfinder/CPathfinder.cpp

@@ -25,6 +25,7 @@
 #include "../mapObjects/MiscObjects.h"
 #include "../mapping/CMap.h"
 #include "../spells/CSpellHandler.h"
+#include "spells/ISpellMechanics.h"
 
 VCMI_LIB_NAMESPACE_BEGIN
 
@@ -502,7 +503,9 @@ CPathfinderHelper::CPathfinderHelper(const IGameInfoCallback & gameInfo, const C
 	turn(-1),
 	owner(Hero->tempOwner),
 	hero(Hero),
-	options(Options)
+	options(Options),
+	canCastFly(false),
+	canCastWaterWalk(false)
 {
 	turnsInfo.reserve(16);
 	updateTurnInfo();
@@ -510,11 +513,20 @@ CPathfinderHelper::CPathfinderHelper(const IGameInfoCallback & gameInfo, const C
 
 	whirlpoolProtection = Hero->hasBonusOfType(BonusType::WHIRLPOOL_PROTECTION);
 
-	SpellID flySpell = SpellID::FLY;
-	canCastFly = Hero->canCastThisSpell(flySpell.toSpell());
+	if (options.canUseCast)
+	{
+		for (const auto & spell : LIBRARY->spellh->objects)
+		{
+			if (!spell || !spell->isAdventure())
+				continue;
 
-	SpellID waterWalk = SpellID::WATER_WALK;
-	canCastWaterWalk = Hero->canCastThisSpell(waterWalk.toSpell());
+			if(spell->getAdventureMechanics().givesBonus(hero, BonusType::WATER_WALKING) && hero->canCastThisSpell(spell.get()) && hero->mana >= hero->getSpellCost(spell.get()))
+				canCastWaterWalk = true;
+
+			if(spell->getAdventureMechanics().givesBonus(hero, BonusType::FLYING_MOVEMENT) && hero->canCastThisSpell(spell.get()) && hero->mana >= hero->getSpellCost(spell.get()))
+				canCastFly = true;
+		}
+	}
 }
 
 CPathfinderHelper::~CPathfinderHelper() = default;

+ 2 - 6
lib/spells/CSpellHandler.cpp

@@ -536,12 +536,6 @@ CSpell::TargetInfo::TargetInfo(const CSpell * spell, const int level, spells::Mo
 	clearAffected = levelInfo.clearAffected;
 }
 
-bool DLL_LINKAGE isInScreenRange(const int3 & center, const int3 & pos)
-{
-	int3 diff = pos - center;
-	return diff.x >= -9 && diff.x <= 9 && diff.y >= -8 && diff.y <= 8;
-}
-
 ///CSpellHandler
 std::vector<JsonNode> CSpellHandler::loadLegacyData()
 {
@@ -997,6 +991,8 @@ std::shared_ptr<CSpell> CSpellHandler::loadFromJson(const std::string & scope, c
 			levelObject.cumulativeEffects.push_back(b);
 		}
 
+		levelObject.adventureEffect = levelNode["adventureEffect"];
+
 		if(!levelNode["battleEffects"].Struct().empty())
 		{
 			levelObject.battleEffects = levelNode["battleEffects"];

+ 1 - 2
lib/spells/CSpellHandler.h

@@ -101,6 +101,7 @@ public:
 		std::vector<std::shared_ptr<Bonus>> cumulativeEffects; //deprecated
 
 		JsonNode battleEffects;
+		JsonNode adventureEffect;
 	};
 
 	/** \brief Low level accessor. Don`t use it if absolutely necessary
@@ -289,8 +290,6 @@ private:
 	std::unique_ptr<IAdventureSpellMechanics> adventureMechanics;//(!) do not serialize
 };
 
-bool DLL_LINKAGE isInScreenRange(const int3 &center, const int3 &pos); //for spells like Dimension Door
-
 class DLL_LINKAGE CSpellHandler: public CHandlerBase<SpellID, spells::Spell, CSpell, spells::Service>
 {
 	std::vector<int> spellRangeInHexes(std::string rng) const;

+ 4 - 27
lib/spells/ISpellMechanics.cpp

@@ -27,11 +27,6 @@
 #include "Problem.h"
 
 #include "adventure/AdventureSpellMechanics.h"
-#include "adventure/DimensionDoorMechanics.h"
-#include "adventure/ScuttleBoatMechanics.h"
-#include "adventure/SummonBoatMechanics.h"
-#include "adventure/TownPortalMechanics.h"
-#include "adventure/ViewWorldMechanics.h"
 
 #include "BattleSpellMechanics.h"
 
@@ -648,28 +643,10 @@ IAdventureSpellMechanics::IAdventureSpellMechanics(const CSpell * s)
 
 std::unique_ptr<IAdventureSpellMechanics> IAdventureSpellMechanics::createMechanics(const CSpell * s)
 {
-	switch(s->id.toEnum())
-	{
-	case SpellID::SUMMON_BOAT:
-		return std::make_unique<SummonBoatMechanics>(s);
-	case SpellID::SCUTTLE_BOAT:
-		return std::make_unique<ScuttleBoatMechanics>(s);
-	case SpellID::DIMENSION_DOOR:
-		return std::make_unique<DimensionDoorMechanics>(s);
-	case SpellID::FLY:
-	case SpellID::WATER_WALK:
-	case SpellID::VISIONS:
-	case SpellID::DISGUISE:
-		return std::make_unique<AdventureSpellMechanics>(s); //implemented using bonus system
-	case SpellID::TOWN_PORTAL:
-		return std::make_unique<TownPortalMechanics>(s);
-	case SpellID::VIEW_EARTH:
-		return std::make_unique<ViewEarthMechanics>(s);
-	case SpellID::VIEW_AIR:
-		return std::make_unique<ViewAirMechanics>(s);
-	default:
-		return s->isCombat() ? std::unique_ptr<IAdventureSpellMechanics>() : std::make_unique<AdventureSpellMechanics>(s);
-	}
+	if (s->isCombat())
+		return nullptr;
+
+	return std::make_unique<AdventureSpellMechanics>(s);
 }
 
 VCMI_LIB_NAMESPACE_END

+ 11 - 1
lib/spells/ISpellMechanics.h

@@ -30,6 +30,7 @@ class JsonNode;
 class CStack;
 class CGObjectInstance;
 class CGHeroInstance;
+class IAdventureSpellEffect;
 
 namespace spells
 {
@@ -354,11 +355,20 @@ public:
 
 	virtual bool canBeCast(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const = 0;
 	virtual bool canBeCastAt(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const = 0;
-
 	virtual bool adventureCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const = 0;
 
 	static std::unique_ptr<IAdventureSpellMechanics> createMechanics(const CSpell * s);
+
+	virtual bool givesBonus(const spells::Caster * caster, BonusType which) const = 0;
+
+	template<typename EffectType>
+	const EffectType * getEffectAs(const spells::Caster * caster) const
+	{
+		return dynamic_cast<const EffectType *>(getEffect(caster));
+	}
 protected:
+	virtual const IAdventureSpellEffect * getEffect(const spells::Caster * caster) const = 0;
+
 	const CSpell * owner;
 };
 

+ 46 - 0
lib/spells/adventure/AdventureSpellEffect.cpp

@@ -0,0 +1,46 @@
+/*
+ * AdventureSpellEffect.cpp, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+
+#include "StdInc.h"
+#include "AdventureSpellEffect.h"
+
+#include "../../json/JsonNode.h"
+#include "../../mapObjects/CGHeroInstance.h"
+#include "../../callback/IGameInfoCallback.h"
+
+VCMI_LIB_NAMESPACE_BEGIN
+
+AdventureSpellRangedEffect::AdventureSpellRangedEffect(const JsonNode & config)
+	: rangeX(config["rangeX"].Integer())
+	, rangeY(config["rangeY"].Integer())
+	, ignoreFow(config["ignoreFow"].Bool())
+{
+}
+
+bool AdventureSpellRangedEffect::isTargetInRange(const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const
+{
+	if(!cb->isInTheMap(pos))
+		return false;
+
+	if(caster->getHeroCaster())
+	{
+		int3 center = caster->getHeroCaster()->getSightCenter();
+
+		int3 diff = pos - center;
+		return diff.x >= -rangeX && diff.x <= rangeX && diff.y >= -rangeY && diff.y <= rangeY;
+	}
+
+	if(!ignoreFow && !cb->isVisibleFor(pos, caster->getCasterOwner()))
+		return false;
+
+	return true;
+}
+
+VCMI_LIB_NAMESPACE_END

+ 60 - 0
lib/spells/adventure/AdventureSpellEffect.h

@@ -0,0 +1,60 @@
+/*
+ * AdventureSpellEffect.h, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+
+#pragma once
+
+#include "../ISpellMechanics.h"
+
+VCMI_LIB_NAMESPACE_BEGIN
+
+enum class ESpellCastResult : int8_t
+{
+	OK, // cast successful
+	CANCEL, // cast failed but it is not an error, no mana has been spent
+	PENDING,
+	ERROR // error occurred, for example invalid request from player
+};
+
+class AdventureSpellMechanics;
+
+class IAdventureSpellEffect
+{
+public:
+	virtual ~IAdventureSpellEffect() = default;
+
+	virtual ESpellCastResult applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const {return ESpellCastResult::OK;};
+	virtual ESpellCastResult beginCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters, const AdventureSpellMechanics & mechanics) const {return ESpellCastResult::OK;};
+	virtual void endCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const {};
+	virtual bool canBeCastImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const {return true;};
+	virtual bool canBeCastAtImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const {return true;};
+	virtual std::string getCursorForTarget(const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const {return {};};
+};
+
+class AdventureSpellEffect final : public IAdventureSpellEffect
+{
+public:
+	AdventureSpellEffect() = default;
+};
+
+class DLL_LINKAGE AdventureSpellRangedEffect : public IAdventureSpellEffect
+{
+	int rangeX;
+	int rangeY;
+	bool ignoreFow;
+
+public:
+	AdventureSpellRangedEffect(const JsonNode & config);
+
+	bool isTargetInRange(const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const;
+	std::string getCursorForTarget(const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const override = 0; //must be implemented in derived classes
+	bool canBeCastAtImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const override = 0; //must be implemented in derived classes
+};
+
+VCMI_LIB_NAMESPACE_END

+ 104 - 54
lib/spells/adventure/AdventureSpellMechanics.cpp

@@ -11,17 +11,86 @@
 #include "StdInc.h"
 #include "AdventureSpellMechanics.h"
 
+#include "AdventureSpellEffect.h"
+#include "DimensionDoorEffect.h"
+#include "RemoveObjectEffect.h"
+#include "SummonBoatEffect.h"
+#include "TownPortalEffect.h"
+#include "ViewWorldEffect.h"
+
 #include "../CSpellHandler.h"
 #include "../Problem.h"
 
+#include "../../json/JsonBonus.h"
 #include "../../mapObjects/CGHeroInstance.h"
 #include "../../networkPacks/PacksForClient.h"
+#include "../../callback/IGameInfoCallback.h"
 
 VCMI_LIB_NAMESPACE_BEGIN
 
+std::unique_ptr<IAdventureSpellEffect> AdventureSpellMechanics::createAdventureEffect(const CSpell * s, const JsonNode & node)
+{
+	const std::string & typeID = node["type"].String();
+
+	if(typeID == "generic")
+		return std::make_unique<AdventureSpellEffect>();
+	if(typeID == "dimensionDoor")
+		return std::make_unique<DimensionDoorEffect>(s, node);
+	if(typeID == "removeObject")
+		return std::make_unique<RemoveObjectEffect>(s, node);
+	if(typeID == "summonBoat")
+		return std::make_unique<SummonBoatEffect>(s, node);
+	if(typeID == "townPortal")
+		return std::make_unique<TownPortalEffect>(s, node);
+	if(typeID == "viewWorld")
+		return std::make_unique<ViewWorldEffect>(s, node);
+
+	return std::make_unique<AdventureSpellEffect>();
+}
+
 AdventureSpellMechanics::AdventureSpellMechanics(const CSpell * s)
 	: IAdventureSpellMechanics(s)
 {
+	for(int level = 0; level < GameConstants::SPELL_SCHOOL_LEVELS; level++)
+	{
+		const JsonNode & config = s->getLevelInfo(level).adventureEffect;
+
+		levelOptions[level].effect = createAdventureEffect(s, config);
+		levelOptions[level].castsPerDay = config["castsPerDay"].Integer();
+		levelOptions[level].castsPerDayXL = config["castsPerDayXL"].Integer();
+
+		levelOptions[level].bonuses = s->getLevelInfo(level).effects;
+
+		for(const auto & elem : config["bonuses"].Struct())
+		{
+			auto b = JsonUtils::parseBonus(elem.second);
+			b->sid = BonusSourceID(s->id);
+			b->source = BonusSource::SPELL_EFFECT;
+			levelOptions[level].bonuses.push_back(b);
+		}
+	}
+}
+
+AdventureSpellMechanics::~AdventureSpellMechanics() = default;
+
+const AdventureSpellMechanics::LevelOptions & AdventureSpellMechanics::getLevel(const spells::Caster * caster) const
+{
+	int schoolLevel = caster->getSpellSchoolLevel(owner);
+	return levelOptions.at(schoolLevel);
+}
+
+const IAdventureSpellEffect * AdventureSpellMechanics::getEffect(const spells::Caster * caster) const
+{
+	return getLevel(caster).effect.get();
+}
+
+bool AdventureSpellMechanics::givesBonus(const spells::Caster * caster, BonusType which) const
+{
+	for (const auto & bonus : getLevel(caster).bonuses)
+		if (bonus->type == which)
+			return true;
+
+	return false;
 }
 
 bool AdventureSpellMechanics::canBeCast(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const
@@ -44,24 +113,30 @@ bool AdventureSpellMechanics::canBeCast(spells::Problem & problem, const IGameIn
 
 		if(heroCaster->mana < cost)
 			return false;
-	}
 
-	return canBeCastImpl(problem, cb, caster);
-}
+		std::stringstream cachingStr;
+		cachingStr << "source_" << vstd::to_underlying(BonusSource::SPELL_EFFECT) << "id_" << owner->id.num;
+		int castsAlreadyPerformedThisTurn = caster->getHeroCaster()->getBonuses(Selector::source(BonusSource::SPELL_EFFECT, BonusSourceID(owner->id)), cachingStr.str())->size();
+		int3 mapSize = cb->getMapSize();
+		bool mapSizeIsAtLeastXL = mapSize.x * mapSize.y * mapSize.z >= GameConstants::TOURNAMENT_RULES_DD_MAP_TILES_THRESHOLD;
+		bool useAlternativeLimit = mapSizeIsAtLeastXL && getLevel(caster).castsPerDayXL != 0;
+		int castsLimit = useAlternativeLimit ? getLevel(caster).castsPerDayXL : getLevel(caster).castsPerDay;
 
-bool AdventureSpellMechanics::canBeCastAt(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const
-{
-	return canBeCast(problem, cb, caster) && canBeCastAtImpl(problem, cb, caster, pos);
-}
+		if(castsLimit > 0 && castsLimit <= castsAlreadyPerformedThisTurn ) //limit casts per turn
+		{
+			MetaString message = MetaString::createFromTextID("core.genrltxt.338");
+			caster->getCasterName(message);
+			problem.add(std::move(message));
+			return false;
+		}
+	}
 
-bool AdventureSpellMechanics::canBeCastImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const
-{
-	return true;
+	return getLevel(caster).effect->canBeCastImpl(problem, cb, caster);
 }
 
-bool AdventureSpellMechanics::canBeCastAtImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const
+bool AdventureSpellMechanics::canBeCastAt(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const
 {
-	return true;
+	return canBeCast(problem, cb, caster) && getLevel(caster).effect->canBeCastAtImpl(problem, cb, caster, pos);
 }
 
 bool AdventureSpellMechanics::adventureCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
@@ -71,7 +146,7 @@ bool AdventureSpellMechanics::adventureCast(SpellCastEnvironment * env, const Ad
 	if(!canBeCastAt(problem, env->getCb(), parameters.caster, parameters.pos))
 		return false;
 
-	ESpellCastResult result = beginCast(env, parameters);
+	ESpellCastResult result = getLevel(parameters.caster).effect->beginCast(env, parameters, *this);
 
 	if(result == ESpellCastResult::OK)
 		performCast(env, parameters);
@@ -79,43 +154,21 @@ bool AdventureSpellMechanics::adventureCast(SpellCastEnvironment * env, const Ad
 	return result != ESpellCastResult::ERROR;
 }
 
-ESpellCastResult AdventureSpellMechanics::applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
+void AdventureSpellMechanics::giveBonuses(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
 {
-	if(owner->hasEffects())
+	for(const auto & b : getLevel(parameters.caster).bonuses)
 	{
-		//todo: cumulative effects support
-		const auto schoolLevel = parameters.caster->getSpellSchoolLevel(owner);
-
-		std::vector<Bonus> bonuses;
-
-		owner->getEffects(bonuses, schoolLevel, false, parameters.caster->getEnchantPower(owner));
-
-		for(const Bonus & b : bonuses)
-		{
-			GiveBonus gb;
-			gb.id = ObjectInstanceID(parameters.caster->getCasterUnitId());
-			gb.bonus = b;
-			env->apply(gb);
-		}
-
-		return ESpellCastResult::OK;
-	}
-	else
-	{
-		//There is no generic algorithm of adventure cast
-		env->complain("Unimplemented adventure spell");
-		return ESpellCastResult::ERROR;
+		GiveBonus gb;
+		gb.id = ObjectInstanceID(parameters.caster->getCasterUnitId());
+		gb.bonus = *b;
+		gb.bonus.duration = parameters.caster->getEnchantPower(owner);
+		env->apply(gb);
 	}
-}
 
-ESpellCastResult AdventureSpellMechanics::beginCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
-{
-	return ESpellCastResult::OK;
-}
-
-void AdventureSpellMechanics::endCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
-{
-	// no-op, only for implementation in derived classes
+	GiveBonus gb;
+	gb.id = ObjectInstanceID(parameters.caster->getCasterUnitId());
+	gb.bonus = Bonus(BonusDuration::ONE_DAY, BonusType::NONE, BonusSource::SPELL_EFFECT, 0, BonusSourceID(owner->id));
+	env->apply(gb);
 }
 
 void AdventureSpellMechanics::performCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
@@ -128,16 +181,13 @@ void AdventureSpellMechanics::performCast(SpellCastEnvironment * env, const Adve
 	asc.spellID = owner->id;
 	env->apply(asc);
 
-	ESpellCastResult result = applyAdventureEffects(env, parameters);
+	ESpellCastResult result = getLevel(parameters.caster).effect->applyAdventureEffects(env, parameters);
 
-	switch(result)
+	if (result == ESpellCastResult::OK)
 	{
-		case ESpellCastResult::OK:
-			parameters.caster->spendMana(env, cost);
-			endCast(env, parameters);
-			break;
-		default:
-			break;
+		giveBonuses(env, parameters);
+		parameters.caster->spendMana(env, cost);
+		getLevel(parameters.caster).effect->endCast(env, parameters);
 	}
 }
 

+ 23 - 20
lib/spells/adventure/AdventureSpellMechanics.h

@@ -14,33 +14,36 @@
 
 VCMI_LIB_NAMESPACE_BEGIN
 
-enum class ESpellCastResult
-{
-	OK, // cast successful
-	CANCEL, // cast failed but it is not an error, no mana has been spent
-	PENDING,
-	ERROR // error occurred, for example invalid request from player
-};
+class IAdventureSpellEffect;
 
-class AdventureSpellMechanics : public IAdventureSpellMechanics
+class AdventureSpellMechanics final : public IAdventureSpellMechanics, boost::noncopyable
 {
-public:
-	AdventureSpellMechanics(const CSpell * s);
+	struct LevelOptions
+	{
+		std::unique_ptr<IAdventureSpellEffect> effect;
+		std::vector<std::shared_ptr<Bonus>> bonuses;
+		int castsPerDay;
+		int castsPerDayXL;
+	};
 
-	bool canBeCast(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const final;
-	bool canBeCastAt(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const final;
+	std::array<LevelOptions, GameConstants::SPELL_SCHOOL_LEVELS> levelOptions;
 
-	bool adventureCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const override final;
+	const LevelOptions & getLevel(const spells::Caster * caster) const;
+	void giveBonuses(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const;
+	std::unique_ptr<IAdventureSpellEffect> createAdventureEffect(const CSpell * s, const JsonNode & node);
 
-protected:
-	///actual adventure cast implementation
-	virtual ESpellCastResult applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const;
-	virtual ESpellCastResult beginCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const;
-	virtual void endCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const;
-	virtual bool canBeCastImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const;
-	virtual bool canBeCastAtImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const;
+public:
+	AdventureSpellMechanics(const CSpell * s);
+	~AdventureSpellMechanics();
 
 	void performCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const;
+
+private:
+	bool canBeCast(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const final;
+	bool canBeCastAt(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const final;
+	bool adventureCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const final;
+	const IAdventureSpellEffect * getEffect(const spells::Caster * caster) const final;
+	bool givesBonus(const spells::Caster * caster, BonusType which) const final;
 };
 
 VCMI_LIB_NAMESPACE_END

+ 141 - 0
lib/spells/adventure/DimensionDoorEffect.cpp

@@ -0,0 +1,141 @@
+/*
+ * DimensionDoorEffect.cpp, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+
+#include "StdInc.h"
+#include "DimensionDoorEffect.h"
+
+#include "../CSpellHandler.h"
+
+#include "../../IGameSettings.h"
+#include "../../callback/IGameInfoCallback.h"
+#include "../../mapObjects/CGHeroInstance.h"
+#include "../../mapping/TerrainTile.h"
+#include "../../networkPacks/PacksForClient.h"
+
+VCMI_LIB_NAMESPACE_BEGIN
+
+DimensionDoorEffect::DimensionDoorEffect(const CSpell * s, const JsonNode & config)
+	: AdventureSpellRangedEffect(config)
+	, cursor(config["cursor"].String())
+	, cursorGuarded(config["cursorGuarded"].String())
+	, movementPointsRequired(config["movementPointsRequired"].Integer())
+	, movementPointsTaken(config["movementPointsTaken"].Integer())
+	, waterLandFailureTakesPoints(config["waterLandFailureTakesPoints"].Bool())
+	, exposeFow(config["exposeFow"].Bool())
+{
+}
+
+std::string DimensionDoorEffect::getCursorForTarget(const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const
+{
+	if(!cb->getSettings().getBoolean(EGameSettings::DIMENSION_DOOR_TRIGGERS_GUARDS))
+		return cursor;
+
+	if (!exposeFow && !cb->isVisibleFor(pos, caster->getCasterOwner()))
+		return cursor;
+
+	if (!cb->isTileGuardedUnchecked(pos))
+		return cursor;
+
+	return cursorGuarded;
+}
+
+bool DimensionDoorEffect::canBeCastImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const
+{
+	if(!caster->getHeroCaster())
+		return false;
+
+	if(caster->getHeroCaster()->movementPointsRemaining() <= movementPointsRequired)
+	{
+		problem.add(MetaString::createFromTextID("core.genrltxt.125"));
+		return false;
+	}
+
+	return true;
+}
+
+bool DimensionDoorEffect::canBeCastAtImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const
+{
+	if (!isTargetInRange(cb, caster, pos))
+		return false;
+
+	int3 casterPosition = caster->getHeroCaster()->getSightCenter();
+	const TerrainTile * dest = cb->getTileUnchecked(pos);
+	const TerrainTile * curr = cb->getTileUnchecked(casterPosition);
+
+	if(!dest)
+		return false;
+
+	if(!curr)
+		return false;
+
+	if(exposeFow)
+	{
+		if(!dest->isClear(curr))
+			return false;
+	}
+	else
+	{
+		if(dest->blocked())
+			return false;
+	}
+
+	return true;
+}
+
+ESpellCastResult DimensionDoorEffect::applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
+{
+	int3 casterPosition = parameters.caster->getHeroCaster()->getSightCenter();
+	const TerrainTile * dest = env->getCb()->getTile(parameters.pos);
+	const TerrainTile * curr = env->getCb()->getTile(casterPosition);
+
+	if(!dest->isClear(curr))
+	{
+		InfoWindow iw;
+		iw.player = parameters.caster->getCasterOwner();
+
+		// tile is either blocked or not possible to move (e.g. water <-> land)
+		if(waterLandFailureTakesPoints)
+		{
+			// SOD: DD to such "wrong" terrain results in mana and move points spending, but fails to move hero
+			iw.text = MetaString::createFromTextID("core.genrltxt.70"); // Dimension Door failed!
+			env->apply(iw);
+			// no return - resources will be spent
+		}
+		else
+		{
+			// HotA: game will show error message without taking mana or move points, even when DD into terra incognita
+			iw.text = MetaString::createFromTextID("vcmi.dimensionDoor.seaToLandError");
+			env->apply(iw);
+			return ESpellCastResult::CANCEL;
+		}
+	}
+
+	SetMovePoints smp;
+	smp.hid = ObjectInstanceID(parameters.caster->getCasterUnitId());
+	if(movementPointsTaken < static_cast<int>(parameters.caster->getHeroCaster()->movementPointsRemaining()))
+		smp.val = parameters.caster->getHeroCaster()->movementPointsRemaining() - movementPointsTaken;
+	else
+		smp.val = 0;
+	env->apply(smp);
+
+	return ESpellCastResult::OK;
+}
+
+void DimensionDoorEffect::endCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
+{
+	int3 casterPosition = parameters.caster->getHeroCaster()->getSightCenter();
+	const TerrainTile * dest = env->getCb()->getTile(parameters.pos);
+	const TerrainTile * curr = env->getCb()->getTile(casterPosition);
+
+	if(dest->isClear(curr))
+		env->moveHero(ObjectInstanceID(parameters.caster->getCasterUnitId()), parameters.caster->getHeroCaster()->convertFromVisitablePos(parameters.pos), EMovementMode::DIMENSION_DOOR);
+}
+
+VCMI_LIB_NAMESPACE_END

+ 37 - 0
lib/spells/adventure/DimensionDoorEffect.h

@@ -0,0 +1,37 @@
+/*
+ * DimensionDoorEffect.h, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+
+#pragma once
+
+#include "AdventureSpellEffect.h"
+
+VCMI_LIB_NAMESPACE_BEGIN
+
+class DimensionDoorEffect final : public AdventureSpellRangedEffect
+{
+	std::string cursor;
+	std::string cursorGuarded;
+	int movementPointsRequired;
+	int movementPointsTaken;
+	bool waterLandFailureTakesPoints;
+	bool exposeFow;
+
+public:
+	DimensionDoorEffect(const CSpell * s, const JsonNode & config);
+
+private:
+	bool canBeCastImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const final;
+	bool canBeCastAtImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const final;
+	ESpellCastResult applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const final;
+	void endCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const final;
+	std::string getCursorForTarget(const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const final;
+};
+
+VCMI_LIB_NAMESPACE_END

+ 0 - 166
lib/spells/adventure/DimensionDoorMechanics.cpp

@@ -1,166 +0,0 @@
-/*
- * DimensionDoorMechanics.cpp, part of VCMI engine
- *
- * Authors: listed in file AUTHORS in main folder
- *
- * License: GNU General Public License v2.0 or later
- * Full text of license available in license.txt file, in main folder
- *
- */
-
-#include "StdInc.h"
-#include "DimensionDoorMechanics.h"
-
-#include "../CSpellHandler.h"
-
-#include "../../IGameSettings.h"
-#include "../../callback/IGameInfoCallback.h"
-#include "../../mapObjects/CGHeroInstance.h"
-#include "../../mapping/TerrainTile.h"
-#include "../../networkPacks/PacksForClient.h"
-
-VCMI_LIB_NAMESPACE_BEGIN
-
-DimensionDoorMechanics::DimensionDoorMechanics(const CSpell * s)
-	: AdventureSpellMechanics(s)
-{
-}
-
-bool DimensionDoorMechanics::canBeCastImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const
-{
-	if(!caster->getHeroCaster())
-		return false;
-
-	if(caster->getHeroCaster()->movementPointsRemaining() <= 0) //unlike town portal non-zero MP is enough
-	{
-		problem.add(MetaString::createFromTextID("core.genrltxt.125"));
-		return false;
-	}
-
-	const auto schoolLevel = caster->getSpellSchoolLevel(owner);
-
-	std::stringstream cachingStr;
-	cachingStr << "source_" << vstd::to_underlying(BonusSource::SPELL_EFFECT) << "id_" << owner->id.num;
-
-	int castsAlreadyPerformedThisTurn = caster->getHeroCaster()->getBonuses(Selector::source(BonusSource::SPELL_EFFECT, BonusSourceID(owner->id)), cachingStr.str())->size();
-	int castsLimit = owner->getLevelPower(schoolLevel);
-
-	bool isTournamentRulesLimitEnabled = cb->getSettings().getBoolean(EGameSettings::DIMENSION_DOOR_TOURNAMENT_RULES_LIMIT);
-	if(isTournamentRulesLimitEnabled)
-	{
-		int3 mapSize = cb->getMapSize();
-
-		bool meetsTournamentRulesTwoCastsRequirements =  mapSize.x * mapSize.y * mapSize.z >= GameConstants::TOURNAMENT_RULES_DD_MAP_TILES_THRESHOLD && schoolLevel == MasteryLevel::EXPERT;
-
-		castsLimit = meetsTournamentRulesTwoCastsRequirements ? 2 : 1;
-	}
-
-	if(castsAlreadyPerformedThisTurn >= castsLimit) //limit casts per turn
-	{
-		MetaString message = MetaString::createFromTextID("core.genrltxt.338");
-		caster->getCasterName(message);
-		problem.add(std::move(message));
-		return false;
-	}
-
-	return true;
-}
-
-bool DimensionDoorMechanics::canBeCastAtImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const
-{
-	if(!cb->isInTheMap(pos))
-		return false;
-
-	if(cb->getSettings().getBoolean(EGameSettings::DIMENSION_DOOR_ONLY_TO_UNCOVERED_TILES))
-	{
-		if(!cb->isVisibleFor(pos, caster->getCasterOwner()))
-			return false;
-	}
-
-	int3 casterPosition = caster->getHeroCaster()->getSightCenter();
-
-	const TerrainTile * dest = cb->getTileUnchecked(pos);
-	const TerrainTile * curr = cb->getTileUnchecked(casterPosition);
-
-	if(!dest)
-		return false;
-
-	if(!curr)
-		return false;
-
-	if(!isInScreenRange(casterPosition, pos))
-		return false;
-
-	if(cb->getSettings().getBoolean(EGameSettings::DIMENSION_DOOR_EXPOSES_TERRAIN_TYPE))
-	{
-		if(!dest->isClear(curr))
-			return false;
-	}
-	else
-	{
-		if(dest->blocked())
-			return false;
-	}
-
-	return true;
-}
-
-ESpellCastResult DimensionDoorMechanics::applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
-{
-	const auto schoolLevel = parameters.caster->getSpellSchoolLevel(owner);
-	const int baseCost = env->getCb()->getSettings().getInteger(EGameSettings::HEROES_MOVEMENT_COST_BASE);
-	const int movementCost = baseCost * ((schoolLevel >= 3) ? 2 : 3);
-
-	int3 casterPosition = parameters.caster->getHeroCaster()->getSightCenter();
-	const TerrainTile * dest = env->getCb()->getTile(parameters.pos);
-	const TerrainTile * curr = env->getCb()->getTile(casterPosition);
-
-	if(!dest->isClear(curr))
-	{
-		InfoWindow iw;
-		iw.player = parameters.caster->getCasterOwner();
-
-		// tile is either blocked or not possible to move (e.g. water <-> land)
-		if(env->getCb()->getSettings().getBoolean(EGameSettings::DIMENSION_DOOR_FAILURE_SPENDS_POINTS))
-		{
-			// SOD: DD to such "wrong" terrain results in mana and move points spending, but fails to move hero
-			iw.text = MetaString::createFromTextID("core.genrltxt.70"); // Dimension Door failed!
-			env->apply(iw);
-			// no return - resources will be spent
-		}
-		else
-		{
-			// HotA: game will show error message without taking mana or move points, even when DD into terra incognita
-			iw.text = MetaString::createFromTextID("vcmi.dimensionDoor.seaToLandError");
-			env->apply(iw);
-			return ESpellCastResult::CANCEL;
-		}
-	}
-
-	GiveBonus gb;
-	gb.id = ObjectInstanceID(parameters.caster->getCasterUnitId());
-	gb.bonus = Bonus(BonusDuration::ONE_DAY, BonusType::NONE, BonusSource::SPELL_EFFECT, 0, BonusSourceID(owner->id));
-	env->apply(gb);
-
-	SetMovePoints smp;
-	smp.hid = ObjectInstanceID(parameters.caster->getCasterUnitId());
-	if(movementCost < static_cast<int>(parameters.caster->getHeroCaster()->movementPointsRemaining()))
-		smp.val = parameters.caster->getHeroCaster()->movementPointsRemaining() - movementCost;
-	else
-		smp.val = 0;
-	env->apply(smp);
-
-	return ESpellCastResult::OK;
-}
-
-void DimensionDoorMechanics::endCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
-{
-	int3 casterPosition = parameters.caster->getHeroCaster()->getSightCenter();
-	const TerrainTile * dest = env->getCb()->getTile(parameters.pos);
-	const TerrainTile * curr = env->getCb()->getTile(casterPosition);
-
-	if(dest->isClear(curr))
-		env->moveHero(ObjectInstanceID(parameters.caster->getCasterUnitId()), parameters.caster->getHeroCaster()->convertFromVisitablePos(parameters.pos), EMovementMode::DIMENSION_DOOR);
-}
-
-VCMI_LIB_NAMESPACE_END

+ 0 - 30
lib/spells/adventure/DimensionDoorMechanics.h

@@ -1,30 +0,0 @@
-/*
- * DimensionDoorMechanics.h, part of VCMI engine
- *
- * Authors: listed in file AUTHORS in main folder
- *
- * License: GNU General Public License v2.0 or later
- * Full text of license available in license.txt file, in main folder
- *
- */
-
-#pragma once
-
-#include "AdventureSpellMechanics.h"
-
-VCMI_LIB_NAMESPACE_BEGIN
-
-class DimensionDoorMechanics final : public AdventureSpellMechanics
-{
-public:
-	DimensionDoorMechanics(const CSpell * s);
-
-protected:
-	bool canBeCastImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const override;
-	bool canBeCastAtImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const override;
-
-	ESpellCastResult applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const override;
-	void endCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const override;
-};
-
-VCMI_LIB_NAMESPACE_END

+ 84 - 0
lib/spells/adventure/RemoveObjectEffect.cpp

@@ -0,0 +1,84 @@
+/*
+ * RemoveObjectEffect.cpp, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+
+#include "StdInc.h"
+#include "RemoveObjectEffect.h"
+
+#include "../CSpellHandler.h"
+
+#include "../../callback/IGameInfoCallback.h"
+#include "../../mapObjects/CGHeroInstance.h"
+#include "../../mapping/CMap.h"
+#include "../../networkPacks/PacksForClient.h"
+#include "../../modding/IdentifierStorage.h"
+
+VCMI_LIB_NAMESPACE_BEGIN
+
+RemoveObjectEffect::RemoveObjectEffect(const CSpell * s, const JsonNode & config)
+	: AdventureSpellRangedEffect(config)
+	, owner(s)
+	, failMessage(MetaString::createFromTextID("core.genrltxt.337")) //%s tried to scuttle the boat, but failed
+	, cursor(config["cursor"].String())
+{
+	for(const auto & objectNode : config["objects"].Struct())
+	{
+		if(objectNode.second.Bool())
+		{
+			LIBRARY->identifiers()->requestIdentifier(objectNode.second.getModScope(), "object", objectNode.first, [this](si32 index)
+			{
+				removedObjects.push_back(MapObjectID(index));
+			});
+		}
+	}
+}
+
+std::string RemoveObjectEffect::getCursorForTarget(const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const
+{
+	return cursor;
+}
+
+bool RemoveObjectEffect::canBeCastAtImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const
+{
+	if (!isTargetInRange(cb, caster, pos))
+		return false;
+
+	const TerrainTile * t = cb->getTileUnchecked(pos);
+	if(!t || t->visitableObjects.empty())
+		return false;
+
+	const CGObjectInstance * topObject = cb->getObj(t->visitableObjects.back());
+
+	return vstd::contains(removedObjects, topObject->ID);
+}
+
+ESpellCastResult RemoveObjectEffect::applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
+{
+	const auto schoolLevel = parameters.caster->getSpellSchoolLevel(owner);
+	//check if spell works at all
+	if(env->getRNG()->nextInt(0, 99) >= owner->getLevelPower(schoolLevel)) //power is % chance of success
+	{
+		InfoWindow iw;
+		iw.player = parameters.caster->getCasterOwner();
+		iw.text = failMessage;
+		parameters.caster->getCasterName(iw.text);
+		env->apply(iw);
+		return ESpellCastResult::OK;
+	}
+
+	const TerrainTile & t = env->getMap()->getTile(parameters.pos);
+
+	RemoveObject ro;
+	ro.initiator = parameters.caster->getCasterOwner();
+	ro.objectID = t.visitableObjects.back();
+	env->apply(ro);
+	return ESpellCastResult::OK;
+}
+
+VCMI_LIB_NAMESPACE_END

+ 33 - 0
lib/spells/adventure/RemoveObjectEffect.h

@@ -0,0 +1,33 @@
+/*
+ * RemoveObjectEffect.h, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+
+#pragma once
+
+#include "AdventureSpellEffect.h"
+
+VCMI_LIB_NAMESPACE_BEGIN
+
+class RemoveObjectEffect final : public AdventureSpellRangedEffect
+{
+	const CSpell * owner;
+	std::vector<MapObjectID> removedObjects;
+	MetaString failMessage;
+	std::string cursor;
+
+public:
+	RemoveObjectEffect(const CSpell * s, const JsonNode & config);
+
+private:
+	bool canBeCastAtImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const final;
+	ESpellCastResult applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const final;
+	std::string getCursorForTarget(const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const final;
+};
+
+VCMI_LIB_NAMESPACE_END

+ 0 - 78
lib/spells/adventure/ScuttleBoatMechanics.cpp

@@ -1,78 +0,0 @@
-/*
- * ScuttleBoatMechanics.cpp, part of VCMI engine
- *
- * Authors: listed in file AUTHORS in main folder
- *
- * License: GNU General Public License v2.0 or later
- * Full text of license available in license.txt file, in main folder
- *
- */
-
-#include "StdInc.h"
-#include "ScuttleBoatMechanics.h"
-
-#include "../CSpellHandler.h"
-
-#include "../../callback/IGameInfoCallback.h"
-#include "../../mapObjects/CGHeroInstance.h"
-#include "../../mapping/CMap.h"
-#include "../../networkPacks/PacksForClient.h"
-
-VCMI_LIB_NAMESPACE_BEGIN
-
-ScuttleBoatMechanics::ScuttleBoatMechanics(const CSpell * s)
-	: AdventureSpellMechanics(s)
-{
-}
-
-bool ScuttleBoatMechanics::canBeCastAtImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const
-{
-	if(!cb->isInTheMap(pos))
-		return false;
-
-	if(caster->getHeroCaster())
-	{
-		int3 casterPosition = caster->getHeroCaster()->getSightCenter();
-
-		if(!isInScreenRange(casterPosition, pos))
-			return false;
-	}
-
-	if(!cb->isVisibleFor(pos, caster->getCasterOwner()))
-		return false;
-
-	const TerrainTile * t = cb->getTile(pos);
-	if(!t || t->visitableObjects.empty())
-		return false;
-
-	const CGObjectInstance * topObject = cb->getObj(t->visitableObjects.back());
-	if(topObject->ID != Obj::BOAT)
-		return false;
-
-	return true;
-}
-
-ESpellCastResult ScuttleBoatMechanics::applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
-{
-	const auto schoolLevel = parameters.caster->getSpellSchoolLevel(owner);
-	//check if spell works at all
-	if(env->getRNG()->nextInt(0, 99) >= owner->getLevelPower(schoolLevel)) //power is % chance of success
-	{
-		InfoWindow iw;
-		iw.player = parameters.caster->getCasterOwner();
-		iw.text.appendLocalString(EMetaText::GENERAL_TXT, 337); //%s tried to scuttle the boat, but failed
-		parameters.caster->getCasterName(iw.text);
-		env->apply(iw);
-		return ESpellCastResult::OK;
-	}
-
-	const TerrainTile & t = env->getMap()->getTile(parameters.pos);
-
-	RemoveObject ro;
-	ro.initiator = parameters.caster->getCasterOwner();
-	ro.objectID = t.visitableObjects.back();
-	env->apply(ro);
-	return ESpellCastResult::OK;
-}
-
-VCMI_LIB_NAMESPACE_END

+ 0 - 28
lib/spells/adventure/ScuttleBoatMechanics.h

@@ -1,28 +0,0 @@
-/*
- * ScuttleBoatMechanics.h, part of VCMI engine
- *
- * Authors: listed in file AUTHORS in main folder
- *
- * License: GNU General Public License v2.0 or later
- * Full text of license available in license.txt file, in main folder
- *
- */
-
-#pragma once
-
-#include "AdventureSpellMechanics.h"
-
-VCMI_LIB_NAMESPACE_BEGIN
-
-class ScuttleBoatMechanics final : public AdventureSpellMechanics
-{
-public:
-	ScuttleBoatMechanics(const CSpell * s);
-
-protected:
-	bool canBeCastAtImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster, const int3 & pos) const override;
-
-	ESpellCastResult applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const override;
-};
-
-VCMI_LIB_NAMESPACE_END

+ 43 - 20
lib/spells/adventure/SummonBoatMechanics.cpp → lib/spells/adventure/SummonBoatEffect.cpp

@@ -1,5 +1,5 @@
 /*
- * SummonBoatMechanics.cpp, part of VCMI engine
+ * SummonBoatEffect.cpp, part of VCMI engine
  *
  * Authors: listed in file AUTHORS in main folder
  *
@@ -10,23 +10,44 @@
 
 #include "StdInc.h"
 
-#include "SummonBoatMechanics.h"
+#include "SummonBoatEffect.h"
 
 #include "../CSpellHandler.h"
 
 #include "../../mapObjects/CGHeroInstance.h"
 #include "../../mapObjects/MiscObjects.h"
 #include "../../mapping/CMap.h"
+#include "../../modding/IdentifierStorage.h"
 #include "../../networkPacks/PacksForClient.h"
 
 VCMI_LIB_NAMESPACE_BEGIN
 
-SummonBoatMechanics::SummonBoatMechanics(const CSpell * s)
-	: AdventureSpellMechanics(s)
+SummonBoatEffect::SummonBoatEffect(const CSpell * s, const JsonNode & config)
+	: owner(s)
+	, useExistingBoat(config["useExistingBoat"].Bool())
 {
+	if (!config["createdBoat"].isNull())
+	{
+		LIBRARY->identifiers()->requestIdentifier("core:boat", config["createdBoat"], [=](int32_t boatTypeID)
+		{
+			createdBoat = BoatId(boatTypeID);
+		});
+	}
+
+}
+
+bool SummonBoatEffect::canCreateNewBoat() const
+{
+	return createdBoat != BoatId::NONE;
 }
 
-bool SummonBoatMechanics::canBeCastImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const
+int SummonBoatEffect::getSuccessChance(const spells::Caster * caster) const
+{
+	const auto schoolLevel = caster->getSpellSchoolLevel(owner);
+	return owner->getLevelPower(schoolLevel);
+}
+
+bool SummonBoatEffect::canBeCastImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const
 {
 	if(!caster->getHeroCaster())
 		return false;
@@ -52,12 +73,10 @@ bool SummonBoatMechanics::canBeCastImpl(spells::Problem & problem, const IGameIn
 	return true;
 }
 
-ESpellCastResult SummonBoatMechanics::applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
+ESpellCastResult SummonBoatEffect::applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
 {
-	const auto schoolLevel = parameters.caster->getSpellSchoolLevel(owner);
-
 	//check if spell works at all
-	if(env->getRNG()->nextInt(0, 99) >= owner->getLevelPower(schoolLevel)) //power is % chance of success
+	if(env->getRNG()->nextInt(0, 99) >= getSuccessChance(parameters.caster)) //power is % chance of success
 	{
 		InfoWindow iw;
 		iw.player = parameters.caster->getCasterOwner();
@@ -69,17 +88,21 @@ ESpellCastResult SummonBoatMechanics::applyAdventureEffects(SpellCastEnvironment
 
 	//try to find unoccupied boat to summon
 	const CGBoat * nearest = nullptr;
-	double dist = 0;
-	for(const auto & b : env->getMap()->getObjects<CGBoat>())
-	{
-		if(b->getBoardedHero() || b->layer != EPathfindingLayer::SAIL)
-			continue; //we're looking for unoccupied boat
 
-		double nDist = b->visitablePos().dist2d(parameters.caster->getHeroCaster()->visitablePos());
-		if(!nearest || nDist < dist) //it's first boat or closer than previous
+	if (useExistingBoat)
+	{
+		double dist = 0;
+		for(const auto & b : env->getMap()->getObjects<CGBoat>())
 		{
-			nearest = b;
-			dist = nDist;
+			if(b->getBoardedHero() || b->layer != EPathfindingLayer::SAIL)
+				continue; //we're looking for unoccupied boat
+
+			double nDist = b->visitablePos().dist2d(parameters.caster->getHeroCaster()->visitablePos());
+			if(!nearest || nDist < dist) //it's first boat or closer than previous
+			{
+				nearest = b;
+				dist = nDist;
+			}
 		}
 	}
 
@@ -93,7 +116,7 @@ ESpellCastResult SummonBoatMechanics::applyAdventureEffects(SpellCastEnvironment
 		cop.initiator = parameters.caster->getCasterOwner();
 		env->apply(cop);
 	}
-	else if(schoolLevel < 2) //none or basic level -> cannot create boat :(
+	else if(!canCreateNewBoat()) //none or basic level -> cannot create boat :(
 	{
 		InfoWindow iw;
 		iw.player = parameters.caster->getCasterOwner();
@@ -103,7 +126,7 @@ ESpellCastResult SummonBoatMechanics::applyAdventureEffects(SpellCastEnvironment
 	}
 	else //create boat
 	{
-		env->createBoat(summonPos, BoatId::NECROPOLIS, parameters.caster->getCasterOwner());
+		env->createBoat(summonPos, createdBoat, parameters.caster->getCasterOwner());
 	}
 	return ESpellCastResult::OK;
 }

+ 34 - 0
lib/spells/adventure/SummonBoatEffect.h

@@ -0,0 +1,34 @@
+/*
+ * SummonBoatEffect.h, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+
+#pragma once
+
+#include "AdventureSpellEffect.h"
+
+VCMI_LIB_NAMESPACE_BEGIN
+
+class DLL_LINKAGE SummonBoatEffect final : public IAdventureSpellEffect
+{
+	const CSpell * owner;
+	BoatId createdBoat = BoatId::NONE;
+	bool useExistingBoat;
+
+public:
+	SummonBoatEffect(const CSpell * s, const JsonNode & config);
+
+	bool canCreateNewBoat() const;
+	int getSuccessChance(const spells::Caster * caster) const;
+
+private:
+	bool canBeCastImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const final;
+	ESpellCastResult applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const final;
+};
+
+VCMI_LIB_NAMESPACE_END

+ 27 - 30
lib/spells/adventure/TownPortalMechanics.cpp → lib/spells/adventure/TownPortalEffect.cpp

@@ -1,5 +1,5 @@
 /*
- * TownPortalMechanics.cpp, part of VCMI engine
+ * TownPortalEffect.cpp, part of VCMI engine
  *
  * Authors: listed in file AUTHORS in main folder
  *
@@ -9,7 +9,9 @@
  */
 
 #include "StdInc.h"
-#include "TownPortalMechanics.h"
+#include "TownPortalEffect.h"
+
+#include "AdventureSpellMechanics.h"
 
 #include "../CSpellHandler.h"
 
@@ -23,15 +25,18 @@
 
 VCMI_LIB_NAMESPACE_BEGIN
 
-TownPortalMechanics::TownPortalMechanics(const CSpell * s):
-	AdventureSpellMechanics(s)
+TownPortalEffect::TownPortalEffect(const CSpell * s, const JsonNode & config)
+	: owner(s)
+	, movementPointsRequired(config["movementPointsRequired"].Integer())
+	, movementPointsTaken(config["movementPointsTaken"].Integer())
+	, allowTownSelection(config["allowTownSelection"].Bool())
+	, skipOccupiedTowns(config["skipOccupiedTowns"].Bool())
 {
 }
 
-ESpellCastResult TownPortalMechanics::applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
+ESpellCastResult TownPortalEffect::applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
 {
 	const CGTownInstance * destination = nullptr;
-	const int moveCost = movementCost(env, parameters);
 
 	if(!parameters.caster->getHeroCaster())
 	{
@@ -39,7 +44,7 @@ ESpellCastResult TownPortalMechanics::applyAdventureEffects(SpellCastEnvironment
 		return ESpellCastResult::ERROR;
 	}
 
-	if(parameters.caster->getSpellSchoolLevel(owner) < 2)
+	if(!allowTownSelection)
 	{
 		std::vector<const CGTownInstance *> pool = getPossibleTowns(env, parameters);
 		destination = findNearestTown(env, parameters, pool);
@@ -47,7 +52,7 @@ ESpellCastResult TownPortalMechanics::applyAdventureEffects(SpellCastEnvironment
 		if(nullptr == destination)
 			return ESpellCastResult::ERROR;
 
-		if(static_cast<int>(parameters.caster->getHeroCaster()->movementPointsRemaining()) < moveCost)
+		if(static_cast<int>(parameters.caster->getHeroCaster()->movementPointsRemaining()) < movementPointsRequired)
 			return ESpellCastResult::ERROR;
 
 		if(destination->getVisitingHero())
@@ -98,7 +103,7 @@ ESpellCastResult TownPortalMechanics::applyAdventureEffects(SpellCastEnvironment
 			return ESpellCastResult::ERROR;
 		}
 
-		if(static_cast<int>(parameters.caster->getHeroCaster()->movementPointsRemaining()) < moveCost)
+		if(static_cast<int>(parameters.caster->getHeroCaster()->movementPointsRemaining()) < movementPointsRequired)
 		{
 			env->complain("This hero has not enough movement points!");
 			return ESpellCastResult::ERROR;
@@ -131,9 +136,8 @@ ESpellCastResult TownPortalMechanics::applyAdventureEffects(SpellCastEnvironment
 	return ESpellCastResult::OK;
 }
 
-void TownPortalMechanics::endCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
+void TownPortalEffect::endCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
 {
-	const int moveCost = movementCost(env, parameters);
 	const CGTownInstance * destination = nullptr;
 
 	if(parameters.caster->getSpellSchoolLevel(owner) < 2)
@@ -154,12 +158,15 @@ void TownPortalMechanics::endCast(SpellCastEnvironment * env, const AdventureSpe
 	{
 		SetMovePoints smp;
 		smp.hid = ObjectInstanceID(parameters.caster->getCasterUnitId());
-		smp.val = std::max<ui32>(0, parameters.caster->getHeroCaster()->movementPointsRemaining() - moveCost);
+		if(movementPointsTaken < static_cast<int>(parameters.caster->getHeroCaster()->movementPointsRemaining()))
+			smp.val = parameters.caster->getHeroCaster()->movementPointsRemaining() - movementPointsTaken;
+		else
+			smp.val = 0;
 		env->apply(smp);
 	}
 }
 
-ESpellCastResult TownPortalMechanics::beginCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
+ESpellCastResult TownPortalEffect::beginCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters, const AdventureSpellMechanics & mechanics) const
 {
 	std::vector<const CGTownInstance *> towns = getPossibleTowns(env, parameters);
 
@@ -178,9 +185,7 @@ ESpellCastResult TownPortalMechanics::beginCast(SpellCastEnvironment * env, cons
 		return ESpellCastResult::CANCEL;
 	}
 
-	const int moveCost = movementCost(env, parameters);
-
-	if(static_cast<int>(parameters.caster->getHeroCaster()->movementPointsRemaining()) < moveCost)
+	if(static_cast<int>(parameters.caster->getHeroCaster()->movementPointsRemaining()) < movementPointsTaken)
 	{
 		InfoWindow iw;
 		iw.player = parameters.caster->getCasterOwner();
@@ -191,7 +196,7 @@ ESpellCastResult TownPortalMechanics::beginCast(SpellCastEnvironment * env, cons
 
 	if(!parameters.pos.isValid() && parameters.caster->getSpellSchoolLevel(owner) >= 2)
 	{
-		auto queryCallback = [this, env, parameters](std::optional<int32_t> reply) -> void
+		auto queryCallback = [&mechanics, env, parameters](std::optional<int32_t> reply) -> void
 		{
 			if(reply.has_value())
 			{
@@ -213,7 +218,7 @@ ESpellCastResult TownPortalMechanics::beginCast(SpellCastEnvironment * env, cons
 				AdventureSpellCastParameters p;
 				p.caster = parameters.caster;
 				p.pos = o->visitablePos();
-				performCast(env, p);
+				mechanics.performCast(env, p);
 			}
 		};
 
@@ -247,7 +252,7 @@ ESpellCastResult TownPortalMechanics::beginCast(SpellCastEnvironment * env, cons
 	return ESpellCastResult::OK;
 }
 
-const CGTownInstance * TownPortalMechanics::findNearestTown(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters, const std::vector <const CGTownInstance *> & pool) const
+const CGTownInstance * TownPortalEffect::findNearestTown(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters, const std::vector <const CGTownInstance *> & pool) const
 {
 	if(pool.empty())
 		return nullptr;
@@ -271,7 +276,7 @@ const CGTownInstance * TownPortalMechanics::findNearestTown(SpellCastEnvironment
 	return *nearest;
 }
 
-std::vector<const CGTownInstance *> TownPortalMechanics::getPossibleTowns(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
+std::vector<const CGTownInstance *> TownPortalEffect::getPossibleTowns(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
 {
 	std::vector<const CGTownInstance *> ret;
 
@@ -281,19 +286,11 @@ std::vector<const CGTownInstance *> TownPortalMechanics::getPossibleTowns(SpellC
 	{
 		for(auto currTown : env->getCb()->getPlayerState(color)->getTowns())
 		{
-			ret.push_back(currTown);
+			if (!skipOccupiedTowns || currTown->getVisitingHero() == nullptr)
+				ret.push_back(currTown);
 		}
 	}
 	return ret;
 }
 
-int32_t TownPortalMechanics::movementCost(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
-{
-	if(parameters.caster != parameters.caster->getHeroCaster()) //if caster is not hero
-		return 0;
-
-	int baseMovementCost = env->getCb()->getSettings().getInteger(EGameSettings::HEROES_MOVEMENT_COST_BASE);
-	return baseMovementCost * ((parameters.caster->getSpellSchoolLevel(owner) >= 3) ? 2 : 3);
-}
-
 VCMI_LIB_NAMESPACE_END

+ 15 - 9
lib/spells/adventure/TownPortalMechanics.h → lib/spells/adventure/TownPortalEffect.h

@@ -1,5 +1,5 @@
 /*
- * TownPortalMechanics.h, part of VCMI engine
+ * TownPortalEffect.h, part of VCMI engine
  *
  * Authors: listed in file AUTHORS in main folder
  *
@@ -10,25 +10,31 @@
 
 #pragma once
 
-#include "AdventureSpellMechanics.h"
+#include "AdventureSpellEffect.h"
 
 VCMI_LIB_NAMESPACE_BEGIN
 
 class CGTownInstance;
 
-class TownPortalMechanics final : public AdventureSpellMechanics
+class DLL_LINKAGE TownPortalEffect final : public IAdventureSpellEffect
 {
+	const CSpell * owner;
+	int movementPointsRequired;
+	int movementPointsTaken;
+	bool allowTownSelection;
+	bool skipOccupiedTowns;
+
 public:
-	TownPortalMechanics(const CSpell * s);
+	TownPortalEffect(const CSpell * s, const JsonNode & config);
 
-protected:
-	ESpellCastResult applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const override;
-	ESpellCastResult beginCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const override;
-	void endCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const override;
+	int getMovementPointsRequired() const { return movementPointsRequired; }
+	bool townSelectionAllowed() const { return allowTownSelection; }
 
 private:
+	ESpellCastResult applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const override;
+	ESpellCastResult beginCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters, const AdventureSpellMechanics & mechanics) const override;
+	void endCast(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const override;
 	const CGTownInstance * findNearestTown(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters, const std::vector<const CGTownInstance *> & pool) const;
-	int32_t movementCost(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const;
 	std::vector<const CGTownInstance *> getPossibleTowns(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const;
 };
 

+ 68 - 0
lib/spells/adventure/ViewWorldEffect.cpp

@@ -0,0 +1,68 @@
+/*
+ * ViewWorldEffect.cpp, part of VCMI engine
+ *
+ * Authors: listed in file AUTHORS in main folder
+ *
+ * License: GNU General Public License v2.0 or later
+ * Full text of license available in license.txt file, in main folder
+ *
+ */
+
+#include "StdInc.h"
+
+#include "ViewWorldEffect.h"
+
+#include "../CSpellHandler.h"
+
+#include "../../CPlayerState.h"
+#include "../../callback/IGameInfoCallback.h"
+#include "../../mapObjects/CGHeroInstance.h"
+#include "../../mapping/CMap.h"
+#include "../../modding/IdentifierStorage.h"
+#include "../../networkPacks/PacksForClient.h"
+
+VCMI_LIB_NAMESPACE_BEGIN
+
+ViewWorldEffect::ViewWorldEffect(const CSpell * s, const JsonNode & config)
+{
+	showTerrain = config["showTerrain"].Bool();
+
+	for(const auto & objectNode : config["objects"].Struct())
+	{
+		if(objectNode.second.Bool())
+		{
+			LIBRARY->identifiers()->requestIdentifier(objectNode.second.getModScope(), "object", objectNode.first, [this](si32 index)
+			{
+				filteredObjects.push_back(MapObjectID(index));
+			});
+		}
+	}
+}
+
+ESpellCastResult ViewWorldEffect::applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
+{
+	ShowWorldViewEx pack;
+
+	pack.player = parameters.caster->getCasterOwner();
+	pack.showTerrain = showTerrain;
+
+	const auto & fowMap = env->getCb()->getPlayerTeam(parameters.caster->getCasterOwner())->fogOfWarMap;
+
+	for(const auto & obj : env->getMap()->getObjects())
+	{
+		//deleted object remain as empty pointer
+		if(obj && vstd::contains(filteredObjects, obj->ID))
+		{
+			ObjectPosInfo posInfo(obj);
+
+			if(fowMap[posInfo.pos.z][posInfo.pos.x][posInfo.pos.y] == 0)
+				pack.objectPositions.push_back(posInfo);
+		}
+	}
+
+	env->apply(pack);
+
+	return ESpellCastResult::OK;
+}
+
+VCMI_LIB_NAMESPACE_END

+ 7 - 7
lib/spells/adventure/SummonBoatMechanics.h → lib/spells/adventure/ViewWorldEffect.h

@@ -1,5 +1,5 @@
 /*
- * SummonBoatMechanics.h, part of VCMI engine
+ * ViewWorldEffect.h, part of VCMI engine
  *
  * Authors: listed in file AUTHORS in main folder
  *
@@ -10,17 +10,17 @@
 
 #pragma once
 
-#include "AdventureSpellMechanics.h"
+#include "AdventureSpellEffect.h"
 
 VCMI_LIB_NAMESPACE_BEGIN
 
-class SummonBoatMechanics final : public AdventureSpellMechanics
+class ViewWorldEffect final : public IAdventureSpellEffect
 {
-public:
-	SummonBoatMechanics(const CSpell * s);
+	std::vector<MapObjectID> filteredObjects;
+	bool showTerrain = false;
 
-protected:
-	bool canBeCastImpl(spells::Problem & problem, const IGameInfoCallback * cb, const spells::Caster * caster) const override;
+public:
+	ViewWorldEffect(const CSpell * s, const JsonNode & config);
 
 	ESpellCastResult applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const override;
 };

+ 0 - 90
lib/spells/adventure/ViewWorldMechanics.cpp

@@ -1,90 +0,0 @@
-/*
- * ViewWorldMechanics.cpp, part of VCMI engine
- *
- * Authors: listed in file AUTHORS in main folder
- *
- * License: GNU General Public License v2.0 or later
- * Full text of license available in license.txt file, in main folder
- *
- */
-
-#include "StdInc.h"
-
-#include "ViewWorldMechanics.h"
-
-#include "../CSpellHandler.h"
-
-#include "../../CPlayerState.h"
-#include "../../callback/IGameInfoCallback.h"
-#include "../../mapObjects/CGHeroInstance.h"
-#include "../../mapping/CMap.h"
-#include "../../networkPacks/PacksForClient.h"
-
-VCMI_LIB_NAMESPACE_BEGIN
-
-ViewMechanics::ViewMechanics(const CSpell * s):
-	AdventureSpellMechanics(s)
-{
-}
-
-ESpellCastResult ViewMechanics::applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const
-{
-	ShowWorldViewEx pack;
-
-	pack.player = parameters.caster->getCasterOwner();
-
-	const auto spellLevel = parameters.caster->getSpellSchoolLevel(owner);
-
-	const auto & fowMap = env->getCb()->getPlayerTeam(parameters.caster->getCasterOwner())->fogOfWarMap;
-
-	for(const auto & obj : env->getMap()->getObjects())
-	{
-		//deleted object remain as empty pointer
-		if(obj && filterObject(obj, spellLevel))
-		{
-			ObjectPosInfo posInfo(obj);
-
-			if(fowMap[posInfo.pos.z][posInfo.pos.x][posInfo.pos.y] == 0)
-				pack.objectPositions.push_back(posInfo);
-		}
-	}
-	pack.showTerrain = showTerrain(spellLevel);
-
-	env->apply(pack);
-
-	return ESpellCastResult::OK;
-}
-
-///ViewAirMechanics
-ViewAirMechanics::ViewAirMechanics(const CSpell * s)
-	: ViewMechanics(s)
-{
-}
-
-bool ViewAirMechanics::filterObject(const CGObjectInstance * obj, const int32_t spellLevel) const
-{
-	return (obj->ID == Obj::ARTIFACT) || (spellLevel > 1 && obj->ID == Obj::HERO) || (spellLevel > 2 && obj->ID == Obj::TOWN);
-}
-
-bool ViewAirMechanics::showTerrain(const int32_t spellLevel) const
-{
-	return false;
-}
-
-///ViewEarthMechanics
-ViewEarthMechanics::ViewEarthMechanics(const CSpell * s)
-	: ViewMechanics(s)
-{
-}
-
-bool ViewEarthMechanics::filterObject(const CGObjectInstance * obj, const int32_t spellLevel) const
-{
-	return (obj->ID == Obj::RESOURCE) || (spellLevel > 1 && obj->ID == Obj::MINE);
-}
-
-bool ViewEarthMechanics::showTerrain(const int32_t spellLevel) const
-{
-	return spellLevel > 2;
-}
-
-VCMI_LIB_NAMESPACE_END

+ 0 - 48
lib/spells/adventure/ViewWorldMechanics.h

@@ -1,48 +0,0 @@
-/*
- * ViewWorldMechanics.h, part of VCMI engine
- *
- * Authors: listed in file AUTHORS in main folder
- *
- * License: GNU General Public License v2.0 or later
- * Full text of license available in license.txt file, in main folder
- *
- */
-
-#pragma once
-
-#include "AdventureSpellMechanics.h"
-
-VCMI_LIB_NAMESPACE_BEGIN
-
-class ViewMechanics : public AdventureSpellMechanics
-{
-public:
-	ViewMechanics(const CSpell * s);
-
-protected:
-	ESpellCastResult applyAdventureEffects(SpellCastEnvironment * env, const AdventureSpellCastParameters & parameters) const override;
-	virtual bool filterObject(const CGObjectInstance * obj, const int32_t spellLevel) const = 0;
-	virtual bool showTerrain(const int32_t spellLevel) const = 0;
-};
-
-class ViewAirMechanics final : public ViewMechanics
-{
-public:
-	ViewAirMechanics(const CSpell * s);
-
-protected:
-	bool filterObject(const CGObjectInstance * obj, const int32_t spellLevel) const override;
-	bool showTerrain(const int32_t spellLevel) const override;
-};
-
-class ViewEarthMechanics final : public ViewMechanics
-{
-public:
-	ViewEarthMechanics(const CSpell * s);
-
-protected:
-	bool filterObject(const CGObjectInstance * obj, const int32_t spellLevel) const override;
-	bool showTerrain(const int32_t spellLevel) const override;
-};
-
-VCMI_LIB_NAMESPACE_END

+ 1 - 0
test/mock/mock_IGameInfoCallback.h

@@ -78,6 +78,7 @@ public:
 	MOCK_CONST_METHOD1(guardingCreaturePosition, int3(int3 pos));
 	MOCK_CONST_METHOD2(checkForVisitableDir, bool(const int3 & src, const int3 & dst));
 	MOCK_CONST_METHOD1(getGuardingCreatures, std::vector<const CGObjectInstance *>(int3 pos));
+	MOCK_CONST_METHOD1(isTileGuardedUnchecked, bool(int3 pos));
 
 	MOCK_METHOD2(pickAllowedArtsSet, void(std::vector<ArtifactID> & out, vstd::RNG & rand));