This is a Python script for the bullet-heaven game, Asgard's Fall.
Given a list of nine large (hexomino) runes, it will tell you whether or not those runes will all fit in the Warpaint Grid, and print a solution.
This is a Python script for the bullet-heaven game, Asgard's Fall.
Given a list of nine large (hexomino) runes, it will tell you whether or not those runes will all fit in the Warpaint Grid, and print a solution.
| #!/usr/bin/env python3 | |
| """ | |
| Asgard's Fall - Warpaint Grid Checker | |
| Check if a specific set of hexominos (runes) can fit in the Warpaint grid. | |
| Uses backtracking to find a valid arrangement (if one exists). | |
| Usage: | |
| from can_fit_pieces import can_fit_pieces, print_solution, WARPAINT | |
| pieces = ['algiz', 'mannaz', 'tiwaz', 'tiwaz', 'hagalaz', | |
| 'hagalaz', 'laguz', 'sowilo', 'sowilo'] | |
| result = can_fit_pieces(pieces) | |
| if result: | |
| print_solution(result, WARPAINT) | |
| """ | |
| from typing import Set, List, Tuple, Optional | |
| from dataclasses import dataclass | |
| Coord = Tuple[int, int] | |
| Shape = Set[Coord] | |
| BACKPACK_ASCII = """ | |
| ## ## | |
| ######## | |
| ######## | |
| ######## | |
| ###### | |
| ###### | |
| ###### | |
| ###### | |
| #### | |
| """.strip('\n').split('\n') | |
| def parse_grid(ascii_art: List[str]) -> Shape: | |
| """Convert ASCII art to set of (row, col) coordinates.""" | |
| coords = set() | |
| for row, line in enumerate(ascii_art): | |
| for col, char in enumerate(line): | |
| if char == '#': | |
| coords.add((row, col)) | |
| return coords | |
| WARPAINT = parse_grid(BACKPACK_ASCII) | |
| # ============================================================================= | |
| # PIECE DEFINITIONS | |
| # ============================================================================= | |
| def parse_piece(ascii_art: str) -> Shape: | |
| """Parse a piece from ASCII art, normalized to origin.""" | |
| lines = ascii_art.strip('\n').split('\n') | |
| coords = set() | |
| for row, line in enumerate(lines): | |
| for col, char in enumerate(line): | |
| if char == '#': | |
| coords.add((row, col)) | |
| return normalize(coords) | |
| def normalize(shape: Shape) -> Shape: | |
| """Normalize shape so minimum row and col are 0.""" | |
| if not shape: | |
| return shape | |
| min_row = min(r for r, c in shape) | |
| min_col = min(c for r, c in shape) | |
| return frozenset((r - min_row, c - min_col) for r, c in shape) | |
| def rotate_90(shape: Shape) -> Shape: | |
| """Rotate shape 90 degrees clockwise.""" | |
| return normalize({(c, -r) for r, c in shape}) | |
| def all_orientations(shape: Shape) -> List[Shape]: | |
| """Get all unique rotations of a shape (no reflections).""" | |
| orientations = set() | |
| current = shape | |
| for _ in range(4): | |
| orientations.add(frozenset(current)) | |
| current = rotate_90(current) | |
| return [set(o) for o in orientations] | |
| # ANSI color codes for terminal output | |
| BOLD = "\033[1m" | |
| COLORS = [ | |
| f"{BOLD}\033[91m", # Bold Red | |
| f"{BOLD}\033[92m", # Bold Green | |
| f"{BOLD}\033[93m", # Bold Yellow | |
| f"{BOLD}\033[94m", # Bold Blue | |
| f"{BOLD}\033[95m", # Bold Magenta | |
| f"{BOLD}\033[96m", # Bold Cyan | |
| f"{BOLD}\033[97m", # Bold White | |
| f"{BOLD}\033[33m", # Bold Orange-ish | |
| f"{BOLD}\033[35m", # Bold Purple | |
| f"{BOLD}\033[36m", # Bold Teal | |
| ] | |
| RESET = "\033[0m" | |
| DIM = "\033[2m" | |
| # Unicode rune characters for display | |
| RUNE_GLYPHS = { | |
| "naudiz": "ᚾ", | |
| "tiwaz": "ᛏ", | |
| "mannaz": "ᛗ", | |
| "eiwaz": "ᛇ", | |
| "algiz": "ᛉ", | |
| "hagalaz": "ᚺ", | |
| "laguz": "ᛚ", | |
| "sowilo": "ᛋ", | |
| "ingwaz": "ᛜ", | |
| "isaz": "ᛁ", | |
| "thurs": "ᚦ", | |
| "jera": "ᛃ", | |
| "gyfu": "ᚷ", | |
| } | |
| HEXOMINOS = { | |
| "naudiz": parse_piece(""" | |
| # | |
| ## | |
| ## | |
| # | |
| """), | |
| "tiwaz": parse_piece(""" | |
| ### | |
| # | |
| # | |
| # | |
| """), | |
| "mannaz": parse_piece(""" | |
| ## | |
| ## | |
| ## | |
| """), | |
| "eiwaz": parse_piece(""" | |
| ## | |
| # | |
| # | |
| ## | |
| """), | |
| "algiz": parse_piece(""" | |
| # # | |
| ### | |
| # | |
| """), | |
| "hagalaz": parse_piece(""" | |
| # | |
| ## | |
| ## | |
| # | |
| """), | |
| "laguz": parse_piece(""" | |
| # | |
| ## | |
| ## | |
| # | |
| """), | |
| "sowilo": parse_piece(""" | |
| # | |
| ## | |
| ## | |
| # | |
| """), | |
| } | |
| TETROMINOS = { | |
| "ingwaz": parse_piece(""" | |
| ## | |
| ## | |
| """), | |
| "isaz": parse_piece(""" | |
| # | |
| # | |
| # | |
| # | |
| """), | |
| "thurs": parse_piece(""" | |
| # | |
| ## | |
| # | |
| """), | |
| "jera": parse_piece(""" | |
| ## | |
| ## | |
| """), | |
| } | |
| # Domino (2 cells) | |
| DOMINOS = { | |
| "gyfu": parse_piece(""" | |
| # | |
| # | |
| """), | |
| } | |
| @dataclass | |
| class Placement: | |
| """A placed piece on the grid.""" | |
| name: str | |
| coords: Shape | |
| def can_place(piece: Shape, at: Coord, grid: Shape) -> Optional[Shape]: | |
| """Check if piece can be placed at position, return placed coords or None.""" | |
| row_off, col_off = at | |
| placed = {(r + row_off, c + col_off) for r, c in piece} | |
| if placed <= grid: | |
| return placed | |
| return None | |
| def can_fit_pieces(hex_names: List[str], grid: Shape = None) -> Optional[List[Placement]]: | |
| """ | |
| Check if a specific set of 9 runes can fit in the Warpaint grid. | |
| Since 9 hexominos = 54 cells and the grid has 56 cells, a domino (gyfu) | |
| will automatically be used to fill the remaining 2 cells. | |
| Args: | |
| hex_names: List of 9 hexomino names (e.g., ['mannaz', 'algiz', ...]) | |
| grid: The grid to fill (defaults to WARPAINT) | |
| Returns: | |
| List of placements if solution exists, None otherwise | |
| Example: | |
| can_fit_pieces(['algiz', 'mannaz', 'laguz', 'tiwaz', 'tiwaz', | |
| 'hagalaz', 'hagalaz', 'sowilo', 'sowilo']) | |
| """ | |
| if grid is None: | |
| grid = WARPAINT.copy() | |
| from collections import Counter | |
| pieces_needed = Counter(hex_names) | |
| def try_fit(remaining_grid, pieces_left, placements, domino_used=False): | |
| if not remaining_grid: | |
| return placements | |
| target = min(remaining_grid) | |
| # Try hexominos first | |
| for name, count in pieces_left.items(): | |
| if count <= 0: | |
| continue | |
| base_shape = HEXOMINOS[name] | |
| for orientation in all_orientations(base_shape): | |
| for piece_cell in orientation: | |
| offset = (target[0] - piece_cell[0], target[1] - piece_cell[1]) | |
| placed = can_place(orientation, offset, remaining_grid) | |
| if placed: | |
| new_pieces = pieces_left.copy() | |
| new_pieces[name] -= 1 | |
| result = try_fit(remaining_grid - placed, new_pieces, | |
| placements + [Placement(name, placed)], domino_used) | |
| if result is not None: | |
| return result | |
| # Try domino (gyfu) if not yet used | |
| if not domino_used: | |
| for name, base_shape in DOMINOS.items(): | |
| for orientation in all_orientations(base_shape): | |
| for piece_cell in orientation: | |
| offset = (target[0] - piece_cell[0], target[1] - piece_cell[1]) | |
| placed = can_place(orientation, offset, remaining_grid) | |
| if placed: | |
| result = try_fit(remaining_grid - placed, pieces_left, | |
| placements + [Placement(name, placed)], True) | |
| if result is not None: | |
| return result | |
| return None | |
| return try_fit(grid, pieces_needed, []) | |
| def print_solution(solution: List[Placement], grid_shape: Shape = None): | |
| """Pretty-print the solution using Unicode rune characters with ANSI colors.""" | |
| if grid_shape is None: | |
| grid_shape = WARPAINT | |
| if not solution: | |
| print("No solution found!") | |
| return | |
| max_row = max(r for r, c in grid_shape) | |
| max_col = max(c for r, c in grid_shape) | |
| # Create display grid with (glyph, color) tuples | |
| display = [[(' ', '') for _ in range(max_col + 1)] for _ in range(max_row + 1)] | |
| for r, c in grid_shape: | |
| display[r][c] = ('·', DIM) | |
| # Assign colors to each unique piece placement | |
| piece_colors = {} | |
| color_idx = 0 | |
| for placement in solution: | |
| if id(placement) not in piece_colors: | |
| piece_colors[id(placement)] = COLORS[color_idx % len(COLORS)] | |
| color_idx += 1 | |
| # Place runes with colors | |
| for placement in solution: | |
| glyph = RUNE_GLYPHS.get(placement.name, '?') | |
| color = piece_colors[id(placement)] | |
| for r, c in placement.coords: | |
| display[r][c] = (glyph, color) | |
| print("\nSOLUTION:") | |
| print("=" * ((max_col + 1) * 2 + 1)) | |
| for row in display: | |
| line = '|' | |
| for glyph, color in row: | |
| line += f"{color}{glyph}{RESET} " | |
| line = line.rstrip() + '|' | |
| print(line) | |
| print("=" * ((max_col + 1) * 2 + 1)) | |
| print("\nPieces used:") | |
| piece_counts = {} | |
| piece_color_map = {} | |
| for p in solution: | |
| if p.name not in piece_counts: | |
| piece_counts[p.name] = 0 | |
| piece_color_map[p.name] = piece_colors[id(p)] | |
| piece_counts[p.name] += 1 | |
| for name, count in sorted(piece_counts.items()): | |
| glyph = RUNE_GLYPHS.get(name, '?') | |
| color = piece_color_map[name] | |
| print(f" {color}{glyph}{RESET} {name}: {count}") | |
| total_hex = sum(1 for p in solution if p.name in HEXOMINOS) | |
| total_tet = sum(1 for p in solution if p.name in TETROMINOS) | |
| total_dom = sum(1 for p in solution if p.name in DOMINOS) | |
| print(f"\nTotal: {total_hex} hexominos, {total_tet} tetrominos, {total_dom} dominos") | |
| # ============================================================================= | |
| # MAIN - Example usage | |
| # ============================================================================= | |
| if __name__ == "__main__": | |
| import sys | |
| if len(sys.argv) > 1: | |
| # Use command-line arguments as piece names | |
| pieces = sys.argv[1:] | |
| # Validate piece names | |
| valid_pieces = set(HEXOMINOS.keys()) | |
| invalid = [p for p in pieces if p not in valid_pieces] | |
| if invalid: | |
| print(f"❌ Unknown rune(s): {', '.join(invalid)}") | |
| print(f"Valid runes: {', '.join(sorted(valid_pieces))}") | |
| sys.exit(1) | |
| if len(pieces) != 9: | |
| print(f"⚠️ Warning: You provided {len(pieces)} runes (expected 9)") | |
| print(f" 9 hexominos × 6 cells = 54 cells") | |
| print(f" Grid has 56 cells, so gyfu (domino) fills the remaining 2") | |
| print() | |
| print(f"Testing: {' '.join(pieces)}") | |
| result = can_fit_pieces(pieces) | |
| if result: | |
| print("✅ These runes fit!") | |
| print_solution(result) | |
| else: | |
| print("❌ These runes cannot fit in the Warpaint grid.") | |
| sys.exit(1) | |
| else: | |
| # No arguments - show help | |
| print("Warpaint Grid Checker - Asgard's Fall") | |
| print() | |
| print("Usage: python can_fit_pieces.py <rune1> <rune2> ... <rune9>") | |
| print() | |
| print(f"Grid: {len(WARPAINT)} cells (9 hexominos + 1 domino)") | |
| print() | |
| print("Available runes (hexominos):") | |
| for name in sorted(HEXOMINOS.keys()): | |
| glyph = RUNE_GLYPHS.get(name, '?') | |
| print(f" {glyph} {name}") | |
| print() | |
| print("Example:") | |
| print(" python can_fit_pieces.py algiz mannaz laguz tiwaz tiwaz hagalaz hagalaz sowilo sowilo") | |