#include "testHarness.h" #include "vf/Villager.h" using namespace vf; namespace { constexpr int groundTop = 4; constexpr int walkY = groundTop + 1; const Vec3i villageCenter{0, walkY, 0}; World makeGround(int radius) { World world; for (int x = -radius; x <= radius; ++x) for (int z = -radius; z <= radius; ++z) for (int y = 0; y <= groundTop; ++y) world.setBlock(x, y, z, Block::Stone); return world; } void placePoi(World& world, int x, int z, Block block) { world.setBlock(x, walkY, z, block); } // A small village: a bell in the middle, two beds, a lectern and a composter. World makeVillage() { World world = makeGround(20); placePoi(world, 0, 0, Block::Bell); placePoi(world, 4, 0, Block::Bed); placePoi(world, 4, 2, Block::Bed); placePoi(world, -4, 0, Block::Lectern); placePoi(world, 0, 5, Block::Composter); return world; } Villager makeWorker(int id, int x, int z, uint32_t seed) { Villager villager = makeVillager(id, {static_cast(x) + 0.5f, static_cast(walkY), static_cast(z) + 0.5f}, seed); villager.profession = VillagerProfession::Unemployed; return villager; } void runTicks(const World& world, Villager& villager, PoiRegistry& pois, const TimeOfDay& clock, const VillagerSchedule& schedule, bool threatened, int ticks) { for (int i = 0; i < ticks; ++i) tickVillager(world, villager, pois, clock, schedule, villageCenter, threatened, 0.05f); } TimeOfDay clockAtFraction(double fraction) { return TimeOfDay(static_cast(fraction * 24000.0)); } float horizontalGap(const Vec3& position, const Vec3i& cell) { float dx = position.x - (static_cast(cell.x) + 0.5f); float dz = position.z - (static_cast(cell.z) + 0.5f); return std::sqrt(dx * dx + dz * dz); } } // namespace VF_TEST(poiRegistryIndexesWorkstationsBedsAndTheBellAroundTheVillage) { World world = makeVillage(); PoiRegistry pois; pois.scan(world, villageCenter, 12, 4); CHECK_EQ(pois.size(), 5); CHECK_EQ(pois.bedCount(), 2); CHECK_EQ(pois.workstationCount(), 2); CHECK(pois.bellIndex() >= 0); CHECK(pois.at(pois.bellIndex()).isBell()); int lectern = pois.indexAt({-4, walkY, 0}); CHECK(lectern >= 0); CHECK(pois.at(lectern).profession == VillagerProfession::Librarian); CHECK(pois.at(lectern).reachable); CHECK_EQ(pois.at(lectern).standCell.y, walkY + 1); int bell = pois.bellIndex(); CHECK_EQ(pois.at(bell).standCell.y, walkY); CHECK_EQ(pois.freeBedCount(), 2); CHECK_EQ(pois.freeWorkstationCount(), 2); } VF_TEST(everyWorkstationBlockMapsBackToTheProfessionItGrants) { for (int raw = 2; raw < static_cast(VillagerProfession::Count); ++raw) { VillagerProfession profession = static_cast(raw); Block station = workstationForProfession(profession); CHECK(station != Block::Air); CHECK(isWorkstationBlock(station)); CHECK(professionForWorkstation(station) == profession); CHECK(professionEmployable(profession)); } CHECK(workstationForProfession(VillagerProfession::Nitwit) == Block::Air); CHECK(professionForWorkstation(Block::Stone) == VillagerProfession::Unemployed); CHECK(!isWorkstationBlock(Block::Bed)); CHECK(!professionEmployable(VillagerProfession::Nitwit)); } VF_TEST(anUnemployedVillagerWalksToAFreeLecternAndBecomesALibrarian) { World world = makeVillage(); PoiRegistry pois; pois.scan(world, villageCenter, 12, 4); Villager villager = makeWorker(1, -1, 0, 77u); TimeOfDay clock = clockAtFraction(0.35); VillagerSchedule schedule; runTicks(world, villager, pois, clock, schedule, false, 1); CHECK(villager.activity == VillagerActivity::SeekWorkstation); CHECK(villager.state == VillagerState::Travelling); runTicks(world, villager, pois, clock, schedule, false, 400); CHECK(villager.profession == VillagerProfession::Librarian); CHECK(villager.workstationIndex >= 0); CHECK_EQ(pois.at(villager.workstationIndex).ownerId, villager.id); CHECK(pois.at(villager.workstationIndex).pos == Vec3i(-4, walkY, 0)); CHECK(villager.activity == VillagerActivity::Work); CHECK(villager.state == VillagerState::AtTarget); CHECK(villager.workTicks > 0); CHECK(villager.bedIndex >= 0); } VF_TEST(aClaimedWorkstationIsInvisibleToTheNextVillagerLookingForWork) { World world = makeVillage(); PoiRegistry pois; pois.scan(world, villageCenter, 12, 4); Villager first = makeWorker(1, -1, 0, 3u); Villager second = makeWorker(2, -2, 0, 9u); TimeOfDay clock = clockAtFraction(0.35); VillagerSchedule schedule; runTicks(world, first, pois, clock, schedule, false, 400); CHECK(first.profession == VillagerProfession::Librarian); runTicks(world, second, pois, clock, schedule, false, 800); CHECK(second.profession == VillagerProfession::Farmer); CHECK(second.workstationIndex != first.workstationIndex); CHECK_EQ(pois.freeWorkstationCount(), 0); CHECK_EQ(pois.freeBedCount(), 0); Villager third = makeWorker(3, 1, 1, 11u); runTicks(world, third, pois, clock, schedule, false, 200); CHECK(third.profession == VillagerProfession::Unemployed); CHECK_EQ(third.workstationIndex, -1); CHECK_EQ(third.bedIndex, -1); CHECK(third.activity == VillagerActivity::SeekWorkstation); CHECK(third.state == VillagerState::Idle); } VF_TEST(villagerWalksTheWholeDayFromBedToWorkToTheBellAndBackToBed) { World world = makeVillage(); PoiRegistry pois; pois.scan(world, villageCenter, 12, 4); Villager villager = makeWorker(1, -1, 0, 5u); VillagerSchedule schedule; TimeOfDay clock(0); std::vector seen; Vec3i bellStand = pois.at(pois.bellIndex()).standCell; bool reachedBellAtNoon = false; for (WorldTick step = 0; step < 24000; step += 20) { clock.setTick(step); runTicks(world, villager, pois, clock, schedule, false, 20); if (seen.empty() || seen.back() != villager.activity) seen.push_back(villager.activity); if (villager.activity == VillagerActivity::Meet && villager.state == VillagerState::AtTarget) { reachedBellAtNoon = true; CHECK(horizontalGap(villager.body.position, bellStand) < 0.01f); } } CHECK(reachedBellAtNoon); CHECK(seen.size() >= 7); CHECK(seen[0] == VillagerActivity::Sleep); CHECK(seen[1] == VillagerActivity::Idle); CHECK(seen[2] == VillagerActivity::SeekWorkstation); CHECK(seen[3] == VillagerActivity::Work); CHECK(seen[4] == VillagerActivity::Meet); CHECK(seen[5] == VillagerActivity::Work); CHECK(seen.back() == VillagerActivity::Sleep); CHECK(villager.profession == VillagerProfession::Librarian); CHECK(villager.workTicks > 1000); CHECK(villager.bedIndex >= 0); CHECK(villager.state == VillagerState::AtTarget); CHECK(horizontalGap(villager.body.position, pois.at(villager.bedIndex).standCell) < 0.01f); } VF_TEST(breakingTheWorkstationDemotesAFreshVillagerBackToUnemployed) { World world = makeVillage(); PoiRegistry pois; pois.scan(world, villageCenter, 12, 4); Villager villager = makeWorker(1, -1, 0, 21u); TimeOfDay clock = clockAtFraction(0.35); VillagerSchedule schedule; runTicks(world, villager, pois, clock, schedule, false, 400); CHECK(villager.profession == VillagerProfession::Librarian); CHECK_EQ(villager.level, 1); world.setBlock(-4, walkY, 0, Block::Air); CHECK_EQ(pois.revalidate(world), 1); runTicks(world, villager, pois, clock, schedule, false, 1); CHECK(villager.profession == VillagerProfession::Unemployed); CHECK_EQ(villager.workstationIndex, -1); CHECK_EQ(villager.experience, 0); runTicks(world, villager, pois, clock, schedule, false, 800); CHECK(villager.profession == VillagerProfession::Farmer); CHECK(pois.at(villager.workstationIndex).block == Block::Composter); } VF_TEST(aVillagerPastLevelOneKeepsItsTradeWhenTheWorkstationIsBroken) { World world = makeVillage(); placePoi(world, -6, 0, Block::Lectern); PoiRegistry pois; pois.scan(world, villageCenter, 12, 4); Villager villager = makeWorker(1, -1, 0, 31u); TimeOfDay clock = clockAtFraction(0.35); VillagerSchedule schedule; runTicks(world, villager, pois, clock, schedule, false, 400); CHECK(villager.profession == VillagerProfession::Librarian); CHECK(grantVillagerExperience(villager, 20)); CHECK_EQ(villager.level, 2); world.setBlock(-4, walkY, 0, Block::Air); pois.revalidate(world); runTicks(world, villager, pois, clock, schedule, false, 1); CHECK(villager.profession == VillagerProfession::Librarian); CHECK_EQ(villager.workstationIndex, -1); CHECK_EQ(villager.experience, 20); runTicks(world, villager, pois, clock, schedule, false, 400); CHECK(villager.workstationIndex >= 0); CHECK(pois.at(villager.workstationIndex).pos == Vec3i(-6, walkY, 0)); CHECK(villager.profession == VillagerProfession::Librarian); } VF_TEST(experienceThresholdsPromoteTheVillagerThroughAllFiveLevels) { CHECK_EQ(villagerExperienceForLevel(1), 0); CHECK_EQ(villagerLevelForExperience(0), 1); CHECK_EQ(villagerLevelForExperience(villagerExperienceForLevel(5)), villagerMaxLevel); Villager villager = makeWorker(1, 0, 0, 4u); int reachedLevels = 1; for (int step = 0; step < 60; ++step) { if (grantVillagerExperience(villager, 5)) ++reachedLevels; CHECK(villager.level <= villagerMaxLevel); } CHECK_EQ(reachedLevels, villagerMaxLevel); CHECK_EQ(villager.level, villagerMaxLevel); CHECK(!grantVillagerExperience(villager, 0)); for (int level = 2; level <= villagerMaxLevel; ++level) CHECK(villagerExperienceForLevel(level) > villagerExperienceForLevel(level - 1)); } VF_TEST(panicOverridesTheScheduleAndSendsTheVillagerBackToItsBed) { World world = makeVillage(); PoiRegistry pois; pois.scan(world, villageCenter, 12, 4); Villager villager = makeWorker(1, -1, 0, 12u); TimeOfDay clock = clockAtFraction(0.35); VillagerSchedule schedule; runTicks(world, villager, pois, clock, schedule, false, 400); CHECK(villager.activity == VillagerActivity::Work); CHECK(villager.profession == VillagerProfession::Librarian); runTicks(world, villager, pois, clock, schedule, true, 1); CHECK(villager.activity == VillagerActivity::Panic); runTicks(world, villager, pois, clock, schedule, true, 600); CHECK(villager.state == VillagerState::AtTarget); CHECK(horizontalGap(villager.body.position, pois.at(villager.bedIndex).standCell) < 0.01f); int workTicksUnderThreat = villager.workTicks; runTicks(world, villager, pois, clock, schedule, true, 100); CHECK_EQ(villager.workTicks, workTicksUnderThreat); runTicks(world, villager, pois, clock, schedule, false, 1); CHECK(villager.activity == VillagerActivity::Work); } VF_TEST(deathReleasesTheBedAndWorkstationForSomebodyElse) { World world = makeVillage(); PoiRegistry pois; pois.scan(world, villageCenter, 12, 4); Villager villager = makeWorker(1, -1, 0, 55u); TimeOfDay clock = clockAtFraction(0.35); VillagerSchedule schedule; runTicks(world, villager, pois, clock, schedule, false, 400); CHECK_EQ(pois.freeWorkstationCount(), 1); CHECK_EQ(pois.freeBedCount(), 1); CHECK(!damageVillager(villager, pois, 10.0f)); CHECK(villager.alive); CHECK(damageVillager(villager, pois, 10.0f)); CHECK(!villager.alive); CHECK(villager.state == VillagerState::Dead); CHECK_EQ(villager.workstationIndex, -1); CHECK_EQ(pois.freeWorkstationCount(), 2); CHECK_EQ(pois.freeBedCount(), 2); CHECK(!damageVillager(villager, pois, 10.0f)); Villager heir = makeWorker(2, -1, 0, 56u); runTicks(world, heir, pois, clock, schedule, false, 400); CHECK(heir.profession == VillagerProfession::Librarian); } VF_TEST(neighboursStandingTogetherTradeGossipBothWays) { Villager first = makeWorker(1, 0, 0, 61u); Villager second = makeWorker(2, 1, 0, 62u); Villager distant = makeWorker(3, 15, 0, 63u); first.gossip.add(GossipKind::MinorNegative, gossipMinorNegativeGain * 4); second.gossip.add(GossipKind::Trading, gossipTradingGain * 6); CHECK(exchangeVillagerGossip(first, second)); CHECK(second.gossip.valueOf(GossipKind::MinorNegative) > 0); CHECK(first.gossip.valueOf(GossipKind::Trading) > 0); CHECK(!exchangeVillagerGossip(first, distant)); CHECK_EQ(distant.gossip.valueOf(GossipKind::MinorNegative), 0); std::vector crowd{first, second, distant}; CHECK_EQ(spreadVillageGossip(crowd), 1); CHECK_EQ(crowd[2].gossip.valueOf(GossipKind::MinorNegative), 0); crowd[2].body.position = {1.5f, static_cast(walkY), 0.5f}; CHECK(spreadVillageGossip(crowd) >= 2); CHECK(crowd[2].gossip.valueOf(GossipKind::MinorNegative) > 0); CHECK(crowd[2].gossip.valueOf(GossipKind::MinorNegative) <= gossipTuning(GossipKind::MinorNegative).shareCap); } VF_TEST(censusCountsBedsWorkstationsAndFoodAndGatesVillageGrowth) { World world = makeVillage(); PoiRegistry pois; pois.scan(world, villageCenter, 12, 4); std::vector villagers{makeWorker(1, 1, 1, 71u), makeWorker(2, 2, 1, 72u)}; VillageCensus empty = takeVillageCensus(villagers, pois, villageCenter, 12); CHECK_EQ(empty.villagers, 2); CHECK_EQ(empty.living, 2); CHECK_EQ(empty.beds, 2); CHECK_EQ(empty.freeBeds, 2); CHECK_EQ(empty.workstations, 2); CHECK_EQ(empty.foodStock, 0); CHECK(!empty.canGrow()); villagers[0].food = villagerBreedingFoodCost; villagers[1].food = villagerBreedingFoodCost; VillageCensus fed = takeVillageCensus(villagers, pois, villageCenter, 12); CHECK_EQ(fed.willingBreeders, 2); CHECK_EQ(fed.foodStock, villagerBreedingFoodCost * 2); CHECK(fed.canGrow()); CHECK(assignVillagerBed(villagers[0], pois)); CHECK(assignVillagerBed(villagers[1], pois)); VillageCensus full = takeVillageCensus(villagers, pois, villageCenter, 12); CHECK_EQ(full.freeBeds, 0); CHECK(!full.canGrow()); CHECK_EQ(full.capacity(), 2); } VF_TEST(breedingSpendsFoodAndFillsTheFreeBeds) { World world = makeVillage(); placePoi(world, 6, 0, Block::Bed); PoiRegistry pois; pois.scan(world, villageCenter, 12, 4); CHECK_EQ(pois.bedCount(), 3); std::vector villagers{makeWorker(1, 1, 1, 81u), makeWorker(2, 2, 1, 82u)}; villagers[0].food = villagerBreedingFoodCost * 2; villagers[1].food = villagerBreedingFoodCost * 2; uint32_t rng = 4242u; int born = breedVillagers(villagers, pois, villageCenter, 12, rng); CHECK_EQ(born, 2); CHECK_EQ(static_cast(villagers.size()), 4); CHECK_EQ(villagers[0].food, 0); CHECK_EQ(villagers[1].food, 0); CHECK_EQ(pois.freeBedCount(), 1); CHECK_EQ(villagers[2].id, 3); CHECK_EQ(villagers[3].id, 4); CHECK(villagers[2].bedIndex >= 0); CHECK(villagers[3].bedIndex >= 0); CHECK(villagers[2].bedIndex != villagers[3].bedIndex); CHECK_EQ(breedVillagers(villagers, pois, villageCenter, 12, rng), 0); } VF_TEST(nitwitsNeverTakeAWorkstationEvenWhenOneIsFree) { int nitwits = 0; int found = -1; for (int id = 1; id <= 40; ++id) { Villager candidate = makeVillager(id, {0.5f, static_cast(walkY), 0.5f}, 900u); if (candidate.profession == VillagerProfession::Nitwit) { ++nitwits; if (found < 0) found = id; } } CHECK(nitwits > 0); CHECK(nitwits < 40); World world = makeVillage(); PoiRegistry pois; pois.scan(world, villageCenter, 12, 4); Villager nitwit = makeVillager(found, {-1.5f, static_cast(walkY), 0.5f}, 900u); CHECK(nitwit.profession == VillagerProfession::Nitwit); CHECK(!nitwit.employed()); TimeOfDay clock = clockAtFraction(0.35); VillagerSchedule schedule; runTicks(world, nitwit, pois, clock, schedule, false, 400); CHECK(nitwit.profession == VillagerProfession::Nitwit); CHECK_EQ(nitwit.workstationIndex, -1); CHECK(nitwit.activity == VillagerActivity::Idle); CHECK_EQ(pois.freeWorkstationCount(), 2); CHECK(nitwit.bedIndex >= 0); CHECK(!assignVillagerWorkstation(nitwit, pois)); }