efim d7d4e2be9d bugfix: need to recognize owner after relogin
playerid is not stable right now, need to use nickname
2023-04-28 11:54:51 +04:00

252 lines
8.7 KiB
Scala

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](_))
}
}