More hook stuff
parent
74e1c9a0e1
commit
75f416f6aa
|
@ -9,8 +9,7 @@ import mvc._
|
|||
object AppXhrC extends LilaController {
|
||||
|
||||
private val xhr = env.appXhr
|
||||
private val pinger = env.pinger
|
||||
private val syncer = env.syncer
|
||||
private val syncer = env.appSyncer
|
||||
|
||||
def sync(gameId: String, color: String, version: Int, fullId: String) = Action {
|
||||
JsonOk(syncer.sync(gameId, color, version, Some(fullId)).unsafePerformIO)
|
||||
|
@ -27,7 +26,7 @@ object AppXhrC extends LilaController {
|
|||
}
|
||||
|
||||
def ping() = Action { implicit request =>
|
||||
JsonOk(pinger.ping(
|
||||
JsonOk(env.pinger.ping(
|
||||
get("username"),
|
||||
get("player_key"),
|
||||
get("watcher"),
|
||||
|
|
|
@ -19,7 +19,11 @@ object LobbyApiC extends LilaController {
|
|||
IOk(api.inc)
|
||||
}
|
||||
|
||||
def create(hookOwnerId: String) = Action { implicit request =>
|
||||
def create(hookOwnerId: String) = Action {
|
||||
IOk(api.create(hookOwnerId))
|
||||
}
|
||||
|
||||
def remove(hookId: String) = Action {
|
||||
IOk(api.remove(hookId))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,13 +9,14 @@ import mvc._
|
|||
object LobbyXhrC extends LilaController {
|
||||
|
||||
private val xhr = env.lobbyXhr
|
||||
private val syncer = env.lobbySyncer
|
||||
|
||||
def syncWithHook(hookId: String) = sync(Some(hookId))
|
||||
|
||||
def syncWithoutHook() = sync(None)
|
||||
|
||||
private def sync(hookId: Option[String]) = Action { implicit request =>
|
||||
JsonOk(xhr.sync(
|
||||
JsonOk(syncer.sync(
|
||||
hookId,
|
||||
getIntOr("auth", 0) == 1,
|
||||
getIntOr("state", 0)
|
||||
|
|
|
@ -17,7 +17,7 @@ sync {
|
|||
sleep = 200 milliseconds
|
||||
}
|
||||
lobby {
|
||||
poll {
|
||||
sync {
|
||||
duration = 10 seconds
|
||||
sleep = 300 milliseconds
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ GET /api/lobby/preload/:hookId controllers.LobbyXhrC.syncWithHook(hookId: S
|
|||
GET /api/lobby/preload controllers.LobbyXhrC.syncWithoutHook
|
||||
POST /api/lobby/inc controllers.LobbyApiC.inc
|
||||
POST /api/lobby/create/:hookOwnerId controllers.LobbyApiC.create(hookOwnerId: String)
|
||||
POST /api/lobby/remove/:hookId controllers.LobbyApiC.remove(hookId: String)
|
||||
|
||||
# Useless, but play2 needs it
|
||||
GET /assets/*file controllers.Assets.at(path="/public", file)
|
||||
|
|
|
@ -9,7 +9,7 @@ import scala.annotation.tailrec
|
|||
import scala.math.max
|
||||
import org.apache.commons.lang3.StringEscapeUtils.escapeXml
|
||||
|
||||
final class Syncer(
|
||||
final class AppSyncer(
|
||||
gameRepo: GameRepo,
|
||||
versionMemo: VersionMemo,
|
||||
aliveMemo: AliveMemo,
|
||||
|
@ -42,7 +42,7 @@ final class Syncer(
|
|||
) filterValues (null !=)
|
||||
} getOrElse failMap
|
||||
}
|
||||
} except (e ⇒ io(failMap))
|
||||
} except (e ⇒ {println(e.getMessage);io(failMap)})
|
||||
|
||||
private def renderEvents(events: List[Event], isPrivate: Boolean) =
|
||||
if (isPrivate) events map {
|
|
@ -27,4 +27,9 @@ case class LobbyApi(
|
|||
_ ← (lobbyMemo++)
|
||||
_ ← hookMemo put hookOwnerId
|
||||
} yield ()
|
||||
|
||||
def remove(hookId: String): IO[Unit] = for {
|
||||
_ ← hookRepo removeId hookId
|
||||
_ ← (lobbyMemo++)
|
||||
} yield ()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,75 @@
|
|||
package lila.system
|
||||
|
||||
import model._
|
||||
import memo._
|
||||
import db._
|
||||
import scalaz.effects._
|
||||
import scala.annotation.tailrec
|
||||
import scala.math.max
|
||||
|
||||
final class LobbySyncer(
|
||||
hookRepo: HookRepo,
|
||||
gameRepo: GameRepo,
|
||||
lobbyMemo: LobbyMemo,
|
||||
duration: Int,
|
||||
sleep: Int) {
|
||||
|
||||
type Response = Map[String, Any]
|
||||
|
||||
def sync(
|
||||
myHookId: Option[String],
|
||||
auth: Boolean,
|
||||
version: Int): IO[Response] = for {
|
||||
newVersion ← versionWait(version)
|
||||
hooks ← if (auth) hookRepo.allOpen else hookRepo.allOpenCasual
|
||||
response ← {
|
||||
val response = () ⇒ stdResponse(newVersion, hooks, myHookId)
|
||||
myHookId some { hookResponse(_, response) } none io { response() }
|
||||
}
|
||||
} yield response
|
||||
|
||||
def hookResponse(myHookId: String, response: () ⇒ Response): IO[Response] =
|
||||
hookRepo ownedHook myHookId flatMap { hookOption ⇒
|
||||
hookOption.fold(
|
||||
hook ⇒ hook.game.fold(
|
||||
ref ⇒ gameRepo game ref.getId.toString.pp map { game ⇒
|
||||
Map("redirect" -> (game fullIdOf game.creatorColor))
|
||||
},
|
||||
io { response() }
|
||||
),
|
||||
io { Map("redirect" -> "") }
|
||||
)
|
||||
}
|
||||
|
||||
def stdResponse(
|
||||
version: Int,
|
||||
hooks: List[Hook],
|
||||
myHookId: Option[String]): Response = Map(
|
||||
"state" -> version,
|
||||
"pool" -> {
|
||||
if (hooks.nonEmpty) Map("hooks" -> renderHooks(hooks, myHookId).toMap)
|
||||
else Map("message" -> "No game available right now, create one!")
|
||||
},
|
||||
"chat" -> null,
|
||||
"timeline" -> ""
|
||||
)
|
||||
|
||||
private def renderHooks(hooks: List[Hook], myHookId: Option[String]) = for {
|
||||
hook ← hooks
|
||||
} yield hook.id -> {
|
||||
hook.render ++ {
|
||||
if (myHookId == Some(hook.ownerId))
|
||||
Map("action" -> "cancel", "id" -> myHookId)
|
||||
else Map("action" -> "join", "id" -> hook.id)
|
||||
}
|
||||
}
|
||||
|
||||
private def versionWait(version: Int): IO[Int] = io {
|
||||
@tailrec
|
||||
def wait(loop: Int): Int = {
|
||||
if (loop == 0 || lobbyMemo.version != version) lobbyMemo.version
|
||||
else { Thread sleep sleep; wait(loop - 1) }
|
||||
}
|
||||
wait(max(1, duration / sleep))
|
||||
}
|
||||
}
|
|
@ -9,42 +9,6 @@ import scala.math.max
|
|||
|
||||
final class LobbyXhr(
|
||||
hookRepo: HookRepo,
|
||||
lobbyMemo: LobbyMemo,
|
||||
duration: Int,
|
||||
sleep: Int) {
|
||||
|
||||
def sync(
|
||||
myHookId: Option[String],
|
||||
auth: Boolean,
|
||||
version: Int): IO[Map[String, Any]] = for {
|
||||
newVersion ← versionWait(version)
|
||||
hooks ← if (auth) hookRepo.allOpen else hookRepo.allOpenCasual
|
||||
} yield Map(
|
||||
"state" -> newVersion,
|
||||
"pool" -> {
|
||||
if (hooks.nonEmpty) Map("hooks" -> renderHooks(hooks, myHookId).toMap)
|
||||
else Map("message" -> "No game available right now, create one!")
|
||||
},
|
||||
"chat" -> null,
|
||||
"timeline" -> ""
|
||||
)
|
||||
|
||||
private def renderHooks(hooks: List[Hook], myHookId: Option[String]) = for {
|
||||
hook ← hooks
|
||||
} yield hook.id -> {
|
||||
hook.render ++ {
|
||||
if (myHookId == Some(hook.ownerId))
|
||||
Map("action" -> "cancel", "id" -> myHookId)
|
||||
else Map("action" -> "join", "id" -> hook.id)
|
||||
}
|
||||
}
|
||||
|
||||
private def versionWait(version: Int): IO[Int] = io {
|
||||
@tailrec
|
||||
def wait(loop: Int): Int = {
|
||||
if (loop == 0 || lobbyMemo.version != version) lobbyMemo.version
|
||||
else { Thread sleep sleep; wait(loop - 1) }
|
||||
}
|
||||
wait(max(1, duration / sleep))
|
||||
}
|
||||
gameRepo: GameRepo,
|
||||
lobbyMemo: LobbyMemo) {
|
||||
}
|
||||
|
|
|
@ -20,11 +20,17 @@ final class SystemEnv(config: Config) {
|
|||
versionMemo = versionMemo,
|
||||
aliveMemo = aliveMemo)
|
||||
|
||||
lazy val appSyncer = new AppSyncer(
|
||||
gameRepo = gameRepo,
|
||||
versionMemo = versionMemo,
|
||||
aliveMemo = aliveMemo,
|
||||
duration = getMilliseconds("sync.duration"),
|
||||
sleep = getMilliseconds("sync.sleep"))
|
||||
|
||||
lazy val lobbyXhr = new LobbyXhr(
|
||||
hookRepo = hookRepo,
|
||||
lobbyMemo = lobbyMemo,
|
||||
duration = getMilliseconds("lobby.poll.duration"),
|
||||
sleep = getMilliseconds("lobby.poll.sleep"))
|
||||
gameRepo = gameRepo,
|
||||
lobbyMemo = lobbyMemo)
|
||||
|
||||
lazy val lobbyApi = new LobbyApi(
|
||||
hookRepo = hookRepo,
|
||||
|
@ -34,12 +40,12 @@ final class SystemEnv(config: Config) {
|
|||
aliveMemo = aliveMemo,
|
||||
hookMemo = hookMemo)
|
||||
|
||||
lazy val syncer = new Syncer(
|
||||
lazy val lobbySyncer = new LobbySyncer(
|
||||
hookRepo = hookRepo,
|
||||
gameRepo = gameRepo,
|
||||
versionMemo = versionMemo,
|
||||
aliveMemo = aliveMemo,
|
||||
duration = getMilliseconds("sync.duration"),
|
||||
sleep = getMilliseconds("sync.sleep"))
|
||||
lobbyMemo = lobbyMemo,
|
||||
duration = getMilliseconds("lobby.sync.duration"),
|
||||
sleep = getMilliseconds("lobby.sync.sleep"))
|
||||
|
||||
lazy val pinger = new Pinger(
|
||||
aliveMemo = aliveMemo,
|
||||
|
|
|
@ -45,7 +45,7 @@ class GameRepo(collection: MongoCollection)
|
|||
update(DBObject("_id" -> a.id), diff(encode(a), encode(b)), false, false)
|
||||
}
|
||||
|
||||
private def diff(a: RawDbGame, b: RawDbGame): DBObject = {
|
||||
def diff(a: RawDbGame, b: RawDbGame): MongoDBObject = {
|
||||
val builder = MongoDBObject.newBuilder
|
||||
def d[A](name: String, f: RawDbGame ⇒ A) {
|
||||
if (f(a) != f(b)) builder += name -> f(b)
|
||||
|
@ -70,7 +70,7 @@ class GameRepo(collection: MongoCollection)
|
|||
d("clock.timer", _.clock.get.timer)
|
||||
}
|
||||
|
||||
MongoDBObject("$set" -> builder.result)
|
||||
MongoDBObject("$set" -> builder.result.pp)
|
||||
}
|
||||
|
||||
def insert(game: DbGame): IO[Option[String]] = io {
|
||||
|
|
|
@ -12,6 +12,14 @@ import scalaz.effects._
|
|||
class HookRepo(collection: MongoCollection)
|
||||
extends SalatDAO[Hook, String](collection) {
|
||||
|
||||
def hook(hookId: String): IO[Option[Hook]] = io {
|
||||
findOneByID(hookId)
|
||||
}
|
||||
|
||||
def ownedHook(ownerId: String): IO[Option[Hook]] = io {
|
||||
findOne(DBObject("ownerId" -> ownerId))
|
||||
}
|
||||
|
||||
def allOpen = hookList(DBObject(
|
||||
"match" -> false
|
||||
))
|
||||
|
@ -24,4 +32,8 @@ class HookRepo(collection: MongoCollection)
|
|||
def hookList(query: DBObject): IO[List[Hook]] = io {
|
||||
find(query) sort DBObject("createdAt" -> 1) toList
|
||||
}
|
||||
|
||||
def removeId(id: String): IO[Unit] = io {
|
||||
remove(DBObject("id" -> id))
|
||||
}
|
||||
}
|
||||
|
|
|
@ -14,6 +14,7 @@ case class DbGame(
|
|||
turns: Int,
|
||||
clock: Option[Clock],
|
||||
lastMove: Option[String],
|
||||
creatorColor: Color,
|
||||
positionHashes: String = "",
|
||||
castles: String = "KQkq",
|
||||
isRated: Boolean = false,
|
||||
|
|
|
@ -2,6 +2,7 @@ package lila.system
|
|||
package model
|
||||
|
||||
import com.novus.salat.annotations._
|
||||
import com.mongodb.DBRef
|
||||
|
||||
case class Hook(
|
||||
@Key("_id") id: String,
|
||||
|
@ -15,7 +16,8 @@ case class Hook(
|
|||
elo: Option[Int],
|
||||
`match`: Boolean,
|
||||
eloRange: Option[String],
|
||||
engine: Boolean) {
|
||||
engine: Boolean,
|
||||
game: Option[DBRef]) {
|
||||
|
||||
def realVariant = Variant(variant) | Standard
|
||||
|
||||
|
|
|
@ -15,6 +15,7 @@ case class RawDbGame(
|
|||
turns: Int,
|
||||
clock: Option[RawDbClock],
|
||||
lastMove: Option[String],
|
||||
creatorColor: String = "white",
|
||||
positionHashes: String = "",
|
||||
castles: String = "KQkq",
|
||||
isRated: Boolean = false,
|
||||
|
@ -24,6 +25,7 @@ case class RawDbGame(
|
|||
whitePlayer ← players find (_.color == "white") flatMap (_.decode)
|
||||
blackPlayer ← players find (_.color == "black") flatMap (_.decode)
|
||||
trueStatus ← Status(status)
|
||||
trueCreatorColor ← Color(creatorColor)
|
||||
trueVariant ← Variant(variant)
|
||||
validClock = clock flatMap (_.decode)
|
||||
if validClock.isDefined == clock.isDefined
|
||||
|
@ -36,6 +38,7 @@ case class RawDbGame(
|
|||
turns = turns,
|
||||
clock = validClock,
|
||||
lastMove = lastMove,
|
||||
creatorColor = trueCreatorColor,
|
||||
positionHashes = positionHashes,
|
||||
castles = castles,
|
||||
isRated = isRated,
|
||||
|
@ -55,6 +58,7 @@ object RawDbGame {
|
|||
turns = turns,
|
||||
clock = clock map RawDbClock.encode,
|
||||
lastMove = lastMove,
|
||||
creatorColor = creatorColor.name,
|
||||
positionHashes = positionHashes,
|
||||
castles = castles,
|
||||
isRated = isRated,
|
||||
|
|
|
@ -21,7 +21,8 @@ trait Fixtures {
|
|||
status = Created,
|
||||
turns = 0,
|
||||
lastMove = None,
|
||||
clock = None
|
||||
clock = None,
|
||||
creatorColor = White
|
||||
)
|
||||
|
||||
def newDbGameWithBoard(b: Board) = newDbGame.update(Game(b), anyMove)
|
||||
|
@ -55,7 +56,8 @@ trait Fixtures {
|
|||
status = Resign,
|
||||
turns = 24,
|
||||
clock = None,
|
||||
lastMove = None
|
||||
lastMove = None,
|
||||
creatorColor = White
|
||||
)
|
||||
|
||||
lazy val dbGame2 = DbGame(
|
||||
|
@ -72,7 +74,8 @@ trait Fixtures {
|
|||
whiteTime = 196250,
|
||||
blackTime = 304100
|
||||
).some,
|
||||
lastMove = Some("a7 c7")
|
||||
lastMove = Some("a7 c7"),
|
||||
creatorColor = White
|
||||
)
|
||||
|
||||
// { "_id" : "7xfxoj4v", "clock" : null, "createdAt" : ISODate("2012-01-28T01:55:33Z"), "creatorColor" : Black, "initialFen" : "rkbbnnqr/pppppppp/8/8/8/8/PPPPPPPP/RKBBNNQR w KQkq - 0 1", "lastMove" : "a3 a8", "pgn" : "d4 d5 f3 Bf5 Ne3 Nd6 Bd2 c6 g4 Bb6 gxf5 Nd7 Qg5 f6 Qg4 h5 Qh4 Rh6 N1g2 Rg6 Qf2 Rg5 c3 e6 Kc1 exf5 Kb1 f4 Nxf4 Nf5 h4 Nxe3 hxg5 Nxd1 Qf1 Nxc3+ Bxc3 a5 Qf2 Nc5 Kc1 Ra6 Rh2 fxg5 dxc5 gxf4 cxb6 Rxb6 Rxh5 g6 Qxb6 Qe6 Qd8+ Qc8 Qxc8+ Kxc8 Rh2 a4 Kc2 b5 Rh7 c5 Bg7 a3 b3 c4 Bf6 cxb3+ axb3 b4 Be5 g5 Bd6 Kd8 Bxb4 d4 Rxa3 d3+ exd3 g4 Ra8#", "players" : [ { "aiLevel" : 1, "color" : White, "id" : "jqsx", "isAi" : true, "isWinner" : true, "ps" : "zb6 dB 6Q12 uN4 DN18 4r76 kk24 3r42 rp68 rP64 sP22 PP0 tp78 vp2 LP8 MP30" }, { "color" : Black, "id" : "7n7r", "ps" : "LB3 PB9 6Q51 IN11 sN5 PR41 7k55 MR17 qP37 zP59 rP7 tP1 DP23 Dp13 Ep49 NP15" } ], "status" : 30, "turns" : 81, "updatedAt" : ISODate("2012-01-28T02:01:28Z"), "userIds" : [ ], "variant" : 2, "winnerUserId" : "" }
|
||||
|
@ -84,7 +87,8 @@ trait Fixtures {
|
|||
status = Mate,
|
||||
turns = 81,
|
||||
clock = None,
|
||||
lastMove = Some("a3 a8")
|
||||
lastMove = Some("a3 a8"),
|
||||
creatorColor = White
|
||||
)
|
||||
|
||||
lazy val dbGame4 = DbGame(
|
||||
|
@ -95,7 +99,8 @@ trait Fixtures {
|
|||
status = Resign,
|
||||
turns = 24,
|
||||
clock = None,
|
||||
lastMove = None
|
||||
lastMove = None,
|
||||
creatorColor = White
|
||||
)
|
||||
|
||||
// from online prod DB
|
||||
|
@ -114,7 +119,8 @@ trait Fixtures {
|
|||
whiteTime = 27610,
|
||||
blackTime = 60240
|
||||
)),
|
||||
lastMove = Some("d8 d2")
|
||||
lastMove = Some("d8 d2"),
|
||||
creatorColor = White
|
||||
)
|
||||
|
||||
def newMove(
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package lila.system
|
||||
|
||||
import model._
|
||||
import lila.chess._
|
||||
import scalaz.{ Success, Failure }
|
||||
import scalaz.effects._
|
||||
|
@ -11,68 +12,77 @@ class GameRepoTest extends SystemTest {
|
|||
val repo = env.gameRepo
|
||||
val anyGame = repo.findOne(DBObject()) flatMap repo.decode get // unsafe but who cares
|
||||
|
||||
"the game repo" should {
|
||||
"find a game" in {
|
||||
"by ID" in {
|
||||
"non existing" in {
|
||||
repo game "haha" must beIO.failure
|
||||
}
|
||||
"existing" in {
|
||||
repo game anyGame.id must beIO.like {
|
||||
case g ⇒ g.id must_== anyGame.id
|
||||
}
|
||||
"diff" should {
|
||||
"empty" in {
|
||||
val raw = RawDbGame encode newDbGame
|
||||
repo.diff(raw, raw) must_== MongoDBObject("$set" -> DBObject())
|
||||
}
|
||||
"pgn" in {
|
||||
val raw = RawDbGame encode newDbGame
|
||||
val newRaw = raw.copy(pgn = "foo")
|
||||
repo.diff(raw, newRaw) must_== MongoDBObject("$set" -> DBObject("pgn" -> "foo"))
|
||||
}
|
||||
}
|
||||
"find a game" should {
|
||||
"by ID" in {
|
||||
"non existing" in {
|
||||
repo game "haha" must beIO.failure
|
||||
}
|
||||
"existing" in {
|
||||
repo game anyGame.id must beIO.like {
|
||||
case g ⇒ g.id must_== anyGame.id
|
||||
}
|
||||
}
|
||||
}
|
||||
"find a player" in {
|
||||
"by private ID" in {
|
||||
"non existing" in {
|
||||
repo player "huhu" must beIO.failure
|
||||
}
|
||||
"existing" in {
|
||||
val player = anyGame.players.head
|
||||
anyGame fullIdOf player map repo.player must beSome.like {
|
||||
case iop ⇒ iop must beIO.like {
|
||||
case (g, p) ⇒ p.id must_== player.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"find a player" should {
|
||||
"by private ID" in {
|
||||
"non existing" in {
|
||||
repo player "huhu" must beIO.failure
|
||||
}
|
||||
"by ID and color" in {
|
||||
"non existing" in {
|
||||
repo.player("haha", White) must beIO.failure
|
||||
}
|
||||
"existing" in {
|
||||
val player = anyGame.players.head
|
||||
repo.player(anyGame.id, player.color) must beIO.like {
|
||||
"existing" in {
|
||||
val player = anyGame.players.head
|
||||
anyGame fullIdOf player map repo.player must beSome.like {
|
||||
case iop ⇒ iop must beIO.like {
|
||||
case (g, p) ⇒ p.id must_== player.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"insert a new game" in {
|
||||
val game = newDbGameWithRandomIds()
|
||||
"find the saved game" in {
|
||||
(for {
|
||||
_ ← repo insert game
|
||||
newGame ← repo game game.id
|
||||
} yield newGame) must beIO.like {
|
||||
case g ⇒ g must_== game
|
||||
}
|
||||
"by ID and color" in {
|
||||
"non existing" in {
|
||||
repo.player("haha", White) must beIO.failure
|
||||
}
|
||||
}
|
||||
"update a game" in {
|
||||
val game = newDbGameWithRandomIds()
|
||||
val updated = game.copy(turns = game.turns + 1)
|
||||
"find the updated game" in {
|
||||
(for {
|
||||
_ ← repo insert game
|
||||
_ ← repo save updated
|
||||
newGame ← repo game game.id
|
||||
} yield newGame) must beIO.like {
|
||||
case g ⇒ g must_== updated
|
||||
"existing" in {
|
||||
val player = anyGame.players.head
|
||||
repo.player(anyGame.id, player.color) must beIO.like {
|
||||
case (g, p) ⇒ p.id must_== player.id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"insert a new game" should {
|
||||
val game = newDbGameWithRandomIds()
|
||||
"find the saved game" in {
|
||||
(for {
|
||||
_ ← repo insert game
|
||||
newGame ← repo game game.id
|
||||
} yield newGame) must beIO.like {
|
||||
case g ⇒ g must_== game
|
||||
}
|
||||
}
|
||||
}
|
||||
"update a game" should {
|
||||
val game = newDbGameWithRandomIds()
|
||||
val updated = game.copy(turns = game.turns + 1)
|
||||
"find the updated game" in {
|
||||
(for {
|
||||
_ ← repo insert game
|
||||
_ ← repo save updated
|
||||
newGame ← repo game game.id
|
||||
} yield newGame) must beIO.like {
|
||||
case g ⇒ g must_== updated
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue