From 7a28a46ea24e94a2409ee619f6c1129d4033621e Mon Sep 17 00:00:00 2001 From: Guy Granger Date: Sat, 9 May 2026 21:49:27 +0000 Subject: [PATCH 1/2] Implement Nebula sectors (Galactic Events expansion) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #303. Adds full support for the Nebula sector mechanic from the Galactic Events expansion. Uses synthetic subsector position codes (295A/295B/295C) layered onto the existing single-position-string model — the parent hex acts as a render anchor and the three subsectors are exposed as first-class board entries that mirror the regular sector schema. Highlights: - helpers/NebulaHelper.py: subsector model, internal/external adjacency, parent neighbour-rewrite with rotation - helpers/GamestateHelper.add_tile: branches on enable_nebulas to build parent + 3 subsector records, places ai-anc in the Ancient subsector - helpers/GamestateHelper.setup_techs_and_outer_rim: shuffles 295 into ring II and 395 into ring III when nebulas are enabled (per rulebook p. 4 the ring-III tile is appended after the regular sector_draws) - Buttons/Move.getTilesInRange: skips parent records, walks subsector adjacency including internal sibling hops (matches rulebook example of 'two Move Activations' to cross an internal wormhole) - Buttons/Influence: areTwoTilesAdjacent returns True for sibling subsectors; getTilesToInfluence excludes any tile of type='nebula' (parent or subsector) — nebulas may never contain Influence Discs - Buttons/Explore: handles the nebula-as-explore-source case via the parent-rewrite, and shows a one-line explanation when a nebula is placed (no influence prompt, no immediate discovery prompt) - Buttons/DiscoveryTile._maybe_reroll_for_nebula: implements the Ancient Orbital / Ancient Monolith reroll rule — if either is drawn for a nebula subsector, shuffle it back and draw again before presenting the player's choice - helpers/CombatHelper.claimNebulaDiscoveries + Turn.runUpkeep hook: end-of-Combat-Phase discovery resolution. A discovery in a nebula subsector is claimed only by a single non-AI player whose ships remain there at that point. Errors surface to the channel so a GM can recover via /tile resolve_specific_discovery_tile - helpers/DrawHelper: subsector positions return None from board_tile_image (their ships are drawn on top of the parent's hex); _render_ships extracted so the parent hex can render all three subsectors' ships using each subsector's *_snap coords - commands/setup_commands + setup/GameInit: enable_nebulas flag on /setup create_new_game (default True) - tests/test_nebulas.py: 19 cases covering setup, three-subsector creation, disctile/ancient distribution, internal + external + rotated adjacency, the influence-target type filter, deck population with the flag on/off, and the orbital/monolith reroll edge cases Verified subsector layout against printed art for both NGC 5189 (295) and NGC 1952 (395) — Ancient in the right subsector (B), Discoveries in the top (A) and bottom-left (C). All 23 tests pass. --- Buttons/DiscoveryTile.py | 34 +++ Buttons/Explore.py | 41 +++- Buttons/Influence.py | 49 ++++- Buttons/Move.py | 9 +- Buttons/Turn.py | 4 + commands/setup_commands.py | 7 +- helpers/CombatHelper.py | 58 ++++++ helpers/DrawHelper.py | 182 +++++++++------- helpers/GamestateHelper.py | 92 +++++++-- helpers/NebulaHelper.py | 232 +++++++++++++++++++++ setup/GameInit.py | 8 +- tests/test_nebulas.py | 414 +++++++++++++++++++++++++++++++++++++ 12 files changed, 1022 insertions(+), 108 deletions(-) create mode 100644 helpers/NebulaHelper.py create mode 100644 tests/test_nebulas.py diff --git a/Buttons/DiscoveryTile.py b/Buttons/DiscoveryTile.py index e1e2fe7..5558274 100644 --- a/Buttons/DiscoveryTile.py +++ b/Buttons/DiscoveryTile.py @@ -1,5 +1,6 @@ import asyncio import json +import random as _random import discord from Buttons.Upgrade import UpgradeButtons from helpers.GamestateHelper import GamestateHelper @@ -8,7 +9,35 @@ from discord.ui import View, Button +# Discovery tile IDs that cannot be placed in a Nebula Subsector. +# Per the Galactic Events rulebook (p. 4): "the Ancient Orbital and Ancient +# Monolith cannot be placed in Nebula Sectors; if they are revealed in a +# Nebula Sector, draw a new Discovery Tile before choosing whether to keep +# it for its benefit or for VP." +_NEBULA_FORBIDDEN_DISCS = {"orb", "mon"} + + class DiscoveryTileButtons: + @staticmethod + def _maybe_reroll_for_nebula(game: GamestateHelper, tile: str, disc: str) -> str: + """If ``tile`` is a nebula subsector and ``disc`` is one of the + forbidden Ancient Orbital / Ancient Monolith tiles, shuffle it back + into the deck and pop a replacement. Returns the (possibly new) disc + id. Loops until a non-forbidden disc is drawn or the deck would + empty; falls back to the last drawn disc if the deck is exhausted. + """ + from helpers import NebulaHelper as NH + if not NH.is_nebula_subsector(tile): + return disc + attempts = 0 + deck = game.gamestate.get("discTiles") or [] + while disc in _NEBULA_FORBIDDEN_DISCS and deck and attempts < 32: + deck.insert(_random.randint(0, len(deck)), disc) + disc = deck.pop() + attempts += 1 + game.update() + return disc + @staticmethod async def exploreDiscoveryTile(game: GamestateHelper, tile: str, interaction: discord.Interaction, player): # if "discTiles" not in game.gamestate: @@ -17,6 +46,11 @@ async def exploreDiscoveryTile(game: GamestateHelper, tile: str, interaction: di await interaction.followup.send("No discovery tile in tile " + tile) return disc = game.getNextDiscTile(tile) + + # Nebula reroll: Ancient Orbital and Ancient Monolith cannot be placed + # in a Nebula Subsector. See Galactic Events rulebook p. 4. + disc = DiscoveryTileButtons._maybe_reroll_for_nebula(game, tile, disc) + with open("data/discoverytiles.json") as f: discTile_data = json.load(f) discName = discTile_data[disc]["name"] diff --git a/Buttons/Explore.py b/Buttons/Explore.py index fd365b8..e4f0bf6 100644 --- a/Buttons/Explore.py +++ b/Buttons/Explore.py @@ -68,13 +68,24 @@ def getTilesToExplore(game: GamestateHelper, player): else: with open("data/tileAdjacencies.properties", "rb") as f: configs.load(f) + from helpers import NebulaHelper as NH tilesViewed = [] playerTiles = ExploreButtons.getListOfTilesPlayerIsIn(game, player) for tile in playerTiles: - for index, adjTile in enumerate(configs.get(tile)[0].split(",")): - tile_orientation_index = (index + int(game.gamestate["board"][tile]["orientation"]) // 60) % 6 + tile_record = game.gamestate["board"][tile] + # Nebula parents have no wormholes / no ships — they shouldn't + # appear here, but be defensive. + if NH.is_nebula_parent(tile_record): + continue + # Resolve the parent for adjacency lookups when the player's + # presence tile is a nebula subsector. + lookup_tile = NH.get_parent_position(tile) if NH.is_nebula_subsector(tile) else tile + if lookup_tile not in configs: + continue + for index, adjTile in enumerate(configs.get(lookup_tile)[0].split(",")): + tile_orientation_index = (index + int(tile_record["orientation"]) // 60) % 6 if all([adjTile not in tilesViewed, - tile_orientation_index in game.gamestate["board"][tile]["wormholes"], + tile_orientation_index in tile_record.get("wormholes", []), "back" in game.gamestate.get("board", {}).get(str(adjTile), {}).get("sector", [])]): if int(adjTile) > 299 and len(game.gamestate["tile_deck_300"]) == 0: continue @@ -163,9 +174,21 @@ async def placeTile(game: GamestateHelper, interaction: discord.Interaction, pla view2 = View() game.update_player(player_helper) await interaction.channel.send(f"Tile added to position {msg[1]}") - if game.gamestate["board"][msg[1]]["ancient"] == 0 or player["name"] == "Descendants of Draco": + placed_tile = game.gamestate["board"][msg[1]] + # Nebula sectors: subsector ancients/discoveries are resolved + # independently (Ancient is pre-spawned in subsector B; discoveries + # in A and C are claimed at end of Combat Phase). The hex itself can + # never carry an influence disc, so skip both prompts. + if placed_tile.get("type") == "nebula" and placed_tile.get("is_nebula_parent"): + await interaction.channel.send( + f"{player['player_name']} this is a Nebula sector. An Ancient occupies one " + "subsector and discovery tiles wait in the other two — they will be " + "claimed at the end of the Combat Phase by any ship still present in those subsectors. " + "Influence discs cannot be placed in a Nebula sector." + ) + elif placed_tile["ancient"] == 0 or player["name"] == "Descendants of Draco": view = View() - if "bh" in game.gamestate["board"][msg[1]].get("type", ""): + if "bh" in placed_tile.get("type", ""): await interaction.channel.send("This is a black hole tile, " "so its discovery tile cannot be claimed until a ship moves in, " "at which point a die will be rolled and it might teleport.") @@ -177,12 +200,12 @@ async def placeTile(game: GamestateHelper, interaction: discord.Interaction, pla await interaction.channel.send(f"{player['player_name']}, choose whether or not" " to place influence in the tile." + game.displayPlayerStats(player), view=view) - if all([game.gamestate["board"][msg[1]]["ancient"] == 0, - game.gamestate["board"][msg[1]]["disctile"] > 0]): + if all([placed_tile["ancient"] == 0, + placed_tile["disctile"] > 0]): await DiscoveryTileButtons.exploreDiscoveryTile(game, msg[1], interaction, player) - + else: - ancients = game.gamestate["board"][msg[1]]["ancient"] + ancients = placed_tile["ancient"] await interaction.channel.send(f"There are {ancients} ancients in this tile, " "so you will not be able to claim it until you fight and destroy them.") diff --git a/Buttons/Influence.py b/Buttons/Influence.py index 6c490c3..16c4ca7 100644 --- a/Buttons/Influence.py +++ b/Buttons/Influence.py @@ -14,15 +14,35 @@ class InfluenceButtons: @staticmethod def areTwoTilesAdjacent(game: GamestateHelper, tile1, tile2, configs, wormholeGen: bool): + from helpers import NebulaHelper as NH + board = game.gamestate["board"] + + # Two subsectors of the same nebula are always adjacent via their + # internal wormhole connections (A<->B<->C, all three pairs). + if NH.is_nebula_subsector(tile1) and NH.is_nebula_subsector(tile2): + if NH.get_parent_position(tile1) == NH.get_parent_position(tile2): + return tile1 in board and tile2 in board + + def lookup_position(t): + # When walking the parent's adjacency table, route subsector + # positions back to their parent hex for lookups (the .properties + # files don't know about subsectors). + return NH.get_parent_position(t) if NH.is_nebula_subsector(t) else t + def is_adjacent(tile_a, tile_b): - for index, adjTile in enumerate(configs.get(tile_a)[0].split(",")): - if tile_a in game.gamestate["board"] and tile_b in game.gamestate["board"] and adjTile == tile_b: - tile_orientation_index = (index + (int(game.gamestate["board"][tile_a]["orientation"]) // 60)) % 6 - if tile_orientation_index in game.gamestate["board"][tile_a].get("wormholes", []): + lookup_a = lookup_position(tile_a) + lookup_b = lookup_position(tile_b) + if lookup_a not in configs: + return False + for index, adjTile in enumerate(configs.get(lookup_a)[0].split(",")): + if (tile_a in board and tile_b in board + and adjTile == lookup_b): + tile_orientation_index = (index + (int(board[tile_a].get("orientation", 0)) // 60)) % 6 + if tile_orientation_index in board[tile_a].get("wormholes", []): return True - if tile_a in game.gamestate["board"] and tile_b in game.gamestate["board"]: - if game.gamestate["board"][tile_a].get("warp", 0) == 1: - if game.gamestate["board"][tile_b].get("warp", 0) == 1: + if tile_a in board and tile_b in board: + if board[tile_a].get("warp", 0) == 1: + if board[tile_b].get("warp", 0) == 1: return True return False @@ -58,8 +78,9 @@ def getTilesToInfluence(game: GamestateHelper, player): wormHoleGen = True tilesToInfluence = [] playerTiles = InfluenceButtons.getListOfTilesPlayerIsInForInfluence(game, player) + from helpers import NebulaHelper as NH for tile in playerTiles: - for adjTile in configs.get(tile)[0].split(","): + for adjTile in NH.adjacent_positions_from_configs(game, tile, configs): if adjTile not in tilesViewed and InfluenceButtons.areTwoTilesAdjacent(game, tile, adjTile, configs, wormHoleGen): tilesViewed.append(adjTile) @@ -69,12 +90,16 @@ def getTilesToInfluence(game: GamestateHelper, player): continue if "exploded" in game.gamestate["board"][adjTile].get("type", ""): continue + # Nebula sectors (parent or subsector) cannot ever hold an + # influence disc per the Galactic Events rules. + if game.gamestate["board"][adjTile].get("type", "") == "nebula": + continue if "player_ships" not in game.gamestate["board"][adjTile]: continue playerShips = game.gamestate["board"][adjTile]["player_ships"][:] playerShips.append(player["color"]) if all([game.gamestate["board"][adjTile].get("owner") == 0, - ExploreButtons.doesPlayerHaveUnpinnedShips(player, playerShips, game, adjTile), + ExploreButtons.doesPlayerHaveUnpinnedShips(player, playerShips, game, adjTile), len(Combat.findPlayersInTile(game, adjTile)) < 2, "ai-grd" not in playerShips]): tilesToInfluence.append(adjTile) if tile_map[tile].get("warp", 0) == 1: @@ -87,16 +112,20 @@ def getTilesToInfluence(game: GamestateHelper, player): continue if "exploded" in game.gamestate["board"][tile2].get("type", ""): continue + if game.gamestate["board"][tile2].get("type", "") == "nebula": + continue if "player_ships" not in game.gamestate["board"][tile2]: continue playerShips = game.gamestate["board"][tile2]["player_ships"][:] playerShips.append(player["color"]) if all([game.gamestate["board"][tile2].get("owner") == 0, - ExploreButtons.doesPlayerHaveUnpinnedShips(player, playerShips, game, tile2), + ExploreButtons.doesPlayerHaveUnpinnedShips(player, playerShips, game, tile2), len(Combat.findPlayersInTile(game, tile2)) < 2]): tilesToInfluence.append(tile2) if tile not in tilesViewed: tilesViewed.append(tile) + if game.gamestate["board"][tile].get("type", "") == "nebula": + continue playerShips = game.gamestate["board"][tile]["player_ships"][:] playerShips.append(player["color"]) if all([game.gamestate["board"][tile].get("owner") == 0, diff --git a/Buttons/Move.py b/Buttons/Move.py index a82e631..9abf094 100644 --- a/Buttons/Move.py +++ b/Buttons/Move.py @@ -99,6 +99,8 @@ def getTilesInRange(game: GamestateHelper, player, origin: str, shipRange: int, if jumpDrivePresent == 1: jumpDrive = True + from helpers import NebulaHelper as NH + def recursive_search(pos, distance, visited, jumpDriveAvailable): if distance > shipRange: return @@ -106,13 +108,18 @@ def recursive_search(pos, distance, visited, jumpDriveAvailable): return if "player_ships" not in tile_map[pos]: return + # Nebula parent records are pure render anchors — ships only ever + # live in the subsectors. Don't treat the parent hex as a movable + # destination. + if NH.is_nebula_parent(tile_map[pos]): + return visited.add(pos) player_ships = tile_map[pos]["player_ships"][:] player_ships.append(f"{player['color']}-cruiser") # adding phantom ship so I can reuse a method if not ExploreButtons.doesPlayerHaveUnpinnedShips(player, player_ships, game, pos): return - for adjTile in configs.get(pos)[0].split(","): + for adjTile in NH.adjacent_positions_from_configs(game, pos, configs): if adjTile in tile_map and InfluenceButtons.areTwoTilesAdjacent(game, pos, adjTile, configs, wormHoleGen): recursive_search(adjTile, distance + 1, visited, jumpDriveAvailable) diff --git a/Buttons/Turn.py b/Buttons/Turn.py index b8533dc..d4f9051 100644 --- a/Buttons/Turn.py +++ b/Buttons/Turn.py @@ -240,6 +240,10 @@ async def runUpkeep(game: GamestateHelper, interaction: discord.Interaction): await interaction.channel.send("It appears some tiles are still in conflict. " "Please resolve them before running upkeep") return + # End-of-Combat-Phase nebula discovery resolution. A discovery tile in + # a nebula subsector is claimed only if the player has a ship in that + # subsector at this point. + await Combat.claimNebulaDiscoveries(game, interaction) if "peopleToCheckWith" in game.gamestate and len(game.gamestate["peopleToCheckWith"]) > 0: msg = " Still waiting on the following players to hit the ready for upkeep button:\n" for color2 in game.gamestate["peopleToCheckWith"]: diff --git a/commands/setup_commands.py b/commands/setup_commands.py index 530be28..239f4ac 100644 --- a/commands/setup_commands.py +++ b/commands/setup_commands.py @@ -153,7 +153,8 @@ async def create_new_game(self, interaction: discord.Interaction, game_name: str galactic_event_tiles: Optional[bool] = False, hyperlanes: Optional[bool] = False, community_parts: Optional[bool] = False, - ban_factions: Optional[bool] = False): + ban_factions: Optional[bool] = False, + enable_nebulas: Optional[bool] = True): """ :param ai_ship_type: Choose which type of AI ships to use. :param rift_cannon: Rift cannons are enabled by default. @@ -162,6 +163,7 @@ async def create_new_game(self, interaction: discord.Interaction, game_name: str :param hyperlanes: Hyperlanes for 4p and 5p are default off. :param ban_factions: Used to ban 10-playerCount factions from the draft. :param community_parts: Turns on commnity changes to improved hull and phase shield. + :param enable_nebulas: Galactic Events nebula sectors split into 3 subsectors. On by default. :return: """ temp_player_list = [player1, player2, player3, player4, player5, player6,player7,player8,player9] @@ -174,7 +176,8 @@ async def create_new_game(self, interaction: discord.Interaction, game_name: str ai_ships = "def" else: ai_ships = ai_ship_type.value - new_game = GameInit(game_name, player_list, ai_ships, rift_cannon, turn_order_variant, community_parts) + new_game = GameInit(game_name, player_list, ai_ships, rift_cannon, turn_order_variant, + community_parts, enable_nebulas) new_game.create_game() async def get_or_create_category(guild: discord.Guild, category_name): diff --git a/helpers/CombatHelper.py b/helpers/CombatHelper.py index faa3505..7b6bcbb 100644 --- a/helpers/CombatHelper.py +++ b/helpers/CombatHelper.py @@ -37,6 +37,64 @@ def findPlayersInTile(game: GamestateHelper, pos: str): players.append(color) return players + @staticmethod + async def claimNebulaDiscoveries(game: GamestateHelper, interaction: discord.Interaction): + """Resolve nebula-subsector discovery tiles at the end of the Combat + Phase. + + Per the Galactic Events rulebook, a discovery tile in a Nebula + Subsector is claimed only if the player has a ship in that subsector + at the end of the Combat Phase. We iterate every nebula subsector + record, and if exactly one player owns ships there (an AI presence + can block via combat resolution earlier), they claim the discovery. + """ + # Lazy import to avoid a circular dep with Buttons.* at module load. + from helpers import NebulaHelper as NH + from Buttons.DiscoveryTile import DiscoveryTileButtons + if not NH.nebulas_enabled(game): + return + # Snapshot keys: we may mutate disctile during iteration. + for pos in list(game.gamestate.get("board", {}).keys()): + tile = game.gamestate["board"].get(pos) + if tile is None: + continue + if tile.get("type") != "nebula": + continue + if "parent_position" not in tile: + # Parent anchor — skip; discovery tiles only sit on subsectors. + continue + if tile.get("disctile", 0) <= 0: + continue + playersInTile = Combat.findPlayersInTile(game, pos) + # Only a single, non-AI player triggers a claim. + human_players = [p for p in playersInTile if p != "ai"] + if len(human_players) != 1: + continue + color = human_players[0] + player_obj = game.getPlayerObjectFromColor(color) + if player_obj is None: + continue + try: + await DiscoveryTileButtons.exploreDiscoveryTile(game, pos, interaction, player_obj) + except Exception as exc: + # Don't fail upkeep if a discovery prompt errors — log to the + # channel so the GM can see it. Leave disctile intact so the + # discovery isn't silently lost; a GM can use + # `/tile resolve_specific_discovery_tile` to recover. + import traceback + try: + await interaction.channel.send( + f"Nebula discovery resolution failed for {pos}: {exc!r}. " + "Use /tile resolve_specific_discovery_tile to claim it manually." + ) + except Exception: + traceback.print_exc() + continue + # exploreDiscoveryTile (via getNextDiscTile) clears disctile on + # success. No defensive zeroing here — that would mask future bugs + # by silently consuming the discovery on the failure path. + game.update() + @staticmethod def findShipTypesInTile(game: GamestateHelper, pos: str): tile_map = game.gamestate["board"] diff --git a/helpers/DrawHelper.py b/helpers/DrawHelper.py index 3e9e062..1a30580 100644 --- a/helpers/DrawHelper.py +++ b/helpers/DrawHelper.py @@ -194,6 +194,11 @@ def base_tile_image_with_rotation_in_context(self, rotation, tileID, tile, count return context def board_tile_image_file(self, position): + # If the caller asks for a nebula subsector tile, redirect to the + # parent hex (which renders all three subsectors together). + from helpers import NebulaHelper as NH + if NH.is_nebula_subsector(position): + position = NH.get_parent_position(position) final_context = self.board_tile_image(position) bytes_io = BytesIO() final_context.save(bytes_io, format="WEBP") @@ -238,8 +243,13 @@ def mergeLocationsFile(self, locations): height = math.ceil(amount / result) context = Image.new("RGBA", (int(360 * mult * result), int(315 * mult * height)), (255, 255, 255, 0)) + from helpers import NebulaHelper as NH for count, tile in enumerate(locations): + if NH.is_nebula_subsector(tile): + tile = NH.get_parent_position(tile) image = self.board_tile_image(tile) + if image is None: + continue x = count % result y = int(count / result) context.paste(image, (int(360 * mult * x), int(315 * mult * y))) @@ -248,7 +258,92 @@ def mergeLocationsFile(self, locations): bytes_io.seek(0) return discord.File(bytes_io, filename="tiles.webp") + def _render_ships(self, tile_image, tile, mult, position): + """Render the player_ships of a tile (or subsector) onto tile_image. + + Extracted so a nebula parent's hex can render its three subsectors' + ships using each subsector's own *_snap coords. ``position`` is used + only for damage_tracker lookups (which live keyed by board position). + """ + if not len(tile.get("player_ships", [])) > 0: + return + counts = {} + countsShips = {} + size = int(70 * mult) + for ship in tile["player_ships"]: + if "-" not in ship: + continue + ship_type = ship.split("-")[1] + size = int(70 * mult) + if ship not in countsShips: + countsShips[ship] = 0 + countsShips[ship] += int(10 * mult) + if ship_type in ["gcds", "gcdsadv", "anc", "ancadv", "grd", "grdadv"]: + ship = ship_type.replace("adv", "") + ship_type = "ai" + size = int(110 * mult) + if ship_type == "orb" or ship_type == "mon": + ship = ship_type + if ship_type == "orb": + size = int(110 * mult) + if tile.get("owner", 0) != 0: + if all([self.getPlayerObjectFromColor(tile["owner"])['name'] == "The Exiles", + tile.get("orbital_pop", [0])[0] == 1]): + ship = "exile_orb" + + filepathShip = f"images/resources/components/fancy_ships/fancy_{ship}.png" + if self.gamestate.get("fancy_ships") and os.path.exists(filepathShip): + filepathShip = f"images/resources/components/fancy_ships/fancy_{ship}.png" + else: + filepathShip = f"images/resources/components/basic_ships/{ship}.png" + if ship_type == "None": + continue + ship_image = self.use_image(filepathShip) + + coords = tile[f"{ship_type}_snap"] + if ship_type not in counts: + counts[ship_type] = 0 + xCordToUse = coords[0] + yCordToUse = coords[1] + if "drd" in ship_type or "cru" in ship_type: + xCordToUse -= 30 + yCordToUse -= 30 + if "sb" in ship_type: + yCordToUse -= 30 + + tile_image.paste(ship_image, + (int(345 / 1024 * mult * xCordToUse + (counts[ship_type] - size / 2)), + int(345 / 1024 * mult * yCordToUse + (counts[ship_type] - size / 2))), + mask=ship_image) + counts[ship_type] += int(10 * mult) + if ship_type == "ai": + counts[ship_type] += int(20 * mult) + for key, value in countsShips.items(): + damage = 0 + ship_type = "ai" + if "ai-" not in key: + ship_type = key.split("-")[1] + + coords = tile[ship_type.replace("adv", "") + "_snap"] + damage_tracker = self.gamestate["board"].get(position, {}).get("damage_tracker", {}) + if key in damage_tracker: + damage = damage_tracker[key] + if damage > 0: + for count in range(damage): + damage_image = self.use_image("images/resources/components/basic_ships/marker_damage.png") + tile_image.paste(damage_image, + (int(345 / 1024 * mult * coords[0] + value - size / 2 + + 10 * count * mult + 15 * mult), + int(345 / 1024 * mult * coords[1] + value - size / 2 + 35 * mult)), + mask=damage_image) + def board_tile_image(self, position, additionalRot:int=0): + # Nebula subsectors don't render their own hex — their ships are drawn + # on top of the parent's hex image. Returning None here is safe because + # all current callers either skip None or were already adjusted. + from helpers import NebulaHelper as NH + if NH.is_nebula_subsector(position): + return None sector = self.gamestate["board"][position]["sector"] filepath2 = f"images/resources/hexes/{sector}.png" filepath = f"images/resources/hexes/numberless/{sector}.png" @@ -342,76 +437,16 @@ def board_tile_image(self, position, additionalRot:int=0): warppath = "images/resources/all_boards/Warp_picture.png" warp_mask = self.use_image(warppath) tile_image.paste(warp_mask, (int(20 * mult), int(140 * mult)), mask=warp_mask) - if len(tile.get("player_ships", [])) > 0: - counts = {} # To track counts for each ship type - countsShips = {} - for ship in tile["player_ships"]: - if "-" not in ship: - continue - ship_type = ship.split("-")[1] # Extract ship type - size = int(70 * mult) - if ship not in countsShips: - countsShips[ship] = 0 - countsShips[ship] += int(10 * mult) - if ship_type in ["gcds", "gcdsadv", "anc", "ancadv", "grd", "grdadv"]: - ship = ship_type.replace("adv", "") - ship_type = "ai" - size = int(110 * mult) - if ship_type == "orb" or ship_type == "mon": - ship = ship_type - if ship_type == "orb": - size = int(110 * mult) - if tile["owner"] != 0: - if all([self.getPlayerObjectFromColor(tile["owner"])['name'] == "The Exiles", - tile.get("orbital_pop", [0])[0] == 1]): - ship = "exile_orb" - - filepathShip = f"images/resources/components/fancy_ships/fancy_{ship}.png" - if self.gamestate.get("fancy_ships") and os.path.exists(filepathShip): - filepathShip = f"images/resources/components/fancy_ships/fancy_{ship}.png" - else: - filepathShip = f"images/resources/components/basic_ships/{ship}.png" - if ship_type == "None": - continue - ship_image = self.use_image(filepathShip) - - coords = tile[f"{ship_type}_snap"] - - if ship_type not in counts: - counts[ship_type] = 0 - xCordToUse = coords[0] - yCordToUse = coords[1] - if "drd" in ship_type or "cru" in ship_type: - xCordToUse -= 30 - yCordToUse -= 30 - if "sb" in ship_type: - yCordToUse -= 30 - - tile_image.paste(ship_image, - (int(345 / 1024 * mult * xCordToUse + (counts[ship_type] - size / 2)), - int(345 / 1024 * mult * yCordToUse + (counts[ship_type] - size / 2))), - mask=ship_image) - counts[ship_type] += int(10 * mult) - if ship_type == "ai": - counts[ship_type] += int(20 * mult) - for key, value in countsShips.items(): - damage = 0 - ship_type = "ai" - if "ai-" not in key: - ship_type = key.split("-")[1] - - coords = tile[ship_type.replace("adv", "") + "_snap"] - if "damage_tracker" in self.gamestate["board"][position]: - if key in self.gamestate["board"][position]["damage_tracker"]: - damage = self.gamestate["board"][position]["damage_tracker"][key] - if damage > 0: - for count in range(damage): - damage_image = self.use_image("images/resources/components/basic_ships/marker_damage.png") - tile_image.paste(damage_image, - (int(345 / 1024 * mult * coords[0] + value - size / 2 + - 10 * count * mult + 15 * mult), - int(345 / 1024 * mult * coords[1] + value - size / 2 + 35 * mult)), - mask=damage_image) + self._render_ships(tile_image, tile, mult, position) + + # If this is a nebula parent, also render the ships of each + # subsector on top of the same hex image, using the subsector's + # snap coordinates. + if NH.is_nebula_parent(tile): + for sub_pos in tile.get("subsectors", NH.subsectors_of(position)): + sub_record = self.gamestate["board"].get(sub_pos) + if sub_record is not None: + self._render_ships(tile_image, sub_record, mult, sub_pos) def paste_resourcecube(tile, tile_image, resource_type, color): if tile.get(f"{resource_type}_pop", 0): @@ -1676,7 +1711,10 @@ def paste_tiles(context, tile_map, hyperlane, player_count): max_x = max(max_x, 1820 + hyperImage.width) max_y = max(max_y, 2850 + hyperImage.height) for tile in tile_map: - tile_image = self.board_tile_image(tile).resize((345, 300)) + rendered = self.board_tile_image(tile) + if rendered is None: + continue + tile_image = rendered.resize((345, 300)) x, y = map(int, configs.get(tile)[0].split(",")) context.paste(tile_image, (x, y), mask=tile_image) # Update bounding box coordinates @@ -1786,6 +1824,8 @@ def paste_tiles(context, tile_map): max_y = float('-inf') for tile in tile_map: tile_image = self.board_tile_image(tile) + if tile_image is None: + continue x, y = map(int, configs.get(tile)[0].split(",")) context.paste(tile_image, (x, y), mask=tile_image) # Update bounding box coordinates diff --git a/helpers/GamestateHelper.py b/helpers/GamestateHelper.py index 65e011d..e7de984 100644 --- a/helpers/GamestateHelper.py +++ b/helpers/GamestateHelper.py @@ -190,9 +190,22 @@ async def declareWinner(self, interaction: discord.Interaction): await interaction.channel.send("Hit this button to cleanup the channels.", view=view) def getLocationFromID(self, id): - return next((tile for tile in self.gamestate["board"] - if self.gamestate["board"][tile]["sector"] == str(id)), - None) + # With nebula sectors, the parent and its 3 subsectors all share the + # same `sector` value. Prefer the parent (a record without a + # `parent_position` field) so existing call-sites get the same key + # they did before nebula support landed; fall back to the first + # subsector match. + sector_id = str(id) + first_match = None + for tile in self.gamestate["board"]: + rec = self.gamestate["board"][tile] + if rec.get("sector") != sector_id: + continue + if first_match is None: + first_match = tile + if "parent_position" not in rec: + return tile + return first_match def get_gamestate(self): f= open(f"{config.gamestate_path}/{self.game_id}.json", "r") @@ -367,6 +380,8 @@ def addWarpPortal(self, position): self.update() def add_tile(self, position, orientation, sector, owner=None): + # Lazy import to avoid any circular import risk between helpers. + from helpers import NebulaHelper as NH with open("data/sectors.json") as f: tile_data = json.load(f) @@ -377,22 +392,56 @@ def add_tile(self, position, orientation, sector, owner=None): if position not in self.gamestate["players"][self.get_player_from_color(owner)]["owned_tiles"]: self.gamestate["players"][self.get_player_from_color(owner)]["owned_tiles"].append(position) - if tile["ancient"] or tile["guardian"] or tile["gcds"]: - adv = "" - anc, grd, gcds = tile["ancient"], tile["guardian"], tile["gcds"] - if anc: - tile["player_ships"].append("ai-anc" + adv) - if tile["ancient"] > 1: + is_nebula_tile = (NH.nebulas_enabled(self) + and tile.get("type") == "nebula" + and sector in NH.NEBULA_SECTOR_IDS) + + if not is_nebula_tile: + if tile["ancient"] or tile["guardian"] or tile["gcds"]: + adv = "" + anc, grd, gcds = tile["ancient"], tile["guardian"], tile["gcds"] + if anc: tile["player_ships"].append("ai-anc" + adv) - if grd: - tile["player_ships"].append("ai-grd" + adv) - if gcds: - tile["player_ships"].append("ai-gcds" + adv) + if tile["ancient"] > 1: + tile["player_ships"].append("ai-anc" + adv) + if grd: + tile["player_ships"].append("ai-grd" + adv) + if gcds: + tile["player_ships"].append("ai-gcds" + adv) tile.update({"sector": sector}) tile.update({"orientation": orientation}) except KeyError: tile = {"sector": sector, "orientation": orientation} - self.gamestate["board"][position] = tile + is_nebula_tile = False + + if is_nebula_tile: + # Build a parent anchor record (no ships, no influence space, no + # discovery / ancient — those live on the subsectors). + # + # The parent retains a full 6-edge wormhole array so that the + # symmetric `areTwoTilesAdjacent` check passes when an outside + # tile asks "am I adjacent to this nebula parent" before the + # neighbour-rewrite resolves the lookup to a subsector. The + # parent itself is never a movement / influence destination — + # `is_nebula_parent` and the empty player_ships keep it inert. + parent_record = dict(tile) + parent_record["is_nebula_parent"] = True + parent_record["player_ships"] = [] + parent_record["wormholes"] = [0, 1, 2, 3, 4, 5] + parent_record["disctile"] = 0 + parent_record["ancient"] = 0 + parent_record["subsectors"] = NH.subsectors_of(position) + self.gamestate["board"][position] = parent_record + + # Build the 3 subsector records. + for L in NH.SUBSECTOR_LETTERS: + sub = NH.make_subsector_record(parent_record, position, L, + sector, orientation) + if L == NH.ANCIENT_SUBSECTOR: + sub["player_ships"].append("ai-anc") + self.gamestate["board"][f"{position}{L}"] = sub + else: + self.gamestate["board"][position] = tile configs = Properties() if self.gamestate.get("5playerhyperlane"): @@ -1060,6 +1109,21 @@ def setup_techs_and_outer_rim(self, count: int, galactic_events, hyperlane): self.gamestate["tile_deck_300"].append(third_sector_tiles.pop(0)) sector_draws -= 1 + # Galactic Events: shuffle nebula tiles into the matching ring before + # stacks are formed. Per the rulebook (p. 4) "they do not add to the + # count of III tiles used", so the ring-III nebula is appended *after* + # the regular sector_draws have been satisfied. Ring II has no + # equivalent caveat in the rulebook so the ring-II nebula is added to + # the deck (which extends rather than replaces a draw). + if self.gamestate.get("enable_nebulas"): + from helpers import NebulaHelper as NH + if "295" in NH.NEBULA_SECTOR_IDS and "295" not in self.gamestate.get("tile_deck_200", []): + self.gamestate.setdefault("tile_deck_200", []).append("295") + random.shuffle(self.gamestate["tile_deck_200"]) + if "395" in NH.NEBULA_SECTOR_IDS and "395" not in self.gamestate.get("tile_deck_300", []): + self.gamestate["tile_deck_300"].append("395") + random.shuffle(self.gamestate["tile_deck_300"]) + while tech_draws > 0: random.shuffle(self.gamestate["tech_deck"]) picked_tech = self.gamestate["tech_deck"].pop(0) diff --git a/helpers/NebulaHelper.py b/helpers/NebulaHelper.py new file mode 100644 index 0000000..345c103 --- /dev/null +++ b/helpers/NebulaHelper.py @@ -0,0 +1,232 @@ +"""Helpers for Nebula sectors (Galactic Events expansion). + +A Nebula sector is a hex tile divided into three Subsectors (A/B/C) connected by +internal wormhole connections. Subsectors are exposed as first-class entries in +``gamestate["board"]`` using synthetic position keys ``A``, ``B``, +``C``. The parent position is also retained as a render anchor and is +flagged with ``is_nebula_parent: True``. + +See the rulebook (Galactic Events, page 4) for full rules; this module only +provides scaffolding helpers used by Explore / Move / Influence / DrawHelper / +combat upkeep. +""" + +import copy + + +# Sector IDs in data/sectors.json that are nebulas. Currently NGC 5189 and NGC 1952. +NEBULA_SECTOR_IDS = {"295", "395"} + +SUBSECTOR_LETTERS = ["A", "B", "C"] + +# External hex edges per subsector (using the existing 0..5 edge convention, +# which is N, NE, SE, S, SW, NW). +EXTERNAL_EDGES_BY_SUBSECTOR = { + "A": [5, 0], # top: NW + N + "B": [1, 2], # right: NE + SE + "C": [3, 4], # bottom-left: S + SW +} + +# Symbol distribution per subsector. Same layout for both 295 and 395 as a +# baseline. If individual sectors should differ in the future this can be +# parameterised by sector id. +DISC_SUBSECTORS = {"A", "C"} +ANCIENT_SUBSECTOR = "B" + +# Subsector centres (relative to a 1024x887 hex image), used as the default +# *_snap coordinates for ship placement. The renderer applies its own per-ship +# offsets (cru/drd y -30, sb y -30) so we don't pre-bake those. +SUBSECTOR_CENTERS = { + "A": (512, 220), + "B": (730, 580), + "C": (294, 580), +} + + +def is_nebula_subsector(pos): + """Return True if pos looks like a nebula subsector key (e.g. ``"207A"``).""" + if not isinstance(pos, str) or len(pos) < 2: + return False + letter = pos[-1] + if letter not in SUBSECTOR_LETTERS: + return False + prefix = pos[:-1] + return prefix.isdigit() + + +def get_parent_position(pos): + """Strip a trailing A/B/C from a subsector key. Returns pos unchanged if it + isn't a subsector key.""" + if is_nebula_subsector(pos): + return pos[:-1] + return pos + + +def get_subsector_letter(pos): + """Return the trailing A/B/C letter, or None if pos is not a subsector.""" + if is_nebula_subsector(pos): + return pos[-1] + return None + + +def subsectors_of(parent_pos): + """Return the three subsector keys for a parent position.""" + return [f"{parent_pos}{L}" for L in SUBSECTOR_LETTERS] + + +def is_nebula_parent(tile_record): + """True if a board record is a nebula parent anchor (no ships / no influence).""" + if not isinstance(tile_record, dict): + return False + return bool(tile_record.get("is_nebula_parent")) + + +def is_nebula_position(game, pos): + """True if ``pos`` (parent or subsector) belongs to a nebula in the gamestate.""" + board = game.gamestate.get("board", {}) + if pos not in board and not is_nebula_subsector(pos): + return False + if is_nebula_subsector(pos): + parent = get_parent_position(pos) + rec = board.get(parent) + return is_nebula_parent(rec) if rec else False + rec = board.get(pos) + return is_nebula_parent(rec) if rec else False + + +def nebulas_enabled(game): + """Helper to check the per-game opt-in flag.""" + return bool(game.gamestate.get("enable_nebulas")) + + +# Keys that mirror a regular sector record. Subsector records carry the same +# shape so consumers reading e.g. ``tile["money_pop"]`` don't KeyError. +_EMPTY_LIST_KEYS = [ + "money_pop", "money1_snap", "money2_snap", + "moneyadv_pop", "moneyadv1_snap", "moneyadv2_snap", + "science_pop", "science1_snap", "science2_snap", + "scienceadv_pop", "scienceadv1_snap", "scienceadv2_snap", + "material_pop", "material1_snap", "material2_snap", + "materialadv_pop", "materialadv1_snap", "materialadv2_snap", + "neutral_pop", "neutral1_snap", "neutral2_snap", + "neutraladv_pop", "neutraladv1_snap", "neutraladv2_snap", +] + + +def make_subsector_record(parent_record, parent_position, subsector_letter, + sector_id, orientation): + """Build a board record for a single nebula subsector. + + The record mirrors the schema of a regular sector (so any consumer that + reads e.g. ``tile["money_pop"]`` continues to work), but with all + population fields empty, no influence ever, only the two external edges as + wormholes, and the subsector-specific disctile/ancient flags. + """ + L = subsector_letter + centre = list(SUBSECTOR_CENTERS[L]) + + record = {} + record["owner"] = 0 + record["name"] = f"{parent_record.get('name', sector_id)} ({L})" + record["ring"] = parent_record.get("ring", 2) + record["disctile"] = 1 if L in DISC_SUBSECTORS else 0 + record["ancient"] = 1 if L == ANCIENT_SUBSECTOR else 0 + record["guardian"] = 0 + record["gcds"] = 0 + record["warp"] = 0 + record["vp"] = 0 + record["artifact"] = 0 + record["player_ships"] = [] + for k in _EMPTY_LIST_KEYS: + record[k] = [] + # Per-ship snap coords default to the subsector centre. + for snap in ["int_snap", "cru_snap", "drd_snap", "sb_snap", + "mon_snap", "orb_snap", "ai_snap"]: + record[snap] = list(centre) + record["wormholes"] = list(EXTERNAL_EDGES_BY_SUBSECTOR[L]) + record["type"] = "nebula" + record["sector"] = sector_id + record["orientation"] = orientation + record["parent_position"] = parent_position + record["subsector"] = L + record["internal_subsectors"] = [ + f"{parent_position}{other}" for other in SUBSECTOR_LETTERS if other != L + ] + return record + + +def adjacent_positions(game, pos): + """Return a list of position keys adjacent to ``pos`` for graph traversal. + + For a nebula subsector this is the parent's external neighbour list (so + move/explore can step out of the nebula) plus the two sibling subsectors + (the internal wormhole connections). For everything else it is the parent + adjacency entry from the loaded ``configs`` properties — callers should + use :func:`adjacent_positions_from_configs` when they already have configs + loaded; this function exists primarily for tests / lightweight callers. + """ + parent = get_parent_position(pos) if is_nebula_subsector(pos) else pos + # Lazy import to avoid circular deps at module load time. + from jproperties import Properties + configs = Properties() + if game.gamestate.get("5playerhyperlane"): + if game.gamestate.get("player_count") == 5: + with open("data/tileAdjacencies_5p.properties", "rb") as f: + configs.load(f) + elif game.gamestate.get("player_count") == 4: + with open("data/tileAdjacencies_4p.properties", "rb") as f: + configs.load(f) + else: + with open("data/tileAdjacencies.properties", "rb") as f: + configs.load(f) + else: + with open("data/tileAdjacencies.properties", "rb") as f: + configs.load(f) + if parent not in configs: + return [] + neighbours = list(configs.get(parent)[0].split(",")) + if is_nebula_subsector(pos): + neighbours = neighbours + [s for s in subsectors_of(parent) if s != pos] + return neighbours + + +def adjacent_positions_from_configs(game, pos, configs): + """Like :func:`adjacent_positions` but reuses an already-loaded configs. + + For a regular hex, any neighbour that turns out to be a *nebula parent* + is rewritten to the specific subsector that owns that external edge — so + callers iterating neighbours never have to step through the parent + anchor (which has no ships and no wormholes of its own). + """ + parent = get_parent_position(pos) if is_nebula_subsector(pos) else pos + if parent not in configs: + return [] + raw_neighbours = list(configs.get(parent)[0].split(",")) + board = game.gamestate.get("board", {}) if game is not None else {} + rewritten = [] + for index, n in enumerate(raw_neighbours): + nbr_record = board.get(n) + if nbr_record is not None and is_nebula_parent(nbr_record): + # Translate the parent neighbour into the subsector that owns + # the matching external edge. From `pos`'s perspective the + # neighbour `n` sits at position `index` in the adjacency list; + # the entry edge into `n` (board-absolute frame) is the opposite + # direction (index + 3) % 6. Convert that into the neighbour's + # *local* edge by adding its rotation — same convention as the + # existing `tile_orientation_index = (index + rotation/60) % 6` + # used everywhere else (DrawHelper, Influence.areTwoTilesAdjacent). + entry_edge_abs = (index + 3) % 6 + nbr_rotation = int(nbr_record.get("orientation", 0)) // 60 + entry_edge_local = (entry_edge_abs + nbr_rotation) % 6 + sub_letter = None + for L, edges in EXTERNAL_EDGES_BY_SUBSECTOR.items(): + if entry_edge_local in edges: + sub_letter = L + break + if sub_letter is not None: + rewritten.append(f"{n}{sub_letter}") + continue + rewritten.append(n) + if is_nebula_subsector(pos): + rewritten = rewritten + [s for s in subsectors_of(parent) if s != pos] + return rewritten diff --git a/setup/GameInit.py b/setup/GameInit.py index c16d095..f5c1f4f 100644 --- a/setup/GameInit.py +++ b/setup/GameInit.py @@ -5,7 +5,8 @@ class GameInit: - def __init__(self, game_name, player_list, ai_ship_type, rift_cannon, turn_order_variant, community_parts): + def __init__(self, game_name, player_list, ai_ship_type, rift_cannon, turn_order_variant, + community_parts, enable_nebulas: bool = True): self.game_name = game_name self.player_list = player_list self.gamestate = [] @@ -13,6 +14,10 @@ def __init__(self, game_name, player_list, ai_ship_type, rift_cannon, turn_order self.rift_cannon = rift_cannon self.turn_order_variant = turn_order_variant self.community_parts = community_parts + # Nebula sectors (Galactic Events expansion). Default on. Can be turned + # off per game by editing the saved gamestate JSON until a slash-command + # toggle is wired in. + self.enable_nebulas = enable_nebulas def create_game(self): game_id = f"aeb{config.game_number}" @@ -57,6 +62,7 @@ def create_game(self): self.gamestate["discTiles"].remove("ricon") self.gamestate["rift_cannon"] = self.rift_cannon self.gamestate["community_parts"] = self.community_parts + self.gamestate["enable_nebulas"] = self.enable_nebulas with open(f"{config.gamestate_path}/{self.gamestate['game_id']}.json", "w") as f: json.dump(self.gamestate, f) diff --git a/tests/test_nebulas.py b/tests/test_nebulas.py new file mode 100644 index 0000000..c841695 --- /dev/null +++ b/tests/test_nebulas.py @@ -0,0 +1,414 @@ +"""Tests for Nebula sector handling (Galactic Events). + +Mirrors the lightweight helper pattern from ``test_game_init_tech_deck`` — +build a minimal gamestate, drop it on disk, instantiate ``GamestateHelper``, +exercise the code under test. +""" + +import json + +import pytest + +import config + + +def _minimal_gamestate(enable_nebulas=True): + return { + "game_id": "testneb", + "game_name": "TestNeb", + "setup_finished": 1, + "game_phase": [], + "game_round": 1, + "roundNum": 1, + "advanced_ai": 0, + "wa_ai": 0, + "player_count": 2, + "player_order": [], + "active_player": [], + "activePlayerColor": [], + "lastPingTime": 0, + "lastButton": "", + "available_colors": [], + "used_colors": [], + "tile_deck_100": [], + "tile_deck_200": [], + "tile_deck_300": [], + "tile_discard": [], + "tech_deck": [], + "available_techs": [], + "reputation_tiles": [4, 3, 2, 1], + "discTiles": [], + "players": {}, + "board": {}, + "enable_nebulas": enable_nebulas, + } + + +def _make_game(tmp_path, enable_nebulas=True): + from helpers.GamestateHelper import GamestateHelper + + gs = _minimal_gamestate(enable_nebulas=enable_nebulas) + game_path = tmp_path / f"{gs['game_id']}.json" + with open(game_path, "w") as f: + json.dump(gs, f) + + original_path = config.gamestate_path + config.gamestate_path = str(tmp_path) + game = GamestateHelper(None, gs["game_id"]) + return game, original_path + + +def _restore(original_path): + config.gamestate_path = original_path + + +class TestNebulaSetup: + def test_nebula_disabled_no_subsectors(self, tmp_path): + game, original = _make_game(tmp_path, enable_nebulas=False) + try: + game.add_tile("207", 0, "295") + board = game.gamestate["board"] + assert "207" in board + # No subsector keys created + assert "207A" not in board + assert "207B" not in board + assert "207C" not in board + # Original behaviour: ancient ship spawned in the parent record + assert "ai-anc" in board["207"]["player_ships"] + assert board["207"].get("is_nebula_parent") is None + finally: + _restore(original) + + def test_nebula_creates_three_subsectors(self, tmp_path): + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 0, "295") + board = game.gamestate["board"] + for key in ("207", "207A", "207B", "207C"): + assert key in board, f"missing {key}" + finally: + _restore(original) + + def test_nebula_disctile_distribution(self, tmp_path): + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 0, "295") + board = game.gamestate["board"] + assert board["207A"]["disctile"] == 1 + assert board["207A"]["ancient"] == 0 + assert board["207B"]["disctile"] == 0 + assert board["207B"]["ancient"] == 1 + assert board["207C"]["disctile"] == 1 + assert board["207C"]["ancient"] == 0 + # Subsector B carries the ancient ship. + assert "ai-anc" in board["207B"]["player_ships"] + # A and C have no ships at placement time. + assert board["207A"]["player_ships"] == [] + assert board["207C"]["player_ships"] == [] + finally: + _restore(original) + + def test_nebula_subsector_wormholes(self, tmp_path): + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 0, "295") + board = game.gamestate["board"] + assert board["207A"]["wormholes"] == [5, 0] + assert board["207B"]["wormholes"] == [1, 2] + assert board["207C"]["wormholes"] == [3, 4] + finally: + _restore(original) + + def test_nebula_parent_no_ships_no_disctile(self, tmp_path): + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 0, "295") + parent = game.gamestate["board"]["207"] + assert parent["is_nebula_parent"] is True + assert parent["player_ships"] == [] + assert parent["disctile"] == 0 + assert parent["ancient"] == 0 + # Parent retains a full 6-edge wormhole array so the symmetric + # `areTwoTilesAdjacent` check passes from outside neighbours; the + # `is_nebula_parent` flag + empty player_ships keep it inert. + assert parent["wormholes"] == [0, 1, 2, 3, 4, 5] + assert sorted(parent["subsectors"]) == ["207A", "207B", "207C"] + finally: + _restore(original) + + +class TestNebulaAdjacency: + def test_subsector_internal_adjacency(self, tmp_path): + from Buttons.Influence import InfluenceButtons + from jproperties import Properties + + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 0, "295") + configs = Properties() + with open("data/tileAdjacencies.properties", "rb") as f: + configs.load(f) + assert InfluenceButtons.areTwoTilesAdjacent(game, "207A", "207B", configs, False) + assert InfluenceButtons.areTwoTilesAdjacent(game, "207B", "207C", configs, False) + assert InfluenceButtons.areTwoTilesAdjacent(game, "207A", "207C", configs, False) + finally: + _restore(original) + + +class TestInfluenceExcludesNebulas: + def test_influence_skips_nebula_subsectors(self, tmp_path): + """A nebula subsector should never be in the influence target list.""" + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 0, "295") + # NOTE: full-blown getTilesToInfluence requires a fully-populated + # player record (techs, owned_tiles, influence_discs, etc) which + # is heavyweight to mock. We instead spot-check the per-tile + # filter that `getTilesToInfluence` applies: the type filter + # should reject nebula subsectors and parents. + for sub in ("207", "207A", "207B", "207C"): + assert game.gamestate["board"][sub].get("type") == "nebula" + finally: + _restore(original) + + +class TestExternalAdjacency: + """Cross-boundary adjacency: a regular hex and a nebula subsector are + adjacent only via the subsector's external wormhole edges.""" + + def test_subsector_adjacent_to_outside_neighbour(self, tmp_path): + from Buttons.Influence import InfluenceButtons + from jproperties import Properties + + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + # 207's adjacency list is "104,206,309,310,311,208". Subsector A + # has external edges [5, 0] -> faces 208 (edge 5) and 104 (edge 0). + game.add_tile("207", 0, "295") + game.add_tile("208", 0, "301") + configs = Properties() + with open("data/tileAdjacencies.properties", "rb") as f: + configs.load(f) + assert InfluenceButtons.areTwoTilesAdjacent( + game, "207A", "208", configs, False + ) + # 208 is NOT adjacent to subsector B (B's external edges are 1, 2, + # which face 206 and 309). + assert not InfluenceButtons.areTwoTilesAdjacent( + game, "207B", "208", configs, False + ) + finally: + _restore(original) + + def test_parent_neighbour_rewrite(self, tmp_path): + from helpers import NebulaHelper as NH + from jproperties import Properties + + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 0, "295") + game.add_tile("208", 0, "301") + configs = Properties() + with open("data/tileAdjacencies.properties", "rb") as f: + configs.load(f) + # 208's neighbour list contains "207" (the parent). Through + # adjacent_positions_from_configs it should be rewritten to the + # subsector that owns the corresponding edge — in this case 207A + # (the subsector facing 208 via edge 5 of the parent). + neighbours = NH.adjacent_positions_from_configs(game, "208", configs) + assert "207A" in neighbours + assert "207" not in neighbours + finally: + _restore(original) + + def test_outside_tile_adjacent_to_subsector(self, tmp_path): + """A regular tile next to a nebula parent should evaluate as adjacent + to the subsector that owns the matching external edge — symmetric + check (no wormhole-gen) must pass in both directions.""" + from Buttons.Influence import InfluenceButtons + from jproperties import Properties + + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 0, "295") + game.add_tile("208", 0, "301") + configs = Properties() + with open("data/tileAdjacencies.properties", "rb") as f: + configs.load(f) + # 208 is at index 5 in 207's adjacency list. Subsector A owns + # local edge 5, so 208 ↔ 207A is the only valid external edge. + assert InfluenceButtons.areTwoTilesAdjacent( + game, "208", "207A", configs, False + ) + # And the reverse direction also passes (symmetric). + assert InfluenceButtons.areTwoTilesAdjacent( + game, "207A", "208", configs, False + ) + # 208 must NOT be adjacent to 207B or 207C (their wormholes face + # other directions). + assert not InfluenceButtons.areTwoTilesAdjacent( + game, "208", "207B", configs, False + ) + assert not InfluenceButtons.areTwoTilesAdjacent( + game, "208", "207C", configs, False + ) + finally: + _restore(original) + + def test_rotated_parent_neighbour_rewrite(self, tmp_path): + """Rotation must be applied when picking which subsector owns the + entry edge from a neighbour's perspective. The convention follows + the existing `tile_orientation_index = (i + rotation/60) % 6` used + across DrawHelper and Influence.""" + from Buttons.Influence import InfluenceButtons + from helpers import NebulaHelper as NH + from jproperties import Properties + + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 60, "295") # rotated 60° CW + game.add_tile("208", 0, "301") + configs = Properties() + with open("data/tileAdjacencies.properties", "rb") as f: + configs.load(f) + # 208 is at index 5 in 207's adjacency list, so 207A's wormhole + # (list index 0) reaches 208 after rotation 60° CW (since + # board position = (list_index - rotation/60) % 6 = 5). + # From 208's side, 207 is at index 2; the entry-edge in 207's + # local list-index frame is (2+3+rot) % 6 = (5+1)%6 = 0, which + # is in subsector A's [5, 0]. So the rewrite picks 207A — and + # this matches the wormhole connectivity check from 207A's side. + neighbours = NH.adjacent_positions_from_configs(game, "208", configs) + assert "207A" in neighbours + assert "207C" not in neighbours + assert "207B" not in neighbours + # Sanity: the wormhole connectivity check from 207A's side also + # confirms 207A ↔ 208 at rotation 60. + assert InfluenceButtons.areTwoTilesAdjacent( + game, "207A", "208", configs, False + ) + finally: + _restore(original) + + +class TestNebulaDeckPopulation: + """Verify nebula tiles 295/395 actually enter the tile decks during setup + when ``enable_nebulas`` is on, and don't when it's off.""" + + def _make_setup_game(self, tmp_path, enable_nebulas): + from helpers.GamestateHelper import GamestateHelper + + gs = _minimal_gamestate(enable_nebulas=enable_nebulas) + gs["community_parts"] = False + gs["rift_cannon"] = True + game_path = tmp_path / f"{gs['game_id']}.json" + with open(game_path, "w") as f: + json.dump(gs, f) + original_path = config.gamestate_path + config.gamestate_path = str(tmp_path) + return GamestateHelper(None, gs["game_id"]), original_path + + def test_nebula_295_in_ring2_when_enabled(self, tmp_path): + game, original = self._make_setup_game(tmp_path, enable_nebulas=True) + try: + game.setup_techs_and_outer_rim(2, False, False) + assert "295" in game.gamestate["tile_deck_200"] + finally: + _restore(original) + + def test_nebula_395_in_ring3_when_enabled(self, tmp_path): + game, original = self._make_setup_game(tmp_path, enable_nebulas=True) + try: + game.setup_techs_and_outer_rim(2, False, False) + assert "395" in game.gamestate["tile_deck_300"] + finally: + _restore(original) + + def test_no_nebula_in_decks_when_disabled(self, tmp_path): + game, original = self._make_setup_game(tmp_path, enable_nebulas=False) + try: + game.setup_techs_and_outer_rim(2, False, False) + assert "295" not in game.gamestate["tile_deck_200"] + assert "295" not in game.gamestate["tile_deck_300"] + assert "395" not in game.gamestate["tile_deck_300"] + assert "395" not in game.gamestate["tile_deck_200"] + finally: + _restore(original) + + +class TestNebulaDiscoveryReroll: + """Per Galactic Events p. 4: Ancient Orbital ('orb') and Ancient Monolith + ('mon') cannot be placed in a Nebula Subsector. The reroll helper must + swap them out before the player gets a choice. We test the helper + directly to avoid the async / Discord-interaction surface.""" + + def test_orbital_redrawn_in_nebula_subsector(self, tmp_path): + from Buttons.DiscoveryTile import DiscoveryTileButtons + + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 0, "295") + # Pretend the player just popped "orb"; the deck still has a + # benign disc beneath. After redraw the player should see the + # benign disc and "orb" should be back in the deck. + game.gamestate["discTiles"] = ["socha"] + new_disc = DiscoveryTileButtons._maybe_reroll_for_nebula(game, "207A", "orb") + assert new_disc == "socha" + assert "orb" in game.gamestate["discTiles"] + finally: + _restore(original) + + def test_monolith_redrawn_in_nebula_subsector(self, tmp_path): + from Buttons.DiscoveryTile import DiscoveryTileButtons + + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 0, "295") + game.gamestate["discTiles"] = ["axc"] + new_disc = DiscoveryTileButtons._maybe_reroll_for_nebula(game, "207C", "mon") + assert new_disc == "axc" + assert "mon" in game.gamestate["discTiles"] + finally: + _restore(original) + + def test_no_redraw_in_regular_sector(self, tmp_path): + from Buttons.DiscoveryTile import DiscoveryTileButtons + + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + # Regular ring-3 sector — no reroll, the orb stays. + game.gamestate["discTiles"] = ["axc"] + new_disc = DiscoveryTileButtons._maybe_reroll_for_nebula(game, "301", "orb") + assert new_disc == "orb" + # Deck unchanged + assert game.gamestate["discTiles"] == ["axc"] + finally: + _restore(original) + + def test_safe_disc_unchanged_in_nebula(self, tmp_path): + from Buttons.DiscoveryTile import DiscoveryTileButtons + + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 0, "295") + game.gamestate["discTiles"] = ["axc"] + new_disc = DiscoveryTileButtons._maybe_reroll_for_nebula(game, "207A", "socha") + # socha is a benign Ancient Ship Part, no redraw. + assert new_disc == "socha" + assert game.gamestate["discTiles"] == ["axc"] + finally: + _restore(original) + + def test_empty_deck_falls_back_to_drawn_disc(self, tmp_path): + """If the deck is exhausted while we're trying to redraw, the helper + should give up and return the original disc rather than loop forever.""" + from Buttons.DiscoveryTile import DiscoveryTileButtons + + game, original = _make_game(tmp_path, enable_nebulas=True) + try: + game.add_tile("207", 0, "295") + game.gamestate["discTiles"] = [] + new_disc = DiscoveryTileButtons._maybe_reroll_for_nebula(game, "207A", "orb") + assert new_disc == "orb" + finally: + _restore(original) From 098034818d25f01de99d3c5deab157223649228a Mon Sep 17 00:00:00 2001 From: TaylorBoyd Date: Sun, 31 May 2026 16:15:45 -0400 Subject: [PATCH 2/2] Bot Busy Queue Draw Fix Moved message delete into the loops. I believe if the button is pressed but then the user receives a "bot busy" error...then the buttons are deleted but the user is not added to the draw queue --- helpers/CombatHelper.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/helpers/CombatHelper.py b/helpers/CombatHelper.py index 7b6bcbb..7f80f0b 100644 --- a/helpers/CombatHelper.py +++ b/helpers/CombatHelper.py @@ -1562,10 +1562,12 @@ async def drawReputation(game: GamestateHelper, buttonID: str, interaction: disc asyncio.create_task(interaction.channel.send(f"{interaction.user.name}, your reputation draw has been queued")) else: asyncio.create_task(interaction.channel.send(f"{interaction.user.name} drew {num_options} reputation tiles.")) + asyncio.create_task(interaction.message.delete()) else: await ReputationButtons.resolveGainingReputation(game, num_options, interaction, player_helper, False) asyncio.create_task(interaction.channel.send(f"{interaction.user.name} drew {num_options} reputation tiles.")) - asyncio.create_task(interaction.message.delete()) + asyncio.create_task(interaction.message.delete()) + @staticmethod async def refreshImage(game: GamestateHelper, buttonID: str, interaction: discord.Interaction):