diff --git a/src/main/scala/Clock.scala b/src/main/scala/Clock.scala index a8ed1bb..e2a2ac2 100755 --- a/src/main/scala/Clock.scala +++ b/src/main/scala/Clock.scala @@ -80,16 +80,23 @@ case class Clock( timer = timer.map(_ => now) ) + def withFrameLag(frameLag: Centis) = updatePlayer(color)(_ withFrameLag frameLag) + def step( metrics: MoveMetrics = MoveMetrics.empty, gameActive: Boolean = true - ) = - (timer match { + ): Clock.WithCompensatedLag[Clock] = + timer match { case None => - metrics.clientLag.fold(this) { l => - updatePlayer(color) { _.recordLag(l) } - } - case Some(t) => { + Clock.WithCompensatedLag( + metrics.clientLag + .fold(this) { l => + updatePlayer(color) { _.recordLag(l) } + } + .switch, + None + ) + case Some(t) => val elapsed = toNow(t) val lag = ~metrics.reportedLag(elapsed) nonNeg @@ -119,9 +126,11 @@ case class Clock( .copy(lag = lagTrack, lastMoveTime = moveTime) } - if (clockActive) newC else newC.hardStop - } - }).switch + Clock.WithCompensatedLag( + (if (clockActive) newC else newC.hardStop).switch, + Some(lagComp) + ) + } def takeback = switch @@ -204,6 +213,8 @@ case class ClockPlayer( def byoyomi = if (berserk) Centis(0) else config.byoyomi def periodsTotal = if (berserk) 0 else config.periodsTotal + + def withFrameLag(frameLag: Centis) = copy(lag = lag.withFrameLag(frameLag, config)) } object ClockPlayer { @@ -341,4 +352,8 @@ object Clock { timer = None ) } + + case class WithCompensatedLag[A](value: A, compensated: Option[Centis]) { + def map[B](f: A => B) = copy(value = f(value)) + } } diff --git a/src/main/scala/DecayingStats.scala b/src/main/scala/DecayingStats.scala index c21d4c0..2336244 100755 --- a/src/main/scala/DecayingStats.scala +++ b/src/main/scala/DecayingStats.scala @@ -11,7 +11,6 @@ final case class DecayingStats( ) extends DecayingRecorder { def record(value: Float): DecayingStats = { val delta = mean - value - copy( mean = value + decay * delta, deviation = decay * deviation + (1 - decay) * math.abs(delta) @@ -24,10 +23,7 @@ final case class DecayingStats( } } -final case class EmptyDecayingStats( - deviation: Float, - decay: Float -) extends DecayingRecorder { +final case class EmptyDecayingStats(deviation: Float, decay: Float) extends DecayingRecorder { def record(value: Float) = DecayingStats( mean = value, @@ -35,7 +31,3 @@ final case class EmptyDecayingStats( decay = decay ) } - -object DecayingStats { - val empty = EmptyDecayingStats -} diff --git a/src/main/scala/Game.scala b/src/main/scala/Game.scala index 717e09c..f628c6e 100755 --- a/src/main/scala/Game.scala +++ b/src/main/scala/Game.scala @@ -15,28 +15,45 @@ case class Game( startedAtMove: Int = 1 ) { - private def applySituation(sit: Situation, metrics: MoveMetrics = MoveMetrics.empty): Game = - copy( - situation = sit, - plies = plies + 1, - usiMoves = sit.history.lastMove.fold(usiMoves)(usiMoves :+ _), - clock = clock map { c => - val newC = c.step(metrics, sit.status.isEmpty) - if (plies - startedAtPly == 1) newC.start else newC - } + private def applySituation( + sit: Situation, + metrics: MoveMetrics = MoveMetrics.empty + ): Clock.WithCompensatedLag[Game] = { + val newClock = applyClock(metrics, sit.status.isEmpty) + Clock.WithCompensatedLag( + copy( + situation = sit, + plies = plies + 1, + usiMoves = sit.history.lastMove.fold(usiMoves)(usiMoves :+ _), + clock = newClock.map(_.value) + ), + newClock.flatMap(_.compensated) ) + } + + private def applyClock( + metrics: MoveMetrics, + gameActive: => Boolean + ): Option[Clock.WithCompensatedLag[Clock]] = + clock.map { prev => + { + val c1 = metrics.frameLag.fold(prev)(prev.withFrameLag) + val c2 = c1.step(metrics, gameActive) + if (plies - startedAtPly == 1) c2.map(_.start) else c2 + } + } - def apply(usi: Usi, metrics: MoveMetrics): Validated[String, Game] = + def apply(usi: Usi, metrics: MoveMetrics): Validated[String, Clock.WithCompensatedLag[Game]] = situation(usi).map(applySituation(_, metrics)) def apply(usi: Usi): Validated[String, Game] = - situation(usi).map(applySituation(_)) + situation(usi).map(applySituation(_).value) - def apply(parsedMove: ParsedMove, metrics: MoveMetrics): Validated[String, Game] = + def apply(parsedMove: ParsedMove, metrics: MoveMetrics): Validated[String, Clock.WithCompensatedLag[Game]] = situation(parsedMove).map(applySituation(_, metrics)) def apply(parsedMove: ParsedMove): Validated[String, Game] = - situation(parsedMove).map(applySituation(_)) + situation(parsedMove).map(applySituation(_).value) def board = situation.board diff --git a/src/main/scala/LagTracker.scala b/src/main/scala/LagTracker.scala index d48fc9b..af051c5 100755 --- a/src/main/scala/LagTracker.scala +++ b/src/main/scala/LagTracker.scala @@ -5,13 +5,14 @@ final case class LagTracker( quota: Centis, quotaMax: Centis, lagEstimator: DecayingRecorder, - uncompStats: Stats = EmptyStats, - lagStats: Stats = EmptyStats, + uncompStats: Stats = Stats.empty, + lagStats: Stats = Stats.empty, // We can remove compEst fields after tuning estimate. compEstSqErr: Int = 0, compEstOvers: Centis = Centis(0), compEstimate: Option[Centis] = None ) { + def onMove(lag: Centis) = { val comp = lag atMost quota val uncomped = lag - comp @@ -57,15 +58,27 @@ final case class LagTracker( def totalLag: Centis = Centis(lagStats.total) def totalUncomped: Centis = Centis(uncompStats.total) + + def withFrameLag(frameLag: Centis, clock: Clock.Config) = copy( + quotaGain = LagTracker.maxQuotaGainFor(clock).atMost { + frameLag + LagTracker.estimatedCpuLag + } + ) } object LagTracker { + + private val estimatedCpuLag = Centis(4) + + def maxQuotaGainFor(config: Clock.Config) = Centis(config.estimateTotalSeconds match { + case i if i >= 180 => 100 + case i if i <= 15 => 20 + case i if i <= 30 => 35 + case i => i / 4 + 30 + }) + def init(config: Clock.Config) = { - val quotaGain = Centis(config.estimateTotalSeconds match { - case i if i >= 180 => 100 - case i if i <= 15 => 35 - case i => i / 3 + 40 - }) + val quotaGain = maxQuotaGainFor(config) LagTracker( quotaGain = quotaGain, quota = quotaGain * 3, @@ -73,4 +86,5 @@ object LagTracker { lagEstimator = EmptyDecayingStats(deviation = 4f, decay = 0.85f) ) } + } diff --git a/src/main/scala/MoveMetrics.scala b/src/main/scala/MoveMetrics.scala index fc97338..cf444b2 100755 --- a/src/main/scala/MoveMetrics.scala +++ b/src/main/scala/MoveMetrics.scala @@ -2,7 +2,8 @@ package shogi case class MoveMetrics( clientLag: Option[Centis] = None, - clientMoveTime: Option[Centis] = None + clientMoveTime: Option[Centis] = None, + frameLag: Option[Centis] = None ) { // Calculate client reported lag given the server's duration for the move. diff --git a/src/main/scala/Stats.scala b/src/main/scala/Stats.scala index 1d328b2..7070490 100755 --- a/src/main/scala/Stats.scala +++ b/src/main/scala/Stats.scala @@ -1,29 +1,8 @@ package shogi // Welford's numerically stable online variance. -sealed trait Stats { - def samples: Int - def mean: Float - def variance: Option[Float] - def record(value: Float): Stats - def +(o: Stats): Stats - - def record[T](values: Iterable[T])(implicit n: Numeric[T]): Stats = - values.foldLeft(this) { (s, v) => - s record n.toFloat(v) - } - - def stdDev = variance.map { math.sqrt(_).toFloat } - - def total = samples * mean -} - -final protected case class StatHolder( - samples: Int, - mean: Float, - sn: Float -) extends Stats { - def variance = (samples > 1) option sn / (samples - 1) +// +final case class Stats(samples: Int, mean: Float, sn: Float) { def record(value: Float) = { val newSamples = samples + 1 @@ -31,52 +10,27 @@ final protected case class StatHolder( val newMean = mean + delta / newSamples val newSN = sn + delta * (value - newMean) - StatHolder( + Stats( samples = newSamples, mean = newMean, sn = newSN ) } - def +(o: Stats) = - o match { - case EmptyStats => this - case StatHolder(oSamples, oMean, oSN) => { - val invTotal = 1f / (samples + oSamples) - val combMean = { - if (samples == oSamples) (mean + oMean) * 0.5f - else (mean * samples + oMean * oSamples) * invTotal - } - - val meanDiff = mean - oMean - - StatHolder( - samples = samples + oSamples, - mean = combMean, - sn = sn + oSN + meanDiff * meanDiff * samples * oSamples * invTotal - ) - } + def record[T](values: Iterable[T])(implicit n: Numeric[T]): Stats = + values.foldLeft(this) { (s, v) => + s record n.toFloat(v) } -} -protected object EmptyStats extends Stats { - val samples = 0 - val mean = 0f - val variance = None + def variance = (samples > 1) option sn / (samples - 1) - def record(value: Float) = - StatHolder( - samples = 1, - mean = value, - sn = 0f - ) + def stdDev = variance.map { Math.sqrt(_).toFloat } - def +(o: Stats) = o + def total = samples * mean } object Stats { - val empty = EmptyStats - - def apply(value: Float) = empty.record(value) - def apply[T: Numeric](values: Iterable[T]) = empty.record(values) + val empty: Stats = Stats(0, 0, 0) + def apply(value: Float): Stats = empty record value + def apply[T: Numeric](values: Iterable[T]): Stats = empty record values } diff --git a/src/test/scala/ClockTest.scala b/src/test/scala/ClockTest.scala index 69e3a03..81502bd 100755 --- a/src/test/scala/ClockTest.scala +++ b/src/test/scala/ClockTest.scala @@ -80,7 +80,7 @@ class ClockTest extends ShogiTest { def clockStep(clock: Clock, wait: Int, lags: Int*) = { (lags.foldLeft(clock) { (clk, lag) => - advance(clk.step(), wait + lag) step durOf(lag) + advance(clk.step().value, wait + lag) step durOf(lag) value } currentClockFor Gote).time.centis } @@ -89,7 +89,7 @@ class ClockTest extends ShogiTest { def clockStart(lag: Int) = { val clock = fakeClock60.step() - ((clock step durOf(lag)) currentClockFor Sente).time.centis + ((clock.value step durOf(lag)).value currentClockFor Sente).time.centis } "start" in { @@ -153,10 +153,10 @@ class ClockTest extends ShogiTest { clockStep60(0, 0, 0) must_== 60 * 100 } "no -> medium lag" in { - clockStep60(0, 0, 300) must_== 5940 + clockStep60(0, 0, 300) must_== 5880 } "no x4 -> big lag" in { - clockStep60(0, 0, 0, 0, 0, 700) must_== 5720 + clockStep60(0, 0, 0, 0, 0, 700) must_== 5615 } } } diff --git a/src/test/scala/StatsTest.scala b/src/test/scala/StatsTest.scala index 3d9826a..b1cb028 100755 --- a/src/test/scala/StatsTest.scala +++ b/src/test/scala/StatsTest.scala @@ -36,8 +36,7 @@ class StatsTest extends Specification { Stats.empty.samples must_== 0 } - "convert to StatHolder" in { - Stats(5) must beLike(StatHolder(0, 0f, 0f).record(5)) + "make Stats" in { "with good stats" in { Stats(5).samples must_== 1 @@ -60,12 +59,5 @@ class StatsTest extends Specification { statsN.variance.get must beApprox(realVar(data)) statsN.samples must_== 400 } - "match concat" in { - statsN must_== (Stats.empty + statsN) - statsN must_== (statsN + Stats.empty) - statsN must beLike(Stats(data take 1) + Stats(data drop 1)) - statsN must beLike(Stats(data take 100) + Stats(data drop 100)) - statsN must beLike(Stats(data take 200) + Stats(data drop 200)) - } } }