diff --git a/src/botc_tokens/commands/create.py b/src/botc_tokens/commands/create.py index 5fd7d62..1d204af 100644 --- a/src/botc_tokens/commands/create.py +++ b/src/botc_tokens/commands/create.py @@ -72,7 +72,8 @@ def run(): overall_task = overall_progress.add_task("Creating Tokens...", total=len(roles)) step_task = step_progress.add_task("Reading roles...") for role in roles: - role_slug = ''.join(e for e in role.name.lower() if e.isalnum()) + role_base = role.id if role.id is not None else role.name + role_slug = ''.join(e for e in role_base.lower() if e.isalnum()) step_progress.update(step_task, description=f"Creating Token for: {role.name}") # Make sure our target directory exists role_output_path = output_path / str(role.home_script) / str(role.type) @@ -98,7 +99,7 @@ def run(): reminder_icon.transform(resize=f"{target_width}x{target_height}") for reminder_text in role.reminders: step_progress.update(step_task, description=f"Creating Token for: {role.name}") - reminder_name = format_filename(f"{role_slug}-Reminder-{reminder_text}") + reminder_name = role_slug + format_filename(f"-Reminder-{reminder_text}") reminder_output_path = role_output_path / f"{reminder_name}.png" duplicate_counter = 1 while reminder_output_path.exists(): @@ -135,8 +136,8 @@ def load_components(component_package): print(f"\n[red]Error:[/][bold] Could not load component: {str(e)}[/]") return None except BadZipFile: - print(f"\n[red]Error:[/][bold] Could not load components from '{component_package}' it does not appear to be a " - "valid components package.[/]") + print(f"\n[red]Error:[/][bold] Could not load components from '{component_package}' it does not appear" + " to be a \nvalid components package.[/]") return None except FileNotFoundError as e: print(f"\n[red]Error:[/][bold] Unable to load components from '{component_package}': {str(e)}") @@ -155,7 +156,7 @@ def find_roles_from_json(json_files): icon = data.get('icon') if icon: data['icon'] = str(json_file.parent / str(data.get('icon'))) - role = Role(data.get('name', "Unknown")) + role = Role(id=data.get('id'), name=data.get('name', "Unknown")) for att in dir(role): if att in data: setattr(role, att, data[att]) diff --git a/src/botc_tokens/commands/update.py b/src/botc_tokens/commands/update.py index 6b42645..b915da4 100644 --- a/src/botc_tokens/commands/update.py +++ b/src/botc_tokens/commands/update.py @@ -36,7 +36,7 @@ def _parse_args(): parser.add_argument('--reminders', type=str, help="JSON file to override reminder guesses from the wiki.") parser.add_argument('-c', '--custom-list', type=str, default=None, - help="JSON file with a custom list of roles to update.") + help="Local or web hosted JSON file with a custom list of roles to update.") parser.add_argument('--use-playtest', action='store_true', help="Use the playtest icon source instead of the wiki.") args = parser.parse_args(sys.argv[2:]) @@ -127,7 +127,7 @@ def run(): if found_role is not None: # Check if the role is in our forced_setup list - if found_role.name.lower() in forced_setup: + if found_role.id.lower() in forced_setup: found_role.affects_setup = True # Write it out @@ -150,19 +150,22 @@ def prep_wiki(script_filter, custom_list=None): # Gather the requested role data wiki = WikiSoup(script_filter) if custom_list: - custom_list_path = Path(custom_list) - if not custom_list_path.exists(): - print(f"[red]Error:[/] Could not find '{custom_list}'") - return None - with open(custom_list_path, "r") as f: - custom_list = json.load(f) - try: - validate(custom_list, json.load(open(data_dir / "role_schema.json"))) - except ValidationError as e: - print(f"[yellow]Warning:[/] The custom json specified does not fit the copy of the TPI schema that I " - f"have. Specifically: {e}" - f"\n\nI will continue, but [yellow]be warned that it might not work[/].") - wiki.role_data = custom_list + if custom_list.startswith("https://"): + wiki.load_from_web_json(custom_list) + else: + custom_list_path = Path(custom_list) + if not custom_list_path.exists(): + print(f"[red]Error:[/] Could not find '{custom_list}'") + return None + with open(custom_list_path, "r") as f: + custom_list = json.load(f) + wiki.role_data = custom_list + try: + validate(wiki.role_data, json.load(open(data_dir / "role_schema.json"))) + except ValidationError as e: + print(f"[yellow]Warning:[/] The custom json specified does not fit the copy of the TPI schema that I " + f"have. Specifically: {e}" + f"\n\nI will continue, but [yellow]be warned that it might not work[/].") else: # Download the official lists from the script tool wiki.load_from_web() @@ -182,7 +185,8 @@ def process_role(role, file, wiki, step_progress, step_task, role_output_path, u use_playtest (bool): Whether to use the playtest icon source instead of the wiki. """ name = role['name'] - found_role = Role(name=name) + role_id = role['id'] + found_role = Role(id=role_id, name=name) # Check if we have a json file for the role if file.exists(): @@ -208,12 +212,12 @@ def process_role(role, file, wiki, step_progress, step_task, role_output_path, u found_role.reminders = get_role_reminders(name, wiki) # Determine night actions - if role.get("firstNight"): + if role.get("firstNight") or has_value(role.get("firstNightReminder")): found_role.first_night = True else: found_role.first_night = True if role['id'] in wiki.night_data['firstNight'] else False - if role.get("otherNight"): + if role.get("otherNight") or has_value(role.get("otherNightReminder")): found_role.other_nights = True else: found_role.other_nights = True if role['id'] in wiki.night_data['otherNight'] else False @@ -240,6 +244,17 @@ def process_role(role, file, wiki, step_progress, step_task, role_output_path, u return found_role +def has_value(value): + """Checks if the provided value is a non-empty string. + + Args: + value (str): The value to be checked. + """ + if isinstance(value, str): + return len(value) > 0 + return False + + def get_role_icon(found_role, role, role_output_path, wiki, use_playtest=False): """Get the icon for a role, using the wiki if needed. @@ -274,7 +289,7 @@ def get_role_icon(found_role, role, role_output_path, wiki, use_playtest=False): print(f"[red]Error:[/] No icon found for {found_role.name}: {str(e)}") return icon_url = urllib.parse.urljoin("https://wiki.bloodontheclocktower.com", icon_url) - icon_path = role_output_path / f"{format_filename(found_role.name)}{Path(icon_url).suffix}" + icon_path = role_output_path / f"{format_filename(found_role.id)}{Path(icon_url).suffix}" icon_path.parent.mkdir(parents=True, exist_ok=True) if not save_icon(found_role, icon_path, icon_url): return diff --git a/src/botc_tokens/helpers/role.py b/src/botc_tokens/helpers/role.py index 64650e6..b7732ad 100644 --- a/src/botc_tokens/helpers/role.py +++ b/src/botc_tokens/helpers/role.py @@ -7,6 +7,7 @@ class Role: """A role in a script. Attributes: + id: The id of the role. name: The name of the role. ability: The description of the role. type: The type of the role (Townsfolk, Outsider, Minion, Demon, or Traveller). @@ -16,6 +17,7 @@ class Role: icon: The filename of the icon. home_script: The name of the script in which the role is found. """ + id: str name: str ability: str = None type: str = None diff --git a/src/botc_tokens/helpers/wiki_soup.py b/src/botc_tokens/helpers/wiki_soup.py index 68da5a0..1ba2cf3 100644 --- a/src/botc_tokens/helpers/wiki_soup.py +++ b/src/botc_tokens/helpers/wiki_soup.py @@ -31,6 +31,23 @@ def load_from_web(self): night_from_web = urlopen("https://script.bloodontheclocktower.com/data/nightsheet.json").read().decode('utf-8') self.night_data = json.loads(night_from_web) + def load_from_web_json(self, custom_url): + """Load the role data from the provided web hosted JSON file.""" + roles_from_web_json = json.loads(urlopen(custom_url).read().decode('utf-8')) + meta_data = next((item for item in roles_from_web_json if item.get('id') == '_meta'), None) + version_name = None + if meta_data: + version_name = meta_data.get('name') + # Filter and convert the roles + roles_with_version = [] + for role in roles_from_web_json: + if role.get('id') != '_meta': + role['version'] = version_name if role.get('version') is None else role['version'] + role['name'] = role['name'] + role['ability'] = role.get('ability') + roles_with_version.append(role) + self.role_data = roles_with_version + def _get_wiki_soup(self, role_name): """Take a role name and return a BeautifulSoup object for the role's wiki page.""" # Check if we have already seen this role diff --git a/tests/commands/test_update.py b/tests/commands/test_update.py index 2b8c4d1..d56e2be 100644 --- a/tests/commands/test_update.py +++ b/tests/commands/test_update.py @@ -15,19 +15,39 @@ from botc_tokens.helpers.role import Role +def web_response(url): + """Routes mock response based on URL. + + Args: + url (str): The URL on which the call has been made. + """ + web_read_mock = MagicMock() + if url.find('roles.json') != -1: + web_read_mock.read.return_value = webmock_list[0] + elif url.find('nightsheet.json') != -1: + web_read_mock.read.return_value = webmock_list[1] + elif url.find('/First') != -1: + web_read_mock.read.return_value = webmock_list[2] + elif url.find('/Second') != -1: + web_read_mock.read.return_value = webmock_list[3] + elif url.find('/Third') != -1: + web_read_mock.read.return_value = webmock_list[4] + elif url.find('bloodstar') != -1: + web_read_mock.read.return_value = webmock_list[5] + return web_read_mock + + @contextmanager def web_mock(): """Mock out actual web access.""" # First create the return data we would expect from the web, in the order we expect it. - wiki_read_mock = MagicMock() - wiki_read_mock.read.side_effect = webmock_list image_read_mock = MagicMock() image_read_mock.read.return_value = (Path(__file__).parent.parent / "data" / "icons" / "1.png").read_bytes() # Now mock out all the web calls to instead return the data we created with patch("botc_tokens.helpers.wiki_soup.urlopen") as wiki_soup_urlopen_mock: - wiki_soup_urlopen_mock.return_value.__enter__.return_value.read = wiki_read_mock - wiki_soup_urlopen_mock.return_value = wiki_read_mock + wiki_soup_urlopen_mock.return_value.__enter__.reuturn_value.read = web_response + wiki_soup_urlopen_mock.side_effect = web_response # Make sure to patch it in the update command as well, since we don't want to actually download the images with patch("botc_tokens.commands.update.urlopen") as update_urlopen_mock: update_urlopen_mock.return_value.__enter__.return_value.read = image_read_mock @@ -63,11 +83,31 @@ def test_update_command(tmp_path): # Verify that it worked expected_files = [ - str(Path("54 - Unreal Experimental") / "townsfolk" / "First.json"), - str(Path("54 - Unreal Experimental") / "townsfolk" / "First.png"), - str(Path("54 - Unreal Experimental") / "demon" / "Second.json"), - str(Path("54 - Unreal Experimental") / "demon" / "Second.png"), - str(Path("99 - Ignored") / "outsider" / "Third.json"), + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.json"), + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.png"), + str(Path("54 - Unreal Experimental") / "demon" / "second.json"), + str(Path("54 - Unreal Experimental") / "demon" / "second.png"), + str(Path("99 - Ignored") / "outsider" / "third.json"), + ] + check_output_folder(output_path, expected_files=expected_files, check_func=check_expected_json) + + +def test_update_command_custom_json_url(tmp_path): + """Test the update command with web hosted custom json url roles.""" + reminders_file = tmp_path / "reminders.json" + with open(reminders_file, "w") as f: + json.dump({"Second": ["SECOND REMINDER"]}, f) + output_path = tmp_path / "roles" + _run_cmd(["--output", str(output_path), "-c", "https://bloodstar.xyz/p/user/script/script.json?1", "--reminders", + str(reminders_file)]) + + # Verify that it worked + expected_files = [ + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.json"), + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.png"), + str(Path("54 - Unreal Experimental") / "demon" / "second.json"), + str(Path("54 - Unreal Experimental") / "demon" / "second.png"), + str(Path("99 - Ignored") / "outsider" / "third.json"), ] check_output_folder(output_path, expected_files=expected_files, check_func=check_expected_json) @@ -76,20 +116,20 @@ def test_update_existing_folder(tmp_path): """Test when a file in the output folder already exists.""" output_path = tmp_path / "roles" output_path.mkdir() - first_file = output_path / "54 - Unreal Experimental" / "townsfolk" / "First.json" + first_file = output_path / "54 - Unreal Experimental" / "townsfolk" / "first.json" first_file.parent.mkdir(parents=True, exist_ok=True) with open(first_file, "w") as f: - json.dump(expected_role_json.get("First.json"), f) + json.dump(expected_role_json.get("first.json"), f) _run_cmd(["--output", str(output_path)]) # Verify that it worked expected_files = [ - str(Path("54 - Unreal Experimental") / "townsfolk" / "First.json"), - str(Path("54 - Unreal Experimental") / "townsfolk" / "First.png"), - str(Path("54 - Unreal Experimental") / "demon" / "Second.json"), - str(Path("54 - Unreal Experimental") / "demon" / "Second.png"), - str(Path("99 - Ignored") / "outsider" / "Third.json"), + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.json"), + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.png"), + str(Path("54 - Unreal Experimental") / "demon" / "second.json"), + str(Path("54 - Unreal Experimental") / "demon" / "second.png"), + str(Path("99 - Ignored") / "outsider" / "third.json"), ] check_output_folder(output_path, expected_files=expected_files, check_func=check_expected_json) @@ -98,7 +138,7 @@ def test_update_bad_json(tmp_path, capsys): """Test when a file in the output folder exists, but isn't in the format we expect.""" output_path = tmp_path / "roles" output_path.mkdir() - first_file = output_path / "54 - Unreal Experimental" / "townsfolk" / "First.json" + first_file = output_path / "54 - Unreal Experimental" / "townsfolk" / "first.json" first_file.parent.mkdir(parents=True, exist_ok=True) with open(first_file, "w") as f: f.write("This is not json") @@ -106,11 +146,10 @@ def test_update_bad_json(tmp_path, capsys): # Verify that we got the files we expected expected_files = [ - str(Path("54 - Unreal Experimental") / "townsfolk" / "First.json"), - str(Path("54 - Unreal Experimental") / "demon" / "Second.json"), - str(Path("54 - Unreal Experimental") / "demon" / "Second.png"), - str(Path("99 - Ignored") / "outsider" / "Third.json"), - str(Path("99 - Ignored") / "outsider" / "Third.png"), + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.json"), + str(Path("54 - Unreal Experimental") / "demon" / "second.json"), + str(Path("54 - Unreal Experimental") / "demon" / "second.png"), + str(Path("99 - Ignored") / "outsider" / "third.json"), ] check_output_folder(output_path, expected_files=expected_files) @@ -130,8 +169,7 @@ def test_update_script_filter(tmp_path): # Verify that it worked expected_files = [ - str(Path("99 - Ignored") / "outsider" / "Third.json"), - str(Path("99 - Ignored") / "outsider" / "Third.png"), + str(Path("99 - Ignored") / "outsider" / "third.json"), ] check_output_folder(output_path, expected_files=expected_files) @@ -152,18 +190,18 @@ def test_update_icon_already_exists(tmp_path): """Test when the icon already exists.""" output_path = tmp_path / "roles" output_path.mkdir() - icon_path = output_path / "54 - Unreal Experimental" / "townsfolk" / "First.png" + icon_path = output_path / "54 - Unreal Experimental" / "townsfolk" / "first.png" icon_path.parent.mkdir(parents=True, exist_ok=True) icon_path.touch() _run_cmd(["--output", str(output_path)]) # Verify that it worked expected_files = [ - str(Path("54 - Unreal Experimental") / "townsfolk" / "First.json"), - str(Path("54 - Unreal Experimental") / "townsfolk" / "First.png"), - str(Path("54 - Unreal Experimental") / "demon" / "Second.json"), - str(Path("54 - Unreal Experimental") / "demon" / "Second.png"), - str(Path("99 - Ignored") / "outsider" / "Third.json"), + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.json"), + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.png"), + str(Path("54 - Unreal Experimental") / "demon" / "second.json"), + str(Path("54 - Unreal Experimental") / "demon" / "second.png"), + str(Path("99 - Ignored") / "outsider" / "third.json"), ] check_output_folder(output_path, expected_files=expected_files) @@ -178,17 +216,17 @@ def test_update_custom_reminders_file(tmp_path): # Verify that it worked expected_files = [ - str(Path("54 - Unreal Experimental") / "townsfolk" / "First.json"), - str(Path("54 - Unreal Experimental") / "townsfolk" / "First.png"), - str(Path("54 - Unreal Experimental") / "demon" / "Second.json"), - str(Path("54 - Unreal Experimental") / "demon" / "Second.png"), - str(Path("99 - Ignored") / "outsider" / "Third.json"), + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.json"), + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.png"), + str(Path("54 - Unreal Experimental") / "demon" / "second.json"), + str(Path("54 - Unreal Experimental") / "demon" / "second.png"), + str(Path("99 - Ignored") / "outsider" / "third.json"), ] check_output_folder(output_path, expected_files=expected_files) - with open(output_path / "54 - Unreal Experimental" / "townsfolk" / "First.json", "r") as f: + with open(output_path / "54 - Unreal Experimental" / "townsfolk" / "first.json", "r") as f: j = json.load(f) assert j["reminders"] == ["Custom reminder"] - with open(output_path / "54 - Unreal Experimental" / "demon" / "Second.json", "r") as f: + with open(output_path / "54 - Unreal Experimental" / "demon" / "second.json", "r") as f: j = json.load(f) assert j["reminders"] == ["SECOND REMINDER"] @@ -197,23 +235,23 @@ def test_update_existing_icon_and_json(tmp_path): """Test when the icon and json file already exist.""" output_path = tmp_path / "roles" output_path.mkdir() - icon_path = output_path / "54 - Unreal Experimental" / "townsfolk" / "First.png" + icon_path = output_path / "54 - Unreal Experimental" / "townsfolk" / "first.png" icon_path.parent.mkdir(parents=True, exist_ok=True) icon_path.touch() - json_path = output_path / "54 - Unreal Experimental" / "townsfolk" / "First.json" + json_path = output_path / "54 - Unreal Experimental" / "townsfolk" / "first.json" json_path.parent.mkdir(parents=True, exist_ok=True) with open(json_path, "w") as f: - json.dump(expected_role_json.get("First.json"), f) - _run_cmd(["--output", str(output_path)]) + json.dump(expected_role_json.get("first.json"), f) + _run_cmd(["--output", str(output_path), "--use-playtest"]) # Verify that it worked expected_files = [ - str(Path("54 - Unreal Experimental") / "townsfolk" / "First.json"), - str(Path("54 - Unreal Experimental") / "townsfolk" / "First.png"), - str(Path("54 - Unreal Experimental") / "demon" / "Second.json"), - str(Path("54 - Unreal Experimental") / "demon" / "Second.png"), - str(Path("99 - Ignored") / "outsider" / "Third.json"), - str(Path("99 - Ignored") / "outsider" / "Third.png"), + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.json"), + str(Path("54 - Unreal Experimental") / "townsfolk" / "first.png"), + str(Path("54 - Unreal Experimental") / "demon" / "second.json"), + str(Path("54 - Unreal Experimental") / "demon" / "second.png"), + str(Path("99 - Ignored") / "outsider" / "third.json"), + str(Path("99 - Ignored") / "outsider" / "third.png"), ] check_output_folder(output_path, expected_files=expected_files) @@ -223,7 +261,7 @@ def test_web_error_getting_icon(tmp_path, capsys): output_path = tmp_path / "roles" wiki = MagicMock() wiki.get_big_icon_url.return_value = "First.png" - found_role = Role(name="First") + found_role = Role(id="first", name="First") with patch("botc_tokens.commands.update.urlopen") as urlopen_mock: image_read_mock = MagicMock() fp = StringIO() # This is necessary to avoid an issue when deconstructing urllib.error.HTTPError diff --git a/tests/helpers/test_role.py b/tests/helpers/test_role.py index 9689a45..a406369 100644 --- a/tests/helpers/test_role.py +++ b/tests/helpers/test_role.py @@ -4,5 +4,5 @@ def test_role_str(): """Make sure the __str__ method works.""" - role = Role(name="Villager", ability="You are a boring villager.") + role = Role(id="villager", name="Villager", ability="You are a boring villager.") assert str(role) == "Villager: You are a boring villager." diff --git a/tests/helpers/test_wiki_soup.py b/tests/helpers/test_wiki_soup.py index f834c5b..e26bf88 100644 --- a/tests/helpers/test_wiki_soup.py +++ b/tests/helpers/test_wiki_soup.py @@ -34,7 +34,7 @@ def test_wiki_soup_creation(): wiki_soup.load_from_web() assert wiki_soup assert wiki_soup.role_data[0]["name"] == "First" - assert wiki_soup.night_data["firstNight"] == ["DUSK", "First"] + assert wiki_soup.night_data["firstNight"] == ["DUSK", "first"] def test_wiki_soup_get_ability_text(): diff --git a/tests/testhelpers.py b/tests/testhelpers.py index 4a04f9f..afb6053 100644 --- a/tests/testhelpers.py +++ b/tests/testhelpers.py @@ -54,7 +54,7 @@ def expect_exit_with_output(capsys, expected_text, expected_code=1): # The first call is for the role data b"""[ { - "id": "First", + "id": "first", "name": "First", "roleType": "townsfolk", "print": "unused", @@ -63,7 +63,7 @@ def expect_exit_with_output(capsys, expected_text, expected_code=1): "isDisabled": false }, { - "id": "Second", + "id": "second", "name": "Second", "roleType": "demon", "print": "unused", @@ -72,7 +72,7 @@ def expect_exit_with_output(capsys, expected_text, expected_code=1): "isDisabled": false }, { - "id": "Third", + "id": "third", "name": "Third", "roleType": "outsider", "print": "unused", @@ -85,12 +85,12 @@ def expect_exit_with_output(capsys, expected_text, expected_code=1): b"""{ "firstNight": [ "DUSK", - "First" + "first" ], "otherNight": [ "DUSK", - "First", - "Second" + "first", + "second" ] }""", # The third call is for the first role's wiki page @@ -119,33 +119,65 @@ def expect_exit_with_output(capsys, expected_text, expected_code=1): """, + b"""[ + { + "id": "_meta", + "name": "54 - Unreal Experimental" + }, + { + "id": "first", + "image": "https://www.bloodstar.xyz/first.png", + "name": "First", + "team": "townsfolk", + "firstNightReminder": "first night wakes", + "otherNightReminder": "other nights wakes" + }, + { + "id": "second", + "image": "https://www.bloodstar.xyz/second.png", + "name": "Second", + "team": "demon", + "otherNightReminder": "reminder text" + }, + { + "id": "third", + "name": "Third", + "roleType": "outsider", + "print": "unused", + "icon": "unused", + "version": "99 - Ignored", + "isDisabled": false + } + ]""", ] - expected_role_json = { - "First.json": { + "first.json": { + 'id': 'first', 'ability': 'First ability description', 'affects_setup': False, 'first_night': True, 'home_script': '54 - Unreal Experimental', - 'icon': 'First.png', + 'icon': 'first.png', 'name': 'First', 'other_nights': True, 'reminders': [], 'type': 'townsfolk' }, - "Second.json": { + "second.json": { + 'id': 'second', 'ability': 'Second ability description [Affects Setup]', 'affects_setup': True, 'first_night': False, 'home_script': '54 - Unreal Experimental', - 'icon': 'Second.png', + 'icon': 'second.png', 'name': 'Second', 'other_nights': True, 'reminders': ["SECOND REMINDER"], 'type': 'demon' }, - "Third.json": { + "third.json": { + "id": "third", "name": "Third", "ability": "", "type": "outsider",