From 0c33791ddd7a891c2764a486c25e385502da031e Mon Sep 17 00:00:00 2001 From: Peter Osterlund Date: Sun, 25 Dec 2016 11:48:04 +0100 Subject: [PATCH] DroidFish: English text to speech support for announcing moves. --- DroidFish/res/values/strings.xml | 18 +- DroidFish/res/xml/preferences.xml | 14 +- .../src/org/petero/droidfish/DroidFish.java | 37 +++- .../org/petero/droidfish/GUIInterface.java | 4 +- .../src/org/petero/droidfish/Speech.java | 209 ++++++++++++++++++ .../gamelogic/DroidChessController.java | 7 +- .../org/petero/droidfish/gamelogic/Game.java | 27 ++- .../petero/droidfish/gamelogic/GameTest.java | 63 ++++-- .../droidfish/gamelogic/SpeechTest.java | 162 ++++++++++++++ 9 files changed, 490 insertions(+), 51 deletions(-) create mode 100644 DroidFish/src/org/petero/droidfish/Speech.java create mode 100644 DroidFishTest/src/org/petero/droidfish/gamelogic/SpeechTest.java diff --git a/DroidFish/res/values/strings.xml b/DroidFish/res/values/strings.xml index 78ea588..e7ef65f 100644 --- a/DroidFish/res/values/strings.xml +++ b/DroidFish/res/values/strings.xml @@ -12,6 +12,7 @@ 1000000 1 5000 + off \ CPU Usage\n\ If you leave DroidFish running in the background and GameMode is set to \ @@ -219,6 +220,9 @@ you are not actively using the program.\ Port Network Engine Failed to start engine + Failed to initialize text to speech + Text to speech data missing + Text to speech not supported for this language Engine terminated UCI protocol error Network engine configuration error @@ -296,8 +300,8 @@ you are not actively using the program.\ Auto scroll titlebar if player names are too long Quick Move Input From and To squares can be touched in any order. Move is played as soon as uniquely defined. - Enable Sounds - Play sound when computer makes a move + Move Announcement + Announcement sound when computer makes a move Enable Vibration Vibrate when computer makes a move Fullscreen Mode @@ -696,4 +700,14 @@ you are not actively using the program.\ selectEngine engineOptions + + off + sound + speech_en + + + Off + Play sound + English Speech + diff --git a/DroidFish/res/xml/preferences.xml b/DroidFish/res/xml/preferences.xml index caa23be..eb0dbcd 100644 --- a/DroidFish/res/xml/preferences.xml +++ b/DroidFish/res/xml/preferences.xml @@ -157,12 +157,14 @@ android:summary="@string/prefs_vibrateEnabled_summary" android:defaultValue="false"> - - + + . +*/ + +package org.petero.droidfish; + +import java.util.Locale; + +import org.petero.droidfish.gamelogic.Move; +import org.petero.droidfish.gamelogic.Piece; +import org.petero.droidfish.gamelogic.Position; +import org.petero.droidfish.gamelogic.TextIO; + +import android.content.Context; +import android.speech.tts.TextToSpeech; +import android.speech.tts.TextToSpeech.OnInitListener; +import android.widget.Toast; + +/** Handles text to speech translation. */ +public class Speech { + private TextToSpeech tts; + boolean initialized = false; + boolean supported = false; + String toSpeak = null; + + public void initialize(final Context context) { + if (initialized) + return; + tts = new TextToSpeech(context, new OnInitListener() { + @Override + public void onInit(int status) { + initialized = true; + int toast = -1; + if (status == TextToSpeech.SUCCESS) { + int code = tts.setLanguage(Locale.US); + switch (code) { + case TextToSpeech.LANG_AVAILABLE: + case TextToSpeech.LANG_COUNTRY_AVAILABLE: + case TextToSpeech.LANG_COUNTRY_VAR_AVAILABLE: + supported = true; + say(toSpeak); + break; + case TextToSpeech.LANG_MISSING_DATA: + toast = R.string.tts_data_missing; + break; + case TextToSpeech.LANG_NOT_SUPPORTED: + toast = R.string.tts_not_supported_for_lang; + break; + default: + break; + } + } else { + toast = R.string.tts_failed_to_init; + } + if (toast != -1) + Toast.makeText(context, toast, Toast.LENGTH_LONG).show(); + } + }); + } + + @SuppressWarnings("deprecation") + public void say(String text) { + if (initialized) { + if (supported && text != null) + tts.speak(text, TextToSpeech.QUEUE_FLUSH, null); + toSpeak = null; + } else { + toSpeak = text; + } + } + + /** Immediately cancel all speech output. */ + public void flushQueue() { + toSpeak = null; + if (tts != null) + tts.stop(); + } + + /** Shut down the speech engine. */ + public void shutdown() { + if (tts != null) { + tts.shutdown(); + tts = null; + initialized = false; + supported = false; + } + } + + /** Convert move "move" in position "pos" to a sentence and speak it. */ + public void say(Position pos, Move move, String langStr) { + String s = moveToText(pos, move, langStr); +// System.out.printf("%.3f Speech.say(): %s\n", System.currentTimeMillis() * 1e-3, s); + if (!s.isEmpty()) + say(s); + } + + /** Convert move "move" in position "pos" to a sentence that can be spoken. */ + public static String moveToText(Position pos, Move move, String langStr) { + if (move == null || !langStr.equals("en")) + return ""; + + String moveStr = TextIO.moveToString(pos, move, false, false); + int piece = Piece.makeWhite(pos.getPiece(move.from)); + boolean capture = pos.getPiece(move.to) != Piece.EMPTY; + boolean promotion = move.promoteTo != Piece.EMPTY; + boolean check = moveStr.endsWith("+"); + boolean checkMate = moveStr.endsWith("#"); + boolean castle = false; + + if (piece == Piece.WPAWN && !capture) { + int fx = Position.getX(move.from); + int tx = Position.getX(move.to); + if (fx != tx) + capture = true; // En passant + } + + StringBuilder sentence = new StringBuilder(); + + if (piece == Piece.WKING) { + int fx = Position.getX(move.from); + int tx = Position.getX(move.to); + if (fx == 4 && tx == 6) { + sentence.append("Short castle"); + castle = true; + } else if (fx == 4 && (tx == 2)) { + sentence.append("Long castle"); + castle = true; + } + } + + if (!castle) { + boolean pawnMove = piece == Piece.WPAWN; + if (!pawnMove) + sentence.append(pieceName(piece)).append(' '); + + if (capture) { + int i = moveStr.indexOf("x"); + String from = moveStr.substring(pawnMove ? 0 : 1, i); + if (!from.isEmpty()) + sentence.append(getFromWord(from)).append(' '); + String to = moveStr.substring(i + 1, i + 3); + sentence.append(to.startsWith("e") ? "take " : "takes "); + sentence.append(to).append(' '); + } else { + int nSkip = (promotion ? 1 : 0) + ((check | checkMate) ? 1 : 0); + int i = moveStr.length() - nSkip; + String from = moveStr.substring(pawnMove ? 0 : 1, i - 2); + if (!from.isEmpty()) + sentence.append(from).append(' '); + String to = moveStr.substring(i - 2, i); + sentence.append(to).append(' '); + } + + if (promotion) + sentence.append(pieceName(move.promoteTo)).append(' '); + } + + if (checkMate) { + removeLastSpace(sentence); + sentence.append(". Check mate!"); + } else if (check) { + removeLastSpace(sentence); + sentence.append(". Check!"); + } + + return sentence.toString().trim(); + } + + /** Get the name of a non-pawn piece. Return empty string if no such piece. */ + private static String pieceName(int piece) { + piece = Piece.makeWhite(piece); + switch (piece) { + case Piece.WKING: return "King"; + case Piece.WQUEEN: return "Queen"; + case Piece.WROOK: return "Rook"; + case Piece.WBISHOP: return "Bishop"; + case Piece.WKNIGHT: return "Knight"; + default: return ""; + } + } + + /** Transform a "from" file or file+rank to a word. */ + private static String getFromWord(String from) { + if ("a".equals(from)) + return "ae"; + return from; + } + + /** If the last character in the StringBuilder is a space, remove it. */ + private static void removeLastSpace(StringBuilder sb) { + int len = sb.length(); + if (len > 0 && sb.charAt(len - 1) == ' ') + sb.setLength(len - 1); + } +} diff --git a/DroidFish/src/org/petero/droidfish/gamelogic/DroidChessController.java b/DroidFish/src/org/petero/droidfish/gamelogic/DroidChessController.java index f0a11a4..a20e7ee 100644 --- a/DroidFish/src/org/petero/droidfish/gamelogic/DroidChessController.java +++ b/DroidFish/src/org/petero/droidfish/gamelogic/DroidChessController.java @@ -1023,10 +1023,10 @@ public class DroidChessController { return; searchId++; Position oldPos = new Position(game.currPos()); - game.processString(cmd); + Pair res = game.processString(cmd); ponderMove = ponder; updateGameMode(); - gui.computerMoveMade(); + gui.movePlayed(game.prevPos(), res.second, true); listener.clearSearchInfo(searchId); updateComputeThreads(); setSelection(); @@ -1113,7 +1113,8 @@ public class DroidChessController { } if (m.promoteTo == promoteTo) { String strMove = TextIO.moveToString(pos, m, false, false, moves); - game.processString(strMove); + Pair res = game.processString(strMove); + gui.movePlayed(game.prevPos(), res.second, false); return true; } } diff --git a/DroidFish/src/org/petero/droidfish/gamelogic/Game.java b/DroidFish/src/org/petero/droidfish/gamelogic/Game.java index 36cca4e..3eff862 100644 --- a/DroidFish/src/org/petero/droidfish/gamelogic/Game.java +++ b/DroidFish/src/org/petero/droidfish/gamelogic/Game.java @@ -133,18 +133,19 @@ public class Game { /** * Update the game state according to move/command string from a player. * @param str The move or command to process. - * @return True if str was understood, false otherwise. - */ - public final boolean processString(String str) { + * @return Pair where first item is true if str was understood, false otherwise. + * Second item is move played, or null if no move was played. */ + /** Like processString, but also returns the move played, if any. */ + public final Pair processString(String str) { if (getGameState() != GameState.ALIVE) - return false; + return new Pair(false, null); if (str.startsWith("draw ")) { String drawCmd = str.substring(str.indexOf(" ") + 1); - handleDrawCmd(drawCmd, true); - return true; + Move m = handleDrawCmd(drawCmd, true); + return new Pair(true, m); } else if (str.equals("resign")) { addToGameTree(new Move(0, 0, 0), "resign"); - return true; + return new Pair(true, null); } Move m = TextIO.UCIstringToMove(str); @@ -154,10 +155,10 @@ public class Game { if (m == null) m = TextIO.stringToMove(currPos(), str); if (m == null) - return false; + return new Pair(false, null); addToGameTree(m, pendingDrawOffer ? "draw offer" : ""); - return true; + return new Pair(true, m); } /** Try claim a draw using a command string. Does not play the move involved @@ -455,7 +456,8 @@ public class Game { return new Pair>(pos, mList); } - private final void handleDrawCmd(String drawCmd, boolean playDrawMove) { + private final Move handleDrawCmd(String drawCmd, boolean playDrawMove) { + Move ret = null; Position pos = tree.currentPos; if (drawCmd.startsWith("rep") || drawCmd.startsWith("50")) { boolean rep = drawCmd.startsWith("rep"); @@ -509,18 +511,19 @@ public class Game { } else { pendingDrawOffer = true; if (m != null && playDrawMove) { - processString(ms); + ret = processString(ms).second; } } } else if (drawCmd.startsWith("offer ")) { pendingDrawOffer = true; String ms = drawCmd.substring(drawCmd.indexOf(" ") + 1); if (TextIO.stringToMove(pos, ms) != null) { - processString(ms); + ret = processString(ms).second; } } else if (drawCmd.equals("accept")) { if (haveDrawOffer()) addToGameTree(new Move(0, 0, 0), "draw accept"); } + return ret; } } diff --git a/DroidFishTest/src/org/petero/droidfish/gamelogic/GameTest.java b/DroidFishTest/src/org/petero/droidfish/gamelogic/GameTest.java index 44edb26..48402ad 100644 --- a/DroidFishTest/src/org/petero/droidfish/gamelogic/GameTest.java +++ b/DroidFishTest/src/org/petero/droidfish/gamelogic/GameTest.java @@ -39,36 +39,48 @@ public class GameTest extends TestCase { Game game = new Game(null, new TimeControlData()); assertEquals(false, game.haveDrawOffer()); - boolean res = game.processString("e4"); + Pair p = game.processString("e4"); + boolean res = p.first; assertEquals(true, res); + assertEquals(TextIO.UCIstringToMove("e2e4"), p.second); assertEquals(false, game.haveDrawOffer()); - res = game.processString("draw offer e5"); + p = game.processString("draw offer e5"); + res = p.first; assertEquals(true, res); + assertEquals(TextIO.UCIstringToMove("e7e5"), p.second); assertEquals(true, game.haveDrawOffer()); assertEquals(Game.GameState.ALIVE, game.getGameState()); // Draw offer does not imply draw assertEquals(Piece.BPAWN, game.currPos().getPiece(Position.getSquare(4, 4))); // e5 move made - res = game.processString("draw offer Nf3"); + p = game.processString("draw offer Nf3"); + res = p.first; assertEquals(true, res); + assertEquals(TextIO.UCIstringToMove("g1f3"), p.second); assertEquals(true, game.haveDrawOffer()); assertEquals(Game.GameState.ALIVE, game.getGameState()); // Draw offer does not imply draw assertEquals(Piece.WKNIGHT, game.currPos().getPiece(Position.getSquare(5, 2))); // Nf3 move made - res = game.processString("Nc6"); + p = game.processString("Nc6"); + res = p.first; assertEquals(true, res); + assertEquals(TextIO.UCIstringToMove("b8c6"), p.second); assertEquals(false, game.haveDrawOffer()); assertEquals(Game.GameState.ALIVE, game.getGameState()); assertEquals(Piece.BKNIGHT, game.currPos().getPiece(Position.getSquare(2, 5))); // Nc6 move made - res = game.processString("draw offer Bb5"); + p = game.processString("draw offer Bb5"); + res = p.first; assertEquals(true, res); + assertEquals(TextIO.UCIstringToMove("f1b5"), p.second); assertEquals(true, game.haveDrawOffer()); assertEquals(Game.GameState.ALIVE, game.getGameState()); assertEquals(Piece.WBISHOP, game.currPos().getPiece(Position.getSquare(1, 4))); // Bb5 move made - res = game.processString("draw accept"); + p = game.processString("draw accept"); + res = p.first; assertEquals(true, res); + assertEquals(null, p.second); assertEquals(Game.GameState.DRAW_AGREE, game.getGameState()); // Draw by agreement game.undoMove(); // Undo "draw accept" @@ -100,11 +112,15 @@ public class GameTest extends TestCase { assertEquals(false, game.haveDrawOffer()); assertEquals(Game.GameState.ALIVE, game.getGameState()); - res = game.processString("draw offer e5"); + p = game.processString("draw offer e5"); + res = p.first; assertEquals(true, res); + assertEquals(null, p.second); assertEquals(TextIO.startPosFEN, TextIO.toFEN(game.currPos())); // Move invalid, not executed - res = game.processString("e4"); + p = game.processString("e4"); + res = p.first; assertEquals(true, res); + assertEquals(TextIO.UCIstringToMove("e2e4"), p.second); assertEquals(true, game.haveDrawOffer()); // Previous draw offer still valid assertEquals(Piece.WPAWN, game.currPos().getPiece(Position.getSquare(4, 3))); // e4 move made @@ -126,15 +142,20 @@ public class GameTest extends TestCase { public void testDraw50() throws ChessParseError { Game game = new Game(null, new TimeControlData()); assertEquals(false, game.haveDrawOffer()); - boolean res = game.processString("draw 50"); + Pair p = game.processString("draw 50"); + boolean res = p.first; assertEquals(true, res); + assertEquals(null, p.second); assertEquals(Game.GameState.ALIVE, game.getGameState()); // Draw claim invalid - res = game.processString("e4"); + p = game.processString("e4"); + res = p.first; + assertEquals(true, res); + assertEquals(TextIO.UCIstringToMove("e2e4"), p.second); assertEquals(true, game.haveDrawOffer()); // Invalid claim converted to draw offer String fen = "8/4k3/8/P7/8/8/8/1N2K2R w K - 99 83"; game.setPos(TextIO.readFEN(fen)); - res = game.processString("draw 50"); + game.processString("draw 50"); assertEquals(Game.GameState.ALIVE, game.getGameState()); // Draw claim invalid game.setPos(TextIO.readFEN(fen)); @@ -163,8 +184,10 @@ public class GameTest extends TestCase { assertEquals(true, game.haveDrawOffer()); // Previous invalid claim converted to offer game.processString("draw 50"); assertEquals(Game.GameState.ALIVE, game.getGameState()); // 50 move counter reset. - res = game.processString("draw accept"); + p = game.processString("draw accept"); + res = p.first; assertEquals(true, res); + assertEquals(null, p.second); assertEquals(Game.GameState.DRAW_AGREE, game.getGameState()); // Can accept previous implicit offer fen = "3k4/R7/3K4/8/8/8/8/8 w - - 99 78"; @@ -301,12 +324,16 @@ public class GameTest extends TestCase { public void testProcessString() throws ChessParseError { Game game = new Game(null, new TimeControlData()); assertEquals(TextIO.startPosFEN, TextIO.toFEN(game.currPos())); - boolean res = game.processString("Nf3"); + Pair p = game.processString("Nf3"); + boolean res = p.first; assertEquals(true, res); + assertEquals(TextIO.UCIstringToMove("g1f3"), p.second); assertEquals(1, game.currPos().halfMoveClock); assertEquals(1, game.currPos().fullMoveCounter); - res = game.processString("d5"); + p = game.processString("d5"); + res = p.first; assertEquals(true, res); + assertEquals(TextIO.UCIstringToMove("d7d5"), p.second); assertEquals(0, game.currPos().halfMoveClock); assertEquals(2, game.currPos().fullMoveCounter); @@ -336,12 +363,16 @@ public class GameTest extends TestCase { game.setPos(TextIO.readFEN(fen)); assertEquals(pos, game.currPos()); - res = game.processString("junk"); + p = game.processString("junk"); + res = p.first; assertEquals(false, res); + assertEquals(null, p.second); game.newGame(); - res = game.processString("e7e5"); + p = game.processString("e7e5"); + res = p.first; assertEquals(false, res); + assertEquals(null, p.second); } /** diff --git a/DroidFishTest/src/org/petero/droidfish/gamelogic/SpeechTest.java b/DroidFishTest/src/org/petero/droidfish/gamelogic/SpeechTest.java new file mode 100644 index 0000000..24c3c0a --- /dev/null +++ b/DroidFishTest/src/org/petero/droidfish/gamelogic/SpeechTest.java @@ -0,0 +1,162 @@ +/* + DroidFish - An Android chess program. + Copyright (C) 2016 Peter Ă–sterlund, peterosterlund2@gmail.com + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . +*/ + +package org.petero.droidfish.gamelogic; + +import org.petero.droidfish.Speech; + +import junit.framework.TestCase; + +public class SpeechTest extends TestCase { + public SpeechTest() { + } + + public void testEnglish() { + String lang = "en"; + { + Game game = new Game(null, new TimeControlData()); + Pair res = game.processString("e4"); + assertEquals("e4", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("d5"); + assertEquals("d5", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("exd5"); + assertEquals("e takes d5", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("Qxd5"); + assertEquals("Queen takes d5", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("Ne2"); + assertEquals("Knight e2", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("Nf6"); + assertEquals("Knight f6", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("Nbc3"); + assertEquals("Knight b c3", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("e5"); + assertEquals("e5", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("b4"); + assertEquals("b4", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("a5"); + assertEquals("a5", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("a3"); + assertEquals("a3", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("axb4"); + assertEquals("ae takes b4", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("axb4"); + assertEquals("ae takes b4", Speech.moveToText(game.prevPos(), res.second, lang)); + } + { + Game game = new Game(null, new TimeControlData()); + Pair res = game.processString("d4"); + assertEquals("d4", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("e5"); + assertEquals("e5", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("dxe5"); + assertEquals("d take e5", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("f6"); + assertEquals("f6", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("exf6"); + assertEquals("e takes f6", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("Bb4"); + assertEquals("Bishop b4. Check!", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("c3"); + assertEquals("c3", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("Ne7"); + assertEquals("Knight e7", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("cxb4"); + assertEquals("c takes b4", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("O-O"); + assertEquals("Short castle", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("fxg7"); + assertEquals("f takes g7", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("h6"); + assertEquals("h6", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("gxf8Q+"); + assertEquals("g takes f8 Queen. Check!", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("Kxf8"); + assertEquals("King takes f8", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("b5"); + assertEquals("b5", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("a5"); + assertEquals("a5", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("bxa6"); + assertEquals("b takes a6", Speech.moveToText(game.prevPos(), res.second, lang)); + } + { + Game game = new Game(null, new TimeControlData()); + Pair res = game.processString("f4"); + assertEquals("f4", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("e5"); + assertEquals("e5", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("g4"); + assertEquals("g4", Speech.moveToText(game.prevPos(), res.second, lang)); + + res = game.processString("Qh4"); + assertEquals("Queen h4. Check mate!", Speech.moveToText(game.prevPos(), res.second, lang)); + } + { + Game game = new Game(null, new TimeControlData()); + playMoves(game, "d4 d5 Nc3 Nc6 Bf4 Bf5 Qd2 Qd7"); + Pair res = game.processString("O-O-O"); + assertEquals("Long castle", Speech.moveToText(game.prevPos(), res.second, lang)); + playMoves(game, "Nxd4 Nxd5 Qxd5 Qxd4 Qxd4 Nf3 Qxd1 Kxd1"); + res = game.processString("O-O-O"); + assertEquals("Long castle. Check!", Speech.moveToText(game.prevPos(), res.second, lang)); + } + { + Game game = new Game(null, new TimeControlData()); + playMoves(game, "e4 e5 h3 Bb4 Ne2 Bc3"); + Pair res = game.processString("Nexc3"); + assertEquals("Knight e takes c3", Speech.moveToText(game.prevPos(), res.second, lang)); + } + } + + private void playMoves(Game game, String moves) { + for (String move : moves.split(" ")) { + Pair res = game.processString(move); + assertTrue(res.first); + } + } +}