Checkpoint before multisocketing

This commit is contained in:
Thibault Duplessis 2012-04-15 23:18:46 +02:00
parent b98154109c
commit 8f1677e198
29 changed files with 294 additions and 105 deletions

View file

@ -12,6 +12,7 @@ import scalaz.effects._
import socket._
import lobby._
import game._
import RichDuration._
final class Cron(env: SystemEnv)(implicit app: Application) {
@ -22,10 +23,12 @@ final class Cron(env: SystemEnv)(implicit app: Application) {
env.lobbyHub -> WithHooks(env.hookMemo.putAll)
}
spawn("nb_players", 5 seconds) {
Future.traverse(env.lobbyHub :: env.gameHubMaster :: Nil)(a
(a ? GetNbMembers).mapTo[Int]
) map (xs NbPlayers(xs.sum)) pipeTo env.lobbyHub pipeTo env.gameHubMaster
spawn("nb_players", 1 seconds) {
pipeToHubs {
Future.traverse(hubs)(a
(a ? GetNbMembers).mapTo[Int]
) map (xs NbPlayers(xs.sum))
}
}
spawnIO("hook_cleanup_dead", 2 seconds) {
@ -36,8 +39,14 @@ final class Cron(env: SystemEnv)(implicit app: Application) {
env.hookRepo.cleanupOld
}
spawnMessage("online_username", 3 seconds) {
env.lobbyHub -> WithUsernames(env.userRepo.updateOnlineUsernames)
spawn("online_username", 3 seconds) {
Future.traverse(hubs) { a
(a ? GetUsernames).mapTo[Iterable[String]]
} map (_.flatten) onComplete {
case Right(usernames)
(env.userRepo updateOnlineUsernames usernames).unsafePerformIO
case Left(e) println(e)
}
}
spawnIO("game_cleanup_unplayed", 2 hours) {
@ -55,14 +64,20 @@ final class Cron(env: SystemEnv)(implicit app: Application) {
}
def spawn(name: String, freq: Duration)(op: Unit) = {
Akka.system.scheduler.schedule(freq, freq)(op)
Akka.system.scheduler.schedule(freq, freq.randomize())(op)
}
def spawnIO(name: String, freq: Duration)(op: IO[Unit]) = {
Akka.system.scheduler.schedule(freq, freq) { op.unsafePerformIO }
Akka.system.scheduler.schedule(freq, freq.randomize()) { op.unsafePerformIO }
}
def spawnMessage(name: String, freq: Duration)(to: (ActorRef, Any)) = {
Akka.system.scheduler.schedule(freq, freq, to._1, to._2)
Akka.system.scheduler.schedule(freq, freq.randomize(), to._1, to._2)
}
def hubs = env.siteHub :: env.lobbyHub :: env.gameHubMaster :: Nil
def pipeToHubs(future: Future[_]) {
hubs foreach (future pipeTo _)
}
}

18
app/Duration.scala Normal file
View file

@ -0,0 +1,18 @@
package lila
import akka.util.Duration
import akka.util.duration._
import scala.util.Random
import scala.math.round
object RichDuration {
implicit def richDuration(d: Duration) = new {
def randomize(ratio: Float = 0.1f): Duration = {
val m = d.toMillis
val m2 = round(m + (ratio * m * 2 * Random.nextFloat) - (ratio * m))
m2 millis
}
}
}

View file

@ -44,7 +44,7 @@ final class Finisher(
def outoftimes(games: List[DbGame]): List[IO[Unit]] =
games map { g
outoftime(g).fold(
msgs putStrLn(g.id + " " + (msgs.list mkString "\n")),
msgs putStrLn(g.id + " " + msgs.shows),
_ map (_ Unit) // events are lost
): IO[Unit]
}

View file

@ -53,7 +53,7 @@ final class Hand(
def resign(fullId: String): IOValidEvents = attempt(fullId, finisher.resign)
def outoftime(fullId: String): IOValidEvents = attempt(fullId, finisher outoftime _.game)
def outoftime(ref: PovRef): IOValidEvents = attemptRef(ref, finisher outoftime _.game)
def drawClaim(fullId: String): IOValidEvents = attempt(fullId, finisher.drawClaim)
@ -108,15 +108,14 @@ final class Hand(
pov.game.clock filter (_ pov.game.playable) map { clock
val color = !pov.color
val newClock = clock.giveTime(color, moretimeSeconds)
val g2 = pov.game withClock newClock
val progress = pov.game withClock newClock
for {
progress messenger.systemMessage(
g2, "%s + %d seconds".format(color, moretimeSeconds)
) map { es
Progress(pov.game, ClockEvent(newClock).pp :: es)
}
_ gameRepo save progress
} yield progress.events
events messenger.systemMessage(
progress.game, "%s + %d seconds".format(color, moretimeSeconds)
)
progress2 = progress ++ (ClockEvent(newClock) :: events)
_ gameRepo save progress2
} yield progress2.events
} toValid "cannot add moretime"
)

View file

@ -16,7 +16,12 @@ import command._
final class SystemEnv(config: Config) {
lazy val gameHistory = () new socket.History(
lazy val siteHub = Akka.system.actorOf(Props(new site.Hub), name = "site_hub")
lazy val siteSocket = new site.Socket(
hub = siteHub)
lazy val gameHistory = () new game.History(
timeout = getMilliseconds("game.message.lifetime"))
lazy val gameHubMemo = new game.HubMemo(
@ -32,7 +37,7 @@ final class SystemEnv(config: Config) {
hubMemo = gameHubMemo,
messenger = messenger)
lazy val lobbyHistory = new socket.History(
lazy val lobbyHistory = new lobby.History(
timeout = getMilliseconds("lobby.message.lifetime"))
lazy val lobbyHub = Akka.system.actorOf(Props(new lobby.Hub(

View file

@ -11,7 +11,7 @@ trait FenBased {
newSituation Forsyth << fen toValid "Cannot parse engine FEN: " + fen
reverseEngineer = new ReverseEngineering(game, newSituation.board)
poss = reverseEngineer.move.mapFail(msgs
("ReverseEngineering failure: " + (msgs.list mkString "\n") + "\n--------\n" + game.board + "\n" + newSituation.board + "\n" + fen).wrapNel
("ReverseEngineering failure: " + msgs.shows + "\n--------\n" + game.board + "\n" + newSituation.board + "\n" + fen).wrapNel
).err
(orig, dest) = poss
newGameAndMove game(orig, dest)

View file

@ -17,7 +17,7 @@ object AiC extends LilaController {
craftyServer(fen = getOr("fen", ""), level = getIntOr("level", 1))
} map { res
res.fold(
err BadRequest(err.list mkString "\n"),
err BadRequest(err.shows),
op Ok(op.unsafePerformIO)
)
}

View file

@ -14,11 +14,17 @@ import libs.iteratee._
import scalaz.effects._
object AppXhrC extends LilaController {
object AppC extends LilaController {
private val hand = env.hand
def socket(gameId: String, color: String) =
def socket = WebSocket.async[JsValue] { implicit request
env.siteSocket.join(
uid = get("uid") err "Socket UID missing",
username = get("username"))
}
def gameSocket(gameId: String, color: String) =
WebSocket.async[JsValue] { implicit request
env.gameSocket.join(
gameId = gameId ~ { i println("Attempt to connect to " + i) },
@ -29,10 +35,6 @@ object AppXhrC extends LilaController {
username = get("username")).unsafePerformIO
}
def outoftime(fullId: String) = Action {
IOk(perform(fullId, hand.outoftime))
}
def abort(fullId: String) = performAndRedirect(fullId, hand.abort)
def resign(fullId: String) = performAndRedirect(fullId, hand.resign)
@ -47,16 +49,12 @@ object AppXhrC extends LilaController {
def drawDecline(fullId: String) = performAndRedirect(fullId, hand.drawDecline)
def nbPlayers = Action { Ok(0) }
def nbGames = Action { Ok(env.gameRepo.countPlaying.unsafePerformIO) }
type IOValidEvents = IO[Valid[List[Event]]]
private def perform(fullId: String, op: String IOValidEvents): IO[Unit] =
op(fullId) flatMap { res
res.fold(
failures putStrLn(failures.list mkString "\n"),
putFailures,
events env.gameSocket.send(DbGame takeGameId fullId, events)
)
}

View file

@ -19,12 +19,12 @@ trait LilaController extends Controller with ContentTypes with RequestGetter {
def JsonIOk(map: IO[Map[String, Any]]) = JsonOk(map.unsafePerformIO)
def ValidOk(valid: Valid[Unit]) = valid.fold(
e BadRequest(e.list mkString "\n"),
e BadRequest(e.shows),
_ Ok("ok")
)
def ValidIOk(valid: IO[Valid[Unit]]) = valid.unsafePerformIO.fold(
e BadRequest(e.list mkString "\n"),
e BadRequest(e.shows),
_ Ok("ok")
)

View file

@ -82,7 +82,7 @@ class GameRepo(collection: MongoCollection)
d(name + "lastDrawOffer", _.players(i).lastDrawOffer)
d(name + "isOfferingDraw", _.players(i).isOfferingDraw)
}
a.clock.pp foreach { c
a.clock foreach { c
d("clock.c", _.clock.get.c)
d("clock.w", _.clock.get.w)
d("clock.b", _.clock.get.b)

View file

@ -32,7 +32,7 @@ class UserRepo(collection: MongoCollection)
}
def updateOnlineUsernames(usernames: Iterable[String]): IO[Unit] = io {
val names = usernames map (_.toLowerCase)
val names = usernames.toList.map(_.toLowerCase).distinct
collection.update(
("usernameCanonical" $nin names) ++ ("isOnline" -> true),
$set ("isOnline" -> false),

45
app/game/History.scala Normal file
View file

@ -0,0 +1,45 @@
package lila
package game
import scala.math.max
import play.api.libs.json._
import scalaz.effects._
import chess.Color
import model.Event
import memo.Builder
final class History(timeout: Int) {
case class VersionedEvent(js: JsObject, only: Option[Color], own: Boolean) {
def visible(color: Color, owner: Boolean): Boolean =
if (own && !owner) false else only.fold(_ == color, true)
def visible(member: Member): Boolean = visible(member.color, member.owner)
}
private var privateVersion = 0
private val events = memo.Builder.expiry[Int, VersionedEvent](timeout)
def version = privateVersion
def since(v: Int): List[VersionedEvent] =
(v + 1 to version).toList map event flatten
private def event(v: Int) = Option(events getIfPresent v)
def +=(event: Event): VersionedEvent = {
privateVersion = privateVersion + 1
val vevent = VersionedEvent(
js = JsObject(Seq(
"v" -> JsNumber(privateVersion),
"t" -> JsString(event.typ),
"d" -> event.data
)),
only = event.only,
own = event.owner)
events.put(privateVersion, vevent)
vevent
}
}

View file

@ -1,13 +1,11 @@
package lila
package game
import socket.{ History, LilaEnumerator }
import model._
import socket._
import chess.{ Color, White, Black }
import akka.actor._
import akka.event.Logging
import play.api.libs.json._
import play.api.libs.iteratee._
import play.api.Play.current
@ -16,11 +14,12 @@ import scalaz.effects._
final class Hub(gameId: String, history: History) extends Actor {
private var members = Map.empty[String, Member]
private val log = Logging(context.system, this)
def receive = {
case WithMembers(op) op(members.values.pp).unsafePerformIO
case WithMembers(op) op(members.values).unsafePerformIO
case GetUsernames sender ! usernames
case IfEmpty(op) members.isEmpty.fold(op, io()).unsafePerformIO
@ -33,51 +32,48 @@ final class Hub(gameId: String, history: History) extends Actor {
case IsConnected(color) sender ! member(color).isDefined
case Join(uid, version, color, owner, username) {
val channel = new LilaEnumerator[JsValue](history since version)
val msgs = history since version filter (_.visible(color, owner)) map (_.js)
val channel = new LilaEnumerator[JsValue](msgs)
val member = Member(channel, PovRef(gameId, color), owner, username)
members = members + (uid -> member)
sender ! Connected(member)
notifyCrowd()
notify(crowdEvent)
}
case Events(events) events foreach notifyVersion
case Events(events) events match {
case Nil
case single :: Nil notify(single)
case multi notify(multi)
}
case Quit(uid) {
members = members - uid
notifyCrowd()
notify(crowdEvent)
}
case Close {
members.values foreach { _.channel.close() }
self ! PoisonPill
}
case msg log.info("GameHub unknown message: " + msg)
}
private def notifyCrowd() {
notifyVersion("crowd", JsObject(Seq(
"white" -> JsBoolean(member(White).isDefined),
"black" -> JsBoolean(member(Black).isDefined),
"watchers" -> JsNumber(members.values count (_.watcher))
)))
private def crowdEvent = CrowdEvent(
white = member(White).isDefined,
black = member(Black).isDefined,
watchers = members.values count (_.watcher))
private def notify(e: Event) {
val vevent = history += e
members.values filter vevent.visible foreach (_.channel push vevent.js)
}
private def member(color: Color): Option[Member] =
members.values find { m m.owner && m.color == color }
private def notifyVersion(e: Event) {
val vmsg = history += makeMessage(e.typ, e.data)
val m1 = if (e.owner) members.values filter (_.owner) else members.values
val m2 = e.only.fold(color m1 filter (_.color == color), m1)
m2 foreach (_.channel push vmsg)
}
private def notifyVersion(t: String, d: JsValue) {
notifyVersion(new Event {
val typ = t
val data = d
})
private def notify(events: List[Event]) {
val vevents = events map history.+=
members.values foreach { member
member.channel push JsObject(Seq(
"t" -> JsString("batch"),
"d" -> JsArray(vevents filter (_ visible member) map (_.js))
))
}
}
private def notifyAll(t: String, data: JsValue) {
@ -85,6 +81,14 @@ final class Hub(gameId: String, history: History) extends Actor {
members.values.foreach(_.channel push msg)
}
private def member(color: Color): Option[Member] =
members.values find { m m.owner && m.color == color }
private def usernames: Iterable[String] = members.values collect {
case Owner(_, _, Some(username)) username
case Watcher(_, _, Some(username)) username
}
private def makeMessage(t: String, data: JsValue) =
JsObject(Seq("t" -> JsString(t), "d" -> data))
}

View file

@ -8,7 +8,6 @@ import akka.pattern.{ ask, pipe }
import akka.util.duration._
import akka.util.{ Duration, Timeout }
import akka.dispatch.{ Future }
import akka.event.Logging
import scalaz.effects._
import socket._
@ -16,7 +15,6 @@ final class HubMaster(hubMemo: HubMemo) extends Actor {
private implicit val timeout = Timeout(200 millis)
private implicit val executor = Akka.system.dispatcher
private val log = Logging(context.system, this)
def receive = {
@ -24,11 +22,13 @@ final class HubMaster(hubMemo: HubMemo) extends Actor {
(a ? GetNbMembers).mapTo[Int]
) map (_.sum) pipeTo sender
case GetUsernames Future.traverse(hubActors)(a
(a ? GetUsernames).mapTo[Iterable[String]]
) map (_.flatten) pipeTo sender
case WithHubs(op) => op(hubMemo.all).unsafePerformIO
case msg @ NbPlayers(nb) hubActors foreach (_ ! msg)
case msg log.info("HubMaster unknown message: " + msg)
}
def hubActors = hubMemo.all.values

View file

@ -56,16 +56,16 @@ final class Socket(
promotion = d str "promotion"
op = for {
events hand.play(povRef, orig, dest, promotion)
_ events.fold(
errors putStrLn(errors.list mkString "\n"),
events send(povRef.gameId, events))
_ events.fold(putFailures, events send(povRef.gameId, events))
} yield ()
} op.unsafePerformIO
case "moretime" (for {
res hand moretime povRef
op io {
res.fold(println, events hub ! Events(events))
}
op res.fold(putFailures, events io(hub ! Events(events)))
} yield op).unsafePerformIO
case "outoftime" (for {
res hand outoftime povRef
op res.fold(putFailures, events io(hub ! Events(events)))
} yield op).unsafePerformIO
}
}

View file

@ -17,6 +17,7 @@ sealed trait Member {
def gameId = ref.gameId
def color = ref.color
def className = owner.fold("Owner", "Watcher")
def show = username | "Anonymous"
override def toString = "%s(%s-%s,%s)".format(className, gameId, color, username)
}
object Member {

View file

@ -1,5 +1,5 @@
package lila
package socket
package lobby
import scala.math.max
import play.api.libs.json._

View file

@ -5,20 +5,18 @@ import db.MessageRepo
import socket._
import akka.actor._
import akka.event.Logging
import play.api.libs.json._
import play.api.libs.iteratee._
final class Hub(messageRepo: MessageRepo, history: History) extends Actor {
private var members = Map.empty[String, Member]
private val log = Logging(context.system, this)
def receive = {
case WithHooks(op) op(hookOwnerIds).unsafePerformIO
case WithHooks(op) op(hookOwnerIds).unsafePerformIO
case WithUsernames(op) op(usernames).unsafePerformIO
case GetUsernames sender ! usernames
case Join(uid, version, username, hookOwnerId) {
val channel = new LilaEnumerator[JsValue](history since version)
@ -62,8 +60,6 @@ final class Hub(messageRepo: MessageRepo, history: History) extends Actor {
case NbPlayers(nb) notifyAll("nbp", JsNumber(nb))
case Quit(uid) { members = members - uid }
case msg log.info("LobbyHub unknown message: " + msg)
}
private def notifyMember(t: String, data: JsValue)(member: Member) {

View file

@ -4,7 +4,6 @@ package lobby
import model._
import memo._
import db._
import socket.History
import scalaz.effects._
final class Preload(

View file

@ -12,7 +12,6 @@ case class Member(
}
case class WithHooks(op: Iterable[String] => IO[Unit])
case class WithUsernames(op: Iterable[String] => IO[Unit])
case class AddHook(hook: model.Hook)
case class RemoveHook(hook: model.Hook)
case class BiteHook(hook: model.Hook, game: model.DbGame)

View file

@ -101,6 +101,7 @@ case class DbGame(
val events =
Event.possibleMoves(game.situation, White) ::
Event.possibleMoves(game.situation, Black) ::
StateEvent(game.situation.color, game.turns) ::
(Event fromMove move) :::
(Event fromSituation game.situation)
@ -190,7 +191,7 @@ case class DbGame(
if !c.isRunning || (c outoftime player.color)
} yield player
def withClock(c: Clock) = copy(clock = Some(c))
def withClock(c: Clock) = Progress(this, copy(clock = Some(c)))
def creator = player(creatorColor)

View file

@ -6,7 +6,7 @@ import play.api.libs.json._
import chess._
import Pos.{ piotr, allPiotrs }
trait Event {
sealed trait Event {
def typ: String
def data: JsValue
def only: Option[Color] = None
@ -142,3 +142,23 @@ object ClockEvent {
clock remainingTime White,
clock remainingTime Black)
}
case class StateEvent(color: Color, turns: Int) extends Event {
def typ = "state"
def data = JsObject(Seq(
"color" -> JsString(color.name),
"turns" -> JsNumber(turns)
))
}
case class CrowdEvent(
white: Boolean,
black: Boolean,
watchers: Int) extends Event {
def typ = "crowd"
def data = JsObject(Seq(
"white" -> JsBoolean(white),
"black" -> JsBoolean(black),
"watchers" -> JsNumber(watchers)
))
}

47
app/site/Hub.scala Normal file
View file

@ -0,0 +1,47 @@
package lila
package site
import socket._
import akka.actor._
import play.api.libs.json._
import play.api.libs.iteratee._
final class Hub extends Actor {
private var members = Map.empty[String, Member]
def receive = {
case GetUsernames sender ! usernames
case Join(uid, username) {
val channel = new LilaEnumerator[JsValue](Nil)
members = members + (uid -> Member(channel, username))
sender ! Connected(channel)
}
case GetNbMembers sender ! members.size
case NbPlayers(nb) notifyAll("nbp", JsNumber(nb))
case Quit(uid) { members = members - uid }
}
private def notifyMember(t: String, data: JsValue)(member: Member) {
val msg = JsObject(Seq("t" -> JsString(t), "d" -> data))
member.channel push msg
}
private def notifyAll(t: String, data: JsValue) {
val msg = makeMessage(t, data)
members.values.foreach(_.channel push msg)
}
private def usernames: Iterable[String] = members.values collect {
case Member(_, Some(username)) username
}
private def makeMessage(t: String, data: JsValue) =
JsObject(Seq("t" -> JsString(t), "d" -> data))
}

30
app/site/Socket.scala Normal file
View file

@ -0,0 +1,30 @@
package lila
package site
import akka.actor._
import akka.pattern.ask
import akka.util.duration._
import akka.util.Timeout
import play.api._
import play.api.libs.json._
import play.api.libs.iteratee._
import play.api.libs.concurrent._
import scalaz.effects._
final class Socket(hub: ActorRef) {
implicit val timeout = Timeout(1 second)
def join(uid: String, username: Option[String]): SocketPromise =
(hub ? Join(uid, username)).asPromise map {
case Connected(channel)
val iteratee = Iteratee.foreach[JsValue] { event
Unit
} mapDone { _
hub ! Quit(uid)
}
(iteratee, channel)
}
}

15
app/site/model.scala Normal file
View file

@ -0,0 +1,15 @@
package lila
package site
import scalaz.effects.IO
case class Member(
channel: Channel,
username: Option[String]) {
}
case class Join(
uid: String,
username: Option[String])
case class Quit(uid: String)
case class Connected(channel: Channel)

View file

@ -3,4 +3,5 @@ package socket
case object GetNbMembers
case class NbPlayers(nb: Int)
case object GetUsernames
case object Close

View file

@ -1,15 +1,13 @@
# App Public API
GET /socket/:gameId/:color lila.controllers.AppXhrC.socket(gameId: String, color: String)
GET /how-many-players-now lila.controllers.AppXhrC.nbPlayers
GET /how-many-games-now lila.controllers.AppXhrC.nbGames
GET /abort/:fullId lila.controllers.AppXhrC.abort(fullId: String)
GET /resign/:fullId lila.controllers.AppXhrC.resign(fullId: String)
GET /draw-claim/:fullId lila.controllers.AppXhrC.drawClaim(fullId: String)
POST /outoftime/:fullId lila.controllers.AppXhrC.outoftime(fullId: String)
GET /draw-accept/:fullId lila.controllers.AppXhrC.drawAccept(fullId: String)
GET /draw-offer/:fullId lila.controllers.AppXhrC.drawOffer(fullId: String)
GET /draw-cancel/:fullId lila.controllers.AppXhrC.drawCancel(fullId: String)
GET /draw-decline/:fullId lila.controllers.AppXhrC.drawDecline(fullId: String)
GET /socket lila.controllers.AppC.socket
GET /socket/:gameId/:color lila.controllers.AppC.gameSocket(gameId: String, color: String)
GET /abort/:fullId lila.controllers.AppC.abort(fullId: String)
GET /resign/:fullId lila.controllers.AppC.resign(fullId: String)
GET /draw-claim/:fullId lila.controllers.AppC.drawClaim(fullId: String)
GET /draw-accept/:fullId lila.controllers.AppC.drawAccept(fullId: String)
GET /draw-offer/:fullId lila.controllers.AppC.drawOffer(fullId: String)
GET /draw-cancel/:fullId lila.controllers.AppC.drawCancel(fullId: String)
GET /draw-decline/:fullId lila.controllers.AppC.drawDecline(fullId: String)
GET /ai lila.controllers.AiC.run
@ -21,8 +19,6 @@ POST /api/reload-table/:gameId lila.controllers.AppApiC.reloadTable(gameId:
POST /api/rematch-accept/:gameId/:color/:newGameId lila.controllers.AppApiC.rematchAccept(gameId: String, color: String, newGameId: String)
GET /api/activity/:gameId/:color lila.controllers.AppApiC.activity(gameId: String, color: String)
GET /api/game-version/:gameId lila.controllers.AppApiC.gameVersion(gameId: String)
GET /api/nb-players lila.controllers.AppXhrC.nbPlayers
POST /api/outoftime/:fullId lila.controllers.AppXhrC.outoftime(fullId: String)
# Lobby Public API
GET /lobby/cancel/:ownerId lila.controllers.LobbyC.cancel(ownerId: String)

View file

@ -14,7 +14,7 @@ trait Dependencies {
val specs2 = "org.specs2" %% "specs2" % "1.8.2"
val casbah = "com.mongodb.casbah" %% "casbah" % "2.1.5-1"
val salat = "com.novus" %% "salat-core" % "0.0.8-SNAPSHOT"
val scalalib = "com.github.ornicar" %% "scalalib" % "1.24"
val scalalib = "com.github.ornicar" %% "scalalib" % "1.25"
val hasher = "com.roundeights" % "hasher" % "0.3" from "http://cloud.github.com/downloads/Nycto/Hasher/hasher_2.9.1-0.3.jar"
val config = "com.typesafe.config" % "config" % "0.3.0"
val json = "com.codahale" %% "jerkson" % "0.5.0"