def resolve_expansion(self) -> None: """The destination is neutral or belongs to the same player. We will simply move the units from the origin to the destination and set the ownership.""" if self.destination.owner is not None and self.destination.owner != self.issuer: # When this Instruction was originally created, it was considered an Expansion. # However, other Instructions have occurred before this one, and the empty Territory # has been taken by another Player. Therefore, we should now resolve this as an Invasion instead. logger.debug( f'Movement to {self.destination.id} was originally an expansion, but a new owner ({self.destination.owner.name}) has been detected; resolving to invasion') return self.resolve_invasion() logger.debug(f'Movement to {self.destination.id} is considered an expansion or relocation') self.destination.set_owner(self.issuer) while self._num_troops > self._num_troops_moved: # Note: we regard each troop here as being identical. if ( (unit := self.origin.take_unit(Troop, 1, self._allow_insufficient_troops)) is None and self._allow_insufficient_troops ): # Not all requested units could be moved, because there are too few available. # However, this Instruction was marked as allowed to occur with insufficient troops, so this is OK. # Stop moving more units and continue as usual. break unit.move(self.destination) self._num_troops_moved += 1
def assert_is_valid(self) -> None: if self.territory not in self.issuer.owned_territories: raise IssuerDoesNotOwnTerritory() if not (territories_with_hq := {t for t in self.issuer.owned_territories if t.has_construct(Headquarter)}): logger.debug("Issuer has no Headquarter, hence may spawn here") return
def resolve_skirmish(self, skirmishes: list[Movement]) -> None: """This is a skirmish with other Instructions. Right now we're assuming each Instruction belongs to a different player, and hence they can be treated as individual armies. Note that two Instructions with the same destination can belong to the same player and they will be treated as two separate armies; however, those two armies do not skirmish.""" while skirmishes: logger.debug(f'{self} has skirmishes with:') logger.indents += 1 for skirmish in skirmishes: logger.debug(f'- {skirmish}') logger.indents -= 1 issuers = {instruction.issuer for instruction in skirmishes} issuers.add(self.issuer) # First, we will check which party has the least amount of troops in this skirmish. # We can then subtract troops from all skirmishes up until this point, after which # that party (or parties) has run out of troops. The skirmish may continue with fewer # parties involved, but that can be its own separate resolve. min_troops_to_move_among_skirmishes = min( list(map(lambda instruction: instruction._num_troops - instruction._num_troops_moved, skirmishes)) + [self._num_troops - self._num_troops_moved] ) logger.debug(f'Removing {min_troops_to_move_among_skirmishes} troops from all skirmishing territories') for i in range(min_troops_to_move_among_skirmishes): # Subtract a troop. self.origin.take_unit(Troop).remove() self._num_troops_moved += 1 # Subtract a troop from all involved parties. for skirmish in skirmishes: skirmish.origin.take_unit(Troop).remove() skirmish._num_troops_moved += 1 # At least one skirmish has now been completed. Mark those instructions as executed. for skirmish in skirmishes: if skirmish._num_troops_moved == skirmish._num_troops: skirmish.is_executed = True if self._num_troops_moved == self._num_troops: # This instruction is finished. There may be remaining skirmishes, but they will # be resolved when their contents are being evaluated in their own turn, so there # is no need to continue the loop or process this Instruction any further. return # We may continue fighting! We have defeated at least one opponent, but there may be some left. # We can resolve the remaining skirmishes through the loop. skirmishes = [skirmish for skirmish in skirmishes if not skirmish.is_executed] # If we reach here, we have defeated all opponent skirmishes. # If the origin has troops remaining, the remainder of the units # move onwards to the target territory. if self.origin.take_unit(Troop, self._num_troops - self._num_troops_moved, True): logger.debug(f'Skirmish has been resolved; issuer has at least {self._num_troops - self._num_troops_moved} troops remaining') if self.destination.is_neutral(): logger.debug('Destination was rendered neutral by the skirmish') self.resolve_expansion() else: self.resolve_invasion()
def resolve_invasion(self) -> None: """When the Instruction has been marked to be an invasion, this function resolves it. By default we will assume no troops have yet been moved, but this may be altered by the *num_troops_moved* parameter. This is useful in case of skirmishes that wind up having a section that is to be parsed as an invasion.""" # First, we need to check whether the target territory is also moving to a (third) territory in this set. # If so, that invasion must be resolved first. if higher_priority_movements := [ instruction for instruction in self.instruction_set.movements if instruction.origin == self.destination and not instruction.is_executed ]: logger.info(f"This invasion has superseding movements:\n - " + "\n - ".join( [str(invasion) for invasion in higher_priority_movements])) try: for instruction in higher_priority_movements: instruction.execute() except (InstructionAlreadyExecuting, UnwindingLoopedInstructions) as exception: # We must have a circular loop. Check all the instrutions that are currently executing, # and take the one with the lowest origin territory number. As a tie-breaker, that one will be # used to resolve the circle first. first_origin_id = min([instruction.origin.id for instruction in self.instruction_set.movements if instruction.is_executing]) self._is_part_of_loop = True if isinstance(exception, InstructionAlreadyExecuting): # Only print some useful logging when first encountering the loop, to avoid clutter during unwinding. logger.info(f"Found circular set of instructions\n - " + "\n - ".join( [str(instruction) for instruction in self.instruction_set.instructions if instruction.is_executing] )) logger.info(f"The chosen origin id to be executed from is {first_origin_id}") if self.origin.id == first_origin_id: logger.debug(f"Found instruction with lowest origin id ({self.origin.id}), resolving first") else: # Re-raise the exception so the previous instruction in the loop can catch it. logger.debug(f"The current instruction is not the one with lowest origin id; skipping") raise UnwindingLoopedInstructions() logger.info(f"Resolved superseding invasions for instruction id {self.id}") for instruction in [instruction for instruction in self.instruction_set.instructions if instruction.is_executing and instruction != self]: # The current instruction may now go through. If however this instruction was chosen because of a circular loop, # then the other instructions in the loop are still set as executing, while they are no longer being executed. # Hence, set their flag back to False so they can be processed as a normal chain. instruction.is_executing = False
def tearDown(self) -> None: logger.indents -= 1 logger.debug(f"Finished test: [b][yellow]{self._testMethodName}[/yellow][/b]")
def setUp(self) -> None: self.world = World() logger.debug(f"Running test: [b][yellow]{self._testMethodName}[/yellow][/b]") logger.indents += 1
def execute(self) -> Movement: """Execute the order. This will alter the territories it belongs to.""" if self.is_executing: raise InstructionAlreadyExecuting() if self.is_executed: raise InstructionAlreadyExecuted() logger.info(f'[blue]Executing[/blue]: {self}') self.is_executing = True self.assert_is_valid() if not self.instruction_set: logger.error('Instruction is not in InstructionSet') raise InstructionNotInInstructionSet() if self._num_troops <= 0 or self._num_troops - self._num_troops_moved <= 0: logger.warning( f'No troops left to execute instruction ({self._num_troops} instructed, {self._num_troops - self._num_troops_moved} available)') match self.instruction_type: case InstructionType.EXPANSION | InstructionType.DISTRIBUTION: """The destination is neutral or belongs to the same player. We will simply move the units from the origin to the destination and set the ownership.""" logger.debug('Instruction is of type Expansion or Distribution') self.resolve_expansion() case InstructionType.SKIRMISH: if not self.skirmishing_movements: logger.error('Instruction is marked as Skirmish but has no Skirmishing Instructions') raise InstructionNoSkirmishingInstructions() """There are other Instructions that conflict with this one. This leads to skirmishes. We will have to resolve those skirmishes first.""" logger.debug(f'Found {len(self.skirmishing_movements)} skirmish' + ( 'es' if len(self.skirmishing_movements) > 1 else '')) # All skirmishes involved will be marked to allow insufficient Troops, so that # the skirmish that continues as an invasion/expansion can proceed normally. self.allow_insufficient_troops() for skirmish in self.skirmishing_movements: skirmish.allow_insufficient_troops() self.resolve_skirmish(self.skirmishing_movements) case InstructionType.INVASION: """We are dealing with an invasion here: the target territory already belongs to another player. We will have to resolve the battle and units will be lost.""" logger.debug('Resolving invasion (not an expansion and no skirmishes found)') self.resolve_invasion() case _: logger.error(f'Unknown instruction type: {self.instruction_type.name}') raise InvalidInstructionType(self.instruction_type.name) self.is_executed = True self.is_executing = False logger.info( f'[green]Finished execution[/green] of Instruction id {self.id} with results: \n' + f' - origin : (id={self.origin.id}, {self.origin.owner.name}, troops={len(self.origin.all(Troop))})\n' + f' - destination: (id={self.destination.id}, {self.destination.owner.name if self.destination.owner else None}, troops={len(self.destination.all(Troop))})') if self._is_part_of_loop: if movements_from_target := [ instruction for instruction in self.instruction_set.movements if instruction.origin == self.destination and not instruction.is_executed ]: logger.info( f"This instruction was part of a loop; resolving instructions from target:\n - " + "\n - ".join( [str(instruction) for instruction in movements_from_target])) for instruction in movements_from_target: instruction.allow_insufficient_troops().execute()
class Movement(Instruction): """A basic order is invoked by someone and concerns the movement of some units from an origin to a destination.""" origin: Territory destination: Territory skirmishing_movements: list[Movement] = None mutual_invasion: Movement = None world: World = None _allow_insufficient_troops: bool = False _is_part_of_loop: bool = False _num_troops: int = 0 _num_troops_moved: int = 0 def __init__(self, issuer: Player, origin: Territory | int, destination: Territory | int, num_troops: int = 0, instruction_set: InstructionSet = None, world: World = None): super().__init__(issuer=issuer, instruction_set=instruction_set) if world: self.world = world self.origin = origin self.destination = destination # When provided with a World instance, we can resolve integers as arguments by looking in the # World object to fetch the Territories that belong to it. This gives a convenient way to pass # in integer arguments to denominate Territories. if self.world and isinstance(origin, int): self.origin = self.world.territories[origin] if self.world and isinstance(destination, int): self.destination = self.world.territories[destination] self._num_troops = num_troops self._num_troops_moved = 0 # Make sure to register this Instruction to its InstructionSet. if self.instruction_set and self not in self.instruction_set.instructions: self.instruction_set.add_instruction(self) def __hash__(self): return hash(self.id) def __str__(self) -> str: return ( f'{{id={self.id}, issuer={self.issuer.name}}} (id={self.origin.id}, owner={self.origin.owner.name if self.origin.owner else None}, troops={len(self.origin.all(Troop))})-[{self._num_troops - self._num_troops_moved}/{self._num_troops}]->' + f'(id={self.destination.id}, {self.destination.owner.name if self.destination.owner else None}, troops={len(self.destination.all(Troop))})' ) def allow_insufficient_troops(self, value: bool = True) -> Movement: self._allow_insufficient_troops = value return self def assert_is_valid(self) -> None: """Do some sanity checks to determine whether this Instruction makes sense. This method should be called before or during execution to prevent unwanted changes to the world map.""" if self.origin.owner != self.issuer: logger.error('Invalid instruction: issuer is not the origin owner') raise IssuerDoesNotOwnTerritory("Issuer is not the origin owner") if self._num_troops > (troops_in_origin := len({unit for unit in self.origin.all(Troop)})): if self._allow_insufficient_troops: logger.info( f'Insufficient troops ({troops_in_origin}) in origin; {self._num_troops} requested, but partial assignment is allowed') else: logger.error( f'Invalid instruction: insufficient troops in origin territory: {self._num_troops} requested, {troops_in_origin} found') raise InsufficientUnitsException(Troop, self._num_troops) if self.destination not in self.origin.adjacent_territories: raise TargetTerritoryNotAdjacent("Destination is not linked to the origin") logger.debug('Instruction is valid')
# Apply the 2-Troop penalty to the attacker. is_mutual_invasion = self.mutual_invasion and not self.mutual_invasion.is_executed should_incur_penalty = not (self.mutual_invasion and self.mutual_invasion.is_executed) if should_incur_penalty: origin_penalty = 0 destination_penalty = 0 for _ in range(NUM_INVASION_PENALTY): total_troops_to_attack -= 1 origin_penalty += 1 if is_mutual_invasion: self.destination.remove_unit(Troop) destination_penalty += 1 self._num_troops_moved += 1 logger.debug(f'Applied {origin_penalty} troop penalty to the invader') if is_mutual_invasion: logger.info(f'Also applied {destination_penalty} troop penalty to the target due to mutual invasion') while total_troops_to_attack > 0: # Remove a troop from both sides in an equal ratio, as long as this is possible and we still have troops to move. if self.destination.all(Troop): total_troops_to_attack -= 1 self.destination.remove_unit(Troop, 1, self._allow_insufficient_troops) else: # Either army is completely exhausted. The battle ends. logger.debug( f'At least one army is completely exhausted; the battle ends after {self._num_troops_moved} of requested {self._num_troops} invaders have been killed') break # Determine whether the attacker has units left. If so, move them to the target