Skip to content
33 changes: 24 additions & 9 deletions src/main/scala/Clock.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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))
}
}
10 changes: 1 addition & 9 deletions src/main/scala/DecayingStats.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -24,18 +23,11 @@ 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,
deviation = deviation,
decay = decay
)
}

object DecayingStats {
val empty = EmptyDecayingStats
}
43 changes: 30 additions & 13 deletions src/main/scala/Game.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 21 additions & 7 deletions src/main/scala/LagTracker.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -57,20 +58,33 @@ 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,
quotaMax = quotaGain * 7,
lagEstimator = EmptyDecayingStats(deviation = 4f, decay = 0.85f)
)
}

}
3 changes: 2 additions & 1 deletion src/main/scala/MoveMetrics.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
70 changes: 12 additions & 58 deletions src/main/scala/Stats.scala
Original file line number Diff line number Diff line change
@@ -1,82 +1,36 @@
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
val delta = value - mean
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
}
8 changes: 4 additions & 4 deletions src/test/scala/ClockTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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 {
Expand Down Expand Up @@ -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
}
}
}
Expand Down
10 changes: 1 addition & 9 deletions src/test/scala/StatsTest.scala
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
}
}
}