summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/miinaharava/__main__.py57
-rw-r--r--src/miinaharava/app.py42
-rw-r--r--src/miinaharava/board/__init__.py4
-rw-r--r--src/miinaharava/board/board.py252
-rw-r--r--src/miinaharava/board/static.py34
-rw-r--r--src/miinaharava/bots/__init__.py4
-rw-r--r--src/miinaharava/bots/bot.py142
-rw-r--r--src/miinaharava/bots/dssp.py75
-rw-r--r--src/miinaharava/bots/simple.py30
-rw-r--r--src/miinaharava/cmdline.py117
-rw-r--r--src/miinaharava/game/__init__.py2
-rw-r--r--src/miinaharava/game/game.py41
-rw-r--r--src/miinaharava/tests/__init__.py0
-rw-r--r--src/miinaharava/tests/test_app.py241
-rw-r--r--src/miinaharava/tests/test_board.py295
-rw-r--r--src/miinaharava/tests/test_bot.py54
-rw-r--r--src/miinaharava/tui/__init__.py3
-rw-r--r--src/miinaharava/tui/ansi.py46
-rw-r--r--src/miinaharava/tui/ansi_draw.py58
-rw-r--r--src/miinaharava/tui/kbd.py86
-rw-r--r--src/miinaharava/tui/static.py77
-rw-r--r--src/miinaharava/tui/tui.py109
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}"
+ )