diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..bdd7bab --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +extend-ignore = E251 +max-line-length = 120 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 04a64c7..b4f5743 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,16 +1,18 @@ name: Tests on: - - push - - pull_request + pull_request: + push: + branches: + - main jobs: test: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-latest, windows-latest] - python-version: ['3.10'] + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ['3.10', '3.11'] steps: - uses: actions/checkout@v2 @@ -18,5 +20,7 @@ jobs: uses: actions/setup-python@v2 with: python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: python -m pip install pyyaml - name: Test with Python unittest run: python -m unittest diff --git a/README.md b/README.md index 468d0dc..8a10fa5 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,15 @@ # encounter -A script that manages a combat encounter for tabletop rpgs. -Run with python command in your shell environment. -Enter help in the program to view a list of available commands and their functions. +An interactive command-line script that manages a combat encounter for tabletop rpgs. +Run from the highest level directory (the one containing bestiary.txt) using the python command in your terminal. + +* Install with pip to run from anywhere. + +Enter `help` once the program has started to view a list of available commands and their functions. + +* Create your own NPCs to add to your encounter. See the `make` command to create them dynamically or check the provided bestiary.yaml file for the proper format. ## Dependencies -* Python 3.10 or higher -* Recommended to have a "bestiary.txt" file. An example bestiary file with its associated formatting is included. - * If no bestiary file is found the program loads some generic entries. +* Python 3.11 or higher +* PyYAML diff --git a/bestiary.txt b/bestiary.txt deleted file mode 100644 index 3e4b773..0000000 --- a/bestiary.txt +++ /dev/null @@ -1,15 +0,0 @@ -#Example bestiary file for encounter -#Lines beginning with "#" are comments -#Format to create custom NPC instances is as follows -#Name, Max HP, AC -#Extra values, descriptions, and notes separated with a comma will be ignored -# -#Nature -Wolf, 11, 13 -Fox, 3, 12, this note will be ignored -Spider, 1, 12, A tiny creepy crawley creature -#Fantasy -Skeleton, 13, 13 -Zombie, 16, 15 -#Human -Townsfolk, 9, 12 \ No newline at end of file diff --git a/bestiary.yaml b/bestiary.yaml new file mode 100644 index 0000000..3fbb804 --- /dev/null +++ b/bestiary.yaml @@ -0,0 +1,33 @@ +# Example bestiary file for an encounter + +# Creatures +Wolf: + hp: 11 + ac: 13 +Fox: + hp: 3 + ac: 12 + comment: this note will be ignored +Spider: + hp: 1 + ac: 12 + description: A tiny creepy crawley creature + +# Fantasy +Skeleton: + hp: 13 + ac: 13 +Zombie: + hp: 16 + ac: 15 +Dragon: + hp: 150 + ac: 18 + +# Human +Townsfolk: + hp: 9 + ac: 12 +Guard: + hp: 25 + ac: 13 diff --git a/pyproject.toml b/pyproject.toml index 1b03c56..7f9f996 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,12 @@ build-backend = "setuptools.build_meta" [project] name = "encounter" -version = "4.17.0" -requires-python = ">=3.10" +version = "4.20.1" +description = "Interactive command-line tool for managing tabletop combat encounters." +requires-python = ">=3.11" +dependencies = [ + "pyyaml", +] [tool.setuptools] package-dir = {"" = "src"} diff --git a/src/commands.py b/src/commands.py new file mode 100644 index 0000000..4087ac7 --- /dev/null +++ b/src/commands.py @@ -0,0 +1,758 @@ +from abc import ABC, abstractmethod +from textwrap import dedent + +import yaml + +from src.npc import NPC, NPCList, findList + + +class Command(ABC): + def __init__(self): + self.names: list[str] = ["command", "test"] + self.description: str = "This command has no defined description yet." + self.details: str | None = None + self.usageStr: str = "This command has no defined usage yet." + + def usage(self) -> None: + print("Usage: " + self.usageStr) + + @staticmethod + def encounterEmpty() -> None: + print("The encounter is empty. Add some NPCs to it and try again.") + + @staticmethod + def OOBSelection(referenceList: NPCList) -> None: + print("Your selection contains values out of range for the " + referenceList.name) + print("Adjust your selection and try again.") + + @abstractmethod + def execute(self, args = []) -> None: + raise NotImplementedError("This command has not been implemented yet.") + + +class load(Command): + def __init__(self, bestiary): + super().__init__() + self.names = ["load"] + self.bestiary = bestiary + self.description = "Replaces the loaded bestiary." + self.details = dedent("""\ + Searches the absolute address provided for a valid bestiary file. + The correct format for a file is provided in an example file "bestiary.txt". + If the provided file cannot be loaded the current list will be kept. + If the current list is empty and a new list cannot be found + then some primitive entries will be generated.\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "load " + + def execute(self, args = []): + numArgs = len(args) + + if numArgs == 1: + try: + bestiary_text = open(args[0].strip()) + except FileNotFoundError: + print("Selected bestiary file could not be found.") + if len(self.bestiary) == 0: + print("Loading placeholder bestiary.") + self.bestiary.data.append(NPC("Human", 5, 12)) + self.bestiary.data.append(NPC("Animal", 3, 10)) + self.bestiary.data.append(NPC("Enemy", 10, 13)) + else: + self.bestiary.data.clear() + num_npc_loaded = 0 + with bestiary_text: + try: + file = yaml.load(bestiary_text, Loader=yaml.BaseLoader) + except yaml.YAMLError: + print("Something is wrong the syntax of your bestiary file.") + print("Try validating the YAML file?") + exit() + else: + for npc, attributes in file.items(): + name = npc + try: + hp = int(attributes["hp"]) + ac = int(attributes["ac"]) + npc = NPC(name, hp, ac) + except KeyError as key: + print(f"NPC \"{name}\" is missing the {key} attribute!") + except TypeError: + print(f"Formatting of NPC \"{name}\" is incorrect somehow!") + except ValueError as attr_err: + print(f"The NPC \"{name}\" has an invalid attribute!") + print(attr_err) + else: + self.bestiary.data.append(npc) + num_npc_loaded += 1 + print(f"Successfully loaded {num_npc_loaded}/{len(file.items())} NPCs") + else: + self.usage() + + +class displayHelp(Command): + def __init__(self, commands: list[Command]): + super().__init__() + self.names = ["help", "?"] + self.commands = commands + self.description = "Prints a list of availible commands." + self.usageStr = "help [command_name]" + + def execute(self, args = []): + numArgs = len(args) + + if numArgs == 1: + if args[0].lower() in ["quit", "q", "exit"]: + print("Exits the program.") + print("Usage: {quit | q | exit}") + else: + found = False + for command in self.commands: + if args[0].lower() in command.names: + print(command.description) + command.usage() + if command.details is not None: + print("\nNote:") + print(command.details) + found = True + break + + if not found: + print("Unrecognized command.") + print("Type help or ? to learn how to use availible commands.") + else: + if numArgs == 0: + spacing = 0 + for command in self.commands: + if len(command.names[0]) > spacing: + spacing = len(command.names[0]) + print("quit".ljust(spacing) + ": " + "Exits the program.") + for command in self.commands: + print(command.names[0].ljust(spacing) + ": " + command.description) + print() + print("For more detailed information > Usage: " + self.usageStr) + else: + self.usage() + + +class displayMenu(Command): + def __init__(self, referenceLists: list[NPCList]): + super().__init__() + self.names = ["list", "display", "show"] + self.referenceLists = referenceLists + self.description = "Displays the selected list of NPCs." + self.details = dedent("""\ + The list command can be called using the aliases "display" and "show". + The selected list can be any valid alias for their respective list. + Allowed aliases for "bestiary" are "book" and "b". + Allowed aliases for "encounter" are "e", "combat", and "c".\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "list [all | bestiary | encounter]" + + def execute(self, args = []): + numArgs = len(args) + + if numArgs <= 1: + if numArgs == 0 or args[0].lower() == "all": + for list in self.referenceLists: + print(list.toMenu()) + if list is not self.referenceLists[-1]: + print() # Print newline between all lists + else: + list = findList(args[0], self.referenceLists) + + if list is not None: + print(list.toMenu()) + else: + print("Unknown list selected.") + else: + self.usage() + + +def isInt(string: str) -> bool: + if string.isnumeric(): + return True + else: + try: + int(string) + except ValueError: + return False + else: + return True + + +def isValidInt(selector: str, referencList: NPCList) -> bool: + selected = selector.split(",") + for index in selected: + if not isInt(index) or int(index) <= 0 or int(index) > len(referencList): + return False + return True + + +def copyNPC(bestiary: NPCList, index: int, other: NPCList) -> None: + npc = bestiary.data[index] + copy = NPC(npc.name, npc.maxHP, npc.ac) + other.data.append(copy) + + +class addNPC(Command): + def __init__(self, referenceLists): + super().__init__() + self.names = ["add"] + self.referenceLists = referenceLists + self.description = "Adds an NPC to the encounter." + self.details = dedent("""\ + Reference entries in the bestiary by number. + Multiple NPCs (even multiple of the same type) can be added at the same time + in a comma separated list without spaces. + Can be used with the all selector.\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "add " + + def execute(self, args = []): + bestiary = findList("bestiary", self.referenceLists) + if bestiary is None: + raise TypeError("Bestiary list must be an NPCList.") + encounter = findList("encounter", self.referenceLists) + if encounter is None: + raise TypeError("Encounter list must be an NPCList.") + + if len(args) == 1: + if args[0].lower() == "all": + for index, _ in enumerate(bestiary.data): + copyNPC(bestiary, index, encounter) + elif not isInt(args[0]): + self.usage() + return + elif not isValidInt(args[0], bestiary): + Command.OOBSelection(bestiary) + return + else: + selected = args[0].split(",") + + for index in selected: + copyNPC(bestiary, int(index) - 1, encounter) + else: + self.usage() + + +class clearNPCList(Command): + def __init__(self, referenceLists): + super().__init__() + self.names = ["clear"] + self.referenceLists = referenceLists + self.description = "Removes all NPCs from a list." + self.usageStr = "clear {all | bestiary | encounter}" + + def execute(self, args = []): + if len(args) == 1: + if args[0].lower() == "all": + for list in self.referenceLists: + list.data.clear() + print(list.toMenu()) + if list is not self.referenceLists[-1]: + print() + else: + list = findList(args[0], self.referenceLists) + + if list is not None: + list.data.clear() + print(list.toMenu()) + else: + print("Unknown list selected.") + else: + self.usage() + + +class removeNPC(Command): + def __init__(self, encounter): + super().__init__() + self.names = ["remove"] + self.encounter = encounter + self.description = "Removes selected NPC(s) from the encounter." + self.details = dedent("""\ + Can be used with the all selector.\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "remove " + + def execute(self, args = []): + if (len(self.encounter) < 1): + Command.encounterEmpty() + return + + if len(args) == 1: + if args[0].lower() == "all": + self.encounter.data.clear() + else: + if not isValidInt(args[0], self.encounter): + Command.OOBSelection(self.encounter) + return + + selected = args[0].split(",") + # Remove duplicates and reverse sort the input + selected = sorted(list(set(selected)), reverse = True) + + for index in selected: + self.encounter.data.pop(int(index) - 1) + else: + self.usage() + + +def areAllDefeated(encounter: NPCList): + for npc in encounter.data: + if npc.currentHP > 0: + return False + return True + + +class attack(Command): + def __init__(self, encounter): + super().__init__() + self.names = ["attack"] + self.encounter = encounter + self.description = "Initiantiates D&D like combat with an NPC." + self.details = dedent("""\ + The attack command is interactive meaning if you leave out + a required field you will be asked for the data instead of + the command throwing an error state.\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "attack [hit] [damage]" + + def execute(self, args = []): + if (len(self.encounter) < 1): + Command.encounterEmpty() + return + + lenArgs = len(args) + + if lenArgs > 3 or lenArgs < 1: + self.usage() + return + + for i in range(lenArgs): + if not isInt(args[i]): + self.usage() + return + + if not isValidInt(args[0], self.encounter): + Command.OOBSelection(self.encounter) + return + + npc = self.encounter.data[int(args[0]) - 1] + if npc.currentHP <= 0: + print("Enemy already defeated.") + return + + if lenArgs == 3: + if int(args[1]) >= npc.ac: + npc.currentHP = max(0, npc.currentHP - int(args[2])) + print(npc.nick + " took " + args[2] + " damage.") + else: + print("Attack misses " + npc.nick + ".") + elif lenArgs == 2: + if int(args[1]) >= npc.ac: + damage = input("Roll for damage: ") + if damage.isnumeric() is True: + amt = int(damage) + npc.currentHP = max(0, npc.currentHP - amt) + print(npc.nick + " took " + damage + " damage.") + else: + print("Damage must be a number.") + else: + print("Attack misses " + npc.nick + ".") + elif lenArgs == 1: + accuracy = input("Roll for hit: ") + if accuracy.isnumeric() is True: + accuracy = int(accuracy) + if accuracy >= npc.ac: + damage = input("Roll for damage: ") + if damage.isnumeric() is True: + amt = int(damage) + npc.currentHP = max(0, npc.currentHP - amt) + print(npc.nick + " took " + damage + " damage.") + else: + print("Damage must be a number.") + else: + print("Attack misses " + npc.nick + ".") + else: + print("Accuracy must be a number.") + + if npc is not None and npc.currentHP <= 0: + npc.currentRank = 0 + print(npc.nick + " has been defeated.") + if areAllDefeated(self.encounter): + print("Party has defeated all enemies.") + + +class damage(Command): + def __init__(self, encounter): + super().__init__() + self.names = ["damage"] + self.encounter = encounter + self.description = "Directly subtracts from selected NPCs' health." + self.details = dedent("""\ + Can be used with the all selector.\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "damage " + + def execute(self, args = []): + if (len(self.encounter) < 1): + Command.encounterEmpty() + return + + if len(args) == 2 and isInt(args[1]): + if int(args[1]) < 1: + print("Amount must be more than zero.") + return + if args[0].lower() == "all": + for npc in self.encounter.data: + if npc.currentHP > 0: + npc.currentHP = max(0, npc.currentHP - int(args[1])) + if npc.currentHP <= 0: + npc.currentRank = 0 + print(npc.nick + " has been defeated.") + if areAllDefeated(self.encounter): + print("Party has defeated all enemies.") + else: + if not isValidInt(args[0], self.encounter): + Command.OOBSelection(self.encounter) + return + + selected = args[0].split(",") + selected = list(set(selected)) + + for index in selected: + npc = self.encounter.data[int(index) - 1] + + if npc.currentHP <= 0: + print(npc.nick + " already defeated.") + continue + + npc.currentHP = max(0, npc.currentHP - int(args[1])) + + if npc.currentHP <= 0: + npc.currentRank = 0 + print(npc.nick + " has been defeated.") + if areAllDefeated(self.encounter): + print("Party has defeated all enemies.") + return + else: + self.usage() + + +class smite(Command): + def __init__(self, encounter): + super().__init__() + self.names = ["smite", "kill"] + self.encounter = encounter + self.description = "Immediately kills an NPC." + self.details = dedent("""\ + The smite command can be called using the alias "kill". + Supports the all selector, i.e. "kill all" will smite all NPCs in the encounter.\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "smite " + + def execute(self, args = []): + if (len(self.encounter) < 1): + Command.encounterEmpty() + return + + if len(args) == 1: + if args[0].lower() == "all": + for npc in self.encounter.data: + npc.currentHP = 0 + npc.currentRank = 0 + else: + selected = args[0].split(",") + selected = list(set(selected)) # Remove duplicates from the selection + + for index in selected: + if not isValidInt(args[0], self.encounter): + Command.OOBSelection(self.encounter) + return + + for index in selected: + npc = self.encounter.data[int(index) - 1] + if npc.currentHP <= 0: + print(npc.nick + " already defeated.") + return + else: + npc.currentHP = 0 + npc.currentRank = 0 + + if areAllDefeated(self.encounter): + print("Party has defeated all enemies.") + else: + self.usage() + + +class heal(Command): + def __init__(self, encounter): + super().__init__() + self.names = ["heal"] + self.encounter = encounter + self.description = "Directly adds to selected NPCs' health." + self.details = dedent("""\ + Can be used with the all selector.\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "heal " + + def __healNPC(self, npc: NPC, amount: int) -> int: + originalHP = npc.currentHP + npc.currentHP = originalHP + amount + npc.currentHP = min(npc.maxHP, npc.currentHP) + return npc.currentHP - originalHP + + def execute(self, args = []): + if (len(self.encounter) < 1): + Command.encounterEmpty() + return + + if len(args) == 2 and isInt(args[1]): + if int(args[1]) < 1: + print("Amount must be more than zero.") + return + if args[0].lower() == "all": + for npc in self.encounter.data: + npc.currentRank = npc.maxRank + healedAmt = self.__healNPC(npc, int(args[1])) + if healedAmt > 0: + print(f"{npc.nick} was healed {healedAmt} points.") + else: + if not isValidInt(args[0], self.encounter): + Command.OOBSelection(self.encounter) + return + + selected = args[0].split(",") + selected = list(set(selected)) + + for index in selected: + npc = self.encounter.data[int(index) - 1] + npc.currentRank = npc.maxRank + healedAmt = self.__healNPC(npc, int(args[1])) + print(npc.nick + " was healed " + str(healedAmt) + " points.") + else: + self.usage() + + +class status(Command): + def __init__(self, encounter): + super().__init__() + self.names = ["status"] + self.encounter = encounter + self.description = "Displays selected NPCs' current stats." + self.details = dedent("""\ + Displays the current health of the selected NPC in the encounter. + Additionally displays the contents of notes if any. + Supports the all selector.\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "status " + + def execute(self, args = []): + if (len(self.encounter) < 1): + Command.encounterEmpty() + return + + if len(args) == 1: + if args[0].lower() == "all": + print("Status:") + for npc in self.encounter.data: + print(npc.combatStatus()) + elif isValidInt(args[0], self.encounter): + selected = args[0].split(",") + selected = list(set(selected)) + + print("Status:") + for index in selected: + npc = self.encounter.data[int(index) - 1] + + print(npc.combatStatus()) + else: + Command.OOBSelection(self.encounter) + else: + self.usage() + + +class info(Command): + def __init__(self, bestiary): + super().__init__() + self.names = ["info", "details"] + self.bestiary = bestiary + self.description = "Displays detailed stats for a bestiary entry." + self.usageStr = "info " + + def execute(self, args = []): + if len(args) == 1 and isInt(args[0]): + if isValidInt(args[0], self.bestiary): + print("INFO:") + print(self.bestiary.data[int(args[0]) - 1].detailedInfo()) + else: + Command.OOBSelection(self.bestiary) + else: + self.usage() + + +class make(Command): + def __init__(self, bestiary): + super().__init__() + self.names = ["make"] + self.bestiary = bestiary + self.description = "Creates an NPC and adds it to the bestiary." + self.details = dedent("""\ + Entries added in this manner are temporary and will + not persist across reloads of the program.\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "make " + + def execute(self, args=[]) -> None: + if len(args) == 3 and not args[0].isnumeric() and isInt(args[1]) and isInt(args[2]): + self.bestiary.data.append(NPC(args[0], int(args[1]), int(args[2]))) + else: + self.usage() + + +class name(Command): + def __init__(self, encounter): + super().__init__() + self.names = ["nickname", "name", "nn"] + self.encounter = encounter + self.description = "Gives a specific name to an NPC in the encounter." + self.details = dedent("""\ + Nicknames work on a per NPC basis. Multiple NPCs may have the + same nickname. The nickname does not replace the NPCs original + name and will still be displayed alongside it. + Can be called with the alias "name" or "nn".\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "name " + + def execute(self, args=[]) -> None: + if (len(self.encounter) < 1): + Command.encounterEmpty() + return + + if len(args) == 2 and args[0].isnumeric(): + if isValidInt(args[0], self.encounter): + self.encounter.data[int(args[0]) - 1].nick = args[1] + else: + Command.OOBSelection(self.encounter) + else: + self.usage() + + +class mark(Command): + def __init__(self, encounter): + super().__init__() + self.names = ["mark", "note"] + self.encounter = encounter + self.description = "Mark an NPC with a symbol and note." + self.details = dedent("""\ + Can be used with the all selector. Will place an "*" next to the + NPC's name in any list display of NPCs. If this command is run on the same + NPC again the new note will overwrite their old note. This can be used + to delete a note entirely by replacing it with an empty note. + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "mark [note]" + + def execute(self, args=[]) -> None: + if (len(self.encounter) < 1): + Command.encounterEmpty() + return + + if len(args) >= 1: + if args[0].lower() == "all": + for npc in self.encounter.data: + npc.marked = True + if len(args) > 1: + npc.note = " ".join(args[1:]) + else: + npc.note = "" + else: + if not isValidInt(args[0], self.encounter): + Command.OOBSelection(self.encounter) + return + + selected = args[0].split(",") + selected = list(set(selected)) + + for index in selected: + npc = self.encounter.data[int(index) - 1] + npc.marked = True + if len(args) > 1: + npc.note = " ".join(args[1:]) + else: + npc.note = "" + else: + self.usage() + + +class unmark(Command): + def __init__(self, encounter): + super().__init__() + self.names = ["unmark"] + self.encounter = encounter + self.description = "Remove mark and symbol from an NPC." + self.details = dedent("""\ + Can be used with the all selector.\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "unmark " + + def execute(self, args=[]) -> None: + if (len(self.encounter) < 1): + Command.encounterEmpty() + return + + if len(args) == 1: + if args[0].lower() == "all": + for npc in self.encounter.data: + npc.marked = False + npc.note = "" + else: + if not isValidInt(args[0], self.encounter): + Command.OOBSelection(self.encounter) + return + + selected = args[0].split(",") + selected = list(set(selected)) + + for index in selected: + npc = self.encounter.data[int(index) - 1] + npc.marked = False + npc.note = "" + else: + self.usage() + + +class rank(Command): + def __init__(self, encounter: NPCList): + super().__init__() + self.names = ["rank", "initiative"] + self.encounter = encounter + self.description = "Assigns NPCs a rank order." + self.details = dedent("""\ + NPCs order within the encounter will be determined by their rank. + NPCs with a higher value will appear higher in the list. + Assigning an NPC a rank of 0 or below removes their ranking. + This command can also be called with the alias "initiative".\ + """).strip().replace("\n", " ").replace("\r", "") + self.usageStr = "rank " + + def execute(self, args=[]) -> None: + if (len(self.encounter) < 1): + Command.encounterEmpty() + return + + if len(args) == 2 and isValidInt(args[0], self.encounter) and isInt(args[1]): + rank = max(int(args[1]), 0) + npc = self.encounter.data[int(args[0]) - 1] + if npc.currentHP > 0: + npc.currentRank = rank + npc.maxRank = rank + else: + npc.maxRank = rank + else: + self.usage() + + +if __name__ == "__main__": + print("Something seems wrong, this file is not meant to be executed.") + print("Did you mean to run encounter instead?") diff --git a/src/encounter.py b/src/encounter.py index c56bfb5..95e9e5a 100644 --- a/src/encounter.py +++ b/src/encounter.py @@ -1,615 +1,69 @@ -from abc import ABC, abstractmethod +import src.commands as cmd +from src.npc import NPCList -class NPC: - def __init__(self, name: str, maxHP: int, ac: int): - # Type assertions - if type(name) != str: - raise TypeError("Name must be a string.") - if type(maxHP) != int: - raise TypeError("HP must be an integer.") - if type(ac) != int: - raise TypeError("AC must be an integer.") - - # Value assertions - if len(name) < 1: - raise ValueError("Name must be at least length 1.") - if maxHP < 1: - raise ValueError("HP must be at least 1.") - if ac < 0: - raise ValueError("AC must be at least 0.") - - # Value assignment - self.name = name - self.maxHP = self.currentHP = maxHP - self.ac = int(ac) - - def __str__(self): - if self.currentHP > 0: - return self.name - else: - return self.name + " [X]" - - def equals(self, other): - if self == other: - return True - if other is None: - return False - if self.name != other.name: - return False - if self.maxHP != other.maxHP: - return False - if self.currentHP != other.currentHP: - return False - if self.ac != other.ac: - return False - return True - - def combatStatus(self) -> str: - if self.currentHP > 0: - return self.name + " [" + str(self.currentHP) + "/" + str(self.maxHP) + "]" - else: - return self.name + " [Dead]" - - def detailedInfo(self) -> str: - info = "" - info += "NAME: " + str(self.name) + "\n" - info += "MAX HP: " + str(self.maxHP) + "\n" - info += "AC: " + str(self.ac) - return info - - -class NPCList: - def __init__(self, names: list[str]): - # Error Checking - if type(names) != list: - raise TypeError("Names must be a list of strings.") - - if len(names) < 1: - raise ValueError("List must contain at least one entry.") - - # Value assignment - self.names = names - self.name = names[0] - self.data: list[NPC] = [] - - def toMenu(self): - info = self.name.upper() + ":\n" - - if len(self.data) == 0: - info += "EMPTY" - else: - for i in self.data[:-1]: - info += str(self.data.index(i) + 1) + " " + str(i) + "\n" - info += str(self.data.index(self.data[-1]) + 1) + " " + str(self.data[-1]) - - return info - - -def findList(name: str, referenceLists: list[NPCList]) -> NPCList | None: - name = name.lower() - for list in referenceLists: - if name in list.names: - return list - return None - - -class Command(ABC): - def __init__(self): - self.names: list[str] = ['Command', 'Test'] - self.description: str = "This Command has no defined description yet." - self.usageStr: str = "This command has no defined usage yet." - - def usage(self) -> None: - print("Usage: " + self.usageStr) - - @abstractmethod - def execute(self, args = []) -> None: - raise NotImplementedError("This command has not been implemented yet.") - - -class load(Command): - def __init__(self, bestiary): - super().__init__() - self.names = ['load'] - self.bestiary = bestiary - self.description = "Replaces the default bestiary." - self.usageStr = "load " - - def execute(self, args = []): - numArgs = len(args) - - if numArgs == 1: - try: - bestiaryFile = open(args[0]) - except FileNotFoundError: - print("Selected bestiary file could not be found.") - if len(self.bestiary.data) == 0: - print("Loading placeholder bestiary.") - self.bestiary.data.append(NPC("Human", 5, 12)) - self.bestiary.data.append(NPC("Animal", 3, 10)) - self.bestiary.data.append(NPC("Enemy", 10, 13)) - else: - self.bestiary.data.clear() - for line in bestiaryFile: - if not line.startswith("#"): - line = line.rstrip("\n").split(",") - npc = NPC(line[0], int(line[1]), int(line[2])) - self.bestiary.data.append(npc) - bestiaryFile.close() - print("Bestiary loaded.") - else: - self.usage() - - -class displayHelp(Command): - def __init__(self, commands: list[Command]): - super().__init__() - self.names = ['help', '?'] - self.commands = commands - self.description = "Prints a list of availible commands." - self.usageStr = "help [command_name]" - - def execute(self, args = []): - numArgs = len(args) - - if numArgs == 1: - if args[0].lower() in ["quit", "q", "exit"]: - print("Exits the program.") - print("Usage: {quit | q | exit}") - else: - found = False - for command in self.commands: - if args[0].lower() in command.names: - print(command.description) - command.usage() - found = True - break - - if not found: - print("Unrecognized command.") - print("Type help or ? to learn how to use availible commands.") - else: - if numArgs == 0: - spacing = 0 - for command in self.commands: - if len(command.names[0]) > spacing: - spacing = len(command.names[0]) - print("quit".ljust(spacing) + ": " + "Exits the program.") - for command in self.commands: - print(command.names[0].ljust(spacing) + ": " + command.description) - print("") - print("For more detailed information > Usage: " + self.usageStr) - else: - self.usage() - - -class displayMenu(Command): - def __init__(self, referenceLists: list[NPCList]): - super().__init__() - self.names = ['list', 'display', 'show'] - self.referenceLists = referenceLists - self.description = "Displays a list of NPCs." - self.usageStr = "list [all | bestiary | encounter]" - - def execute(self, args = []): - numArgs = len(args) - - if numArgs == 1 and args[0] != "all": - list = findList(args[0], self.referenceLists) - - if list is not None: - print(list.toMenu()) - else: - print("Unknown list selected.") - else: - if numArgs == 0 or args[0] == "all": - for list in self.referenceLists: - print(list.toMenu()) - if list is not self.referenceLists[-1]: - print() - else: - self.usage() - - -def isInt(string: str) -> bool: - if string.isnumeric(): - return True - else: - try: - int(string) - except ValueError: - return False - else: - return True - - -def isValidInt(selector: str, list: list[NPC]) -> bool: - selected = selector.split(",") - for index in selected: - if not isInt(index) or int(index) <= 0 or int(index) > len(list): - return False - return True - - -def copyNPC(bestiaryList, index: int, list: list[NPC]) -> None: - npc = bestiaryList[index - 1] - copy = NPC(npc.name, npc.maxHP, npc.ac) - list.append(copy) - - -class addNPC(Command): - def __init__(self, referenceLists): - super().__init__() - self.names = ['add'] - self.referenceLists = referenceLists - self.description = "Adds an NPC to the encounter." - self.usageStr = "add " - - def execute(self, args = []): - bestiary = findList("bestiary", self.referenceLists) - encounter = findList("encounter", self.referenceLists) - - if len(args) == 1: - selected = args[0].split(",") - - for index in selected: - if not isInt(index) or int(index) > len(bestiary.data) or int(index) <= 0: - self.usage() - return - - for index in selected: - copyNPC(bestiary.data, int(index), encounter.data) - print(encounter.toMenu()) - else: - self.usage() - - -class clearNPCList(Command): - def __init__(self, referenceLists): - super().__init__() - self.names = ['clear'] - self.referenceLists = referenceLists - self.description = "Clears a list of NPCs." - self.usageStr = "clear {all | bestiary | encounter}" - - def execute(self, args = []): - if len(args) == 1: - if args[0].lower() == "all": - for list in self.referenceLists: - list.data.clear() - print(list.toMenu()) - if list is not self.referenceLists[-1]: - print() - else: - list = findList(args[0], self.referenceLists) - - if list is not None: - list.data.clear() - print(list.toMenu()) - else: - print("Unknown list selected.") - else: - self.usage() - - -class removeNPC(Command): - def __init__(self, encounter): - super().__init__() - self.names = ['remove'] - self.encounter = encounter - self.description = "Removes an NPC from the encounter." - self.usageStr = "remove " - - def execute(self, args = []): - if len(args) == 1: - if args[0] == "all": - self.encounter.data.clear() - print(self.encounter.toMenu()) - else: - selected = args[0].split(",") - - for index in selected: - if not isValidInt(index, self.encounter.data): - return - - # Remove duplicates and reverse sort the input - selected = sorted(list(set(selected)), reverse = True) - - for index in selected: - self.encounter.data.pop(int(index) - 1) - print(self.encounter.toMenu()) - else: - self.usage() - - -def areAllDefeated(encounter): - for npc in encounter: - if npc.currentHP > 0: - return False - return True - - -class attack(Command): - def __init__(self, encounter): - super().__init__() - self.names = ['attack'] - self.encounter = encounter - self.description = "Initiantiates D&D like combat with an NPC." - self.usageStr = "attack [hit] [damage]" - - def execute(self, args = []): - lenArgs = len(args) - npc = None - - if lenArgs > 3 or lenArgs < 1: - self.usage() - return - - if not isValidInt(args[0], self.encounter.data): - self.usage() - return - - npc = self.encounter.data[int(args[0]) - 1] - if npc.currentHP <= 0: - print("Enemy already defeated.") - return - - if lenArgs == 3: - if not isInt(args[1]) or not isInt(args[2]): - self.usage() - return - - if int(args[1]) >= npc.ac: - npc.currentHP = max(0, npc.currentHP - int(args[2])) - print(npc.name + " took " + args[2] + " damage.") - else: - print("Attack misses " + npc.name + ".") - elif lenArgs == 2: - if not isInt(args[1]): - self.usage() - return - - if int(args[1]) >= npc.ac: - damage = input("Roll for damage: ") - if damage.isnumeric() is True: - amt = int(damage) - npc.currentHP = max(0, npc.currentHP - amt) - print(npc.name + " took " + damage + " damage.") - else: - print("Damage must be a number.") - else: - print("Attack misses " + npc.name + ".") - elif lenArgs == 1: - accuracy = input("Roll for hit: ") - if accuracy.isnumeric() is True: - accuracy = int(accuracy) - if accuracy >= npc.ac: - damage = input("Roll for damage: ") - if damage.isnumeric() is True: - amt = int(damage) - npc.currentHP = max(0, npc.currentHP - amt) - print(npc.name + " took " + damage + " damage.") - else: - print("Damage must be a number.") - else: - print("Attack misses " + npc.name + ".") - else: - print("Accuracy must be a number.") - - if npc is not None: - if npc.currentHP <= 0: - print(npc.name + " has been defeated.") - if areAllDefeated(self.encounter.data): - print("Party has defeated all enemies.") - - -class damage(Command): - def __init__(self, encounter): - super().__init__() - self.names = ['damage'] - self.encounter = encounter - self.description = "Directly subtracts from an NPC's health." - self.usageStr = "damage " - - def execute(self, args = []): - if len(args) == 2: - if not isInt(args[1]): - self.usage() - return - if isValidInt(args[0], self.encounter.data) is True: - npc = self.encounter.data[int(args[0]) - 1] - - if npc.currentHP <= 0: - print("Enemy already defeated.") - return - - npc.currentHP = max(0, npc.currentHP - int(args[1])) - if npc.currentHP <= 0: - print(npc.name + " has been defeated.") - if areAllDefeated(self.encounter.data): - print("Party has defeated all enemies.") - else: - self.usage() - - -class smite(Command): - def __init__(self, encounter): - super().__init__() - self.names = ['smite', 'kill'] - self.encounter = encounter - self.description = "Immediately kills an NPC." - self.usageStr = "smite " - - def execute(self, args = []): - if len(self.encounter.data) > 0: - if len(args) == 1: - if isValidInt(args[0], self.encounter.data) is True: - npc = self.encounter.data[int(args[0]) - 1] - if npc.currentHP <= 0: - print("Enemy already defeated.") - return - else: - npc.currentHP = 0 - print(npc.name + " was defeated.") - - if areAllDefeated(self.encounter.data): - print("Party has defeated all enemies.") - else: - self.usage() - else: - print("Encounter is empty. There is no one to smite.") - - -class heal(Command): - def __init__(self, encounter): - super().__init__() - self.names = ['heal'] - self.encounter = encounter - self.description = "Directly adds to an NPC's health." - self.usageStr = "heal " - - def execute(self, args = []): - if len(args) == 2: - if not isInt(args[1]): - self.usage() - return - if isValidInt(args[0], self.encounter.data): - npc = self.encounter.data[int(args[0]) - 1] - origHP = npc.currentHP - - npc.currentHP = npc.currentHP + int(args[1]) - - if npc.currentHP > npc.maxHP: - npc.currentHP = npc.maxHP - - healedAmt = npc.currentHP - origHP - - print(npc.name + " was healed " + str(healedAmt) + " points.") - else: - self.usage() - - -class status(Command): - def __init__(self, encounter): - super().__init__() - self.names = ['status'] - self.encounter = encounter - self.description = "Displays an NPC's current stats." - self.usageStr = "status " - - def execute(self, args = []): - if len(args) == 1: - if isinstance(args[0], str) and args[0].lower() == "all": - print("Status:") - for npc in self.encounter.data: - print(npc.combatStatus()) - elif isValidInt(args[0], self.encounter.data): - npc = self.encounter.data[int(args[0]) - 1] - print("Status:") - print(npc.combatStatus()) - else: - self.usage() - else: - self.usage() - - -class info(Command): - def __init__(self, bestiary): - super().__init__() - self.names = ['info', 'details'] - self.bestiary = bestiary - self.description = "Displays detailed stats for a bestiary entry." - self.usageStr = "info " - - def execute(self, args = []): - if len(args) == 1: - if isInt(args[0]): - if isValidInt(args[0], self.bestiary.data): - print("INFO:") - print(self.bestiary.data[int(args[0]) - 1].detailedInfo()) - else: - self.usage() - else: - self.usage() - - -class make(Command): - def __init__(self, bestiary): - super().__init__() - self.names = ["make"] - self.bestiary = bestiary - self.description = "Creates an NPC for the bestiary" - self.usageStr = "make " +def initialize_commands(encounter: NPCList) -> list[cmd.Command]: + bestiary = NPCList(["bestiary", "book", "b"]) + referenceLists = [bestiary, encounter] - def execute(self, args=[]) -> None: - if len(args) == 3: - if args[0].isnumeric() or not isInt(args[1]) or not isInt(args[2]): - self.usage() - return - self.bestiary.data.append(NPC(args[0], int(args[1]), int(args[2]))) - print(self.bestiary.toMenu()) - else: - self.usage() + commands = [] + commands.append(cmd.load(bestiary)) + commands.append(cmd.displayMenu(referenceLists)) + commands.append(cmd.addNPC(referenceLists)) + commands.append(cmd.removeNPC(encounter)) + commands.append(cmd.clearNPCList(referenceLists)) + commands.append(cmd.smite(encounter)) + commands.append(cmd.damage(encounter)) + commands.append(cmd.attack(encounter)) + commands.append(cmd.heal(encounter)) + commands.append(cmd.status(encounter)) + commands.append(cmd.info(bestiary)) + commands.append(cmd.make(bestiary)) + commands.append(cmd.name(encounter)) + commands.append(cmd.mark(encounter)) + commands.append(cmd.unmark(encounter)) + commands.append(cmd.rank(encounter)) + commands.append(cmd.displayHelp(commands)) + return commands def main(): - bestiary = NPCList(['bestiary', 'book', 'b']) - encounter = NPCList(['encounter', 'e', "combat", "c"]) - referenceLists = [bestiary, encounter] - - # Instantiate commands - commands = [ - load(bestiary), - displayMenu(referenceLists), - addNPC(referenceLists), - removeNPC(encounter), - clearNPCList(referenceLists), - smite(encounter), - damage(encounter), - attack(encounter), - heal(encounter), - status(encounter), - info(bestiary), - make(bestiary) - ] - - commands.append(displayHelp(commands)) + encounter = NPCList(["encounter", "e", "combat", "c"]) + commands = initialize_commands(encounter) - # Load default bestiary - commands[0].execute(["bestiary.txt"]) + for command in commands: + if "load" in command.names: + command.execute(["bestiary.yaml"]) + break + prompt = "\nType a command: " print("Type help or ? to get a list of availible commands.") - # command loop while True: - print() - usrRequest = input("Type a command: ").lower().split(" ") - - action = None + encounter.data.sort(reverse = True) - if usrRequest != ['']: - action = usrRequest[0] - - if action in ['quit', 'q', 'exit']: - break + userInput = input(prompt).strip().split(" ") + userInput = [token for token in userInput if not token.isspace() and token != ""] - args = [] - - if (len(usrRequest) > 1): - for index in range(1, len(usrRequest)): - args.append(usrRequest[index]) + if not len(userInput) > 0: + prompt = "\nType a command: " + else: + prompt = "\ncmd: " - found = False - for command in commands: - if action in command.names: - command.execute(args) - found = True + userCommand = userInput.pop(0).lower() + if userCommand in ["quit", "q", "exit"]: break - if not found: - print("Unrecognized command.") - print("Type help or ? to learn how to use availible commands.") + found = False + for command in commands: + if userCommand in command.names: + command.execute(userInput) + found = True + break + + if not found: + print("Unrecognized command.") + print("Type help or ? to learn how to use availible commands.") if __name__ == "__main__": diff --git a/src/npc.py b/src/npc.py new file mode 100644 index 0000000..95fddbc --- /dev/null +++ b/src/npc.py @@ -0,0 +1,135 @@ +from typing import Optional + + +class NPC: + def __init__(self, name: str, maxHP: int, ac: int, nick: str | None = None): + # Type assertions + if type(name) != str: + raise TypeError("Name must be a string.") + if type(maxHP) != int: + raise TypeError("HP must be an integer.") + if type(ac) != int: + raise TypeError("AC must be an integer.") + + # Value assertions + if len(name) < 1: + raise ValueError("Name must be at least length 1.") + if name.isspace(): + raise ValueError("Name must not be blank.") + if nick is not None and len(nick) < 1: + raise ValueError("Nickname must be at least length 1.") + if nick is not None and nick.isspace(): + raise ValueError("Nickname must not be blank.") + if maxHP < 1: + raise ValueError("HP must be at least 1.") + if ac < 0: + raise ValueError("AC must be at least 0.") + + # Value assignment + self.marked = False + self.note = "" + self.name = name + self.nick = name if (nick is None) else nick + self.maxHP = self.currentHP = maxHP + self.ac = int(ac) + self.maxRank = self.currentRank = 0 + + def __str__(self): + rank = f"({self.currentRank}) " if (self.currentRank > 0) else "" + name = self.name if (self.nick == self.name) else self.nick + mark = "*" if self.marked else "" + is_dead = " [X]" if (self.currentHP == 0) else "" + + return f"{rank}{name}{mark}{is_dead}" + + def __lt__(self, other): + return self.currentRank < other.currentRank + + def equals(self: "NPC", other: Optional["NPC"]) -> bool: + if self == other: + return True + if other is None: + return False + if self.name != other.name: + return False + if self.nick != other.nick: + return False + if self.maxHP != other.maxHP: + return False + if self.currentHP != other.currentHP: + return False + if self.ac != other.ac: + return False + if self.note != other.note: + return False + if self.maxRank != other.maxRank: + return False + if self.currentRank != other.currentRank: + return False + return True + + def combatStatus(self) -> str: + name = (self.name if (self.nick == self.name) + else f"{self.nick} ({self.name})") + health = " [Dead]" if (self.currentHP == 0) else f" [{self.currentHP}/{self.maxHP}]" + + if self.marked: + note = "\n> Note: " + if not self.note.isspace() and len(self.note) > 0: + note += self.note + else: + note += "EMPTY" + else: + note = "" + + return f"{name}{health}{note}" + + def detailedInfo(self) -> str: + info = "" + info += "NAME: " + str(self.name) + "\n" + info += "MAX HP: " + str(self.maxHP) + "\n" + info += "AC: " + str(self.ac) + return info + + +class NPCList: + def __init__(self, names: list[str]): + # Error Checking + if type(names) != list: + raise TypeError("Names must be a list of strings.") + + if len(names) < 1: + raise ValueError("List must contain at least one entry.") + + # Value assignment + self.names = names + self.name = names[0] + self.data: list[NPC] = [] + + def __len__(self): + return len(self.data) + + def toMenu(self): + info = self.name.upper() + ":\n" + + if len(self.data) == 0: + info += "EMPTY" + return info + else: + for i in self.data: + info += str(self.data.index(i) + 1) + " " + str(i) + "\n" + + return info[:-1] + + +def findList(name: str, referenceLists: list[NPCList]) -> NPCList | None: + name = name.lower() + for list in referenceLists: + if name in list.names: + return list + return None + + +if __name__ == "__main__": + print("Something seems wrong, this file is not meant to be executed.") + print("Did you mean to run encounter instead?") diff --git a/tests/test_encounter.py b/tests/test_encounter.py index 43e0ae1..3e054f3 100644 --- a/tests/test_encounter.py +++ b/tests/test_encounter.py @@ -1,6 +1,6 @@ import unittest -from src.encounter import isInt -from src.encounter import NPC +from src.commands import isInt +from src.npc import NPC class TestSubMethods(unittest.TestCase):