diff options
author | Aineopintojen-harjoitustyo-Algoritmit-j <github-hy-tiralabra@v.hix.fi> | 2024-02-17 09:41:48 +0200 |
---|---|---|
committer | Aineopintojen-harjoitustyo-Algoritmit-j <github-hy-tiralabra@v.hix.fi> | 2024-02-17 09:41:48 +0200 |
commit | e785dbd4f726c5716f21071ed25dc35ac87c0c74 (patch) | |
tree | 781373b78380a1ffd1ea8c5dc8ceb2bd313631e9 /src/miinaharava | |
parent | 4eff4a32cfa594cc2a3df3885de92d407edc6675 (diff) |
Dev tools and directory structure rework.
Diffstat (limited to 'src/miinaharava')
-rw-r--r-- | src/miinaharava/__main__.py | 57 | ||||
-rw-r--r-- | src/miinaharava/app.py | 42 | ||||
-rw-r--r-- | src/miinaharava/board/__init__.py | 4 | ||||
-rw-r--r-- | src/miinaharava/board/board.py | 252 | ||||
-rw-r--r-- | src/miinaharava/board/static.py | 34 | ||||
-rw-r--r-- | src/miinaharava/bots/__init__.py | 4 | ||||
-rw-r--r-- | src/miinaharava/bots/bot.py | 142 | ||||
-rw-r--r-- | src/miinaharava/bots/dssp.py | 75 | ||||
-rw-r--r-- | src/miinaharava/bots/simple.py | 30 | ||||
-rw-r--r-- | src/miinaharava/cmdline.py | 117 | ||||
-rw-r--r-- | src/miinaharava/game/__init__.py | 2 | ||||
-rw-r--r-- | src/miinaharava/game/game.py | 41 | ||||
-rw-r--r-- | src/miinaharava/tests/__init__.py | 0 | ||||
-rw-r--r-- | src/miinaharava/tests/test_app.py | 241 | ||||
-rw-r--r-- | src/miinaharava/tests/test_board.py | 295 | ||||
-rw-r--r-- | src/miinaharava/tests/test_bot.py | 54 | ||||
-rw-r--r-- | src/miinaharava/tui/__init__.py | 3 | ||||
-rw-r--r-- | src/miinaharava/tui/ansi.py | 46 | ||||
-rw-r--r-- | src/miinaharava/tui/ansi_draw.py | 58 | ||||
-rw-r--r-- | src/miinaharava/tui/kbd.py | 86 | ||||
-rw-r--r-- | src/miinaharava/tui/static.py | 77 | ||||
-rw-r--r-- | src/miinaharava/tui/tui.py | 109 |
22 files changed, 1769 insertions, 0 deletions
diff --git a/src/miinaharava/__main__.py b/src/miinaharava/__main__.py new file mode 100644 index 0000000..ba7a25f --- /dev/null +++ b/src/miinaharava/__main__.py @@ -0,0 +1,57 @@ +""" __main__.py - Tästä suoritus alkaa """ +import sys + +from app import App +from cmdline import args + +vars(args)['board'] = None + +if args.count is None and args.file is None: + app = App(args) + IS_WIN = app.run() + del app + sys.exit(not IS_WIN) # Exit koodeissa 0 on onnistunut suoritus + +WIN_COUNT = 0 + +if args.file is None: + args.autoplay = 2 + RUN_COUNT = args.count + for i in range(RUN_COUNT): + print(end=f" \rSuoritus {i+1:>6}/{RUN_COUNT} ") + print(end=f"({100*WIN_COUNT/(i if i else 1):.1f}%)..") + if not args.quiet: + print() + app = App(args) + WIN_COUNT+=app.run() + del app +else: + RUN_COUNT = 0 + with open(args.file, "r", encoding="utf-8") as bfile: + board = [] + while True: + line = bfile.readline() + if not line or (line[0]!='.' and line[0]!='@'): + if board: + WIN_PERCENT = (100*WIN_COUNT/RUN_COUNT) if RUN_COUNT else 0 + print(end= + f" \rAjo ...{args.file[-18:]:} ({RUN_COUNT+1}): " + f"({WIN_PERCENT:.1f}%).." + ) + if not args.quiet: + print() + args.board = board + app = App(args) + WIN_COUNT += app.run() + RUN_COUNT += 1 + del app + board = [] + if not line: + break + continue + board.append([x=='@' for x in line if x in ('.', '@')]) + +print( + f"\n## Voittoja {WIN_COUNT}/{RUN_COUNT} " + f"({(100*WIN_COUNT/RUN_COUNT) if RUN_COUNT else 0:.1f}%)" +) diff --git a/src/miinaharava/app.py b/src/miinaharava/app.py new file mode 100644 index 0000000..8553fe5 --- /dev/null +++ b/src/miinaharava/app.py @@ -0,0 +1,42 @@ +""" app.py - pääohjelma """ +from board import Board, Level +from tui import Tui +from game import Game +from bots import SimpleBot, DSSPBot + +# pylint: disable = too-few-public-methods +class App: + """ App - Luokka pääohjelmalle""" + def __init__(self, args=None): + board_opts = {'level': Level.BEGINNER} + tui_opts = {'bot': DSSPBot} + if args: + # pylint: disable = multiple-statements + if args.intermediate: board_opts['level'] = Level.INTERMEDIATE + if args.expert: board_opts['level'] = Level.EXPERT + if args.board: + board_opts['board'] = args.board + else: + if args.mines: board_opts['mines'] = args.mines + if args.size: + board_opts['width'] = args.size[0] + board_opts['height'] = args.size[1] + + if args.bot==0: tui_opts['bot'] = None + if args.bot==1: tui_opts['bot'] = SimpleBot + tui_opts['autoplay'] = args.autoplay > 0 + tui_opts['interactive'] = args.autoplay != 2 + tui_opts['suppress'] = args.quiet + tui_opts['delay'] = args.delay + + self.board = Board(**board_opts) + tui_opts['level_name'] = self.board.get_level_name() + tui_opts['height'] = self.board.get_height() + self.ui = Tui(**tui_opts) + self.game = Game(self.board,self.ui) + + def run(self): + """ käynnistää pelin """ + while self.game.next(): + pass + return self.board.is_winning() diff --git a/src/miinaharava/board/__init__.py b/src/miinaharava/board/__init__.py new file mode 100644 index 0000000..ba87812 --- /dev/null +++ b/src/miinaharava/board/__init__.py @@ -0,0 +1,4 @@ +""" board - tämä alimoduli hoitaa pelilaudan käsittelyn sääntöjen mukaan """ + +from .board import Board +from .static import Level, LevelSpecs, Tile diff --git a/src/miinaharava/board/board.py b/src/miinaharava/board/board.py new file mode 100644 index 0000000..36ceb62 --- /dev/null +++ b/src/miinaharava/board/board.py @@ -0,0 +1,252 @@ +""" board/board.py - Pelilaudan käsittelyyn tarkoitetut jutut """ +from random import randrange +from sys import stderr +from copy import deepcopy + +from .static import Level, Tile, LevelSpecs + + +class Board(): + """ Board - Luokka joka pitää huolen pelilaudasta ja siihen kohdistuvista + siirroista. + """ + def __init__(self, + level = Level.BEGINNER, + width = None, + height = None, + mines = None, + board = None): + # pylint: disable = too-many-arguments + + if ( not width or not height or + width not in range(2,51) or + height not in range(2,51) ): + width, height = None, None + + self.__level = level + self.__width, self.__height, self.__mines =LevelSpecs[self.__level][:3] + + if width: + self.__width = width + if height: + self.__height = height + if height or width or mines: + self.__mines = mines + + if self.__mines not in range(1,self.__width*self.__height): + self.__mines = self.__width + + + if board and self.__validate_board(board): + self.__width, self.__height = self.__get_board_dimensions(board) + self.__mines = self.__get_board_mines(board) + else: + board = None + + for _, specs in LevelSpecs.items(): + if (self.__width, self.__height, self.__mines) == specs[:3]: + self.__level_name = specs[3] + break + else: + self.__level_name = "Mukautettu" + + self.__level_name += ( f" ({self.__width}x{self.__height}" + f", {self.__mines} miinaa)" ) + + self.__tiles = None + self.__masked = None + + self.__initialize_tiles() + if board: + self.__populate_with_board(board) + else: + self.__randomize_mines() + self.__calculate_neighbours() + + def __validate_board(self, board): + w = len(board[0]) + h = len(board) + if w not in range(2,51) or h not in range(2,51): + return False + for line in board: + if len(line)!=w: + return False + if self.__get_board_mines(board) not in range (1, w*h): + return False + return True + + + def __get_board_dimensions(self, board): + return len(board[0]), len(board) + + + def __get_board_mines(self, board): + return sum((sum(x) for x in board)) + + + def __populate_with_board(self, board): + for y in range(self.__height): + for x in range(self.__width): + if board[y][x]: + self.__tiles[x][y] = Tile.MINE + + def __initialize_tiles(self): + """ alustaa pelilaudan matriisit """ + w, h = self.__width, self.__height + self.__tiles = [[Tile.BLANK for _ in range(h)] for _ in range(w)] + self.__masked = [[Tile.UNOPENED for _ in range(h)] for _ in range(w)] + + + def __randomize_mines(self): + """ arpoo pelilaudalle pommit """ + for _ in range(self.__mines): + while True: + x, y = randrange(0,self.__width), randrange(0,self.__height) + if self.__tiles[x][y] != Tile.BLANK: + continue + self.__tiles[x][y] = Tile.MINE + break + + + def __calculate_neighbours(self): + """ laskee naapurissa olevien pommien määrät valmiiksi laudalle """ + for y in range(self.__height): + for x in range(self.__width): + if self.__tiles[x][y] == Tile.MINE: + continue + neighbouring_mines = 0 + for nx, ny in self.get_neighbours_coords(x,y): + if self.__tiles[nx][ny] == Tile.MINE: + neighbouring_mines += 1 + self.__tiles[x][y] = neighbouring_mines + + + def invalid_coordinates(self, x, y): + """ onko koordinaatit pelilaudan ulkopuolella """ + return x < 0 or x >= self.__width or y < 0 or y >= self.__height + + + def get_neighbours_coords(self, x, y, include_home = False): + """ antaa listan naapureiden koordinaateista """ + offsets = ( + (-1,-1), (0,-1), (1,-1), + (-1, 0), (0, 0), (1, 0), + (-1, 1), (0, 1), (1, 1) + ) if include_home else ( + (-1,-1), (0,-1), (1,-1), + (-1, 0), (1, 0), + (-1, 1), (0, 1), (1, 1) + ) + coordinates=[] + for dx,dy in offsets: + if not self.invalid_coordinates(x+dx, y+dy): + coordinates.append( (x+dx, y+dy) ) + return coordinates + + + def get_view(self): + """ antaa matriisin nykyisestä pelinäkymästä """ + view = deepcopy(self.__masked) + for y in range(self.__height): + for x in range(self.__width): + if not view[x][y]: + view[x][y]=self.__tiles[x][y] + return view + + + def is_winning(self): + """ tarkistaa onko peli voitettu """ + for y in range(self.__height): + for x in range(self.__width): + if self.__tiles[x][y] == Tile.MINE: + if not self.__masked[x][y]: + return False + else: + if self.__masked[x][y]: + return False + return True + + + def collect_area(self, x, y, area = None): + """ tunnustelee ja palauttaa tyhjän alueen koordinaatit """ + if area is None: + area = {(x,y)} + to_test = [] + for nx, ny in self.get_neighbours_coords(x, y): + if self.__tiles[nx][ny] == Tile.BLANK and (nx,ny) not in area: + to_test.append((nx, ny)) + area.add((nx, ny)) + for tx, ty in to_test: + area=area.union(self.collect_area(tx, ty, area)) + return area + + + def get_mask(self, x, y): + """ onko ruutu vielä piilossa """ + return self.__masked[x][y] + + + def flag(self, x, y, flag=-1): + """ aseta lippu peitetylle ruudulle""" + if self.invalid_coordinates(x, y): + print("Koordinaatit on pelilaudan ulkopuolella", file=stderr) + return False + + if self.__masked[x][y] not in range(10,14): + print("Ruudulla odottamaton lippu tai se on avattu", file=stderr) + return False + + if flag == -1: + self.__masked[x][y] += 1 if self.__masked[x][y] < 13 else -3 + return True + + if flag not in range(10,14): + print("Lippua jota asetat ei ole olemassa", file=stderr) + return False + + self.__masked[x][y]=flag + return True + + + def guess(self, x, y): + """ tee arvaus """ + if self.invalid_coordinates(x, y): + print("Koordinaatit on pelilaudan ulkopuolella", file=stderr) + return False + + if not self.__masked[x][y]: + print("Ruutu on jo avattu", file=stderr) + return False + + self.__masked[x][y] = 0 + + if self.__tiles[x][y] == Tile.MINE: + return False + + if self.__tiles[x][y] == Tile.BLANK: + for cx, cy in self.collect_area( x, y ): + for nx, ny in self.get_neighbours_coords(cx, cy, True): + self.__masked[nx][ny] = 0 + + return True + + def reveal(self): + """ näytä koko lauta """ + w, h = self.__width, self.__height + self.__masked = [[0 for _ in range(h)] for _ in range(w)] + + def get_width(self): + """ palauttaa laudan leveyden """ + return self.__width + + def get_height(self): + """ palauttaa laudan korkeuden """ + return self.__height + + def get_mines(self): + """ palauttaa pommien määrän """ + return self.__mines + + def get_level_name(self): + """ palauttaa vaikesutason nimen""" + return self.__level_name diff --git a/src/miinaharava/board/static.py b/src/miinaharava/board/static.py new file mode 100644 index 0000000..4804ded --- /dev/null +++ b/src/miinaharava/board/static.py @@ -0,0 +1,34 @@ +""" board/static.py - määrittelyjä pelilaudan muuttumattomille asoille """ + +from enum import Enum, IntEnum + +class Level(Enum): + """ vaikeustasot """ + BEGINNER = 0 + INTERMEDIATE = 1 + EXPERT = 2 + + +class Tile(IntEnum): + """ alueiden selitteet """ + BLANK = 0 + ONE = 1 + TWO = 2 + THREE = 3 + FOUR = 4 + FIVE = 5 + SIX = 6 + SEVEN = 7 + EIGHT = 8 + MINE = 9 + FLAG_MINE = 10 + FLAG_FREE = 11 + UNOPENED = 12 + FLAG_UNKNOWN = 13 + + +LevelSpecs = { + Level.BEGINNER: ( 9, 9, 10, "Aloittelija"), + Level.INTERMEDIATE: ( 16, 16, 40, "Keskivaikea"), + Level.EXPERT: ( 30, 16, 99, "Edistynyt") +} diff --git a/src/miinaharava/bots/__init__.py b/src/miinaharava/bots/__init__.py new file mode 100644 index 0000000..f346d07 --- /dev/null +++ b/src/miinaharava/bots/__init__.py @@ -0,0 +1,4 @@ +""" bots - tämä alimoduli tarjoaa tekoälyn """ + +from .simple import SimpleBot +from .dssp import DSSPBot diff --git a/src/miinaharava/bots/bot.py b/src/miinaharava/bots/bot.py new file mode 100644 index 0000000..2f3baae --- /dev/null +++ b/src/miinaharava/bots/bot.py @@ -0,0 +1,142 @@ +""" bots/bot.py - bottien kantaisä """ +from tui import Action +from board import Tile + +class Bot(): + """ Bot - perusluokka perittäväksi """ + def __init__(self, **opts): + self.uncertain = opts['uncertain'] if 'uncertain' in opts else False + self.safe_tiles = set() + self.mine_tiles = set() + self.matrix = [] + self.w = 0 + self.h = 0 + + def search(self): + """ search - etsii pommeja tai vapaita ko joukkoihin """ + return False + + def lucky_guess(self): + """ lucky_guess - lisää yhden arvatun vapaan vapaiden joukkoon """ + return Action.NOOP, 0, 0 + + def get_hint_from_list(self): + """ palauttaa vihjeen suoraan listalta """ + if self.safe_tiles: + x, y = self.safe_tiles.pop() + return Action.SAFE, x, y + if self.mine_tiles: + x, y = self.mine_tiles.pop() + return Action.MINE, x, y + return Action.NOOP, 0, 0 + + def saved_hints(self): + """ poistetaan auenneet laatat ja palautetaan onko muuveja """ + for tile in list(self.safe_tiles): + if self.known_tile(tile): + self.safe_tiles.remove(tile) + for tile in list(self.mine_tiles): + if self.known_tile(tile): + self.mine_tiles.remove(tile) + return self.safe_tiles or self.mine_tiles + + def hint(self, matrix, cursor_x, cursor_y): + """ antaa vinkin. tässä tapauksessa ei mitään """ + self.matrix = matrix + self.w, self.h = self.get_dimensions() + + if self.saved_hints(): + return self.get_hint_from_list() + if self.search(): + return self.get_hint_from_list() + if self.uncertain and self.lucky_guess(): + return self.get_hint_from_list() + return Action.NOOP, cursor_x, cursor_y + + def get_dimensions(self): + """ palauttaa matriisin dimensiot """ + return len(self.matrix), len(self.matrix[0]) + + def get_neighbours(self, tile): + """ palauttaa viereiset koordinaatit joukkona """ + x, y = tile + offsets = ( + (-1, -1), ( 0, -1), ( 1, -1), + (-1, 0), ( 1, 0), + (-1, 1), ( 0, 1), ( 1, 1), + ) + tiles=set() + for ox, oy in offsets: + if ox+x in range(self.w): + if oy+y in range(self.h): + tiles.add((ox+x, oy+y)) + return tiles + + def get_value(self, tile): + """ palauttaa laatan arvon """ + return self.matrix[tile[0]][tile[1]] + + def remove_number_tiles(self, tiles): + """ poistaa vapaat ja vapaaksi merkityt alueet ja numerolaatat """ + for tile in list(tiles): + if self.matrix[tile[0]][tile[1]] < Tile.FLAG_MINE: + tiles.remove(tile) + + def remove_mine_tiles(self, tiles): + """ poistaa pommit ja pommiksi merkityt """ + count=0 + for tile in list(tiles): + if self.matrix[tile[0]][tile[1]] in (Tile.MINE, Tile.FLAG_MINE): + tiles.remove(tile) + count+=1 + return count + + def known_tile(self, tile): + """ tutkii onko laatta tiedetty """ + return self.matrix[tile[0]][tile[1]] < Tile.UNOPENED + + def number_tile(self, tile): + """ tutkii onko numerolaatta """ + return 0 < self.matrix[tile[0]][tile[1]] < Tile.MINE + + def count_unknowns(self, tiles): + """ laskee tunnistamattomat laatat """ + count=0 + for tile in list(tiles): + if not self.known_tile(tile): + count+=1 + return count + + + def get_interesting_tiles(self): + """ palauttaa laatat joiden naapureissa on vaihtelua """ + tiles = set() + for x in range(self.w): + for y in range(self.h): + if self.number_tile((x,y)): + n = self.get_neighbours((x,y)) + l = len(n) + r = self.count_unknowns(n) + if r in range(1,l-1): + tiles.add((x,y)) + return tiles + + def get_border_tiles(self): + """ palauttaa palauttaa numerolaatat joiden vieressä avaamaton """ + tiles = set() + for x in range(self.w): + for y in range(self.h): + if self.number_tile((x,y)): + n = self.get_neighbours((x,y)) + if self.count_unknowns(n): + tiles.add((x,y)) + return tiles + + def get_unknown_tiles(self): + """ palauttaa kaikki tuntemattomat laatat """ + tiles = set() + for x in range(self.w): + for y in range(self.h): + if not self.known_tile((x,y)): + tiles.add((x,y)) + return tiles diff --git a/src/miinaharava/bots/dssp.py b/src/miinaharava/bots/dssp.py new file mode 100644 index 0000000..1815b49 --- /dev/null +++ b/src/miinaharava/bots/dssp.py @@ -0,0 +1,75 @@ +""" bots/dssp.py - päättelee kahden vierekkäisen laatan perusteella """ +from random import sample + +from .simple import SimpleBot + +class DSSPBot(SimpleBot): + """ DSSPBot - perustyhmä botti """ + + def search(self): + """ search - etsii kahden vierekkäisen laatan perusteella""" + if super().search(): + return True + tiles = list(self.get_interesting_tiles()) + pairs = [] + # pylint: disable = consider-using-enumerate + for i in range(len(tiles)): + for j in range(i+1,len(tiles)): + if abs(tiles[i][0]-tiles[j][0])==1 or abs(tiles[i][1]-tiles[j][1])==1: + pairs.append((tiles[i],tiles[j])) + pairs.append((tiles[j],tiles[i])) + + for tile1, tile2 in pairs: + c1 = self.get_value(tile1) + c2 = self.get_value(tile2) + n1 = self.get_neighbours(tile1) + n2 = self.get_neighbours(tile2) + self.remove_number_tiles(n1) + self.remove_number_tiles(n2) + c1 -= self.remove_mine_tiles(n1) + c2 -= self.remove_mine_tiles(n2) + + # otetaan vain alue1:n laatat pois vähennetään se pommeista + # näin tiedetään montako pommia on jäätävä yhteiselle alueelle + nc = n1 & n2 + n1 = n1 - nc + n2 = n2 - nc + cc = c1 - len(n1) + + # jos yhteiselle alueelle ei jääkkään pommeja + if cc < 1: + continue + + # vähennetään yhteinen alue ja sen pommit alueesta 2 + # jos jäljelle ei jää miinoja merkataan alueet seiffeiks + c2 -= cc + + if c2 == 0: + for safe in n2: + self.safe_tiles.add(safe) + + return self.saved_hints() + + def lucky_guess(self): + heatmap = dict.fromkeys(self.get_unknown_tiles(), float(0)) + tiles = self.get_border_tiles() + for tile in tiles: + n = self.get_neighbours(tile) + c = self.get_value(tile) - self.remove_mine_tiles(n) + self.remove_number_tiles(n) + for ntile in n: + heatmap[ntile] += c/len(n) + + for tile in heatmap: + if tile[0] in range(1,self.w-1): + heatmap[tile]+=0.005 + if tile[1] in range(1,self.h-1): + heatmap[tile]+=0.005 + + best = min((x for _, x in heatmap.items())) + best_tiles = [x for x,y in heatmap.items() if y == best] + + if best_tiles: + self.safe_tiles.add(sample(best_tiles,1)[0]) + return True + return False diff --git a/src/miinaharava/bots/simple.py b/src/miinaharava/bots/simple.py new file mode 100644 index 0000000..46b9506 --- /dev/null +++ b/src/miinaharava/bots/simple.py @@ -0,0 +1,30 @@ +""" bots/simple.py - yksinkertainen botti joka etsii vain yhdeltä laatalta """ +from random import sample + +from .bot import Bot + +class SimpleBot(Bot): + """ SimpleBot - perustyhmä botti """ + + def search(self): + """ simple_search - jos viereisten avaamattomien määrä tästmää """ + tiles = self.get_interesting_tiles() + for tile in tiles: + c = self.get_value(tile) + n = self.get_neighbours(tile) + self.remove_number_tiles(n) + c -= self.remove_mine_tiles(n) + if c == 0: + for safe in n: + self.safe_tiles.add(safe) + if c == len(n): + for mine in n: + self.mine_tiles.add(mine) + return self.saved_hints() + + def lucky_guess(self): + tiles = self.get_unknown_tiles() + if tiles: + self.safe_tiles.add(sample(sorted(tiles),1)[0]) + return True + return False diff --git a/src/miinaharava/cmdline.py b/src/miinaharava/cmdline.py new file mode 100644 index 0000000..533c40f --- /dev/null +++ b/src/miinaharava/cmdline.py @@ -0,0 +1,117 @@ +""" cmdline.py - komentorivin käsittely argparse moduulilla """ +import sys +from argparse import ArgumentParser + +from tui import KEY_DESCRIPTIONS + +parser = ArgumentParser( + prog='miinaharava', + description='Klassisen miinaharavapelin terminaali toteutus.', + add_help=False +) +level_group = parser.add_argument_group('Vaikeustaso') +level_group.add_argument( + '-i', '--intermediate', + help='keskivaikea (oletus on aloittelija)', + action='store_true' +) +level_group.add_argument( + '-e', '--expert', + help='edistynyt (vaatii 100 merkkiä leveän terminaalin)', + action='store_true' +) + + +custom_group = parser.add_argument_group('Mukautettu vaikeustaso') +def board_size(wxh_string): + """ parsija laudan koolle, että on varmasti muotoa {leveys}x{korkeys} """ + w, h = wxh_string.split('x') + return (int(w), int(h)) +custom_group.add_argument( + '-s', '--size', + metavar='<S>', + type= board_size, + dest='size', + help='Pelikentän koko, missä <S> on {leveys}x{korkeus}.' +) +custom_group.add_argument( + '-m', '--mines', + metavar='<M>', + type=int, + dest='mines', + help='Säätää pelilaulla olevien miinojen määrän <M>:ksi.', +) + + +hint_group = parser.add_argument_group('Tekoäly') +hint_group.add_argument( + '-a', '--auto', + dest='autoplay', + default=0, + action='count', + help='Pelaa tekoälyn vihjeet. [-aa] Pelaa myös epävarmat.' +) +hint_group.add_argument( + '-b', '--bot', metavar='<B>', + choices=range(3), + type=int, + default=2, + help='Valitsee tekoälyn <B>, missä: 0: Ei tekoälyä 1: Yksinkertainen, 2: DSSP (oletus)', +) +hint_group.add_argument( + '-d', '--delay', metavar='<D>', + type=float, + help='Odottaa ennen tekoälyn siirtoa <D> sadasosasekuntia.', +) + +batch_group = parser.add_argument_group('Automatisointi') +batch_group.add_argument( + '-q', '--quiet', + help='Tulostaa minimaalisesti (asettaa myös [-aa])', + action='store_true' +) +batch_group.add_argument( + '-c', '--count', + metavar='<C>', + type=int, + dest='count', + help='Suorittaa ohelmaa <C> kertaa ja tulostaa voitto-osuuden.', +) + +def filename( fn_string ): + """ filename - parser tyyppi joka testaa saako tiedoston auki """ + try: + with open(fn_string, "r", encoding="utf-8"): + pass + except FileNotFoundError as e: + raise ValueError from e + return fn_string +batch_group.add_argument( + '-f', '--file', + metavar='<F>', + type=filename, + dest='file', + help='Pelaa tiedostossa <F> olevat miinaharavakentät.', +) + +misc_group = parser.add_argument_group('Sekalaista') +misc_group.add_argument( + '-h', '--help', + help='Tulostaa tämän viestin', + action='store_true' +) +misc_group.add_argument( + '-k', '--keys', + help='Tulostaa pelin näppäinkartan.', + action='store_true' +) + +args = parser.parse_args() + +if args.help: + parser.print_help() + sys.exit() + +if args.keys: + print(end=KEY_DESCRIPTIONS) + sys.exit() diff --git a/src/miinaharava/game/__init__.py b/src/miinaharava/game/__init__.py new file mode 100644 index 0000000..066e2e3 --- /dev/null +++ b/src/miinaharava/game/__init__.py @@ -0,0 +1,2 @@ +""" game - pelin kulkuun liittyvä logiikka """ +from .game import Game diff --git a/src/miinaharava/game/game.py b/src/miinaharava/game/game.py new file mode 100644 index 0000000..fefcf8f --- /dev/null +++ b/src/miinaharava/game/game.py @@ -0,0 +1,41 @@ +""" game/game.py - pelin etenemiseen liittyvä ohjaus """ +from tui import Action + +class Game: + """ Game - peli """ + def __init__(self, board, ui): + self.board = board + self.ui = ui + self.x, self.y = board.get_width()//2, board.get_height()//2 + + + def __del__(self): + self.board.reveal() + self.ui.game_end(self.board.get_view()) + + + def next(self): + """ seuraava kiitos vai jotain muuta? """ + action, self.x, self.y = self.ui.matrix_selector( + self.board.get_view(), self.x, self.y + ) + match action: + case Action.QUIT: + return False + case Action.OPEN: + if self.board.get_mask(self.x, self.y): + if not self.board.guess(self.x, self.y): + self.ui.game_over( + self.board.get_view(), self.x, self.y + ) + return False + if self.board.is_winning(): + self.ui.game_win(self.board.get_view(), self.x, self.y) + return False + case Action.FLAG: + self.board.flag(self.x, self.y) + case Action.MINE: + self.board.flag(self.x, self.y, 10) + case Action.SAFE: + self.board.flag(self.x, self.y, 11) + return True diff --git a/src/miinaharava/tests/__init__.py b/src/miinaharava/tests/__init__.py new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/miinaharava/tests/__init__.py diff --git a/src/miinaharava/tests/test_app.py b/src/miinaharava/tests/test_app.py new file mode 100644 index 0000000..1d13a71 --- /dev/null +++ b/src/miinaharava/tests/test_app.py @@ -0,0 +1,241 @@ +"""test_app.py - Testaa pelin suoritusta""" +# pylint: disable = missing-class-docstring, too-few-public-methods + +from io import StringIO +import unittest +from unittest.mock import patch + +from app import App + +from tui import Action + + +class KbdTest: + # pylint: disable = unused-argument, missing-function-docstring + def __init__(self, actions): + self.actions = actions + def read_action(self): + if self.actions: + action, _, _ = self.actions.pop(0) + else: + action = Action.NOOP + return action + def read_matrix_action(self, w, h, x, y): + return self.actions.pop(0) if self.actions else (Action.NOOP,x,y) + + +class TestAppClass(unittest.TestCase): + """ Testit itse appille """ + class DefaultArgs: + autoplay = 2 + intermediate = None + expert = None + board = None + mines = None + size = None + bot = None + quiet = None + delay = None + + sure_win_board = [ + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,1,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0] + ] + + sure_lose_board = [ + [1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1], + [1,1,1,1,0,1,1,1,1], + [1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1], + [1,1,1,1,1,1,1,1,1] + ] + + dssp_win_board = [ + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,0,0,0,0,0,0,0,0], + [0,1,1,0,1,1,0,1,0] + ] + + mini_board = [ + [0,0], + [0,1] + ] + + + def test_run(self): + """ Testataan että edes pyörähtää """ + app = App(self.DefaultArgs) + app.run() + del app + + def test_quit(self): + """ Testataan Quittaamista """ + app = App() + app.ui.kbd=KbdTest([ + (Action.QUIT,0,0), + (Action.OPEN,0,0) + ]) + app.run() + del app + + def test_many_games(self): + """ Varman voiton lauta palauttaa true """ + class Args(self.DefaultArgs): + quiet = True + for _ in range(50): + app = App(Args) + app.run() + del app + Args.intermediate = True + for _ in range(20): + app = App(Args) + app.run() + del app + Args.expert = True + for _ in range(10): + app = App(Args) + app.run() + del app + + def test_sure_win(self): + """ Varman voiton lauta palauttaa true """ + class Args(self.DefaultArgs): + board = self.sure_win_board + quiet = True + app = App(Args) + self.assertTrue(app.run()) + del app + + def test_dssp_win(self): + """ Varman voiton lauta palauttaa true """ + class Args(self.DefaultArgs): + board = self.dssp_win_board + app = App(Args) + self.assertTrue(app.run()) + del app + + def test_no_dssp_win_with_simple(self): + """ Varman voiton lauta palauttaa true """ + class Args(self.DefaultArgs): + board = self.dssp_win_board + quiet = True + bot = 1 + while True: + app = App(Args) + if not app.run(): + break + del app + + def test_sure_lose(self): + """ Varman häviön lauta palauttaa false """ + class Args(self.DefaultArgs): + board = self.sure_lose_board + app = App(Args) + self.assertFalse(app.run()) + del app + + def test_custom_size(self): + """ Varman häviön lauta palauttaa false """ + class Args(self.DefaultArgs): + size = (4, 4) + with patch('sys.stdout', new = StringIO()) as captured: + app = App(Args) + app.run() + self.assertIn("Mukautettu (4x4", captured.getvalue()) + del app + + def test_sure_win_with_actions(self): + """ Varman voiton lauta palauttaa true """ + class Args(self.DefaultArgs): + board = self.sure_win_board + autoplay = 0 + bot = 0 + app = App(Args) + app.ui.kbd=KbdTest([ + (Action.SAFE,0,0), + (Action.OPEN,0,0) + ]) + self.assertTrue(app.run()) + del app + + def test_sure_lose_with_actions(self): + """ Varman voiton lauta palauttaa true """ + class Args(self.DefaultArgs): + board = self.sure_lose_board + autoplay = 0 + app = App(Args) + app.ui.kbd=KbdTest([ + (Action.FLAG,0,0), + (Action.MINE,0,0), + (Action.OPEN,0,0) + ]) + self.assertFalse(app.run()) + del app + + def test_auto_play_hints(self): + """ Vihjeiden automaattipelaaminen toimii """ + class Args(self.DefaultArgs): + board = self.dssp_win_board + autoplay = 1 + app = App(Args) + app.ui.kbd=KbdTest([ + (Action.OPEN,0,0), + (Action.HINT,0,0), + ]) + self.assertTrue(app.run()) + del app + + def test_delay(self): + """ Hidastus toimii """ + class Args(self.DefaultArgs): + board = self.dssp_win_board + delay = 5 + app = App(Args) + with patch('time.sleep') as patched_sleep: + self.assertTrue(app.run()) + del app + patched_sleep.assert_called() + + def test_delay_can_be_off(self): + """ Hidastus ei ole aina päälle """ + class Args(self.DefaultArgs): + board = self.dssp_win_board + app = App(Args) + with patch('time.sleep') as patched_sleep: + self.assertTrue(app.run()) + del app + patched_sleep.assert_not_called() + + def test_botless_play(self): + """ Hidastus toimii """ + class Args(self.DefaultArgs): + board = self.mini_board + autoplay = 0 + delay = 50000 + app = App(Args) + app.ui.kbd=KbdTest([ + (Action.OPEN,0,0), + (Action.HINT,0,0), + (Action.OPEN,1,0), + (Action.HINT,0,0), + (Action.OPEN,0,1) + ]) + self.assertTrue(app.run()) + del app diff --git a/src/miinaharava/tests/test_board.py b/src/miinaharava/tests/test_board.py new file mode 100644 index 0000000..8aeac89 --- /dev/null +++ b/src/miinaharava/tests/test_board.py @@ -0,0 +1,295 @@ +""" tests/test_board.py - Testit pelilaudalle """ + +import unittest + +from board import Board, Level, LevelSpecs + +def matrix_equals(m1, m2): + """ matrix_equals - apufunktio testaa onko matriisit samat """ + if len(m1)!=len(m2): + return False + # pylint: disable = consider-using-enumerate + for i in range(len(m1)): + if m1[i] != m2[i]: + return False + return True + +def matrix_swap_xy(m): + """ matrix_swap_xy - palauttaa matriisin korvattu x -> y ja y -> x """ + if not m: + return None + w, h = len(m[0]), len(m) + ret_m = [[0 for _ in range(h)] for _ in range(w)] + for y in range(h): + for x in range(w): + ret_m[x][y]=m[y][x] + return ret_m + + +class TestBoardClass(unittest.TestCase): + """ pelilauden testit kattava luokka """ + + def test_init_works_and_defaults_beginner(self): + """ pelilautaolion luominen onnistuu ja defaulttaa aloittelijaksi """ + b = Board() + self.assertEqual(b.get_width(), LevelSpecs[Level.BEGINNER][0]) + self.assertEqual(b.get_height(), LevelSpecs[Level.BEGINNER][1]) + self.assertEqual(b.get_mines(), LevelSpecs[Level.BEGINNER][2]) + self.assertTrue(b.get_width()>0) + + + def test_init_with_level(self): + """ olion luominen onnistuu vaikeustasolla""" + b = Board(level=Level.EXPERT) + self.assertEqual(b.get_width(), LevelSpecs[Level.EXPERT][0]) + self.assertEqual(b.get_height(), LevelSpecs[Level.EXPERT][1]) + self.assertEqual(b.get_mines(), LevelSpecs[Level.EXPERT][2]) + + + def test_init_with_custom_dimentions(self): + """ mukautetun kentän luominen onnistuu """ + b = Board(width=13, height=14, mines=15) + self.assertEqual(b.get_width(), 13) + self.assertEqual(b.get_height(), 14) + self.assertEqual(b.get_mines(), 15) + b = Board(width=22, height=12) + self.assertEqual(b.get_width(), 22) + self.assertEqual(b.get_height(), 12) + self.assertEqual(b.get_mines(), 22) + + + def test_init_with_incorect_dimensions(self): + """ luominen ei saa onnitua mahdottomilla mitoilla """ + b = Board(width=1, height=999, mines=0) + self.assertEqual(b.get_width(), LevelSpecs[Level.BEGINNER][0]) + self.assertEqual(b.get_height(), LevelSpecs[Level.BEGINNER][1]) + self.assertEqual(b.get_mines(), LevelSpecs[Level.BEGINNER][2]) + + + def test_init_with_valid_board(self): + """ Pelilaudan luominen onnistuu kelvollisella asettelulla """ + t = [ + [0,0,0,0], + [0,0,0,1], + [0,0,0,0] + ] + b = Board(board = t) + self.assertEqual(b.get_width(), 4) + self.assertEqual(b.get_height(), 3) + self.assertEqual(b.get_mines(), 1) + + + def test_init_with_invalid_board(self): + """ Yritetään luoda peli kelvottomalla laudalla """ + t = [ + [0,0,0,0,0], + [0,0,0,1], + [0,0,0,0,0] + ] + b = Board(board = t) + # Resetoituihan aloittelijan lauta + self.assertIn(LevelSpecs[Level.BEGINNER][3], b.get_level_name()) + + t = [ + [0,1,0,0,0], + ] + b = Board(board = t) + self.assertIn(LevelSpecs[Level.BEGINNER][3], b.get_level_name()) + + t = [ + [0,0,0,0], + [0,0,0,0], + [0,0,0,0] + ] + b = Board(board = t) + self.assertIn(LevelSpecs[Level.BEGINNER][3], b.get_level_name()) + + t = [ + [1,1,1], + [1,1,1], + [1,1,1] + ] + b = Board(board = t) + self.assertIn(LevelSpecs[Level.BEGINNER][3], b.get_level_name()) + + + def test_tiles_and_masks_ok(self): + """ Luohan luokka sisäiset laatat ja maskit oikein """ + # pylint: disable = protected-access + t = [ + [0,0,0,0], + [0,0,0,1], + [0,0,0,0] + ] + b = Board(board = t) + self.assertEqual(b.get_width(), 4) + self.assertEqual(b.get_height(), 3) + self.assertEqual(b.get_mines(), 1) + + # testataan onko laatat tallennettu oikein luokkaan + t = matrix_swap_xy([ + [0,0,1,1], + [0,0,1,9], + [0,0,1,1] + ]) + self.assertTrue(matrix_equals(b._Board__tiles, t)) + + # onko maksit asetettu oikein + t = matrix_swap_xy([ + [12,12,12,12], + [12,12,12,12], + [12,12,12,12] + ]) + self.assertTrue(matrix_equals(b._Board__masked, t)) + + + def test_get_view_and_guess(self): + """ laudan näkymä on oikein senkin jälkeen kun on arvattu """ + + t = [ + [0,0,1], + [0,0,0], + [0,0,0] + ] + b = Board(board=t) + + # Antaahan pelikenttä pelkkää maskia aluksi + t = matrix_swap_xy([ + [12,12,12], + [12,12,12], + [12,12,12] + ]) + self.assertTrue(matrix_equals(b.get_view(), t)) + + # avataan yläkulma -> palatuu True + self.assertTrue(b.guess(0,0)) + + # onko näkymä nyt oikein + t = matrix_swap_xy([ + [0,1,12], + [0,1,1], + [0,0,0] + ]) + self.assertTrue(matrix_equals(b.get_view(), t)) + + # avataan alakulma jossa miina -> palautuu False + self.assertFalse(b.guess(2,0)) + + + def test_is_winning(self): + """ toimiiko voittotilanteen tunnistus """ + + t = [ + [0,1], + [0,0], + ] + b = Board(board=t) + self.assertFalse(b.is_winning()) + + # Avataan ruutu jolla ei tule viellä voittoa + t = matrix_swap_xy([ + [1,12], + [12,12] + ]) + self.assertTrue(b.guess(0,0)) + self.assertTrue(matrix_equals(b.get_view(), t)) + self.assertFalse(b.is_winning()) + + # Avataan loputkin ruudut, jolloin pitäisi voittaa + t = matrix_swap_xy([ + [1,12], + [1,1] + ]) + self.assertTrue(b.guess(0,1)) + self.assertTrue(b.guess(1,1)) + self.assertTrue(matrix_equals(b.get_view(), t)) + self.assertTrue(b.is_winning()) + + # Lupuksi avataan miina jolloin voittoa ei enää pitäisi olla + t = matrix_swap_xy([ + [1,9], + [1,1] + ]) + self.assertFalse(b.guess(1,0)) + self.assertTrue(matrix_equals(b.get_view(), t)) + self.assertFalse(b.is_winning()) + + + def test_error_conditions_in_guess(self): + """ ruudun avaus alueen ulkopuolelta tai avatussa ruudussa ei onnistu""" + t = [ + [0,1], + [0,0], + ] + b = Board(board=t) + self.assertFalse(b.guess(2,2)) + self.assertTrue(b.guess(0,0)) + self.assertFalse(b.guess(0,0)) + + + def test_get_mask(self): + """ maski annetaan oikein """ + t = [ + [1,0], + [0,0], + ] + b = Board(board=t) + self.assertEqual(b.get_mask(1,0), 12) + self.assertTrue(b.guess(1,0)) + self.assertFalse(b.get_mask(1,0)) + + + def test_flag(self): + """ ruudun lipun vaihto ja asetus toimii """ + t = [ + [0,0], + [0,1], + ] + b = Board(board=t) + self.assertEqual(b.get_mask(0,0), 12) + self.assertTrue(b.flag(0,0)) + self.assertEqual(b.get_mask(0,0), 13) + self.assertTrue(b.flag(0,0)) + self.assertEqual(b.get_mask(0,0), 10) + self.assertTrue(b.flag(0,0)) + self.assertEqual(b.get_mask(0,0), 11) + self.assertTrue(b.flag(0,0)) + self.assertEqual(b.get_mask(0,0), 12) + self.assertTrue(b.flag(0,0,10)) + self.assertEqual(b.get_mask(0,0), 10) + + + def test_flag_error_conditions(self): + """ liputus ei onnistu jos avattu, alueen ulkopuolella, outo arvo """ + t = [ + [0,1], + [1,1], + ] + b = Board(board=t) + self.assertFalse(b.flag(0,0,6)) # Lippu jota ei ole + self.assertFalse(b.flag(2,2)) # Alueen ulkopuolella + self.assertTrue(b.guess(0,0)) + self.assertFalse(b.flag(0,0)) # Avattu laatta + + + def test_reveal(self): + """ paljastuksen jälkeen näkyy laatat sellaisenaan """ + t = [ + [0,1], + [1,1], + ] + b = Board(board=t) + b.reveal() + t = matrix_swap_xy([ + [3,9], + [9,9] + ]) + self.assertTrue(matrix_equals(b.get_view(), t)) + + + def test_get_level_name(self): + """ Testataan että nykyinen vaikeustaso palautuu oikein """ + b = Board(level=Level.INTERMEDIATE) + self.assertIn(LevelSpecs[Level.INTERMEDIATE][3], b.get_level_name()) + b = Board(level=Level.INTERMEDIATE, width=25, mines=2) + self.assertIn("Mukautettu", b.get_level_name()) diff --git a/src/miinaharava/tests/test_bot.py b/src/miinaharava/tests/test_bot.py new file mode 100644 index 0000000..4dab148 --- /dev/null +++ b/src/miinaharava/tests/test_bot.py @@ -0,0 +1,54 @@ +""" tests/test_bot.py - Testaa botin toimintaa""" +# pylint: disable = missing-class-docstring, too-few-public-methods, protected-access + +import unittest + +from board import Board, Tile +from bots import DSSPBot, SimpleBot +from tui import Action + +class TestBotClass(unittest.TestCase): + """ botin testit""" + def test_init(self): + """ olioden luominen onnistuu """ + DSSPBot() + SimpleBot() + + def correctly_marking(self, open_free=False, bot_type=DSSPBot): + """ Testaa onko miinat miinoja ja vapaat vapaita alkuun avatusta """ + for _ in range(500): + brd = Board() + # jos ei aukea ylälaidasta otetaan seuraava + if not brd.guess(0,0): + continue + # vain varmat liikut + bot = bot_type(uncertain=False) + + tested = set() + while True: + action, x, y = bot.hint(brd.get_view(), 0, 0) + if (x,y) in tested: + break + tested.add((x,y)) + if action == Action.SAFE: + self.assertTrue( brd._Board__tiles[x][y] < Tile.MINE ) + if open_free: + brd.guess(x,y) + if action == Action.MINE: + self.assertTrue( brd._Board__tiles[x][y] == Tile.MINE ) + + def test_dssp_marks_correctly_with_open(self): + """ Testaa onko dssp:n miinat miinoja ja avaa vapaat """ + self.correctly_marking(True, DSSPBot) + + def test_simple_marks_correctly_with_open(self): + """ Testaa onko dssp:n miinat miinoja ja avaa vapaat """ + self.correctly_marking(True, SimpleBot) + + def test_dssp_marks_correctly(self): + """ Testaa onko dssp:n miinat miinoja ja vapaat vapaita """ + self.correctly_marking(False, DSSPBot) + + def test_simple_marks_correctly(self): + """ Testaa onko simple:n miinat miinoja ja vapaat vapaita """ + self.correctly_marking(False, SimpleBot) diff --git a/src/miinaharava/tui/__init__.py b/src/miinaharava/tui/__init__.py new file mode 100644 index 0000000..0c8d632 --- /dev/null +++ b/src/miinaharava/tui/__init__.py @@ -0,0 +1,3 @@ +""" tui - hoitaa käyttäjälle katseltavaa ja havaitsee syötteet """ +from .tui import Tui +from .static import Action, KEY_DESCRIPTIONS diff --git a/src/miinaharava/tui/ansi.py b/src/miinaharava/tui/ansi.py new file mode 100644 index 0000000..c25ff6c --- /dev/null +++ b/src/miinaharava/tui/ansi.py @@ -0,0 +1,46 @@ +""" ansi.py - ansi ohjauskomentoja. värit jne """ + +class Ansi: + """ Ansi - Luokallinen staattisia metodeja ansi komennoille """ + + BLACK = 0 + RED = 1 + GREEN = 2 + YELLOW = 3 + BLUE = 4 + MAGENTA = 5 + CYAN = 6 + WHITE = 7 + GRAY = 8 + BRIGHT_RED = 9 + BRIGHT_GREEN = 0xA + BRIGHT_YELLOW = 0xB + BRIGHT_BLUE = 0xC + BRIGHT_MAGENTA = 0xD + BRIGHT_CYAN = 0xE + BRIGHT_WHITE = 0xF + + @staticmethod + def color(color): + """ asettaa tekstin värin """ + if color in range(16): + print(end=f"\033[{'1;' if color//8 else ''}3{color%8}m") + + + @staticmethod + def bg(color): + """ asettaa tekstin taustan värin""" + if color in range(8): + print(end=f"\033[4{color}m") + + + @staticmethod + def cup(lines): + """ liikuttaa kursoria ylöspäin""" + print(end=f"\033[{lines}F") + + + @staticmethod + def reset(): + """ resetoi tekstin värin ja muut attribuutit perusarvoille """ + print(end="\033[0m") diff --git a/src/miinaharava/tui/ansi_draw.py b/src/miinaharava/tui/ansi_draw.py new file mode 100644 index 0000000..ba71fdb --- /dev/null +++ b/src/miinaharava/tui/ansi_draw.py @@ -0,0 +1,58 @@ +""" tui/ansi_draw.py - perustukset ansi tulostelulle """ +# pylint: disable = multiple-imports +from .ansi import Ansi +from .static import TileTypes + +class AnsiDraw(): + """ AnsiDraw - "piirtelee" näytölle kirjailmilla """ + def __init__(self, height = 9, name = ""): + print(end='\n'*height+name+": Peli alkaa.") + + def __del__(self): + print() + + def __tile(self, tile, hilighted): + """ "piirtää" yhden ruudun """ + for ch, colors in zip(TileTypes[tile].text, TileTypes[tile].colors): + color, bg = colors + Ansi.color(Ansi.BLACK if hilighted else color) + Ansi.bg(Ansi.CYAN if hilighted else bg) + print(end=ch) + Ansi.reset() + + + def matrix(self, matrix, hx, hy): + """ "piirtää" ruudukon """ + Ansi.cup(len(matrix[0])) + # pylint: disable=consider-using-enumerate + for y in range(len(matrix[0])): + for x in range(len(matrix)): + hilight = matrix[x][y] != 9 and x == hx and y == hy + self.__tile(matrix[x][y], hilight) + print() + + + def status_line(self, text): + """ draw_status_line - tulostaa pelitietorivin""" + print(end=text+'\r') + +class SuppressDraw(): + """ SuppressDraw - vain status """ + # pylint: disable = unused-argument + + def matrix(self, matrix, hx, hy): + """ "piirtää" ruudukon """ + + def status_line(self, text): + """ draw_status_line - tulostaa pelitietorivin""" + print(end=text+'\r') + +class NoDraw(): + """ NoDraw - ei mitään """ + # pylint: disable = unused-argument + + def matrix(self, matrix, hx, hy): + """ "piirtää" ruudukon """ + + def status_line(self, text): + """ draw_status_line - tulostaa pelitietorivin""" diff --git a/src/miinaharava/tui/kbd.py b/src/miinaharava/tui/kbd.py new file mode 100644 index 0000000..a31e56f --- /dev/null +++ b/src/miinaharava/tui/kbd.py @@ -0,0 +1,86 @@ +""" tui/kbd.py - näppäimistön käsittellijä """ +# pylint: disable = multiple-imports +import termios, fcntl, sys, os, io +from time import sleep +from .static import ActionKeys, Action + +class NoKbd(): + """ NoKbd - näppis-ei-käsittelijä """ + # pylint: disable = unused-argument + def read_action(self): + """ read_action - ilman näppistä -> loppu """ + return Action.QUIT + + def read_matrix_action(self, w, h, x, y): + """ read_matrix_action - ilman näppistä -> loppu """ + return Action.QUIT, 0, 0 + +class Kbd(): + """ Kbd - näppiskäsittelijä """ + def __init__(self): + # Vaatii hieman terminaaliasetusten muokkaamista jotta yksittäiset + # napin painallukset voidaan lukea + # https://stackoverflow.com/questions/983354/how-do-i-wait-for-a-pressed-key + try: + fd = sys.stdin.fileno() + self.oldterm = termios.tcgetattr(fd) + + newattr = termios.tcgetattr(fd) + newattr[3] = newattr[3] & ~termios.ICANON & ~termios.ECHO + termios.tcsetattr(fd, termios.TCSANOW, newattr) + + self.oldflags = fcntl.fcntl(fd, fcntl.F_GETFL) + fcntl.fcntl(fd, fcntl.F_SETFL, self.oldflags | os.O_NONBLOCK) + # Testeissä ei voi mukata termilaalia + except io.UnsupportedOperation: + pass + + def __del__(self): + # palautetaan terminaali takaisin alkupetäiseen uskoon + try: + fd = sys.stdin.fileno() + termios.tcsetattr(fd, termios.TCSAFLUSH, self.oldterm) + fcntl.fcntl(fd, fcntl.F_SETFL, self.oldflags) + # Testeissä ei voi mukata termilaalia + except io.UnsupportedOperation: + pass + + def read_action(self): + """ lukee näppäimistölä käyttäjän toiminnon """ + while True: + # Ehkä riittää jos näppäimiä luetaan 50x sekunnissa + sleep(0.02) + try: + keycode = sys.stdin.read(16) + except KeyboardInterrupt: + return Action.QUIT + if keycode: + for key, action in ActionKeys.items(): + if keycode.startswith(key): + return action + + def read_matrix_action(self, w, h, x, y): + """ read_matrix_action - lukee actionit ja pitää huolen koordinaat""" + action = self.read_action() + match action: + case Action.QUIT | Action.HINT: + return (action, x, y) + case Action.OPEN | Action.FLAG | Action.MINE | Action.SAFE: + return (action, x, y) + case Action.UP: + y = y-1 if y > 0 else 0 + case Action.LEFT: + x = x-1 if x > 0 else 0 + case Action.DOWN: + y = y+1 if y < h-1 else y + case Action.RIGHT: + x = x+1 if x < w-1 else x + case Action.TOP: + y = 0 + case Action.BOTTOM: + y = h-1 + case Action.BEGIN: + x = 0 + case Action.END: + x = w-1 + return (Action.NOOP, x, y) diff --git a/src/miinaharava/tui/static.py b/src/miinaharava/tui/static.py new file mode 100644 index 0000000..50dbb44 --- /dev/null +++ b/src/miinaharava/tui/static.py @@ -0,0 +1,77 @@ +""" tui/static.py - Staattiset määritykset tui:ssa tarvittaville jutuille. """ +from enum import Enum +from dataclasses import dataclass + +from board import Tile + +class Action(Enum): + """ tominnot, joita voidaan saada palautusrvona """ + QUIT = 0 # Pelin lopetus + OPEN = 1 # Ruudun avaaminen + FLAG = 2 # Ruudun liputus + HINT = 3 # Anna vihjeet + AUTO = 4 # Pelaa automaattisesti + LEFT = 5 # Liikkumiset... + RIGHT = 6 + UP = 7 + DOWN = 8 + TOP = 9 + BOTTOM = 10 + BEGIN = 11 + END = 12 + NOOP = 13 # ei mitään - tarvitaan, ettei mätsää ansikoodeja esciin + MINE = 14 # merkkaa pommi + SAFE = 15 # merkkaa turvallinen + +# ActionKeys - Ohjelma vertaa syötteen alkua näihin ja palauttaa ekan +ActionKeys = { + "\033[A": Action.UP, "\033[D": Action.LEFT, + "\033[C": Action.RIGHT, '\033[B': Action.DOWN, "\033[5~": Action.TOP, + "\033[6~": Action.BOTTOM, "\033[7~": Action.BEGIN,"\033[8~": Action.END, + "\033[": Action.NOOP, "\033": Action.QUIT, "t": Action.SAFE, + "w": Action.UP, "a": Action.LEFT, "s": Action.DOWN, + "d": Action.RIGHT, " ": Action.OPEN, "\n": Action.OPEN, + "l": Action.QUIT, "?": Action.HINT, "b": Action.HINT, + "f": Action.FLAG, "q": Action.QUIT, "m": Action.MINE, + "\t": Action.FLAG, "9": Action.MINE, "0": Action.SAFE +} + +KEY_DESCRIPTIONS = """Näppäinasettelu: + + YLÖS, ALAS, VASEN, OIKEA, PGDN, PGUP, HOME, END, w, a, s, d + Kursorin liikuttaminen pelilaudalla + + ENTER, SPACE Avaa laatta + + f, TAB Vaihda laatan merkintää + m, 9 Merkitse miinaksi + t, 0 Merkitse turvalliseksi + + ?, b Vihje tekoälyltä + + l, q, ESC Pelin lopetus +""" + +@dataclass +class TileType: + """ ruututyyppien tallennusmuotojen kuvaus""" + text: str # Teksti + colors: [] # Lista (väri, tausta) pareja tekstin kaunistamiseen + + +TileTypes = { + Tile.BLANK: TileType( "[ ]", [(0x7,0), (0x7,0), (0x7,0)] ), + Tile.ONE: TileType( "[1]", [(0xA,0), (0xA,0), (0xA,0)] ), + Tile.TWO: TileType( "[2]", [(0xB,0), (0xB,0), (0xB,0)] ), + Tile.THREE: TileType( "[3]", [(0xD,0), (0xD,0), (0xD,0)] ), + Tile.FOUR: TileType( "[4]", [(0x9,0), (0x9,0), (0x9,0)] ), + Tile.FIVE: TileType( "[5]", [(0x9,0), (0x9,0), (0x9,0)] ), + Tile.SIX: TileType( "[6]", [(0x9,0), (0x9,0), (0x9,0)] ), + Tile.SEVEN: TileType( "[7]", [(0x9,0), (0x9,0), (0x9,0)] ), + Tile.EIGHT: TileType( "[8]", [(0x9,0), (0x9,0), (0x9,0)] ), + Tile.MINE: TileType( "[@]", [(0xF,1), (0xF,1), (0xF,1)] ), + Tile.FLAG_MINE: TileType( "[×]", [(0x8,7), (0x1,7), (0x8,7)] ), + Tile.FLAG_FREE: TileType( "[•]", [(0x8,7), (0x2,7), (0x8,7)] ), + Tile.UNOPENED: TileType( "[#]", [(0x8,7), (0x8,7), (0x8,7)] ), + Tile.FLAG_UNKNOWN: TileType( "[?]", [(0x8,7), (0x0,7), (0x8,7)] ) +} diff --git a/src/miinaharava/tui/tui.py b/src/miinaharava/tui/tui.py new file mode 100644 index 0000000..d7f7fb3 --- /dev/null +++ b/src/miinaharava/tui/tui.py @@ -0,0 +1,109 @@ +""" tui/tui.py - runko käyttöliittymälle """ +import time +from .static import Action +from .kbd import Kbd, NoKbd +from .ansi_draw import AnsiDraw, SuppressDraw + + +class Tui(): + """ Tui - Luokka käyttäjän interaktiota varten """ + # pylint: disable = too-many-arguments, too-many-instance-attributes + def __init__(self, + bot = None, + autoplay = False, + interactive = True, + suppress = False, + height = 9, + level_name = "outo lauta", + delay = 0): + + # jos ei ole bottia pitää olla interaktiivinen + if bot is None: + autoplay = False + interactive = True + suppress = False + + # jos ei mitään näytetä ei voi olla interaktiivinen + if suppress: + interactive = False + + # automaattipeli pitää olla päällä jos ei interaktiivinen + if not interactive: + autoplay = True + + if delay and delay not in range(0,500): + delay = 50 + + self.autoplay = autoplay + self.interactive = interactive + self.suppress = suppress + self.height = height + self.level_name = level_name + self.delay = delay + + self.bot = bot(uncertain=not self.interactive) if bot else None + + self.kbd = Kbd() if self.interactive else NoKbd() + + if self.suppress: + self.draw = SuppressDraw() + else: + self.draw = AnsiDraw(height=self.height, name=self.level_name) + + def matrix_selector(self, matrix, x, y): + """ valinta matriisita """ + + # automaattipeli avaa botin vinkit heti + if self.autoplay: + action, x, y = self.bot.hint(matrix, x, y) + if action != Action.NOOP: + if self.delay: + self.draw.matrix(matrix, x, y) + time.sleep(self.delay/100) + return Action.OPEN if action==Action.SAFE else action, x, y + + + # ilman näppiskäsittelijää voidaan lopettaa + if not self.interactive: + return Action.QUIT, 0, 0 + + w, h = len(matrix), len(matrix[0]) + while True: + self.draw.matrix(matrix, x, y) + action, x, y = self.kbd.read_matrix_action(w, h, x, y) + match action: + case Action.QUIT: + return (action, x, y) + case Action.OPEN | Action.FLAG | Action.MINE | Action.SAFE: + if matrix[x][y] >= 10: + return (action, x, y) + case Action.HINT: + if self.bot is not None: + return self.bot.hint(matrix, x, y) + + def game_over(self, matrix, x, y): + """ tehtävät kun kuolee """ + self.draw.matrix(matrix, x, y) + self.draw.status_line( + f"{self.level_name}: " + + ("K " if self.suppress else f"{'Kuolit!':<30}") + ) + self.kbd.read_action() + + def game_win(self, matrix, x, y): + """ tehtävät kun voittaa """ + self.draw.matrix(matrix, x, y) + self.draw.status_line( + f"{self.level_name}: " + + ("V " if self.suppress else f"{'Voitit!':<30}") + ) + self.kbd.read_action() + + def game_end(self, matrix): + """ tehtävät ihan pelin lopuksi """ + if self.interactive: + self.draw.matrix(matrix, -1, -1) + self.draw.status_line( + f"{self.level_name}: " + + f"{'Kiitos!':<30}" + ) |