Homework 2 Notes
1. Instructions
This assignment is about creating the Cave
and Dungeon
. We will also add more functionality to the Denizen
class. However, the derived classes of Denizen
will be unchanged.
-
Cave
:Cave
is a class that represents a single cave in the dungeon.- Attributes:
id
: a unique identifier for the cavetunnels
: astd::unordered_map<int, std::weak_ptr<Cave>>
of the caves that are adjacent to this cavedenizens
: astd::set<std::shared_ptr<Denizen>, CompareThings>
of the denizens that are currently in this cave
- Methods:
void ConnectTo(const std::shared_ptr<Cave> &destination)
: adds a tunnel to the destination cave.int GetCaveId()
: returns the cave's id.std::vector<int> GetConnectedIds()
: returns a vector of the ids of the caves that are connected to this cave.void AddDenizen(const std::shared_ptr<Denizen> &newDenizen)
: adds a denizen to the cave.bool HasDenizens()
: returns true if the cave has any denizens.bool HasDenizen(const DenizenIdentifier &identifier)
: returns true if the cave has a denizen with the given identifier.IDungeon &GetDungeon()
returns a reference to the dungeon that this cave is in. There is of course only one dungeon. This method is used to call the methods of the dungeon.- The will be more methods in the future, but these are the ones that are needed for this assignment.
-
Dungeon
:Dungeon
is a class that represents the entire dungeon. It is in charge of managing all the caves and the denizens.Dungeon
is actually a derived class ofIDungeon
, which is an interface that is used by theCave
class to call the methods of the dungeon.- Attributes:
provider
: astd::shared_ptr<IRandomProvider>
that is used to generate random numbers.caveDenizens
: astd::unordered_map
of all the caves in the dungeon.caves
: astd::unordered_map
of all the denizens in the dungeon.
- Methods:
const std::shared_ptr<Cave> &FindCave(int caveId)
: use thecaves
attribute to find the cave with the given id and return a pointer to it.void AddDenizen(const std::shared_ptr<Denizen> &newDenizen)
: add a denizen a random cave.void AddToCave(const std::shared_ptr<Denizen> &denizen, int caveId)
: add a denizen to a specific cave.- There will be more methods in the future, but these are the ones that are needed for this assignment.
-
Context
:Context
is a class that represents the current state of the game. It is in charge of managing the game state and notifying the user of changes.- Attributes:
random_provider
: astd::shared_ptr<IRandomProvider>
that is used to generate random numbers.- There will be other attributes in the future that handle state changes and user notifications.
-
IRandomProvider
:IRandomProvider
is an interface that provides a method for generating random numbers. The whole game is designed such that we can implement the concrete class independently of the rest of the game.- Methods:
int MakeRandomCave()
: returns a random number between 1 and 20.int MakeRandomTunnel()
: returns a random number between 0 and 2.float MakeRandomNumber()
: returns a random number between 0 and 1.
At the end of the assignment, our directory structure should look like this (disregard the VS project files if you're using a different IDE):
.
├── Assignment2Solution.sln
├── CppUnitLite
│ ├── CppUnitLite.vcxproj
│ ├── Failure.cpp
│ ├── Failure.h
│ ├── Main.cpp
│ ├── Makefile
│ ├── Test.cpp
│ ├── Test.h
│ ├── TestHarness.h
│ ├── TestRegistry.cpp
│ ├── TestRegistry.h
│ ├── TestResult.cpp
│ ├── TestResult.h
│ ├── WFailure.cpp
│ ├── WFailure.h
│ └── XxxTest.cpp
├── HuntTheWumpusLib
│ ├── Arrow.cpp
│ ├── Arrow.h
│ ├── Bat.cpp
│ ├── Bat.h
│ ├── Cave.cpp
│ ├── Cave.h
│ ├── Context.h
│ ├── Denizen.cpp
│ ├── Denizen.h
│ ├── Dungeon.cpp
│ ├── Dungeon.h
│ ├── HuntTheWumpusLib.vcxproj
│ ├── HuntTheWumpusLib.vcxproj.user
│ ├── Hunter.cpp
│ ├── Hunter.h
│ ├── Makefile
│ ├── Pit.cpp
│ ├── Pit.h
│ ├── RandomProvider.h
│ ├── Wumpus.cpp
│ └── Wumpus.h
├── Makefile
└── UnitTestHuntTheWumpus
├── Makefile
├── TestArrow.cpp
├── TestBat.cpp
├── TestCave.cpp
├── TestDungeon.cpp
├── TestHelperDungeon.h
├── TestHelperRandomProvider.h
├── TestHelperTestEnvironment.cpp
├── TestHelperTestEnvironment.h
├── TestHunter.cpp
├── TestPit.cpp
├── TestThingInCave.cpp
├── TestWumpus.cpp
├── UnitTestHuntTheWumpus.cpp
├── UnitTestHuntTheWumpus.vcxproj
├── UnitTestHuntTheWumpus.vcxproj.filters
└── UnitTestHuntTheWumpus.vcxproj.user
2. Implementing Cave
Take a look at Cave.h
:
#pragma once
#include "Denizen.h"
#include <memory>
#include <set>
#include <unordered_map>
#include <vector>
namespace HuntTheWumpus
{
class IDungeon;
struct CompareThings
{
bool operator()(const std::shared_ptr<Denizen> &thing1, const std::shared_ptr<Denizen> &thing2) const;
};
class Cave
{
public:
explicit Cave(int caveId, IDungeon &dungeon);
~Cave() = default;
void ConnectTo(const std::shared_ptr<Cave> &destination);
const std::weak_ptr<Cave> &GetConnectedCave(int caveId);
int GetCaveId() const { return m_caveId; }
std::vector<int> GetConnectedIds() const;
void AddDenizen(const std::shared_ptr<Denizen> &newDenizen);
bool HasDenizens() const { return !m_denizens.empty(); }
bool HasDenizen(const DenizenIdentifier &identifier) const;
IDungeon &GetDungeon() const { return m_dungeon; }
Cave() = delete;
Cave(const Cave&) = delete;
Cave(Cave&&) = delete;
Cave& operator=(const Cave&) = delete;
Cave& operator=(Cave&&) = delete;
private:
int m_caveId;
IDungeon &m_dungeon;
std::set<std::shared_ptr<Denizen>, CompareThings> m_denizens;
std::unordered_map<int, std::weak_ptr<Cave>> m_tunnels;
};
}
Several things to note here:
-
The constructor is marked
explicit
. This is a good practice to follow for single-argument constructors, as it prevents the compiler from performing implicit conversions that may lead to unexpected behavior. For example, if we had a function that took aCave
as an argument, the compiler would not allow us to pass in anint
instead. -
The destructor is marked
default
. This is because STL containers such asstd::set
andstd::unordered_map
will automatically call the destructor of their elements when they are destroyed. Therefore, we don't need to explicitly call the destructor ofm_denizens
andm_tunnels
. -
The other constructors and assignment operators are marked
delete
. This is because eachCave
is unique and should not be copied or moved. Having control over the copy and move semantics of our classes is important for preventing bugs and memory leaks. -
The
CompareThings
struct is a functor that is used to compare twoDenizen
objects. It is used by thestd::set
container to sort the denizens in the cave. Thestd::set
can actually also take a function or lambda function as an argument, but the functor approach is more flexible since it can have state. The functor is implemented inCave.cpp
:
bool CompareThings::operator()(const std::shared_ptr<Denizen>& thing1, const std::shared_ptr<Denizen>& thing2) const
{
return thing1->GetPriority() > thing2->GetPriority();
}
where GetPriority()
is a new method that we will add to the Denizen
class later.
ConnectTo
andAddDenizen
are implemented using theemplace
method of their respective containers. This is more efficient than usinginsert
because it avoids the creation of temporary objects.
void Cave::ConnectTo(const std::shared_ptr<Cave>& destination)
{
m_tunnels.emplace(destination->GetCaveId(), destination);
}
void Cave::AddDenizen(const std::shared_ptr<Denizen>& newDenizen)
{
m_denizens.emplace(newDenizen);
}
- The
GetConnectedIds
method takes advantage of several C++17 features. It is implemented using a range-based for loop, with the help of structured bindings for unpacking the key-value pairs of them_tunnels
map. The use ofauto
helps to avoid the need to explicitly specify the type of the iterator. The use ofemplace_back
is more efficient thanpush_back
. ThecaveIds
vector is returned by value, which is fine because the compiler will perform copy elision (return-value optimization) to avoid the unnecessary copy.
std::vector<int> Cave::GetConnectedIds() const
{
std::vector<int> caveIds;
for (auto&& [caveId, cave] : m_tunnels)
{
caveIds.emplace_back(caveId);
}
return caveIds;
}
std::ranges::find_if
returns an iterator to the first element in the range that satisfies the predicate. If no such element is found, it returns an iterator to the end of the range, i.e.,m_denizens.end()
. The lambda function captures theidentifier
argument by reference ([&]
), and theconst
qualifier ensures that the lambda function does not modify the captured variable.
bool Cave::HasDenizen(const DenizenIdentifier &identifier) const
{
const auto found = std::ranges::find_if(m_denizens, [&](const auto &denizen)
{
return denizen->GetIdentifier().m_category == identifier.m_category;
});
return found != m_denizens.end();
}
3. Implementing Dungeon
Take a look at Dungeon.h
:
#pragma once
#include "Denizen.h"
#include "Cave.h"
#include <memory>
#include <unordered_map>
namespace HuntTheWumpus
{
class IDungeon
{
public:
IDungeon() = default;
virtual ~IDungeon() = default;
virtual const std::shared_ptr<Cave> &FindCave(int caveId) = 0;
IDungeon(const IDungeon &) = default;
IDungeon(IDungeon &&) = default;
IDungeon &operator=(const IDungeon &) = default;
IDungeon &operator=(IDungeon &&) = default;
};
class Dungeon final : public IDungeon
{
public:
explicit Dungeon(Context &providers);
~Dungeon() override = default;
const std::shared_ptr<Cave> &FindCave(int caveId) override;
Dungeon(const Dungeon &) = delete;
Dungeon(Dungeon &&) = delete;
Dungeon &operator=(const Dungeon &) = delete;
Dungeon &operator=(Dungeon &&) = delete;
private:
void Initialize(Context &providers);
void AddDenizen(const std::shared_ptr<Denizen> &newDenizen);
void MakeTunnels() const;
void AddToCave(const std::shared_ptr<Denizen> &denizen, int caveId);
Context &m_providers;
std::unordered_map<DenizenIdentifier, std::shared_ptr<Denizen>, DenizenIdentifierHasher> m_caveDenizens;
std::unordered_map<int, std::shared_ptr<Cave>> m_caves;
};
}
Several things to note here:
- The Dungeon is initialized with a
Context
object, which is a class that represents the current state of the game. Under the hood, the constructor calls theInitialize
method, which is implemented as follows:
Dungeon::Dungeon(Context& providers)
: m_providers(providers)
{
Initialize(providers);
}
void Dungeon::Initialize(Context& providers)
{
for (auto idx = 1; idx <= 20; ++idx)
{
m_caves.emplace(idx, std::make_shared<Cave>(idx, *this));
}
MakeTunnels();
AddDenizen( std::make_shared<Bat>(0, m_providers) );
AddDenizen( std::make_shared<Bat>(1, m_providers) );
AddDenizen( std::make_shared<Wumpus>(0, m_providers) );
AddDenizen( std::make_shared<Pit>(0, m_providers) );
AddDenizen( std::make_shared<Pit>(1, m_providers) );
// Put the hunter in a random empty cave.
auto hunterPlaced = false;
while (!hunterPlaced)
{
const auto hunterCave = providers.m_random.MakeRandomCave();
if (m_caves[hunterCave]->HasDenizens())
{
continue;
}
AddToCave(std::make_shared<Hunter>(providers), hunterCave);
hunterPlaced = true;
}
}
- The
m_caves
map is initialized with 20 caves, each with a unique id. Them_caveDenizens
map is empty at this point. - Then,
MakeTunnels
is called to connect the caves together. The network of caves is illustrated below:
and the implementation of MakeTunnels
is as follows:
void Dungeon::MakeTunnels() const
{
const std::vector<std::pair<int, std::vector<int> > > tunnelPairs {
{ 1, {2, 5, 8} },
{ 2, {1, 3, 10} },
{ 3, {2, 4, 12} },
{ 4, {3, 5, 14} },
{ 5, {1, 4, 6} },
{ 6, {5, 7, 15} },
{ 7, {6, 8, 17} },
{ 8, {1, 7, 9} },
{ 9, {8, 10, 18} },
{ 10, {2, 9, 11} },
{ 11, {10, 12, 19} },
{ 12, {3, 11, 13} },
{ 13, {12, 14, 20 } },
{ 14, {4, 13, 15} },
{ 15, {6, 14, 16} },
{ 16, {15, 17, 20} },
{ 17, {7, 16, 18} },
{ 18, {9, 17, 19} },
{ 19, {11, 18, 20} },
{ 20, {13, 16, 19} }
};
for (auto&& [caveId, neighborIds] : tunnelPairs)
{
const auto cave = m_caves.at(caveId);
for (auto&& neighborId : neighborIds)
{
const auto neighbor = m_caves.at(neighborId);
cave->ConnectTo(neighbor);
}
}
}
- Notice how all other denizens are added using the
AddDenizen
method (add to a random cave), but the hunter is added using theAddToCave
method (add to a specific cave). This is because we want to ensure that the hunter is placed in an empty cave. In my opinion, those methods can be better named asAddDenizenToRandomCave
andAddDenizenToCave
, respectively. They are implemented as follows:
void Dungeon::AddDenizen(const std::shared_ptr<Denizen>& newDenizen)
{
auto denizenPlaced = false;
while (!denizenPlaced)
{
const auto denizenCave = m_providers.m_random.MakeRandomCave();
// Verify that cave does not have a like Denizen in it.
if (m_caves[denizenCave]->HasDenizen(newDenizen->GetIdentifier()))
{
continue;
}
AddToCave(newDenizen, denizenCave);
denizenPlaced = true;
}
}
void Dungeon::AddToCave(const std::shared_ptr<Denizen>& denizen, const int caveId)
{
m_caveDenizens.emplace(denizen->GetIdentifier(), denizen);
const auto cave = m_caves.at(caveId);
denizen->EnterCave(cave);
cave->AddDenizen(denizen);
}
- The
FindCave
method is implemented using theat
method of them_caves
map. This is because we want to throw an exception if the cave is not found. Theat
method will throw anstd::out_of_range
exception if the key is not found. The output ofm_caves.at(caveId)
is astd::shared_ptr<Cave>
. Here we prefixauto
withconst
, the deduced type becomes a const-qualified version of the original type.
4. Implementing Context
and IRandomProvider
Take a look at Context.h
:
#pragma once
namespace HuntTheWumpus
{
class IRandomProvider;
struct Context
{
IRandomProvider &m_random;
};
}
It is a simple struct that contains a reference to an IRandomProvider
object. There will be more attributes in the future, but this is all we need for this assignment.
The IRandomProvider
interface is implemented in RandomProvider.h
:
#pragma once
namespace HuntTheWumpus
{
class IRandomProvider
{
public:
IRandomProvider() = default;
virtual ~IRandomProvider() = default;
// Produce random numbers in the [1, 20] range uniformly.
virtual int MakeRandomCave() = 0;
// Produce random numbers in the [0, 2] range uniformly.
virtual int MakeRandomTunnel() = 0;
// Produce random number in the range [0, 1] range uniformly.
virtual float MakeRandomNumber() = 0;
IRandomProvider(const IRandomProvider &) = default;
IRandomProvider(IRandomProvider &&) = default;
IRandomProvider &operator=(const IRandomProvider &) = default;
IRandomProvider &operator=(IRandomProvider &&) = default;
};
}
So, there is actually no concrete implementation of IRandomProvider
in the HuntTheWumpusLib
project. Instead, we will create a test implementation in the UnitTestHuntTheWumpus
project. This is because we want to be able to control the random numbers that are generated during testing. The test implementation is as follows:
class TestRandomProvider final : public HuntTheWumpus::IRandomProvider
{
public:
TestRandomProvider() = default;
~TestRandomProvider() override = default;
int MakeRandomCave() override
{
if(m_sequence == m_randomCaveSequence.end())
{
m_sequence = m_randomCaveSequence.begin();
}
return *m_sequence++;
}
void SetCaveSequence( std::vector<int> &&sequence )
{
m_randomCaveSequence = sequence;
m_sequence = m_randomCaveSequence.end();
}
int MakeRandomTunnel() override { return 0; }
float MakeRandomNumber() override { return m_desiredRandomNumber; }
float m_desiredRandomNumber = 0.0f;
std::vector<int> m_randomCaveSequence;
std::vector<int>::const_iterator m_sequence = m_randomCaveSequence.end();
TestRandomProvider(const TestRandomProvider&) = delete;
TestRandomProvider(TestRandomProvider&&) = delete;
TestRandomProvider& operator=(const TestRandomProvider&) = delete;
TestRandomProvider& operator=(TestRandomProvider&&) = delete;
};
And then we can pass this TestRandomProvider
object to the Context
object in our unit tests:
TestRandomProvider m_provider;
HuntTheWumpus::Context m_context { m_provider };
And then we can set the desired random cave sequence in our unit tests before creating the Dungeon
object:
m_provider.SetCaveSequence({ 1, 2, 3, 4, 5, 6 });
HuntTheWumpus::Dungeon dungeon { m_context };