diff --git a/shared/src/main/scala/com/mogproject/mogami/core/Game.scala b/shared/src/main/scala/com/mogproject/mogami/core/Game.scala index c4a3ab8..1e5e037 100644 --- a/shared/src/main/scala/com/mogproject/mogami/core/Game.scala +++ b/shared/src/main/scala/com/mogproject/mogami/core/Game.scala @@ -2,7 +2,7 @@ package com.mogproject.mogami.core import com.mogproject.mogami._ import com.mogproject.mogami.core.io._ -import com.mogproject.mogami.core.move.MoveBuilder +import com.mogproject.mogami.core.move.{Move => _, MoveBuilderCsa => _, _} import com.mogproject.mogami.util.Implicits._ import scala.annotation.tailrec @@ -12,11 +12,12 @@ import scala.util.Try * Game */ case class Game(initialState: State = State.HIRATE, - moves: Vector[move.Move] = Vector.empty, + moves: Vector[Move] = Vector.empty, gameInfo: GameInfo = GameInfo(), movesOffset: Int = 0, + finalAction: Option[SpecialMove] = None, givenHistory: Option[Vector[State]] = None - ) extends CsaLike with SfenLike with KifGameWriter { + ) extends CsaGameWriter with SfenLike with KifGameWriter { require(history.length == moves.length + 1, "all moves must be valid") @@ -25,7 +26,11 @@ case class Game(initialState: State = State.HIRATE, override def equals(obj: scala.Any): Boolean = obj match { case that: Game => // ignore givenHistory - initialState == that.initialState && moves == that.moves && gameInfo == that.gameInfo && movesOffset == that.movesOffset + initialState == that.initialState && + moves == that.moves && + gameInfo == that.gameInfo && + movesOffset == that.movesOffset && + finalAction == that.finalAction case _ => false } @@ -47,7 +52,12 @@ case class Game(initialState: State = State.HIRATE, } else if (isRepetition) { Drawn // Sennichite } else { - Playing + finalAction match { + case Some(IllegalMove(_)) => IllegallyMoved + case Some(Resign(_)) => Resigned + case Some(TimeUp(_)) => TimedUp + case _ => Playing + } } } @@ -66,9 +76,6 @@ case class Game(initialState: State = State.HIRATE, def makeMove(move: MoveBuilder): Option[Game] = move.toMove(currentState).flatMap(makeMove) - override def toCsaString: String = - (gameInfo :: initialState :: moves.toList) map (_.toCsaString) filter (!_.isEmpty) mkString "\n" - override def toSfenString: String = (initialState.toSfenString :: movesOffset.toString :: moves.map(_.toSfenString).toList).mkString(" ") /** @@ -82,20 +89,7 @@ case class Game(initialState: State = State.HIRATE, (history.drop(1).reverse.takeWhile(s => s.turn == !turn || s.isChecked).count(_.hashCode() == currentState.hashCode()) >= 4) } -object Game extends CsaFactory[Game] with SfenFactory[Game] with KifGameReader { - override def parseCsaString(s: String): Option[Game] = { - def isStateText(t: String) = t.startsWith("P") || t == "+" || t == "-" - - for { - xs <- Some(s.split("[;\n]").filter(s => !s.startsWith("'"))) // ignore comment lines - (a, ys) = xs.span(!isStateText(_)) - (b, c) = ys.span(isStateText) - gi <- GameInfo.parseCsaString(a) - st <- State.parseCsaString(b) - moves = c.flatMap(s => move.MoveBuilderCsa.parseCsaString(s)) if moves.length == c.length - game <- moves.foldLeft(Some(Game(st, Vector.empty, gi)): Option[Game])((g, m) => g.flatMap(_.makeMove(m))) - } yield game - } +object Game extends CsaGameReader with SfenFactory[Game] with KifGameReader { override def parseSfenString(s: String): Option[Game] = { val tokens = s.split(" ") @@ -123,6 +117,11 @@ object Game extends CsaFactory[Game] with SfenFactory[Game] with KifGameReader { case object Drawn extends GameStatus + case object TimedUp extends GameStatus + + case object IllegallyMoved extends GameStatus + + case object Resigned extends GameStatus } } @@ -151,7 +150,8 @@ case class GameInfo(tags: Map[Symbol, String] = Map()) extends CsaLike { example: #KIF version=2.0 encoding=UTF-8 -開始日時:2017/03/13 +開始日時:2017/03/13 ??:?? +終了日時:2017/03/13 ??:?? 場所:81Dojo (ver.2016/03/20) 持ち時間:15分+60秒 手合割:平手 diff --git a/shared/src/main/scala/com/mogproject/mogami/core/io/CsaGameIO.scala b/shared/src/main/scala/com/mogproject/mogami/core/io/CsaGameIO.scala new file mode 100644 index 0000000..0d6d3b3 --- /dev/null +++ b/shared/src/main/scala/com/mogproject/mogami/core/io/CsaGameIO.scala @@ -0,0 +1,96 @@ +package com.mogproject.mogami.core.io + +import com.mogproject.mogami.core.move._ +import com.mogproject.mogami.core.{Game, GameInfo, State} + +import scala.annotation.tailrec + +/** + */ +trait CsaGameIO { + +} + +/** + * Writes Csa-formatted game + */ +trait CsaGameWriter extends CsaGameIO with CsaLike { + def initialState: State + + def moves: Vector[Move] + + def gameInfo: GameInfo + + def finalAction: Option[SpecialMove] + + override def toCsaString: String = + (gameInfo :: initialState :: (moves ++ finalAction).toList) map (_.toCsaString) filter (!_.isEmpty) mkString "\n" + +} + +/** + * Reads Csa-formatted game + */ +trait CsaGameReader extends CsaGameIO with CsaFactory[Game] { + + private def isStateText(t: String): Boolean = t.startsWith("P") || t == "+" || t == "-" + + private def isValidLine(s: String): Boolean = s.nonEmpty && !s.startsWith("'") && !s.startsWith("%CHUDAN") + + private def concatMoveLines(lines: List[String]): List[String] = { + @tailrec + def f(ss: List[String], latest: String, sofar: List[String]): List[String] = (ss, latest.isEmpty) match { + case (x :: xs, true) => f(xs, x, sofar) + case (x :: xs, false) if x.startsWith("T") => f(xs, "", s"${latest},${x}" :: sofar) + case (x :: xs, false) => f(xs, x, latest :: sofar) + case (Nil, true) => sofar.reverse + case (Nil, false) => (latest :: sofar).reverse + } + + f(lines, "", Nil) + } + + // todo: refactor w/ KifGameIO + @tailrec + final protected[io] def parseMovesCsa(chunks: List[String], pending: Option[Move], sofar: Option[Game]): Option[Game] = (chunks, pending, sofar) match { + case (x :: Nil, None, Some(g)) if x.startsWith("%") => // ends with a special move + MoveBuilderCsa.parseTime(x) match { + case Some((ss, tm)) => + (ss match { + case Resign.csaKeyword => Some(Resign(tm)) + case TimeUp.csaKeyword => Some(TimeUp(tm)) + case _ => None // unknown command + }).map(sm => g.copy(finalAction = Some(sm))) + case None => None // format error + } + case (IllegalMove.csaKeyword :: Nil, Some(mv), Some(g)) => // ends with an explicit illegal move + Some(g.copy(finalAction = Some(IllegalMove(mv)))) + case (Nil, Some(mv), Some(g)) => // ends with implicit illegal move + Some(g.copy(finalAction = Some(IllegalMove(mv)))) + case (Nil, None, Some(g)) => sofar // ends without errors + case (x :: xs, None, Some(g)) => MoveBuilderCsa.parseCsaString(x) match { + case Some(bldr) => bldr.toMove(g.currentState, isStrict = false) match { + case Some(mv) => mv.verify.flatMap(g.makeMove) match { + case Some(gg) => parseMovesCsa(xs, None, Some(gg)) // legal move + case None => parseMovesCsa(xs, Some(mv), sofar) // illegal move + } + case None => None // failed to create Move + } + case None => None // failed to parse Move string + } + case _ => None + } + + override def parseCsaString(s: String): Option[Game] = { + for { + xs <- Some(s.split("[,\n]").filter(isValidLine)) + (a, ys) = xs.span(!isStateText(_)) + (b, c) = ys.span(isStateText) + gi <- GameInfo.parseCsaString(a) + st <- State.parseCsaString(b) + chunks = concatMoveLines(c.toList) + game <- parseMovesCsa(chunks, None, Some(Game(st, Vector.empty, gi))) + } yield game + } + +} \ No newline at end of file diff --git a/shared/src/main/scala/com/mogproject/mogami/core/io/CsaLike.scala b/shared/src/main/scala/com/mogproject/mogami/core/io/CsaLike.scala index 8b5def8..a578c42 100644 --- a/shared/src/main/scala/com/mogproject/mogami/core/io/CsaLike.scala +++ b/shared/src/main/scala/com/mogproject/mogami/core/io/CsaLike.scala @@ -7,4 +7,5 @@ trait CsaLike { def toCsaString: String + protected def timeToCsaString(time: Option[Int]): String = time.map(",T" + _.toString).getOrElse("") } diff --git a/shared/src/main/scala/com/mogproject/mogami/core/io/KifGameInterface.scala b/shared/src/main/scala/com/mogproject/mogami/core/io/KifGameIO.scala similarity index 50% rename from shared/src/main/scala/com/mogproject/mogami/core/io/KifGameInterface.scala rename to shared/src/main/scala/com/mogproject/mogami/core/io/KifGameIO.scala index eb265b4..52e2c5a 100644 --- a/shared/src/main/scala/com/mogproject/mogami/core/io/KifGameInterface.scala +++ b/shared/src/main/scala/com/mogproject/mogami/core/io/KifGameIO.scala @@ -1,12 +1,15 @@ package com.mogproject.mogami.core.io -import com.mogproject.mogami.core.move.{Move, MoveBuilderKif} +import com.mogproject.mogami.core.move._ import com.mogproject.mogami.core.{Game, GameInfo, State, StateConstant} +import scala.annotation.tailrec +import scala.util.Try + /** * Common interface for Kif format */ -trait KifGameInterface { +trait KifGameIO { protected val presetStates: Map[String, State] = Map( "平手" -> StateConstant.HIRATE, "香落ち" -> StateConstant.HANDICAP_LANCE, @@ -32,7 +35,7 @@ trait KifGameInterface { /** * Writes Kif-formatted game */ -trait KifGameWriter extends KifGameInterface with KifLike { +trait KifGameWriter extends KifGameIO with KifLike { def initialState: State def moves: Vector[Move] @@ -41,6 +44,8 @@ trait KifGameWriter extends KifGameInterface with KifLike { def movesOffset: Int + def finalAction: Option[SpecialMove] + override def toKifString: String = { // todo: add 上手/下手 val blackName = s"先手:${gameInfo.tags.getOrElse('blackName, "")}" @@ -53,8 +58,9 @@ trait KifGameWriter extends KifGameInterface with KifLike { (whiteName +: ss.take(13)) ++ (blackName +: ss.drop(13)) } - val body = "手数----指手----消費時間--" +: moves.zipWithIndex.map { case (m, n) => - f"${n + movesOffset + 1}%4d ${m.toKifString}" + val ms = moves.map(_.toKifString) ++ finalAction.toList.flatMap(_.toKifString.split('\n')) + val body = "手数----指手----消費時間--" +: ms.zipWithIndex.map { case (m, n) => + f"${n + movesOffset + 1}%4d ${m}" } (header ++ body).mkString("\n") @@ -64,7 +70,54 @@ trait KifGameWriter extends KifGameInterface with KifLike { /** * Reads Kif-formatted game */ -trait KifGameReader extends KifGameInterface with KifFactory[Game] { +trait KifGameReader extends KifGameIO with KifFactory[Game] { + + private def isNormalMove(s: String): Boolean = s.headOption.exists(c => c == '同' || '1' <= c && c <= '9') + + private def isValidLine(s: String): Boolean = s.nonEmpty && !s.startsWith("*") && !s.startsWith("#") + + private def createChunks(xs: Seq[String]): Seq[String] = xs.flatMap { s => { + val chunks = s.trim.split(" ", 2) + if (chunks.length < 2 || Try(chunks(0).toInt).isFailure) + Seq.empty + else + Seq(chunks(1)) + }} + + @tailrec + final protected[io] def parseMovesKif(chunks: List[String], pending: Option[Move], sofar: Option[Game]): Option[Game] = (chunks, pending, sofar) match { + case (x :: Nil, None, Some(g)) if !isNormalMove(x) => // ends with a special move + MoveBuilderKif.parseTime(x) match { + case Some((ss, tm)) => + (ss match { + case Resign.kifKeyword => Some(Resign(tm)) + case TimeUp.kifKeyword => Some(TimeUp(tm)) + case _ => None // unknown command + }).map(sm => g.copy(finalAction = Some(sm))) + case None => None // format error + } + case (x :: Nil, Some(mv), Some(g)) if x.startsWith(IllegalMove.kifKeyword) => // ends with an explicit illegal move + Some(g.copy(finalAction = Some(IllegalMove(mv)))) + case (Nil, Some(mv), Some(g)) => // ends with implicit illegal move + Some(g.copy(finalAction = Some(IllegalMove(mv)))) + case (Nil, None, Some(g)) => sofar // ends without errors + case (x :: xs, None, Some(g)) => MoveBuilderKif.parseKifString(x) match { + case Some(bldr) => bldr.toMove(g.currentState, isStrict = false) match { + case Some(mv) => mv.verify.flatMap(g.makeMove) match { + case Some(gg) => parseMovesKif(xs, None, Some(gg)) // read the next line + case None => parseMovesKif(xs, Some(mv), sofar) + } + case None => + None // failed to create Move + } + case None => + None // failed to parse Move string + } + case _ => + None + } + + override def parseKifString(s: String): Option[Game] = { def getPresetState(ls: Seq[String]): Option[State] = ls.withFilter(_.startsWith("手合割:")).flatMap(ss => presetStates.get(ss.drop(4))).headOption @@ -78,12 +131,12 @@ trait KifGameReader extends KifGameInterface with KifFactory[Game] { }.mkString("\n")) for { - xs <- Some(s.split('\n').filter(s => !s.startsWith("*") && !s.startsWith("#"))) // ignore comment lines + xs <- Some(s.split('\n').filter(isValidLine)) (header, body) = xs.span(!_.startsWith("手数")) gi <- GameInfo.parseKifString(header.mkString("\n")) st <- (getPresetState(header) #:: getDefinedState(header) #:: Stream.empty).flatten.headOption - moves = body.drop(1).flatMap(s => MoveBuilderKif.parseKifString(s.trim.split(" ", 2).drop(1).mkString)) - game <- moves.foldLeft[Option[Game]](Some(Game(st, Vector.empty, gi)))((g, m) => g.flatMap(_.makeMove(m))) + chunks = createChunks(body.drop(1)) + game <- parseMovesKif(chunks.toList, None, Some(Game(st, Vector.empty, gi))) } yield game } } \ No newline at end of file diff --git a/shared/src/main/scala/com/mogproject/mogami/core/io/KifLike.scala b/shared/src/main/scala/com/mogproject/mogami/core/io/KifLike.scala index 630b598..66ba3fc 100644 --- a/shared/src/main/scala/com/mogproject/mogami/core/io/KifLike.scala +++ b/shared/src/main/scala/com/mogproject/mogami/core/io/KifLike.scala @@ -7,4 +7,5 @@ trait KifLike { def toKifString: String + protected def timeToKifString(time: Option[Int]): String = time.map(t => f" (${t / 60}%02d:${t % 60}%02d/)").getOrElse("") } diff --git a/shared/src/main/scala/com/mogproject/mogami/core/move/Move.scala b/shared/src/main/scala/com/mogproject/mogami/core/move/Move.scala index 1d9b6b4..7e8bbd2 100644 --- a/shared/src/main/scala/com/mogproject/mogami/core/move/Move.scala +++ b/shared/src/main/scala/com/mogproject/mogami/core/move/Move.scala @@ -1,10 +1,12 @@ package com.mogproject.mogami.core.move import com.mogproject.mogami._ -import com.mogproject.mogami.core.move.Movement._ import com.mogproject.mogami.core.io._ +import com.mogproject.mogami.core.move.Movement._ import com.mogproject.mogami.util.Implicits._ +import scala.util.Try + /** * Move with complete information @@ -18,16 +20,37 @@ case class Move(player: Player, movement: Option[Movement], // None = no ambiguity captured: Option[Ptype], isCheck: Boolean, - elapsedTime: Option[Int] = None + elapsedTime: Option[Int] = None, + isStrict: Boolean = true // enable strict requirement check ) extends CsaLike with SfenLike with KifLike { - require(!isDrop || !promote, "promote must be false when dropping") - require(!isDrop || captured.isEmpty, "captured must be None when dropping") - require(from.exists(_.isPromotionZone(player)) || to.isPromotionZone(player) || !promote, "either from or to must be in the promotion zone") - require(from.map(_.getDisplacement(player, to)).forall(oldPtype.canMoveTo), "move must be within the capability") - require(to.isLegalZone(newPiece), "to must be legal for the new piece") - require(elapsedTime.forall(_ >= 0), "elapsedTime must be positive or zero") - require(!captured.contains(KING), "king cannot be captured") - require(oldPtype != PAWN || movement.isEmpty, "pawn cannot be ambiguous") + if (isStrict) checkRequirement() + + def checkRequirement(): Unit = { + require(!isDrop || !promote, "promote must be false when dropping") + require(!isDrop || captured.isEmpty, "captured must be None when dropping") + require(from.exists(_.isPromotionZone(player)) || to.isPromotionZone(player) || !promote, "either from or to must be in the promotion zone") + require(from.map(_.getDisplacement(player, to)).forall(oldPtype.canMoveTo), "move must be within the capability") + require(to.isLegalZone(newPiece), "to must be legal for the new piece") + require(elapsedTime.forall(_ >= 0), "elapsedTime must be positive or zero") + require(!captured.contains(KING), "king cannot be captured") + require(oldPtype != PAWN || movement.isEmpty, "pawn cannot be ambiguous") + } + + override def equals(obj: scala.Any): Boolean = obj match { + case that: Move => + // ignore isStrict + player == that.player && + from == that.from && + to == that.to && + newPtype == that.newPtype && + promote == that.promote && + isSameSquare == that.isSameSquare && + movement == that.movement && + captured == that.captured && + isCheck == that.isCheck && + elapsedTime == that.elapsedTime + case _ => false + } def oldPtype: Ptype = if (promote) newPtype.demoted else newPtype @@ -67,4 +90,6 @@ case class Move(player: Player, val promotionStatus = promote.fold("+", couldPromote.fold("=", "")) s"${oldPtype.toEnglishSimpleName}${origin}${movementType}${to.toSfenString}${promotionStatus}" } + + def verify: Option[Move] = if (Try(checkRequirement()).isSuccess) Some(copy(isStrict = true)) else None } diff --git a/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilder.scala b/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilder.scala index b8efc5a..c581823 100644 --- a/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilder.scala +++ b/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilder.scala @@ -79,6 +79,6 @@ trait MoveBuilder { } - def toMove(state: State): Option[Move] + def toMove(state: State, isStrict: Boolean = true): Option[Move] } diff --git a/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilderCsa.scala b/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilderCsa.scala index aa6ba57..01b4095 100644 --- a/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilderCsa.scala +++ b/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilderCsa.scala @@ -3,19 +3,18 @@ package com.mogproject.mogami.core.move import com.mogproject.mogami._ import com.mogproject.mogami.core.io._ +import com.mogproject.mogami.util.Implicits._ import scala.util.Try import scala.util.matching.Regex -sealed trait MoveBuilderCsa extends MoveBuilder with CsaLike { - protected def timeToCsaString(time: Option[Int]): String = time.map(",T" + _.toString).getOrElse("") -} +sealed trait MoveBuilderCsa extends MoveBuilder with CsaLike object MoveBuilderCsa extends CsaFactory[MoveBuilderCsa] { - private[this] val pattern: Regex = """(.{7})(?:,T([0-9]+))?""".r + private[this] val pattern: Regex = """([^,]+)(?:,T([0-9]+))?""".r - private[this] def parseTime(s: String): Option[(String, Option[Int])] = s match { + def parseTime(s: String): Option[(String, Option[Int])] = s match { case pattern(mv, null) => Some((mv, None)) case pattern(mv, tm) => Try(tm.toInt).filter(_ >= 0).map(x => (mv, Some(x))).toOption case _ => None @@ -38,27 +37,28 @@ object MoveBuilderCsa extends CsaFactory[MoveBuilderCsa] { case class MoveBuilderCsaBoard(player: Player, from: Square, to: Square, newPtype: Ptype, elapsedTime: Option[Int] = None) extends MoveBuilderCsa { override def toCsaString: String = List(player, from, to, newPtype).map(_.toCsaString).mkString + timeToCsaString(elapsedTime) - override def toMove(state: State): Option[Move] = + override def toMove(state: State, isStrict: Boolean = true): Option[Move] = for { oldPiece <- state.board.get(from) promote = oldPiece.ptype != newPtype isSame = state.lastMoveTo.contains(to) isCheck = isCheckMove(state, Some(from), to, newPtype) movement = getMovement(state, Some(from), to, oldPiece.ptype) - captured = state.board.get(to).map(_.ptype) - mv <- Try(Move(player, Some(from), to, newPtype, promote, isSame, movement, captured, isCheck, elapsedTime)).toOption + captured = state.board.get(to).map(_.ptype).filter(_ != KING) + mv <- Try(Move(player, Some(from), to, newPtype, promote, isSame, movement, captured, isCheck, elapsedTime, isStrict)).toOption if player == state.turn + if oldPiece.ptype == promote.fold(newPtype.demoted, newPtype) } yield mv } case class MoveBuilderCsaHand(player: Player, to: Square, ptype: Ptype, elapsedTime: Option[Int] = None) extends MoveBuilderCsa { override def toCsaString: String = s"${player.toCsaString}00${to.toCsaString}${ptype.toCsaString}${timeToCsaString(elapsedTime)}" - override def toMove(state: State): Option[Move] = { + override def toMove(state: State, isStrict: Boolean = true): Option[Move] = { val isCheck = isCheckMove(state, None, to, ptype) val movement = getMovement(state, None, to, ptype) for { - mv <- Try(Move(player, None, to, ptype, promote = false, isSameSquare = false, movement, captured = None, isCheck, elapsedTime)).toOption + mv <- Try(Move(player, None, to, ptype, promote = false, isSameSquare = false, movement, captured = None, isCheck, elapsedTime, isStrict)).toOption if player == state.turn } yield mv } diff --git a/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilderKif.scala b/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilderKif.scala index d18589d..5538ed6 100644 --- a/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilderKif.scala +++ b/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilderKif.scala @@ -4,22 +4,20 @@ import com.mogproject.mogami._ import com.mogproject.mogami.core.io.{KifFactory, KifLike} import com.mogproject.mogami.util.Implicits._ -import scala.util.{Success, Try} import scala.util.matching.Regex +import scala.util.{Success, Try} /** * */ -sealed trait MoveBuilderKif extends MoveBuilder with KifLike { - protected def timeToKifString(time: Option[Int]): String = time.map(t => f" (${t / 60}%02d:${t % 60}%02d/)").getOrElse("") -} +sealed trait MoveBuilderKif extends MoveBuilder with KifLike object MoveBuilderKif extends KifFactory[MoveBuilderKif] { private[this] val patternTime: Regex = """([^ ]+)(?:[ ]+[(][ ]*(\d+):[ ]*(\d+)[/](?:[ ]*(\d+):[ ]*(\d+):[ ]*(\d+))?[)])?""".r private[this] val pattern: Regex = """(..)([成]?.)([成打]?)(?:[(]([1-9]{2})[)])?""".r - private[this] def parseTime(s: String): Option[(String, Option[Int])] = s match { + def parseTime(s: String): Option[(String, Option[Int])] = s match { case patternTime(mv, null, null, null, null, null) => Some(mv, None) case patternTime(mv, mm, ss, _, _, _) => (Try(mm.toInt), Try(ss.toInt)) match { case (Success(mx), Success(sx)) if mx <= Int.MaxValue / 60 && sx < 60 => Some(mv, Some(mx * 60 + sx)) @@ -55,7 +53,7 @@ case class MoveBuilderKifBoard(from: Square, to: Option[Square], oldPtype: Ptype override def toKifString: String = to.map(_.toKifString).getOrElse("同 ") + oldPtype.toKifString + promote.fold("成", "") + s"(${from.toCsaString})${timeToKifString(elapsedTime)}" - override def toMove(state: State): Option[Move] = + override def toMove(state: State, isStrict: Boolean = true): Option[Move] = for { oldPiece <- state.board.get(from) newPtype = promote.fold(oldPiece.ptype.promoted, oldPiece.ptype) @@ -66,19 +64,19 @@ case class MoveBuilderKifBoard(from: Square, to: Option[Square], oldPtype: Ptype isSame = to.isEmpty isCheck = isCheckMove(state, Some(from), moveTo, newPtype) movement = getMovement(state, Some(from), moveTo, oldPiece.ptype) - captured = state.board.get(moveTo).map(_.ptype) - mv <- Try(Move(state.turn, Some(from), moveTo, newPtype, promote, isSame, movement, captured, isCheck, elapsedTime)).toOption + captured = state.board.get(moveTo).map(_.ptype).filter(_ != KING) + mv <- Try(Move(state.turn, Some(from), moveTo, newPtype, promote, isSame, movement, captured, isCheck, elapsedTime, isStrict)).toOption } yield mv } case class MoveBuilderKifHand(to: Square, ptype: Ptype, elapsedTime: Option[Int] = None) extends MoveBuilderKif { override def toKifString: String = s"${to.toKifString}${ptype.toKifString}打${timeToKifString(elapsedTime)}" - override def toMove(state: State): Option[Move] = { + override def toMove(state: State, isStrict: Boolean = true): Option[Move] = { val isCheck = isCheckMove(state, None, to, ptype) val movement = getMovement(state, None, to, ptype) for { - mv <- Try(Move(state.turn, None, to, ptype, promote = false, isSameSquare = false, movement, captured = None, isCheck, elapsedTime)).toOption + mv <- Try(Move(state.turn, None, to, ptype, promote = false, isSameSquare = false, movement, captured = None, isCheck, elapsedTime, isStrict)).toOption } yield mv } } diff --git a/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilderSfen.scala b/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilderSfen.scala index 7861c6a..6fb2bf5 100644 --- a/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilderSfen.scala +++ b/shared/src/main/scala/com/mogproject/mogami/core/move/MoveBuilderSfen.scala @@ -49,26 +49,26 @@ object MoveBuilderSfen extends SfenFactory[MoveBuilderSfen] { case class MoveBuilderSfenBoard(from: Square, to: Square, promote: Boolean) extends MoveBuilderSfen { override def toSfenString: String = s"${from.toSfenString}${to.toSfenString}${promote.fold("+", "")}" - override def toMove(state: State): Option[Move] = + override def toMove(state: State, isStrict: Boolean = true): Option[Move] = for { oldPiece <- state.board.get(from) newPtype = promote.fold(oldPiece.ptype.promoted, oldPiece.ptype) isSame = state.lastMoveTo.contains(to) isCheck = isCheckMove(state, Some(from), to, newPtype) movement = getMovement(state, Some(from), to, oldPiece.ptype) - captured = state.board.get(to).map(_.ptype) - mv <- Try(Move(state.turn, Some(from), to, newPtype, promote, isSame, movement, captured, isCheck, None)).toOption + captured = state.board.get(to).map(_.ptype).filter(_ != KING) + mv <- Try(Move(state.turn, Some(from), to, newPtype, promote, isSame, movement, captured, isCheck, None, isStrict)).toOption } yield mv } case class MoveBuilderSfenHand(ptype: Ptype, to: Square) extends MoveBuilderSfen { override def toSfenString: String = s"${Piece(Player.BLACK, ptype).toSfenString}*${to.toSfenString}" - override def toMove(state: State): Option[Move] = { + override def toMove(state: State, isStrict: Boolean = true): Option[Move] = { val isCheck = isCheckMove(state, None, to, ptype) val movement = getMovement(state, None, to, ptype) for { - mv <- Try(Move(state.turn, None, to, ptype, promote = false, isSameSquare = false, movement, None, isCheck, None)).toOption + mv <- Try(Move(state.turn, None, to, ptype, promote = false, isSameSquare = false, movement, None, isCheck, None, isStrict)).toOption } yield mv } } diff --git a/shared/src/main/scala/com/mogproject/mogami/core/move/SpecialMove.scala b/shared/src/main/scala/com/mogproject/mogami/core/move/SpecialMove.scala new file mode 100644 index 0000000..0df4a55 --- /dev/null +++ b/shared/src/main/scala/com/mogproject/mogami/core/move/SpecialMove.scala @@ -0,0 +1,56 @@ +package com.mogproject.mogami.core.move + +import com.mogproject.mogami.core.io.{CsaLike, KifLike} + +sealed trait SpecialMove extends CsaLike with KifLike { + def toJapaneseNotationString: String + + def toWesternNotationString: String +} + +case class IllegalMove(move: Move) extends SpecialMove { + override def toCsaString: String = move.toCsaString + "\n" + IllegalMove.csaKeyword + + override def toKifString: String = move.toKifString + "\n" + IllegalMove.kifKeyword + + override def toJapaneseNotationString: String = move.toJapaneseNotationString + "\n" + IllegalMove.kifKeyword + + override def toWesternNotationString: String = move.toWesternNotationString + "\n" + "Illegal Move" +} + +object IllegalMove { + val csaKeyword = "%ILLEGAL_MOVE" + val kifKeyword = "反則手" +} + +case class Resign(elapsedTime: Option[Int] = None) extends SpecialMove { + override def toCsaString: String = Resign.csaKeyword + timeToCsaString(elapsedTime) + + override def toKifString: String = Resign.kifKeyword + timeToKifString(elapsedTime) + + override def toJapaneseNotationString: String = Resign.kifKeyword + + override def toWesternNotationString: String = "Resign" +} + +object Resign { + val csaKeyword = "%TORYO" + val kifKeyword = "投了" +} + +case class TimeUp(elapsedTime: Option[Int] = None) extends SpecialMove { + override def toCsaString: String = TimeUp.csaKeyword + timeToCsaString(elapsedTime) + + override def toKifString: String = TimeUp.kifKeyword + timeToKifString(elapsedTime) + + override def toJapaneseNotationString: String = TimeUp.kifKeyword + + override def toWesternNotationString: String = "Time Up" +} + +object TimeUp { + val csaKeyword = "%TIME_UP" + val kifKeyword = "切れ負け" +} + +// todo: impl KACHI, [+-]ILLEGAL_ACTION diff --git a/shared/src/test/scala/com/mogproject/mogami/core/GameGen.scala b/shared/src/test/scala/com/mogproject/mogami/core/GameGen.scala index 9a1777b..de8ccdb 100644 --- a/shared/src/test/scala/com/mogproject/mogami/core/GameGen.scala +++ b/shared/src/test/scala/com/mogproject/mogami/core/GameGen.scala @@ -1,6 +1,6 @@ package com.mogproject.mogami.core -import com.mogproject.mogami.core.move.Move +import com.mogproject.mogami.core.move.{Move, Resign, TimeUp} import org.scalacheck.Gen /** @@ -12,9 +12,11 @@ object GameGen { gameInfo <- GameInfoGen.infos state <- StateGen.statesWithFullPieces n <- Gen.choose(0, 50) + finalAction <- Gen.oneOf(None, None, None, Some(Resign()), Some(TimeUp(Some(1)))) } yield { val moves = movesStream(state).take(n) - Game(state, moves.toVector, gameInfo) + val g = Game(state, moves.toVector, gameInfo) + if (g.currentState.isMated) g else g.copy(finalAction = finalAction) } private[this] def movesStream(initState: State): Stream[Move] = { diff --git a/shared/src/test/scala/com/mogproject/mogami/core/GameSpec.scala b/shared/src/test/scala/com/mogproject/mogami/core/GameSpec.scala index 9a96218..9484231 100644 --- a/shared/src/test/scala/com/mogproject/mogami/core/GameSpec.scala +++ b/shared/src/test/scala/com/mogproject/mogami/core/GameSpec.scala @@ -4,6 +4,7 @@ import com.mogproject.mogami._ import com.mogproject.mogami.core.Game.GameStatus._ import com.mogproject.mogami.core.SquareConstant._ import com.mogproject.mogami.core.StateConstant._ +import com.mogproject.mogami.core.move.Resign import org.scalatest.prop.GeneratorDrivenPropertyChecks import org.scalatest.{FlatSpec, MustMatchers} @@ -157,7 +158,7 @@ class GameSpec extends FlatSpec with MustMatchers with GeneratorDrivenPropertyCh "P9+KY+KE+GI+KI+OU+KI+GI+KE+KY\n" + "P+\n" + "P-\n" + - "-\n-5152OU,T2345\n+5958OU") must be(Some(Game(stateHirateInv, Vector( + "-\n-5152OU\nT2345\n+5958OU") must be(Some(Game(stateHirateInv, Vector( Move(WHITE, Some(P51), P52, KING, false, false, None, None, false, Some(2345)), Move(BLACK, Some(P59), P58, KING, false, false, None, None, false) ), GameInfo(Map('whiteName -> "yyy"))))) @@ -207,11 +208,16 @@ class GameSpec extends FlatSpec with MustMatchers with GeneratorDrivenPropertyCh Game.parseCsaString(g.toCsaString) must be(Some(g)) } it must "ignore comments" in { - Game.parseCsaString("PI;-;'comment;-5152OU,T2345\n'comment\n+5958OU") must be(Some(Game(stateHirateInv, Vector( + Game.parseCsaString("PI,-,'comment,-5152OU,T2345,'comment,+5958OU") must be(Some(Game(stateHirateInv, Vector( Move(WHITE, Some(P51), P52, KING, false, false, None, None, false, Some(2345)), Move(BLACK, Some(P59), P58, KING, false, false, None, None, false) ), GameInfo()))) } + it must "parse special moves" in { + Game.parseCsaString("PI,+,+7776FU,T2,%TORYO,T3") mustBe Some(Game(HIRATE, Vector( + Move(BLACK, Some(P77), P76, PAWN, false, false, None, None, false, Some(2)) + ), finalAction = Some(Resign(Some(3))))) + } "Game#toSfenString" must "describe some games" in { dataForTest.map(_.toSfenString) zip sfenForTest foreach { case (a, b) => a must be(b) } @@ -406,7 +412,8 @@ class GameSpec extends FlatSpec with MustMatchers with GeneratorDrivenPropertyCh "+2839OU,T12", "-5848KI,T8", "+3948OU,T12", - "-6958NG,T5" + "-6958NG,T5", + "%TORYO,T32" )) } it must "restore games" in forAll(GameGen.games, minSuccessful(10)) { g => @@ -590,4 +597,9 @@ class GameSpec extends FlatSpec with MustMatchers with GeneratorDrivenPropertyCh "-2111OU" )).get.status mustBe Drawn } + it must "tell special moves" in { + Game.parseCsaString("PI,+,+7776FU,%TORYO").get.status mustBe Resigned + Game.parseCsaString("PI,+,+7776FU,%TIME_UP").get.status mustBe TimedUp + Game.parseCsaString("PI,+,+7775FU,%ILLEGAL_MOVE").get.status mustBe IllegallyMoved + } } diff --git a/shared/src/test/scala/com/mogproject/mogami/core/io/CsaGameIOSpec.scala b/shared/src/test/scala/com/mogproject/mogami/core/io/CsaGameIOSpec.scala new file mode 100644 index 0000000..f9677a3 --- /dev/null +++ b/shared/src/test/scala/com/mogproject/mogami/core/io/CsaGameIOSpec.scala @@ -0,0 +1,58 @@ +package com.mogproject.mogami.core.io + +import com.mogproject.mogami._ +import com.mogproject.mogami.core.SquareConstant._ +import com.mogproject.mogami.core.StateConstant.HIRATE +import com.mogproject.mogami.core.move.{IllegalMove, Move, Resign, TimeUp} +import org.scalatest.prop.GeneratorDrivenPropertyChecks +import org.scalatest.{FlatSpec, MustMatchers} + +class CsaGameIOSpec extends FlatSpec with MustMatchers with GeneratorDrivenPropertyChecks { + + object TestCsaGameReader extends CsaGameReader + + val hirateState = Seq( + "P1-KY-KE-GI-KI-OU-KI-GI-KE-KY", + "P2 * -HI * * * * * -KA * ", + "P3-FU-FU-FU-FU-FU-FU-FU-FU-FU", + "P4 * * * * * * * * * ", + "P5 * * * * * * * * * ", + "P6 * * * * * * * * * ", + "P7+FU+FU+FU+FU+FU+FU+FU+FU+FU", + "P8 * +KA * * * * * +HI * ", + "P9+KY+KE+GI+KI+OU+KI+GI+KE+KY", + "P+", + "P-", + "+" + ) + + "CsaGameWriter#toCsaString" must "describe special moves" in { + Game(HIRATE, finalAction = Some(Resign())).toCsaString mustBe (hirateState ++ Seq("%TORYO")).mkString("\n") + Game(HIRATE, finalAction = Some(Resign(Some(123)))).toCsaString mustBe (hirateState ++ Seq("%TORYO,T123")).mkString("\n") + Game(HIRATE, finalAction = Some(TimeUp())).toCsaString mustBe (hirateState ++ Seq("%TIME_UP")).mkString("\n") + Game(HIRATE, finalAction = Some(TimeUp(Some(123)))).toCsaString mustBe (hirateState ++ Seq("%TIME_UP,T123")).mkString("\n") + Game(HIRATE, finalAction = Some(IllegalMove( + Move(BLACK, Some(P59), P51, KING, false, false, None, None, false, None, false) + ))).toCsaString mustBe (hirateState ++ Seq("+5951OU", "%ILLEGAL_MOVE")).mkString("\n") + Game(HIRATE, finalAction = Some(IllegalMove( + Move(BLACK, Some(P59), P51, KING, false, false, None, None, false, Some(123), false) + ))).toCsaString mustBe (hirateState ++ Seq("+5951OU,T123", "%ILLEGAL_MOVE")).mkString("\n") + } + + "CsaGameReader#parseMovesCsa" must "parse normal moves" in { + TestCsaGameReader.parseMovesCsa(List(), None, Some(Game())) mustBe Some(Game()) + } + it must "parse special moves" in { + TestCsaGameReader.parseMovesCsa(List("%TORYO"), None, Some(Game())) mustBe Some(Game(HIRATE, finalAction = Some(Resign()))) + TestCsaGameReader.parseMovesCsa(List("%TORYO,T123"), None, Some(Game())) mustBe Some(Game(HIRATE, finalAction = Some(Resign(Some(123))))) + TestCsaGameReader.parseMovesCsa(List("%TIME_UP"), None, Some(Game())) mustBe Some(Game(HIRATE, finalAction = Some(TimeUp()))) + TestCsaGameReader.parseMovesCsa(List("%TIME_UP,T123"), None, Some(Game())) mustBe Some(Game(HIRATE, finalAction = Some(TimeUp(Some(123))))) + TestCsaGameReader.parseMovesCsa(List("+5951OU", "%ILLEGAL_MOVE"), None, Some(Game())) mustBe Some(Game(HIRATE, + finalAction = Some(IllegalMove(Move(BLACK, Some(P59), P51, KING, false, false, None, None, false, None, false))) + )) + TestCsaGameReader.parseMovesCsa(List("+5951OU,T123", "%ILLEGAL_MOVE"), None, Some(Game())) mustBe Some(Game(HIRATE, + finalAction = Some(IllegalMove(Move(BLACK, Some(P59), P51, KING, false, false, None, None, false, Some(123), false))) + )) + } + +} diff --git a/shared/src/test/scala/com/mogproject/mogami/core/io/KifGameIOSpec.scala b/shared/src/test/scala/com/mogproject/mogami/core/io/KifGameIOSpec.scala new file mode 100644 index 0000000..16808de --- /dev/null +++ b/shared/src/test/scala/com/mogproject/mogami/core/io/KifGameIOSpec.scala @@ -0,0 +1,84 @@ +package com.mogproject.mogami.core.io + + +import com.mogproject.mogami._ +import com.mogproject.mogami.core.SquareConstant._ +import com.mogproject.mogami.core.StateConstant.HIRATE +import com.mogproject.mogami.core.move.{IllegalMove, Move, Resign, TimeUp} +import org.scalatest.prop.GeneratorDrivenPropertyChecks +import org.scalatest.{FlatSpec, MustMatchers} + +class KifGameIOSpec extends FlatSpec with MustMatchers with GeneratorDrivenPropertyChecks { + + object TestKifGameReader extends KifGameReader + + val hirateState = Seq( + "手合割:平手", + "先手:", + "後手:", + "手数----指手----消費時間--" + ) + + "KifGameWriter#toKifString" must "describe special moves" in { + Game(HIRATE, finalAction = Some(Resign())).toKifString mustBe (hirateState ++ Seq(" 1 投了")).mkString("\n") + Game(HIRATE, finalAction = Some(Resign(Some(123)))).toKifString mustBe (hirateState ++ Seq(" 1 投了 (02:03/)")).mkString("\n") + Game(HIRATE, finalAction = Some(TimeUp())).toKifString mustBe (hirateState ++ Seq(" 1 切れ負け")).mkString("\n") + Game(HIRATE, finalAction = Some(TimeUp(Some(123)))).toKifString mustBe (hirateState ++ Seq(" 1 切れ負け (02:03/)")).mkString("\n") + Game(HIRATE, finalAction = Some(IllegalMove( + Move(BLACK, Some(P59), P51, KING, false, false, None, None, false, None, false) + ))).toKifString mustBe (hirateState ++ Seq(" 1 5一玉(59)", " 2 反則手")).mkString("\n") + Game(HIRATE, finalAction = Some(IllegalMove( + Move(BLACK, Some(P59), P51, KING, false, false, None, None, false, Some(123), false) + ))).toKifString mustBe (hirateState ++ Seq(" 1 5一玉(59) (02:03/)", " 2 反則手")).mkString("\n") + } + + "KifGameReader#parseMovesKif" must "parse normal moves" in { + TestKifGameReader.parseMovesKif(List(), None, Some(Game())) mustBe Some(Game()) + + TestKifGameReader.parseMovesKif(List( + "7六歩(77) ( 0:12/)", "8四歩(83) ( 0:13/)" + ), None, Some(Game())) mustBe Some(Game(HIRATE, Vector( + Move(BLACK, Some(P77), P76, PAWN, false, false, None, None, false, Some(12)), + Move(WHITE, Some(P83), P84, PAWN, false, false, None, None, false, Some(13)) + ))) + } + it must "parse special moves" in { + TestKifGameReader.parseMovesKif(List("投了"), None, Some(Game())) mustBe Some(Game(HIRATE, finalAction = Some(Resign()))) + TestKifGameReader.parseMovesKif(List("投了 ( 2:03/)"), None, Some(Game())) mustBe Some(Game(HIRATE, finalAction = Some(Resign(Some(123))))) + TestKifGameReader.parseMovesKif(List("切れ負け"), None, Some(Game())) mustBe Some(Game(HIRATE, finalAction = Some(TimeUp()))) + TestKifGameReader.parseMovesKif(List("切れ負け (2:3/1:2:3)"), None, Some(Game())) mustBe Some(Game(HIRATE, finalAction = Some(TimeUp(Some(123))))) + TestKifGameReader.parseMovesKif(List("5一玉(59)", "反則手"), None, Some(Game())) mustBe Some(Game(HIRATE, + finalAction = Some(IllegalMove(Move(BLACK, Some(P59), P51, KING, false, false, None, None, false, None, false))) + )) + TestKifGameReader.parseMovesKif(List("5一玉(59) ( 2:03/)", "反則手 ( 0:0/)"), None, Some(Game())) mustBe Some(Game(HIRATE, + finalAction = Some(IllegalMove(Move(BLACK, Some(P59), P51, KING, false, false, None, None, false, Some(123), false))) + )) + } + + "KifGameReader#parseKifString" must "create games" in { + val s = Seq( + "N+", + "N-", + "P1 * * -FU-FU * -FU * * +TO", + "P2 * * * +KY-FU+KI * * -GI", + "P3 * +NK * -KE+KA+KI * * * ", + "P4+KY * * * * * +TO+FU * ", + "P5 * * -NK+FU * * +OU-TO * ", + "P6+FU+FU-TO-TO+FU * +NK * +FU", + "P7 * +KY * -HI * * -NY+TO * ", + "P8-FU+KI-GI * +KA * -OU-GI * ", + "P9 * * -GI-RY-KI * * * * ", + "P+00FU", + "P-", + "+", + "+3546OU", + "-4142FU", + "T4377", + "+3423TO", + "-0031KI", + "+5847KA", + "-6747HI" + ) + Game.parseKifString(Game.parseCsaString(s).get.toKifString).isDefined mustBe true + } +} diff --git a/shared/src/test/scala/com/mogproject/mogami/core/move/MoveSpec.scala b/shared/src/test/scala/com/mogproject/mogami/core/move/MoveSpec.scala index b7c69e4..dfe9b87 100644 --- a/shared/src/test/scala/com/mogproject/mogami/core/move/MoveSpec.scala +++ b/shared/src/test/scala/com/mogproject/mogami/core/move/MoveSpec.scala @@ -45,7 +45,7 @@ class MoveSpec extends FlatSpec with MustMatchers with GeneratorDrivenPropertyCh object TestMoveBuilder extends MoveBuilder { override def isCheckMove(state: State, from: Option[Square], to: Square, newPtype: Ptype): Boolean = super.isCheckMove(state, from, to, newPtype) - override def toMove(state: State) = ??? + override def toMove(state: State, isStrict: Boolean = true) = ??? } "MoveBuilder#isCheckMove" must "return true is the move is check" in {