package industries.sunshine.planningpoker import industries.sunshine.planningpoker.common.Models.* import cats.effect.{Ref, Concurrent} import cats.syntax.all._ import fs2.Stream import fs2.concurrent.Topic import cats.data.EitherT enum RoomError { case RoomAlreadyExists(name: String) case RoomMissing(name: String) case RoomPassIncorrect case NickPassIncorrect } trait RoomService[F[_]] { def createRoom( roomName: String, nickName: String, nickPassword: String, roomPassword: String ): F[Either[RoomError, Room]] def updateRoom(roomId: RoomID, roomUpd: Room => Room): F[Unit] def joinRoom( id: RoomID, nickName: String, nickPassword: String, roomPassword: String ): F[Either[RoomError, PlayerID]] def acceptVote(roomID: RoomID, playerID: PlayerID, vote: String): F[Unit] def endVoting(roomID: RoomID, playerID: PlayerID): F[Unit] def startNewPoll(roomID: RoomID, playerID: PlayerID): F[Unit] def leaveRoom(roomID: RoomID, playerID: PlayerID): F[Unit] def deleteRoom(roomID: RoomID): F[Unit] def getRoom(roomID: RoomID): F[Option[Room]] def subscribe(roomID: RoomID): Stream[F, Room] } class InMemoryRoomService[F[_]: Concurrent](stateRef: Ref[F, Map[RoomID, (Room, Topic[F, Room])]]) extends RoomService[F] { // TODO accept allowed cards and separate request override def createRoom( roomName: String, nickName: String, nickPassword: String, roomPassword: String ): F[Either[RoomError, Room]] = { for { updatesTopic <- Topic[F, Room] room <- stateRef.modify { rooms => val roomId = RoomID(roomName) rooms.get(roomId) match { case Some(_) => rooms -> RoomError.RoomAlreadyExists(roomName).asLeft[Room] case None => val ownerPlayer = Player.create(nickName) val newRoom = Room( roomId, players = List(ownerPlayer), owner = ownerPlayer.name, password = roomPassword, allowedCards = List("XS", "S", "M", "L", "XL"), // TODO accept from front round = RoundState.Voting(Map.empty), playersPasswords = Map(nickName -> nickPassword) ) rooms.updated(newRoom.id, (newRoom, updatesTopic)) -> newRoom.asRight[RoomError] } } } yield room } override def updateRoom(roomId: RoomID, roomUpd: Room => Room): F[Unit] = { for { // modify is function to update state and compute auxillary value to return, here - topic publishUpd <- stateRef.modify[F[Unit]] { state => state.get(roomId) match { case Some((oldRoom, topic)) => val newRoom = roomUpd(oldRoom) state.updated(roomId, (newRoom, topic)) -> topic.publish1(newRoom).void case None => throw new IllegalStateException(s"updateRoom with $roomId on nonexistent room") } } _ <- publishUpd // update and publish are not atomic, sadly races can happen (TODO use atomic ref) } yield () } override def acceptVote(roomID: RoomID, playerID: PlayerID, vote: String): F[Unit] = updateRoom( roomID, room => room.round match { case RoundState.Viewing(_) => room case RoundState.Voting(votes) => if (room.allowedCards.contains(vote)) room.copy(round = RoundState.Voting(votes.updated(playerID, vote))) else room } ) // TODO check permission override def endVoting(roomID: RoomID, playerID: PlayerID): F[Unit] = updateRoom( roomID, room => room.round match { case RoundState.Viewing(_) => room case RoundState.Voting(votes) => room.copy(round = RoundState.Viewing(votes)) } ) override def startNewPoll(roomID: RoomID, playerID: PlayerID): F[Unit] = updateRoom( roomID, room => room.round match { case RoundState.Viewing(_) => room.copy(round = RoundState.Voting(Map.empty)) case RoundState.Voting(votes) => room } ) /** removes player from the active players keeps information on nick password, if one was present */ override def leaveRoom(roomID: RoomID, playerID: PlayerID): F[Unit] = updateRoom( roomID, room => room.copy( players = room.players.filterNot(_.id == playerID), round = room.round.removePlayer(playerID) ) ) override def deleteRoom(roomID: RoomID): F[Unit] = { for { topic <- stateRef.modify[Topic[F, Room]](state => state.get(roomID) match { case Some((oldRoom, topic)) => state.removed(roomID) -> topic case None => throw new IllegalStateException(s"call to delete with $roomID on nonexistent room") // TODO - i'd prefer to swallow these errors } ) _ <- topic.close } yield () } override def getRoom(roomID: RoomID): F[Option[Room]] = { stateRef.get.map(_.get(roomID).map(_._1)) } override def joinRoom( id: RoomID, nickName: String, nickPassword: String, roomPassword: String ): F[Either[RoomError, PlayerID]] = { /** pure function that adds the player to the room need to cover cases: * - player already present, then return as is, i guess * - nick not known - add new player and new nick-password mapping * - nick known - add new player */ def addPlayer(room: Room): (PlayerID, Room) = { room.players.find(_.name == nickName) match { case Some(player) => player.id -> room case None => // player is not present, but potentially was previously val addingPlayer = Player.create(nickName) val roomWithPlayer = room.copy(players = addingPlayer :: room.players) room.playersPasswords.get(nickName) match { case Some(_) => addingPlayer.id -> roomWithPlayer case None => addingPlayer.id -> roomWithPlayer.copy(playersPasswords = roomWithPlayer.playersPasswords.updated(nickName, nickPassword) ) } } } /** to be executed under Ref.modify (i.e with state acquired) checks of whether player can be * added to the room: * - room password is correct * - nickname is either not taken, or correct password was provided * @returns * playerId (new or existing), updatedRoom (to be put into state), topic (to send the udpdate * notification) */ def getWithChecks( rooms: Map[RoomID, (Room, Topic[F, Room])] ): Either[RoomError, (PlayerID, Room, Topic[F, Room])] = for { roomAndTopic <- rooms.get(id).toRight(RoomError.RoomMissing(id.name)) (room, topic) = roomAndTopic _ <- Either.cond(room.password == roomPassword, (), RoomError.RoomPassIncorrect) isNickPassCorrect = room.playersPasswords .get(nickName) .fold(true)(existingPass => existingPass == nickPassword) _ <- Either.cond( isNickPassCorrect, (), RoomError.NickPassIncorrect ) (playerId, updatedRoom) = addPlayer(room) } yield (playerId, updatedRoom, topic) // modify returns tuple (updatedState, valueToReturn) // this particular update either keeps room as is, or adds the player // and returns playerId and topic to be used outside // // NOTE here i have a lot of handwaving to pass topic outside // because it's not possible to send F[Unit] update // inside of the stateRef update (which works with pure functions?), // so room notification change has to be returned to outside val maybeUpdatedStateAndNotification = stateRef.modify { rooms => val maybeAddedUser = getWithChecks(rooms) val updatedState = maybeAddedUser.fold( _ => rooms, { case (playerId, updatedRoom, topic) => rooms.updated(id, (updatedRoom, topic)) } ) val toReturn = maybeAddedUser.map { case (id, updatedRoom, topic) => (id, topic.publish1(updatedRoom).void) } updatedState -> toReturn } // now combining the effects : getting (updatedState & notificationEffect) or error // executing notification // returning only playerId val result = for { updatedState <- EitherT(maybeUpdatedStateAndNotification) (playerId, notification) = updatedState _ <- EitherT.liftF(notification) } yield (playerId) result.value } override def subscribe(roomID: RoomID): Stream[F, Room] = Stream .eval(stateRef.get) .flatMap(rooms => rooms.get(roomID) match { case Some((room, topic)) => topic.subscribe(10) case None => Stream.empty } ) } object RoomService { def make[F[_]: Concurrent]: F[RoomService[F]] = { Ref.of[F, Map[RoomID, (Room, Topic[F, Room])]](Map.empty).map(new InMemoryRoomService[F](_)) } }