From 013468d34e835d85995215b238dc93b75db74596 Mon Sep 17 00:00:00 2001 From: Nate Bowman Date: Sun, 26 Jul 2026 23:15:46 -0400 Subject: [PATCH] Deal with farms and varying gather rates --- aoe/gameplay.py | 3 + aoe/gather.py | 244 ++++++++++++++++++++++++++++++++++++++++++++---- aoe/world.py | 184 ++++++++++++++++++++++++++++-------- runner.py | 9 +- 4 files changed, 382 insertions(+), 58 deletions(-) diff --git a/aoe/gameplay.py b/aoe/gameplay.py index d360c7b..b16857a 100644 --- a/aoe/gameplay.py +++ b/aoe/gameplay.py @@ -10,6 +10,7 @@ class Gameplay(): def print_status(self): + print('-'*80) print(f'Current time: {self.world.current_time}') print() print(f'Civ: {self.world.civ}') @@ -24,6 +25,8 @@ class Gameplay(): print(f'Buildings: {self.world.buildings}') print() print(f'Research: {self.world.research}') + print('-'*80) + print() def build(self, action_time, building, villagers_from, villagers_to): diff --git a/aoe/gather.py b/aoe/gather.py index 7a904a7..f9f39a0 100644 --- a/aoe/gather.py +++ b/aoe/gather.py @@ -1,3 +1,5 @@ +import copy + from aoe.types import * @@ -5,23 +7,174 @@ from aoe.types import * class Gather(): + # Gathering occurs when a villager is assigned to some relevant Task. + # + # Each Task consumes a map resource (e.g., a boar) at some rate. + # That rate is recorded in gather_rates. + # + # Map resources such as boars should not be confused with a player's + # resource stockpile. For example, Wood as a map resource is consumed by + # gathering, whereas the player's Wood resource amount is increased. As + # another example, a boar is a map resource that is consumed and leads to + # an increase in Food in the player's stockpile. + # + # When a Task is performed and a map resource is consumed, one or more + # resources are added to the player's stockpile. The typical case is to + # add one resource. E.g., when chopping wood, wood is added to the + # player's stockpile. However, for some civs or technologies, more than one + # resource may be gathered by a single Task. For example, there is a + # technology that allows woodcutters to generate food as they gather wood. + # + # The resources gathered by a Task are recorded in gathered_resources. + # Each Task is associated with 0 or more Resource->Int key-value pairs. + # A Task gathers all resources associated with itself. The integer values + # in the aforementioned pairs are multipliers. A value of 1 indicates + # that the corresponding resource will be added to the player's stockpile + # at the same rate that the world's resource is consumed. A higher value + # indicates that the resource is added at a more favorable ratio to the + # player, whereas a lower value indicates a less favorable ratio. + # + # To reiterate, a value in gather_rates indicates how quickly a world + # resource is removed. It may have been better named "consumption_rates" + # or something similar. On the other hand, to determine how quickly a + # resource is actually added to the player's stockpile, you must consider + # the gather rate from gather_rates *and* the corresponding multiplier from + # gathered_resources. + # + # Buildings # + # + # Most gathering cannot be performed without one or more buildings. In + # many cases, there are a few options for a building or set of buildings + # that will allow the resource to be gathered. The dictionary + # required_gather_buildings records which buildings are necessary and + # sufficient to perform each gathering Task. For a given Task, there is a + # corresponding list of sets. If the world contains *all* of the buildings + # in *any* of those sets, then the Task can be performed. Otherwise, it + # cannot. + # + # Varying Rates and Resource Exhaustion # + # + # The rate of consumption in gather_rates is not fixed over time. In + # general, as a task is performed, the rate will either slow down or stop + # entirely. For example, a lumber camp gets "stale" and the rate will drop + # unless a new camp is built, and a gold mine will be exhausted and the + # rate will effectively drop to zero until a new mine is built. + # + # Several data structures are involved in the implementation of this idea. + # We assume the world at any given time has some amount of consumables + # accessible to a player, which is stored in gatherable_amounts. This is + # *not* the total amount of, e.g., wood available on the map. It is + # instead the amount of, e.g., wood that can be consumed *at the current + # rate* given the available buildings. This amount goes down as resources + # are gathered and can go up as new buildings, such as lumber camps, are + # built. Resources are subtracted from gatherable_amounts at a rate + # determined by gather_rates and the number of villagers performing a Task. + # + # When the amount drops below 0, either the gather rate decreases (as in + # the case of cutting wood) or that Task stops entirely (as in the case of + # mining gold). If the rate merely decreases, the process repeats itself. + # That is, the value in gatherable_amounts is increased, the value in + # gather_rates is decreased, and another decrease will occur when the new + # amount in gatherable_amounts is exhausted. The dictionary + # gather_decrease_rates contains the multipliers by which the gather rate + # is decreased when resource exhaustion occurs. A value of 0 indicates + # that Task is stopped entirely. + # + # When a new building relevant for gathering, such as a lumber camp, is + # created, the amount of the corresponding consumable in gatherable_amounts + # is increased. If the gather rate was already at its original value, then + # some fixed amount is simply added to the available consumable, which will + # delay the next rate decrease. If the corresponding gather rate had + # previously been decreased, that rate is reset to its ideal value. In + # that case, the amount of available consumable is set to a value rather + # than added to the current value because the current value was available + # at a lower gather rate. + # + # A given building can provide access to various resources and may provide + # access to more than one. For example, a mining camp could provide access + # to gold, stone, both, or neither. The dictionary + # buildings_gather_increase stores information about which Task or group of + # Tasks can have their consumable amount increased when a particular + # building is built. A user can specify which Tasks, if any, benefit from + # a particular instance of a building, but the selected Tasks must be + # available for that building according to buildings_gather_increase for + # the choice to be considered valid. This is especially valuable for Town + # Centers, which can be built near any number of resources. That same data + # structure also stores the amount by which the relevant consumables are + # increased (which is also the reset value for that entry of + # gatherable_amounts in the case where a rate decrease has already + # occurred). + # + # Note that gathered_resources, which contains the multipliers for + # gather_rates and determines how much of a resource the player actually + # receives, is not relevant for determining how quickly a resource is + # exhausted. That calculation involves solely gatherable_amounts, + # gather_rates, the number of villagers performing the Task, and the amount + # of simulated time that passes. + # + # Known Limitations # + # + # There is currently no penalty for having too many villagers gathering at + # a particular building (e.g., 100 lumberjacks at a single wood camp). + # However, if there are not enough lumber camps, the scheduled decrease + # will happen quickly in that case, which will help penalize it somewhat. + # + # We also do not put a ceiling on the amount of a world resource that can + # be accessed. For example, we do not have a fixed amount of berries. If + # a user continues building mills and assigning more villagers to berries, + # they can do so indefinitely. We are trusting the user to know how many + # resources are available on their map. + # + # Farming # + # + # Farming is a little different, and I don't feel like explaining it right + # now. + # + + + + BIG_NUMBER = 1e6 + + def __init__(self): self.gather_rates = { - Tasks.BERRIES: {Resources.FOOD: 18.6/60}, - Tasks.SHEEP: {Resources.FOOD: 19.8/60}, - Tasks.BOAR: {Resources.FOOD: 24.6/60}, - Tasks.HUNT: {Resources.FOOD: 24.6/60}, - Tasks.FARM: {Resources.FOOD: 20/60}, - Tasks.FISH_VILLAGER: {Resources.FOOD: 25.8/60}, - Tasks.FISH_SHIP_SHORE: {Resources.FOOD: 16.8/60}, - Tasks.FISH_SHIP_DEEP: {Resources.FOOD: 29.4/60}, - Tasks.FISHTRAP: {Resources.FOOD: 21/60}, - Tasks.WOOD: {Resources.WOOD: 23.4/60}, - Tasks.GOLD: {Resources.GOLD: 22.8/60}, - Tasks.TRADECART: {Resources.GOLD: 25.9/60}, - Tasks.TRADECOG: {Resources.GOLD: 25.9/60}, - Tasks.RELIC: {Resources.GOLD: 30/60}, - Tasks.STONE: {Resources.STONE: 21.6/60}, + Tasks.BERRIES: 18.6/60, + Tasks.SHEEP: 19.8/60, + Tasks.BOAR: 24.6/60, + Tasks.HUNT: 24.6/60, + Tasks.FARM: 20/60, + Tasks.FISH_VILLAGER: 25.8/60, + Tasks.FISH_SHIP_SHORE: 16.8/60, + Tasks.FISH_SHIP_DEEP: 29.4/60, + Tasks.FISHTRAP: 21/60, + Tasks.WOOD: 23.4/60, + Tasks.GOLD: 22.8/60, + Tasks.TRADECART: 25.9/60, + Tasks.TRADECOG: 25.9/60, + Tasks.RELIC: 30/60, + Tasks.STONE: 21.6/60, + Tasks.BUILD: 0, + Tasks.IDLE: 0, + } + + self.original_gather_rates = copy.deepcopy(self.gather_rates) + + self.gathered_resources = { + Tasks.BERRIES: {Resources.FOOD: 1}, + Tasks.SHEEP: {Resources.FOOD: 1}, + Tasks.BOAR: {Resources.FOOD: 1}, + Tasks.HUNT: {Resources.FOOD: 1}, + Tasks.FARM: {Resources.FOOD: 1}, + Tasks.FISH_VILLAGER: {Resources.FOOD: 1}, + Tasks.FISH_SHIP_SHORE: {Resources.FOOD: 1}, + Tasks.FISH_SHIP_DEEP: {Resources.FOOD: 1}, + Tasks.FISHTRAP: {Resources.FOOD: 1}, + Tasks.WOOD: {Resources.WOOD: 1}, + Tasks.GOLD: {Resources.GOLD: 1}, + Tasks.TRADECART: {Resources.GOLD: 1}, + Tasks.TRADECOG: {Resources.GOLD: 1}, + Tasks.RELIC: {Resources.GOLD: 1}, + Tasks.STONE: {Resources.STONE: 1}, Tasks.BUILD: {}, Tasks.IDLE: {}, } @@ -56,4 +209,63 @@ class Gather(): Tasks.IDLE: [], } - self.improved_gather_buildings = {} + self.gatherable_amounts = { + # These will vary based on the map. + # I am assuming Arabia with: + # 6 forage bushes + # 8 sheep + # 2 boars + # 4 deer + Tasks.BERRIES: 6*125, + Tasks.SHEEP: 8*100, + Tasks.BOAR: 2*340, + Tasks.HUNT: 4*140, + Tasks.FARM: 0, + # TODO next two should be shared + Tasks.FISH_VILLAGER: 0, + Tasks.FISH_SHIP_SHORE: 0, + Tasks.FISH_SHIP_DEEP: 0, + Tasks.FISHTRAP: 0, + Tasks.WOOD: 0, + Tasks.GOLD: 0, + Tasks.TRADECART: Gather.BIG_NUMBER, + Tasks.TRADECOG: Gather.BIG_NUMBER, + Tasks.RELIC: Gather.BIG_NUMBER, + Tasks.STONE: 0, + } + + self.buildings_gather_increase = { + # Made-up numbers for now + Buildings.LUMBERCAMP: {Tasks.WOOD: 1000}, + Buildings.MILL: {Tasks.HUNT: 4*140, + Tasks.BERRIES: 6*125}, + Buildings.MININGCAMP: {Tasks.GOLD: 4*800, + Tasks.STONE: 4*350}, + Buildings.FARM: {Tasks.FARM: 175}, + #Buildings.DOCK: {Tasks.WOOD: 100}, + Buildings.TOWNCENTER: {Tasks.WOOD: 2000, + Tasks.GOLD: 4*800, + Tasks.STONE: 4*350, + Tasks.BERRIES: 6*125, + Tasks.HUNT: 4*140} + #Buildings.FISHTRAP: {Tasks.WOOD: 100}, + } + + self.gather_decrease_rates = { + # Made-up numbers for now + Tasks.BERRIES: 0, + Tasks.SHEEP: 0, + Tasks.BOAR: 0, + Tasks.HUNT: 0, + Tasks.FARM: 0, + Tasks.FISH_VILLAGER: 0.5, + Tasks.FISH_SHIP_SHORE: 0.5, + Tasks.FISH_SHIP_DEEP: 0, + Tasks.FISHTRAP: 0, + Tasks.WOOD: 0.85, + Tasks.GOLD: 0, + Tasks.TRADECART: 1, + Tasks.TRADECOG: 1, + Tasks.RELIC: 1, + Tasks.STONE: 0, + } diff --git a/aoe/world.py b/aoe/world.py index 26e3528..3a72d8f 100644 --- a/aoe/world.py +++ b/aoe/world.py @@ -1,4 +1,6 @@ from collections import defaultdict, Counter +import heapq +import math from aoe.exceptions import * from aoe.costs import * @@ -35,15 +37,21 @@ class World(): # and, if executed successfully, it adds another event to the queue that # actually adds the unit to the world - # TODO farms, reseeding, etc. - # _check_resource_building, _use_resource_building, _free_resource_building - # TODO sheep should die like farms - # TODO need some warning, penalty, etc. if villager is assigned to task - # without appropriate building (e.g., mining with no camp) - # TODO need efficiency penalties on, e.g., lumber camp - # too many vils/camp decreases rate - # "stale" (old) camp decreases rate + # For now, we assume just 1 villager can work a farm at a time. + # This shouldn't be a huge deal to change later. + # We also assume that a player makes optimal choices about farms -- + # villagers are removed from farms with the lowest capacity first and added + # to farms with the highest capacity. + # We keep track of only the closest upcoming farm deletion. + # TODO research + # TODO allow automation: + # reseeding of farms + # queuing of villagers + # possibly other things + # TODO gathering boars and hunting + # spend villager time in exchange for adding gatherable resource + # TODO relics MAX_WORLD_TIME = 3*60*60 # 3 hours @@ -102,23 +110,107 @@ class World(): pass - def _update_gather_rate(self, task): - pass # TODO - #num_farms = self.buildings.count(Buildings.FARM) - #num_workers = self.villager_tasks[task] - #villager_room = + def _schedule_next_gather_rate_decrease(self, task): + if task in [Tasks.IDLE, Tasks.BUILD]: + return + + # remove old decrease + scheduled_decrease = self.rate_decrease_event_times[task] + if scheduled_decrease is not None: + decrease_index = None + for i, event in enumerate(self.events[scheduled_decrease]): + if event[0] == self._decrease_gather_rate: + decrease_index = i + break + del self.events[scheduled_decrease][i] + + rate = self.gather.gather_rates[task] + + if task == Tasks.FARM: + available = self.farms[0] + villagers = min(self.villager_tasks[task], 1) + else: + available = self.gather.gatherable_amounts[task] + villagers = self.villager_tasks[task] + + try: + next_decrease = self.current_time + available/(villagers*rate) + except ZeroDivisionError: + # either villagers or rate was 0, so no next decrease should + # be scheduled + self.rate_decrease_event_times[task] = None + else: + # rounding error will give 1 extra second of gathering + next_decrease = math.ceil(self.current_time + next_decrease) + + self.rate_decrease_event_times[task] = next_decrease + + if task == Tasks.FARM: + self.events[next_decrease].append((self._remove_farm, ())) + else: + self.events[next_decrease].append((self._decrease_gather_rate, + (task,))) + + + def _decrease_gather_rate(self, task): + # assumes rate decrease should trigger; does not check + new_rate = self.gather_decrease_rates[task]*self.gather_rates[task] + self.gather_rates[task] = new_rate + + if new_rate > 0: + self._schedule_next_gather_rate_decrease(task) + else: + self.assign_villager(task, task.IDLE, self.villager_tasks[task]) + + + def _improve_gather_rate(self, building, tasks): + if building == Buildings.FARM: + raise ValueError('Do not call this routine with a farm') + + if tasks is None: + tasks = self.gather.buildings_gather_increase[building].keys() + + for task in tasks: + amount = self.gather.buildings_gather_increase[building][task] + if self.gather.gather_rates[task] < self.gather.original_gather_rates[task]: + self.gather.gather_rates[task] = self.gather.original_gather_rates[task] + self.gather.gatherable_amounts[task] = amount + else: + self.gather.gatherable_amounts[task] += amount + self._schedule_next_gather_rate_decrease(task) def assign_villager(self, previous_task, new_task, count=1): + # new_task of None means kill the villager + + # villager_tasks must be modified only in this function + # if it is changed elsewhere, the rate-decrease schedule will be wrong if self.villager_tasks[previous_task] < count: raise IllegalVillagerMoveException - self._check_gather(new_task, count) + if new_task: + self._check_gather(new_task, count) + self.villager_tasks[new_task] += count + else: + self.villagers -= 1 self.villager_tasks[previous_task] -= count - self.villager_tasks[new_task] += count - self._update_gather_rate(new_task) + if previous_task == Tasks.FARM: + # remove villagers from farms with lowest remaining capacity + for i in range(count): + heapq.heappush_max(self.unused_farms, + heapq.heappop(self.farms)) + + if new_task == Tasks.FARM: + # add villagers to farms with highest remaining capacity + for i in range(count): + heapq.heappush(self.farms, + heapq.heappop_max(self.unused_farms)) + + self._schedule_next_gather_rate_decrease(previous_task) + if new_task: + self._schedule_next_gather_rate_decrease(new_task) def _check_villager_move(self, villagers_from_count): @@ -129,11 +221,10 @@ class World(): def _move_villagers_to_build(self, villagers_from_count): for task, count in villagers_from_count.items(): - self.villager_tasks[task] -= count - self.villager_tasks[Tasks.BUILD] += count + self.assign_villager(task, Tasks.BUILD, count) - def build(self, building, villagers_from, villagers_to): + def build(self, building, villagers_from, villagers_to, building_tasks=None): if type(villagers_from) == Tasks: villagers_from = [villagers_from] if type(villagers_to) == Tasks: @@ -158,33 +249,39 @@ class World(): update_time = self.current_time + build_time self.events[update_time].append((self._add_building, - (building, villagers_to_count,))) + (building, + villagers_to_count, + building_tasks,))) - def _add_building(self, building, villagers_to): - + def _add_building(self, building, villagers_to, tasks_improved): self.buildings.append(building) self._free_production_building(building) - for task, count in villagers_to.items(): - self.villager_tasks[Tasks.BUILD] -= count - self.villager_tasks[task] += count - if building == Buildings.HOUSE: self._free_housing() + if building == Buildings.FARM: - # assumes a farm is worked nonstop after being built - update_time = self.current_time + times.exist_duration[Buildings.FARM] - self.events[update_time].append((self._remove_farm, ())) + self._add_farm() + elif building in self.gather.buildings_gather_increase.keys(): + self._improve_gather_rate(building, tasks_improved) + + for task, count in villagers_to.items(): + self.assign_villager(Tasks.BUILD, task, count) + + + def _add_farm(self): + self.buildings.append(Buildings.FARM) + new_farm_value = self.gather.buildings_gather_increase[Buildings.FARM][Tasks.FARM] + heapq.heappush(self.unused_farms, new_farm_value) def _remove_farm(self): self.buildings.remove(Buildings.FARM) + heapq.heappop(self.farms) - num_farms = self.buildings.count(Buildings.FARM) - if self.villager_tasks[Tasks.FARM] > num_farms: - self.villager_tasks[Tasks.FARM] -= 1 - self.villager_tasks[Tasks.IDLE] += 1 + if self.villager_tasks[Tasks.FARM] > len(self.farms): + self.assign_villager(Tasks.FARM, Tasks.IDLE) def advance_by_time(self, duration): @@ -277,6 +374,9 @@ class World(): def _check_gather(self, task, count=1): + if task in [Tasks.IDLE, Tasks.BUILD]: + return + required = self.gather.required_gather_buildings[task] have = set(self.buildings) @@ -286,8 +386,7 @@ class World(): raise GatherBuildingNotAvailableException if task == task.FARM: - num_farms = self.buildings.count(Buildings.FARM) - if self.villager_tasks[task] + count > num_farms: + if self.villager_tasks[task] + count > len(self.unused_farms): raise GatherBuildingNotAvailableException @@ -315,9 +414,7 @@ class World(): self.population -= 1 if unit == Units.VILLAGER: - self.villagers -= 1 - self.villager_tasks[task] -= 1 - self._update_gather_rate(task) + self.assign_villager(task, None) self._free_housing() @@ -359,9 +456,10 @@ class World(): def _gather_resources_for_duration(self, duration): for task, count in self.villager_tasks.items(): - gather_rates = self.gather.gather_rates[task] - for resource, rate in gather_rates.items(): - self.resources[resource] += count*rate*duration + rate = self.gather.gather_rates[task] + gathered = self.gather.gathered_resources[task] + for resource, mult in gathered.items(): + self.resources[resource] += count*rate*mult*duration def _handle_events(self): @@ -400,10 +498,14 @@ class World(): self.resources = [200, 200, 100, 200] self.research = [] self.buildings = [Buildings.TOWNCENTER] + self.farms = [] + self.unused_farms = [] self.villager_tasks = {t: 0 for t in Tasks} self.villager_tasks[Tasks.IDLE] = self.villagers + self.rate_decrease_event_times = {t: None for t in Tasks} + self.units_being_built = 0 self.production_available = {b: 0 for b in Buildings} self.production_available[Buildings.TOWNCENTER] = 1 diff --git a/runner.py b/runner.py index 457a193..af739a0 100644 --- a/runner.py +++ b/runner.py @@ -11,6 +11,8 @@ from aoe.gameplay import * # TODO need some warning, penalty, etc. if villager is assigned to task # without appropriate building (e.g., mining with no camp) # TODO building time assumes just 1 vil is working for now + # TODO need efficiency penalties on, e.g., "stale" lumber camp + # Set up world @@ -39,6 +41,9 @@ game.build(45, Buildings.LUMBERCAMP, Tasks.WOOD, Tasks.WOOD) game.queue_villager(45, Tasks.WOOD) game.queue_villager(45, Tasks.WOOD) +game.play_until(46) +game.print_status() + game.build(100, Buildings.HOUSE, [Tasks.SHEEP, Tasks.WOOD], [Tasks.IDLE]*2) #game.assign_villager(Build.Stable(), "food", wait_until_finished=True) @@ -46,11 +51,13 @@ game.build(100, Buildings.HOUSE, [Tasks.SHEEP, Tasks.WOOD], [Tasks.IDLE]*2) game.queue_unit(s1, Scout) ''' +game.assign_villager(130, Tasks.IDLE, Tasks.WOOD, 2) + # should generate exception #game.build(200, Buildings.FARM, [Tasks.SHEEP], [Tasks.WOOD]) game.build(200, Buildings.MILL, [Tasks.SHEEP], [Tasks.WOOD]) -game.build(250, Buildings.FARM, [Tasks.SHEEP, Tasks.WOOD], [Tasks.IDLE]) +game.build(250, Buildings.FARM, [Tasks.SHEEP, Tasks.WOOD], [Tasks.IDLE]*2) # Evaluate game game.play_until(800)