~gdanix/CantStopMeNowTelegramBot

7dfe3aae43e82aeff1674fb6725ba50aacd57dd9 — Daniel Trujillo Viedma 1 year, 11 months ago master
Initial commit
A  => .bsp/sbt.json +1 -0
@@ 1,1 @@
{"name":"sbt","version":"1.4.4","bspVersion":"2.0.0-M5","languages":["scala"],"argv":["/usr/lib/jvm/java-8-openjdk-amd64/jre/bin/java","-Xms100m","-Xmx100m","-classpath","/usr/share/sbt/bin/sbt-launch.jar","xsbt.boot.Boot","-bsp"]}
\ No newline at end of file

A  => .gitignore +13 -0
@@ 1,13 @@
# Freedesktop directory file
.directory

# Eclipse project files
.classpath
.cache-main
.cache-tests
.project
.settings

# Compiled binaries
target/
bin/
\ No newline at end of file

A  => README.md +6 -0
@@ 1,6 @@
# Can't Stop Me Now Telegram Bot
---

This is a first, ugly and fast implementation of a Telegram Bot that let's you play [Can't Stop](https://en.wikipedia.org/wiki/Can't_Stop_(board_game)) in Telegram.

This is a simple project to try out my refactoring skills. I will document every commit hoping that someone finds it useful.

A  => build.sbt +78 -0
@@ 1,78 @@
// The simplest possible sbt build file is just one line:

scalaVersion := "2.12.12"
// That is, to create a valid sbt build, all you've got to do is define the
// version of Scala you'd like your project to use.

// ============================================================================

// Lines like the above defining `scalaVersion` are called "settings". Settings
// are key/value pairs. In the case of `scalaVersion`, the key is "scalaVersion"
// and the value is "2.13.3"

// It's possible to define many kinds of settings, such as:

name := "Can't Stop Me Now Telegram Bot"
organization := "org.gDanix"
version := "0.1"

// Note, it's not required for you to define these three settings. These are
// mostly only necessary if you intend to publish your library's binaries on a
// place like Sonatype or Bintray.


// Want to use a published library in your project?
// You can define other libraries as dependencies in your build like this:

libraryDependencies ++= Seq(
	"org.augustjune" %% "canoe" % "0.5.1"
	)

// Here, `libraryDependencies` is a set of dependencies, and by using `+=`,
// we're adding the scala-parser-combinators dependency to the set of dependencies
// that sbt will go and fetch when it starts up.
// Now, in any Scala file, you can import classes, objects, etc., from
// scala-parser-combinators with a regular import.

// TIP: To find the "dependency" that you need to add to the
// `libraryDependencies` set, which in the above example looks like this:

// "org.scala-lang.modules" %% "scala-parser-combinators" % "1.1.2"

// You can use Scaladex, an index of all known published Scala libraries. There,
// after you find the library you want, you can just copy/paste the dependency
// information that you need into your build file. For example, on the
// scala/scala-parser-combinators Scaladex page,
// https://index.scala-lang.org/scala/scala-parser-combinators, you can copy/paste
// the sbt dependency from the sbt box on the right-hand side of the screen.

// IMPORTANT NOTE: while build files look _kind of_ like regular Scala, it's
// important to note that syntax in *.sbt files doesn't always behave like
// regular Scala. For example, notice in this build file that it's not required
// to put our settings into an enclosing object or class. Always remember that
// sbt is a bit different, semantically, than vanilla Scala.

// ============================================================================

// Most moderately interesting Scala projects don't make use of the very simple
// build file style (called "bare style") used in this build.sbt file. Most
// intermediate Scala projects make use of so-called "multi-project" builds. A
// multi-project build makes it possible to have different folders which sbt can
// be configured differently for. That is, you may wish to have different
// dependencies or different testing frameworks defined for different parts of
// your codebase. Multi-project builds make this possible.

// Here's a quick glimpse of what a multi-project build looks like for this
// build, with only one "subproject" defined, called `root`:

// lazy val root = (project in file(".")).
//   settings(
//     inThisBuild(List(
//       organization := "ch.epfl.scala",
//       scalaVersion := "2.13.3"
//     )),
//     name := "hello-world"
//   )

// To learn more about multi-project builds, head over to the official sbt
// documentation at http://www.scala-sbt.org/documentation.html

A  => project/build.properties +1 -0
@@ 1,1 @@
sbt.version=1.4.4

A  => project/plugins.sbt +1 -0
@@ 1,1 @@
addSbtPlugin("com.typesafe.sbteclipse" % "sbteclipse-plugin" % "5.2.4")

A  => src/main/scala/org/gDanix/games/cant_stop_me_now_telegram_bot/CSMNT.scala +294 -0
@@ 1,294 @@
package org.gDanix.games.cant_stop_me_now_telegram_bot

import PRNG._
import cats.data.State
import scala.math.abs

object CSMNT {
  class DiceResult(val value: Int, override val toString: String)
  object One extends DiceResult(1, "⚀")
  object Two extends DiceResult(2, "⚁")
  object Three extends DiceResult(3, "⚂")
  object Four extends DiceResult(4, "⚃")
  object Five extends DiceResult(5, "⚄")
  object Six extends DiceResult(6, "⚅")

  implicit object DiceResultComparator extends Ordering[DiceResult] {
  	override def compare (x: DiceResult, y: DiceResult): Int = x.value - y.value
  }

  def long2DiceResult(input: Long): DiceResult = (scala.math.abs(input) % 6) match {
  	case 0L => One
  	case 1L => Two
  	case 2L => Three
  	case 3L => Four
  	case 4L => Five
  	case 5L => Six
  }

  type Combinations =
  	 ((Int, Int),
  		(Int, Int),
  		(Int, Int))

  def getCombinations: ((DiceResult, DiceResult, DiceResult, DiceResult)) => Combinations = { case (fst, snd, thrd, fth) =>
  	((	fst.value + snd.value, thrd.value + fth.value),
  		(	fth.value + snd.value, thrd.value + fst.value),
  		(	thrd.value + snd.value, fth.value + fst.value))
  }

  def rollDices(prng: PRNG): ((DiceResult, DiceResult, DiceResult, DiceResult), PRNG) = {
  	val firstLong = prng.getLong
  	val firstResult = long2DiceResult(firstLong)
  	val secondPrng = prng.next
  	val secondLong = secondPrng.getLong
  	val secondResult = long2DiceResult(secondLong)
  	val thirdPrng = secondPrng.next
  	val thirdLong = thirdPrng.getLong
  	val thirdResult = long2DiceResult(thirdLong)
  	val fourthPrng = thirdPrng.next
  	val fourthLong = fourthPrng.getLong
  	val fourthResult = long2DiceResult(fourthLong)
  	((firstResult, secondResult, thirdResult, fourthResult), fourthPrng.next)
  }

  // If you have to add another color, must be also added to the 'getNewColor' function below
  trait Color
  case object Black extends Color
  case object Red extends Color
  case object Green extends Color
  case object Blue extends Color
  case object Yellow extends Color

	case class Player(name: String, color: Color)

	case class Token(player: Player, column: Column, height: Int, temporal: Boolean)

	type ColumnID = Int

	case class Column(id: ColumnID, closed: Option[Player])

	def getColumnID: (DiceResult, DiceResult) => ColumnID = { case (diceResult1, diceResult2) => diceResult1.value + diceResult2.value }


  def getColumnMaxHeight(column: ColumnID): Int = {
 		val centerDev = abs(7-column)
  	13-2*centerDev
	}

	case class Board(tokens: List[Token], columns: Map[ColumnID, Column])

	object Board {
		private val initialColumns: Map[ColumnID, Column] = {
			Map (
					2 -> Column(2, None),
					3 -> Column(3, None),
					4 -> Column(4, None),
					5 -> Column(5, None),
					6 -> Column(6, None),
					7 -> Column(7, None),
					8 -> Column(8, None),
					9 -> Column(9, None),
					10 -> Column(10, None),
					11 -> Column(11, None),
					12 -> Column(12, None),
					)
		}

		val emptyBoard: Board = Board(Nil, initialColumns)

	}


	trait GameStatus
	object AddingPlayers extends GameStatus
	object TurnStart extends GameStatus
	object ChoosingContinue extends GameStatus
	object DicesRolled extends GameStatus

	case class Game(players: List[Player], currentPlayer: Int, currentStatus: GameStatus, board: Board)


	object Game {

		val MaximumPlayersReachedException = "No more players allowed (only 4)"
		val PlayerIsAlreadyInTheGame = "The player is already in"
		val NotValidPlayer = "You're not a valid player"

		def getNewPlayerColor(currentColorsInUse: List[Color]): Either[String, Color] = {
			val allColors: Set[Color] = Set(Red, Green, Blue, Yellow)
			val availableColors: Set[Color] = (allColors -- currentColorsInUse)
			availableColors.headOption.toRight(MaximumPlayersReachedException)
		}

		def addPlayer(name: String, currentGame: Game): Either[String, (Player, Game)] =
			if(currentGame.players.filter(_.name == name).nonEmpty) Left(PlayerIsAlreadyInTheGame) else {
				val colorsInUse: List[Color] = currentGame.players.map(_.color)
				for {
					newPlayerColor <- getNewPlayerColor(colorsInUse)
					newPlayer: Player = Player(name, newPlayerColor)
					newPlayerList: List[Player] = newPlayer :: currentGame.players
					newGame = currentGame.copy(players=newPlayerList)
				} yield (newPlayer, newGame)
		}

		private def showGameAddingPlayers(game: Game): String = {
			val players: String = game.players.map(p => s"  ${p.color}: ${p.name}").mkString("\n")
			val playersDesc: String = if(players.isEmpty()) "No hay jugadores!" else s"Jugadores: \n$players"

			playersDesc
		}

		private def showGameTurn(game: Game): String = {

			val boardInfo: String = game.players.map { player =>
				val introLine = s"  ${player.name}: \n"
				val playerTokens = game.board.tokens.filter(_.player == player).groupBy(_.column.id).toList.sortBy(_._1).map{
					case (columnID, t1 :: t2 :: Nil) => if(t1.temporal) {
						val tokenString = s"${t2.height} (temp: ${t1.height})/ max: ${getColumnMaxHeight(columnID)}"
						s"Column $columnID: $tokenString"
					} else {
						val tokenString = s"${t1.height} (temp: ${t2.height})/ max: ${getColumnMaxHeight(columnID)}"
						s"Column $columnID: $tokenString"
					}
					case (columnID, token :: Nil) => {
						val tokenString = if(token.temporal) s"0 (temp: ${token.height})/ max: ${getColumnMaxHeight(columnID)}" else s"${token.height}/ max: ${getColumnMaxHeight(columnID)}"
						s"Column $columnID: $tokenString"
					}
				}.mkString("\n")
				introLine + playerTokens
			}.mkString("\n")

			boardInfo
		}

		def showGame(game: Game): String = game.currentStatus match {
			case AddingPlayers => showGameAddingPlayers(game)
			case _ => showGameTurn(game)
		}

		def init(config: Game, prng: PRNG): (Game, PRNG) = {
			val firstTurn = prng.getLong % config.players.size
			val newGame = config.copy(currentPlayer = firstTurn.toInt, currentStatus = TurnStart)
			(newGame, prng.next)
		}

		def setNextTurnPlayer(config:Game): Game =
			config.copy(currentPlayer = (config.currentPlayer + 1) % config.players.size)

		def cleanTemporalTokens(config:Game): Game = {
			val newTokenList = config.board.tokens.filter(!_.temporal)
			val newBoard = config.board.copy(tokens = newTokenList)
			config.copy(board = newBoard)
		}

		def assignTemporalTokens(config: Game): Game = {
			val curTokens = config.board.tokens.filter(token => token.temporal)
			val curPlayer = config.players(config.currentPlayer)
			val nextTokenList = curTokens.foldLeft(config.board.tokens){ case (tokens, token) =>
				token.copy(temporal = false) :: (tokens.filterNot(t => t.column == token.column && t.player == curPlayer))
			}
			val newBoard = config.board.copy(tokens = nextTokenList)
			config.copy(board = newBoard)
		}

		def closeColumns(config: Game): Game = {
			val nextColumns = config.board.columns.map{ case (id, column) =>
				val maxToken = config.board.tokens.maxBy(_.height)
				if(maxToken.height == getColumnMaxHeight(id)) (id, column.copy(closed = Some(maxToken.player)))
				else (id, column)
			}

			val nextTokens = config.board.tokens.filter(t => nextColumns(t.column.id).closed.isEmpty)

			val newBoard = config.board.copy(tokens = nextTokens, columns = nextColumns)
			config.copy(board = newBoard)
		}

		def checkIfCurrentPlayerWins(config: Game): Option[Player] = {
			val currentPlayer = config.players(config.currentPlayer)
			val columnsClosedByPlayer = config.board.columns.filter{case(_, column) => column.closed.contains(currentPlayer)}
			if(columnsClosedByPlayer.size >= 3) Some(currentPlayer)
			else None
		}

		class CombinationsPossibilities(val list: List[Int])
		case class BothPossible(a: Int, b: Int) extends CombinationsPossibilities(List(a, b)) { override val toString: String = s"/${a}and${b}" }
		case class OneToChoosePossible(a: Int, b: Int) extends CombinationsPossibilities(List(a, b)) { override val toString: String = s"/${a}  /${b}" }
		case class OnePossible(a: Int) extends CombinationsPossibilities(List(a)) { override val toString: String = s"/${a}" }
		case object NonePossible extends CombinationsPossibilities(List()) { override val toString: String = s"" }

		def isColumnClosed(columnID: ColumnID, config: Game): Boolean = config.board.columns(columnID).closed.isDefined

		def getNumTemporalTokensAvailable(config: Game): Int = 3 - (config.board.tokens.filter(_.temporal).size)

		def addToken(token: Token, config: Game): Game = {
			val newTokenList = token :: config.board.tokens
			val newBoard = config.board.copy(tokens = newTokenList)
			config.copy(board = newBoard)
		}

		def deleteToken(token: Token, config: Game): Game = {
			val newTokenList = config.board.tokens.filter(_ != token)
			val newBoard = config.board.copy(tokens = newTokenList)
			config.copy(board = newBoard)
		}

		def attemptMove(columnToMove: Int, config: Game): Option[Game] = for {
			column <- {
				val ret = config.board.columns.get(columnToMove)
				ret
			}
			_ <- {
				val ret = if(isColumnClosed(columnToMove, config)) None else Some()
				ret
			}
			currentPlayer = config.players(config.currentPlayer)
			tokens = config.board.tokens.filter(t => t.temporal && t.column.id == columnToMove && t.player == currentPlayer)
			game <- {
				if(tokens.isEmpty){
					if(getNumTemporalTokensAvailable(config) > 0) Some(addToken(Token(currentPlayer, column, 0, true), config))
					else None
				} else {
					val maxToken = tokens.maxBy(_.height)
					if(maxToken.height < (getColumnMaxHeight(columnToMove)-1)) Some(deleteToken(maxToken, (addToken(maxToken.copy(height = maxToken.height + 1), config))))
					else None
				}
			}
		} yield game

		private def filterOneCombination(a: Int, b: Int, numTokensAvailable: Int, config: Game): CombinationsPossibilities = {
			lazy val bothMovesResult = for {
				afterFirstMove <- attemptMove(a, config)
				afterSecondMove <- attemptMove(b, afterFirstMove)
			} yield afterSecondMove

			lazy val aMoveResult = attemptMove(a, config)
			lazy val bMoveResult = attemptMove(b, config)

			if(bothMovesResult.isDefined) BothPossible(a, b)
			else (aMoveResult, bMoveResult) match {
				case (Some(_), Some(_)) => OneToChoosePossible(a, b)
				case (None, Some(_)) => OnePossible(b)
				case (Some(_), None) => OnePossible(a)
				case (None, None) => NonePossible
			}
		}



		def filter(combinations: Combinations, config: Game): (CombinationsPossibilities, CombinationsPossibilities, CombinationsPossibilities) = {
			val temporalTokensPlaced = config.board.tokens.filter(_.temporal)
			val numTemporalTokensAvailable = 3 - temporalTokensPlaced.size


			val ((a1, b1), (a2, b2), (a3, b3)) = combinations

			(filterOneCombination(a1, b1, numTemporalTokensAvailable, config),
				filterOneCombination(a2, b2, numTemporalTokensAvailable, config),
				filterOneCombination(a3, b3, numTemporalTokensAvailable, config))

		}

	}
}

A  => src/main/scala/org/gDanix/games/cant_stop_me_now_telegram_bot/Main.scala +157 -0
@@ 1,157 @@
package org.gDanix.games.cant_stop_me_now_telegram_bot


import org.gDanix.games.cant_stop_me_now_telegram_bot.CSMNT._
import org.gDanix.games.cant_stop_me_now_telegram_bot.CSMNT.Game._
import cats.effect.{ IO, IOApp, ExitCode, Timer }
import canoe.api._
import canoe.syntax._
import fs2.Stream
import canoe.models.messages.TextMessage
import java.util.GregorianCalendar
import canoe.models.Chat
import scala.util.Try


object MainTest extends App {

}


object Main extends IOApp {

	var currentGame = Game(Nil, -1, AddingPlayers, Board.emptyBoard)
	var prng = PRNG.fromSeed(System.currentTimeMillis())

  def run(args: List[String]): IO[ExitCode] =
    Stream
      .resource(TelegramClient.global[IO](args(0)))
      .flatMap { implicit client => Bot.polling[IO].follow(resetGame, showGame, addMe, startGame) }
      .compile.drain.as(ExitCode.Success)

  def resetGame[F[_]: TelegramClient: Timer]: Scenario[F, Unit] =
    for {
      chat <- Scenario.expect(command("resetgame").chat)
      _     = { currentGame = Game(Nil, -1, AddingPlayers, Board.emptyBoard) }
      _    <- Scenario.eval(chat.send("Ok!"))
    } yield ()

  def showGame[F[_]: TelegramClient: Timer]: Scenario[F, Unit] =
    for {
      chat <- Scenario.expect(command("showgame").chat)
      _    <- Scenario.eval(chat.send(Game.showGame(currentGame)))
    } yield ()

  def addMe[F[_]: TelegramClient: Timer]: Scenario[F, Unit] =
    for {
      messageInfo <- Scenario.expect(command("addme"))
      _ <- if(currentGame.currentStatus == AddingPlayers) commandAddPlayer(messageInfo) else Scenario.eval(messageInfo.chat.send("La partida ya está empezada"))
    } yield ()

  def commandAddPlayer[F[_]: TelegramClient: Timer](messageInfo: TextMessage): Scenario[F, Unit] = {
    	val chat = messageInfo.chat
    	val messageOrExc = for {
      	author <- messageInfo.from.toRight(NotValidPlayer)
      	newPlayerAndGame <- addPlayer(author.firstName, currentGame)
      	(newPlayer , newGame) = newPlayerAndGame
      	_ = { currentGame = newGame }
      } yield s"Ok! Añadido: $newPlayer"

      val message = messageOrExc.merge

      for{
      	_ <- Scenario.eval(chat.send(message))
      } yield ()
    }

  def startGame[F[_]: TelegramClient: Timer]: Scenario[F, Unit] =
   	for {
   		chat <- Scenario.expect(command("startgame").chat)
   		_ <- if(currentGame.currentStatus != AddingPlayers){
   				Scenario.eval(chat.send("The game has already started"))
   			} else {
   				if(currentGame.players.size == 0) {
   					Scenario.eval(chat.send("No players to start the game"))
   				} else {
   					val (nextGame, nextPrng) = Game.init(currentGame, prng); currentGame = nextGame; prng = nextPrng
   					Scenario.eval(chat.send("Ok!")).flatMap(_ => comienzaTurno(chat))
   				}
   			}
   	} yield ()

   	def comienzaTurno[F[_]: TelegramClient: Timer](chat: Chat): Scenario[F, Unit] =
   		for {
   			_ <- Scenario.eval(chat.send(s"Your turn, ${currentGame.players(currentGame.currentPlayer).name}"))
   			_ <- tiraDados(chat)
   		} yield ()

   	def parseOption(combinations: (CombinationsPossibilities, CombinationsPossibilities, CombinationsPossibilities), text: String): Option[Either[Int, (Int, Int)]] = {
   		def parseResult: CombinationsPossibilities => Option[Either[Int, (Int, Int)]] = {
   			case BothPossible(a, b) => Some(Right(a, b))
   			case OnePossible(a) => Some(Left(a))
   			case NonePossible => None
   		}

   		(combinations._1.toString, combinations._2.toString, combinations._3.toString) match {
   			case (`text`, _, _) => parseResult(combinations._1)
   			case (_, `text`, _) => parseResult(combinations._2)
   			case (_, _, `text`) => parseResult(combinations._3)
   			case _ => combinations match {
   				case (OneToChoosePossible(a, b), _, _) if(s"/$a" == text || s"/$b" == text) => if(s"/$a" == text) Some(Left(a)) else Some(Left(b))
   				case (_, OneToChoosePossible(a, b), _) if(s"/$a" == text || s"/$b" == text) => if(s"/$a" == text) Some(Left(a)) else Some(Left(b))
   				case (_, _, OneToChoosePossible(a, b)) if(s"/$a" == text || s"/$b" == text) => if(s"/$a" == text) Some(Left(a)) else Some(Left(b))
   				case _ => None
   			}
   		}
   	}

   	def askOptions[F[_]: TelegramClient: Timer](chat: Chat, combinations: (CombinationsPossibilities, CombinationsPossibilities, CombinationsPossibilities)): Scenario[F, Either[Int, (Int, Int)]] =
   		for {
   			_ <- Scenario.eval(chat.send(s"Possible moves:\n${combinations._1}\n${combinations._2}\n${combinations._3}"))
   			message <- Scenario.expect(textMessage)
   			parsedFinally <- parseOption(combinations, message.text) match {
   				case Some(parsed) => Scenario.pure[F](parsed)
   				case None => Scenario.eval(chat.send("Incorrect option")).flatMap(_ => askOptions(chat, combinations))
   			}
   		} yield parsedFinally

   	def tiraDados[F[_]: TelegramClient: Timer](chat: Chat): Scenario[F, Unit] = {
   		val (rolledDices, nextPRNG) = rollDices(prng)
   		prng = nextPRNG
   		val combinations = getCombinations(rolledDices)
   		val filteredCombinations = Game.filter(combinations, currentGame)
   		if(filteredCombinations == (NonePossible, NonePossible, NonePossible)) {
   			for{
   				_ <- Scenario.eval(chat.send(s"Dice roll: ${rolledDices._1}${rolledDices._2}${rolledDices._3}${rolledDices._4}"))
   				_ <- Scenario.eval(chat.send("No possible moves..."))
   				_ = { currentGame = cleanTemporalTokens(setNextTurnPlayer(currentGame))}
   				_ <- comienzaTurno(chat)
   			} yield ()
   		} else {
   			for {
   				_ <- Scenario.eval(chat.send(s"Dice roll: ${rolledDices._1}${rolledDices._2}${rolledDices._3}${rolledDices._4}"))
   				option <- askOptions(chat, filteredCombinations)
   				_ = option match {
   					case Left(columnID) => { currentGame = attemptMove(columnID, currentGame).get }
   					case Right((aColumnID, bColumnID)) => { currentGame = attemptMove(bColumnID, attemptMove(aColumnID, currentGame).get).get }
   				}
					_ = { currentGame = closeColumns(currentGame) }
					_ <- if(checkIfCurrentPlayerWins(currentGame).isDefined) {
						Scenario.eval(chat.send(s"Congratulations!! Player ${currentGame.players(currentGame.currentPlayer).name} wins!!"))
					} else {
						for {
							_ <- Scenario.eval(chat.send(Game.showGame(currentGame) + "\n\n/pass or /continue"))
							nextAction <- Scenario.expect(textMessage)
							_ <- if(nextAction.text == "/continue") tiraDados(chat)
									else {
										currentGame = setNextTurnPlayer(assignTemporalTokens(currentGame))
										Scenario.eval(chat.send(s"I'll take it as a pass")).flatMap(_ => comienzaTurno(chat))
									}
						} yield ()
					}
   			} yield ()
   		}

   	}

}

A  => src/main/scala/org/gDanix/games/cant_stop_me_now_telegram_bot/PRNG.scala +16 -0
@@ 1,16 @@
package org.gDanix.games.cant_stop_me_now_telegram_bot

import cats.data.State

object PRNG {
	type PRNGState = State[PRNG, Long]

	final case class PRNG(seed: Long) {
  	lazy val next: PRNG = PRNG(seed * 6364136223846793005L + 1442695040888963407L)
  	val getLong: Long = seed
  	lazy val toState: State[PRNG, Long] = State(current => (current.next, current.getLong))
	}

	def fromSeed(seed:Long): PRNG = PRNG(seed).next
//	def fromSeed(seed: Long): PRNGState = PRNG(seed).next.toState
}