diff --git a/build.sbt b/build.sbt index 86718134fa9d..5bb9926e8079 100644 --- a/build.sbt +++ b/build.sbt @@ -259,7 +259,7 @@ lazy val bot = module("bot", lazy val analyse = module("analyse", Seq(tree, memo, ui), tests.bundle -) +).dependsOn(coreI18n % "test->test") lazy val round = module("round", Seq(room, game, user, playban, pref, chat), @@ -309,7 +309,7 @@ lazy val gathering = module("gathering", lazy val tournament = module("tournament", Seq(gathering, room, memo), Seq(lettuce) ++ tests.bundle -) +).dependsOn(coreI18n % "test->test") lazy val swiss = module("swiss", Seq(gathering, room, memo), diff --git a/modules/analyse/src/test/AnnotatorTest.scala b/modules/analyse/src/test/AnnotatorTest.scala index 5edb193bafd2..4a3b8814c81b 100644 --- a/modules/analyse/src/test/AnnotatorTest.scala +++ b/modules/analyse/src/test/AnnotatorTest.scala @@ -41,25 +41,14 @@ class AnnotatorTest extends munit.FunSuite: .state import lila.core.i18n.* - import play.api.i18n.Lang - given Translator = new Translator: - def to(lang: Lang): Translate = Translate(this, lang) - def toDefault: Translate = Translate(this, defaultLang) - val txt = new TranslatorTxt: - def literal(key: I18nKey, args: Seq[Any], lang: Lang): String = key.value - def plural(key: I18nKey, count: Count, args: Seq[Any], lang: Lang): String = key.value - val frag = new TranslatorFrag: - import scalatags.Text.RawFrag - def literal(key: I18nKey, args: Seq[Matchable], lang: Lang): RawFrag = RawFrag(key.value) - def plural(key: I18nKey, count: Count, args: Seq[Matchable], lang: Lang): RawFrag = RawFrag(key.value) + given Translator = TranslatorStub + given play.api.i18n.Lang = defaultLang object LightUserApi: def mock: LightUserApiMinimal = new: val sync = LightUser.GetterSync(id => LightUser.fallback(id.into(UserName)).some) val async = LightUser.Getter(id => fuccess(sync(id))) - given Lang = defaultLang - test("empty game"): assertEquals( annotator(emptyPgn, makeGame(chess.Game(chess.variant.Standard)), none), diff --git a/modules/coreI18n/src/test/TranslatorStub.scala b/modules/coreI18n/src/test/TranslatorStub.scala new file mode 100644 index 000000000000..eb1a00a51237 --- /dev/null +++ b/modules/coreI18n/src/test/TranslatorStub.scala @@ -0,0 +1,14 @@ +package lila.core.i18n + +import play.api.i18n.Lang + +val TranslatorStub = new Translator: + def to(lang: Lang): Translate = Translate(this, lang) + def toDefault: Translate = Translate(this, defaultLang) + val txt = new TranslatorTxt: + def literal(key: I18nKey, args: Seq[Any], lang: Lang): String = key.value + def plural(key: I18nKey, count: Count, args: Seq[Any], lang: Lang): String = key.value + val frag = new TranslatorFrag: + import scalatags.Text.RawFrag + def literal(key: I18nKey, args: Seq[Matchable], lang: Lang): RawFrag = RawFrag(key.value) + def plural(key: I18nKey, count: Count, args: Seq[Matchable], lang: Lang): RawFrag = RawFrag(key.value) diff --git a/modules/tournament/src/main/PlanBuilder.scala b/modules/tournament/src/main/PlanBuilder.scala new file mode 100644 index 000000000000..1294cf58bafc --- /dev/null +++ b/modules/tournament/src/main/PlanBuilder.scala @@ -0,0 +1,182 @@ +package lila.tournament + +import scala.collection.mutable +import scala.collection.SortedSet + +import lila.core.i18n.Translate +import lila.tournament.Schedule.Plan + +object PlanBuilder: + /** Max window for daily or better schedules to be considered overlapping (i.e. if one starts within X hrs + * of the other ending). Using 11.5 hrs here ensures that at least one daily is always cancelled for the + * more important event. But, if a higher importance tourney is scheduled nearly opposite of the daily + * (i.e. 11:00 for a monthly and 00:00 for its daily), two dailies will be cancelled... so don't do this! + */ + private[tournament] val SCHEDULE_DAILY_OVERLAP_MINS = 690 // 11.5 hours + + // Stagger up to 40s. This keeps the calendar clean (by being less than a minute) and is sufficient + // to space things out. It's a bit arbitrary, but ensures that most tournaments start close to when + // the calendar says they start. + private[tournament] val MAX_STAGGER_MS = 40_000L + + private[tournament] abstract class ScheduleWithInterval: + def schedule: Schedule + def duration: java.time.Duration + def startsAt: Instant + + val endsAt = startsAt.plus(duration) + + def interval = TimeInterval(startsAt, endsAt) + + def overlaps(other: ScheduleWithInterval) = interval.overlaps(other.interval) + + // Note: must be kept in sync with [[SchedulerTestHelpers.ExperimentalPruner.pruneConflictsFailOnUsurp]] + // In particular, pruneConflictsFailOnUsurp filters tourneys that couldn't possibly conflict based + // on their hours -- so the same logic (overlaps and daily windows) must be used here. + def conflictsWith(si2: ScheduleWithInterval) = + val s1 = schedule + val s2 = si2.schedule + s1.variant == s2.variant && ( + // prevent daily && weekly within X hours of each other + if s1.freq.isDailyOrBetter && s2.freq.isDailyOrBetter && s1.sameSpeed(s2) then + si2.startsAt.minusMinutes(SCHEDULE_DAILY_OVERLAP_MINS).isBefore(endsAt) && + startsAt.minusMinutes(SCHEDULE_DAILY_OVERLAP_MINS).isBefore(si2.endsAt) + else + ( + s1.variant.exotic || // overlapping exotic variant + s1.hasMaxRating || // overlapping same rating limit + s1.similarSpeed(s2) // overlapping similar + ) && s1.similarConditions(s2) && overlaps(si2) + ) + + /** Kept in sync with [[conflictsWithFailOnUsurp]]. + */ + def conflictsWith(scheds: Iterable[ScheduleWithInterval]): Boolean = + scheds.exists(conflictsWith) + + /** Kept in sync with [[conflictsWith]]. + * + * Raises an exception if a tourney is incorrectly usurped. + */ + @throws[IllegalStateException]("if a tourney is incorrectly usurped") + def conflictsWithFailOnUsurp(scheds: Iterable[ScheduleWithInterval]) = + val conflicts = scheds.filter(conflictsWith).toSeq + val okConflicts = conflicts.filter(_.schedule.freq >= schedule.freq) + if conflicts.nonEmpty && okConflicts.isEmpty then + throw new IllegalStateException(s"Schedule [$schedule] usurped by ${conflicts}") + conflicts.nonEmpty + + private final case class ConcreteSchedule( + val schedule: Schedule, + val startsAt: Instant, + val duration: java.time.Duration + ) extends ScheduleWithInterval + + private[tournament] def getNewTourneys( + existingTourneys: Iterable[Tournament], + plans: Iterable[Plan] + )(using Translate): List[Tournament] = + // Prune plans using the unstaggered scheduled start time. + val existingWithScheduledStart = existingTourneys.flatMap { t => + // Ignore tournaments with schedule=None - they never conflict. + t.schedule.map { s => ConcreteSchedule(s, s.atInstant, t.duration) } + } + + val prunedPlans = pruneConflicts(existingWithScheduledStart, plans) + + plansWithStagger( + // Determine new staggers based on actual (staggered) start time of existing tourneys. + // Unlike pruning, stagger considers Tourneys with schedule=None. + existingTourneys.map(_.startsAt), + prunedPlans + ).map(_.build) // Build Tournament objects from plans + + /** Given a list of existing schedules and a list of possible new plans, returns a subset of the possible + * plans that do not conflict with either the existing schedules or with themselves. Intended to produce + * identical output to [[SchedulerTestHelpers.ExperimentalPruner.pruneConflictsFailOnUsurp]], but this + * variant is more readable and has lower potential for bugs. + */ + private[tournament] def pruneConflicts[A <: ScheduleWithInterval]( + existingSchedules: Iterable[ScheduleWithInterval], + possibleNewPlans: Iterable[A] + ): List[A] = + var allPlannedSchedules = existingSchedules.toList + possibleNewPlans + .foldLeft(Nil): (newPlans, p) => + if p.conflictsWith(allPlannedSchedules) then newPlans + else + allPlannedSchedules ::= p + p :: newPlans + .reverse + + /** Given existing tourneys and possible new plans, returns new Plan objects that are staggered to avoid + * starting at the exact same time as other plans or tourneys. Does NOT filter for conflicts. + */ + private[tournament] def plansWithStagger( + existingEvents: Iterable[Instant], + plans: Iterable[Plan] + ): List[Plan] = + val allInstants = mutable.TreeSet.from(existingEvents) + + plans + .foldLeft(Nil): (allAdjustedPlans, plan) => + val adjustedPlan = staggerPlan(plan, allInstants, MAX_STAGGER_MS) + allInstants += adjustedPlan.startsAt + adjustedPlan :: allAdjustedPlans + .reverse + + /** Given a plan, return an adjusted Plan with a start time that minimizes conflicts with existing events. + */ + private def staggerPlan(plan: Plan, existingEvents: SortedSet[Instant], maxStaggerMs: Long): Plan = + import scala.math.Ordering.Implicits.infixOrderingOps // For comparing Instants. + + val originalStart = plan.startsAt + val originalStartMs = originalStart.toEpochMilli + val maxConflictAt = originalStart.plusMillis(maxStaggerMs) + + // Find all events that start at a similar time to the plan. + val offsetsWithSimilarStart = existingEvents + .iteratorFrom(originalStart) + .takeWhile(_ <= maxConflictAt) + .map(_.toEpochMilli - originalStartMs) + .toSeq + + val staggerMs = findMinimalGoodSlot(0L, maxStaggerMs, offsetsWithSimilarStart) + plan.copy(startsAt = originalStart.plusMillis(staggerMs)) + + /** This method is used find a good stagger value for a new tournament. We want stagger as low as possible, + * because that means tourneys start sooner, but we also want tournaments to be spaced out to avoid server + * DDOS. + * + * The method uses Longs for convenience based on usage, but it could easily be specialized to use floating + * points or other representations. + * + * Overflows are *not* checked, because although this method uses Longs, its arguments are expected to be + * small (e.g. smaller than [[Short.MaxValue]]), and so internal math is not expected to come anywhere near + * a Long overflow. + * + * @param hi + * must be >= low + * @param sortedExisting + * must be sorted and contain only values in [low, hi] + * + * @return + * the lowest value in [low, hi] with maximal distance to elts of sortedExisting + */ + private[tournament] def findMinimalGoodSlot(low: Long, hi: Long, sortedExisting: Iterable[Long]): Long = + if sortedExisting.isEmpty then low + else + val iter = sortedExisting.iterator + var prevElt = iter.next + // nothing is at low element so gap is equiv to 2x size, centered at low + var maxGapLow = low - (prevElt - low) + var maxGapLen = (prevElt - low) * 2L + while iter.hasNext do + val elt = iter.next + if elt - prevElt > maxGapLen then + maxGapLow = prevElt + maxGapLen = elt - prevElt + prevElt = elt + // Use hi only if it's strictly better than all other gaps. + if (hi - prevElt) * 2L > maxGapLen then hi + else maxGapLow + maxGapLen / 2L diff --git a/modules/tournament/src/main/Schedule.scala b/modules/tournament/src/main/Schedule.scala index 7238e9c134a8..80993c302ba4 100644 --- a/modules/tournament/src/main/Schedule.scala +++ b/modules/tournament/src/main/Schedule.scala @@ -127,8 +127,8 @@ case class Schedule( def perfKey: PerfKey = PerfKey.byVariant(variant) | Schedule.Speed.toPerfKey(speed) - def plan = Schedule.Plan(this, None) - def plan(build: Tournament => Tournament) = Schedule.Plan(this, build.some) + def plan = Schedule.Plan(this, atInstant, None) + def plan(build: Tournament => Tournament) = Schedule.Plan(this, atInstant, build.some) override def toString = s"${atInstant} $freq ${variant.key} ${speed.key}(${Schedule.clockFor(this)}) $conditions $position" @@ -144,73 +144,17 @@ object Schedule: at = tour.startsAt.dateTime ) - /** Max window for daily or better schedules to be considered overlapping (i.e. if one starts within X hrs - * of the other ending). Using 11.5 hrs here ensures that at least one daily is always cancelled for the - * more important event. But, if a higher importance tourney is scheduled nearly opposite of the daily - * (i.e. 11:00 for a monthly and 00:00 for its daily), two dailies will be cancelled... so don't do this! - */ - private[tournament] val SCHEDULE_DAILY_OVERLAP_MINS = 690 // 11.5 * 60 - - private[tournament] trait ScheduleWithInterval: - def schedule: Schedule - def startsAt: Instant - def duration: java.time.Duration - - def endsAt = startsAt.plus(duration) - - def interval = TimeInterval(startsAt, duration) - - def overlaps(other: ScheduleWithInterval) = interval.overlaps(other.interval) - - // Note: must be kept in sync with [[SchedulerTestHelpers.ExperimentalPruner.pruneConflictsFailOnUsurp]] - // In particular, pruneConflictsFailOnUsurp filters tourneys that couldn't possibly conflict based - // on their hours -- so the same logic (overlaps and daily windows) must be used here. - def conflictsWith(si2: ScheduleWithInterval) = - val s1 = schedule - val s2 = si2.schedule - s1.variant == s2.variant && ( - // prevent daily && weekly within X hours of each other - if s1.freq.isDailyOrBetter && s2.freq.isDailyOrBetter && s1.sameSpeed(s2) then - si2.startsAt.minusMinutes(SCHEDULE_DAILY_OVERLAP_MINS).isBefore(endsAt) && - startsAt.minusMinutes(SCHEDULE_DAILY_OVERLAP_MINS).isBefore(si2.endsAt) - else - ( - s1.variant.exotic || // overlapping exotic variant - s1.hasMaxRating || // overlapping same rating limit - s1.similarSpeed(s2) // overlapping similar - ) && s1.similarConditions(s2) && overlaps(si2) - ) - - /** Kept in sync with [[conflictsWithFailOnUsurp]]. - */ - def conflictsWith(scheds: Iterable[ScheduleWithInterval]): Boolean = - scheds.exists(conflictsWith) - - /** Kept in sync with [[conflictsWith]]. - * - * Raises an exception if a tourney is incorrectly usurped. - */ - @throws[IllegalStateException]("if a tourney is incorrectly usurped") - def conflictsWithFailOnUsurp(scheds: Iterable[ScheduleWithInterval]) = - val conflicts = scheds.filter(conflictsWith).toSeq - val okConflicts = conflicts.filter(_.schedule.freq >= schedule.freq) - if conflicts.nonEmpty && okConflicts.isEmpty then - throw new IllegalStateException(s"Schedule [$schedule] usurped by ${conflicts}") - conflicts.nonEmpty - - case class Plan(schedule: Schedule, buildFunc: Option[Tournament => Tournament]) - extends ScheduleWithInterval: + case class Plan(schedule: Schedule, startsAt: Instant, buildFunc: Option[Tournament => Tournament]) + extends PlanBuilder.ScheduleWithInterval: def build(using Translate): Tournament = - val t = Tournament.scheduleAs(withConditions(schedule), minutes) + val t = Tournament.scheduleAs(withConditions(schedule), startsAt, minutes) buildFunc.fold(t) { _(t) } def map(f: Tournament => Tournament) = copy( buildFunc = buildFunc.fold(f)(f.compose).some ) - override def startsAt = schedule.atInstant - def minutes = durationFor(schedule) override def duration = java.time.Duration.ofMinutes(minutes) @@ -423,21 +367,3 @@ object Schedule: accountAge = none, allowList = none ) - - /** Given a list of existing schedules and a list of possible new plans, returns a subset of the possible - * plans that do not conflict with either the existing schedules or with themselves. Intended to produce - * identical output to [[SchedulerTestHelpers.ExperimentalPruner.pruneConflictsFailOnUsurp]], but this - * variant is more readable and has lower potential for bugs. - */ - private[tournament] def pruneConflicts[A <: ScheduleWithInterval]( - existingSchedules: Iterable[ScheduleWithInterval], - possibleNewPlans: Iterable[A] - ): List[A] = - var allPlannedSchedules = existingSchedules.toList - possibleNewPlans - .foldLeft(Nil): (newPlans, p) => - if p.conflictsWith(allPlannedSchedules) then newPlans - else - allPlannedSchedules ::= p - p :: newPlans - .reverse diff --git a/modules/tournament/src/main/Tournament.scala b/modules/tournament/src/main/Tournament.scala index 16ed6895a4d9..8aedd6fe67cf 100644 --- a/modules/tournament/src/main/Tournament.scala +++ b/modules/tournament/src/main/Tournament.scala @@ -155,7 +155,6 @@ case class Tournament( case class EnterableTournaments(tours: List[Tournament], scheduled: List[Tournament]) object Tournament: - val minPlayers = 2 def fromSetup(setup: TournamentSetup)(using me: Me) = @@ -186,7 +185,7 @@ object Tournament: hasChat = setup.hasChat | true ) - def scheduleAs(sched: Schedule, minutes: Int)(using Translate) = + def scheduleAs(sched: Schedule, startsAt: Instant, minutes: Int)(using Translate) = Tournament( id = makeId, name = sched.name(full = false), @@ -201,7 +200,7 @@ object Tournament: mode = Mode.Rated, conditions = sched.conditions, schedule = sched.some, - startsAt = sched.atInstant.plusSeconds(ThreadLocalRandom.nextInt(60)) + startsAt = startsAt ) def tournamentUrl(tourId: TourId): String = s"https://lichess.org/tournament/$tourId" diff --git a/modules/tournament/src/main/TournamentScheduler.scala b/modules/tournament/src/main/TournamentScheduler.scala index c96401afefc6..bc022f382679 100644 --- a/modules/tournament/src/main/TournamentScheduler.scala +++ b/modules/tournament/src/main/TournamentScheduler.scala @@ -11,27 +11,6 @@ import lila.common.LilaScheduler import lila.core.i18n.Translator import lila.gathering.Condition -/** This case class (and underlying trait) exists to ensure conflicts are checked against a tournament's true - * interval, rather than the interval which could be inferred from the tournament's schedule parameter via - * [[Schedule.durationFor]] - * - * Such a mismatch could occur if durationFor is modified and existing tournaments are rehydrated from db. - * Another source of mismatch is that tourney actual start is tweaked from scheduled start by a random number - * of seconds (see [[Tournament.scheduleAs]]) - */ -private[tournament] case class ConcreteSchedule( - schedule: Schedule, - startsAt: Instant, - duration: java.time.Duration -) extends Schedule.ScheduleWithInterval - -private[tournament] case class ConcreteTourney( - tournament: Tournament, - schedule: Schedule, - startsAt: Instant, - duration: java.time.Duration -) extends Schedule.ScheduleWithInterval - final private class TournamentScheduler(tournamentRepo: TournamentRepo)(using Executor, Scheduler, @@ -42,19 +21,9 @@ final private class TournamentScheduler(tournamentRepo: TournamentRepo)(using given play.api.i18n.Lang = lila.core.i18n.defaultLang tournamentRepo.scheduledUnfinished.flatMap: dbScheds => try - val plans = TournamentScheduler.allWithConflicts() - val possibleTourneys = plans.map(p => (p.schedule, p.build)).map { case (s, t) => - ConcreteTourney(t, s, t.startsAt, t.duration) - } - - val existingSchedules = dbScheds.flatMap { t => - // Ignore tournaments with schedule=None - they never conflict. - t.schedule.map { ConcreteSchedule(_, t.startsAt, t.duration) } - } - - val prunedTourneys = Schedule.pruneConflicts(existingSchedules, possibleTourneys) - - tournamentRepo.insert(prunedTourneys.map(_.tournament)).logFailure(logger) + val newPlans = TournamentScheduler.allWithConflicts() + val tourneysToAdd = PlanBuilder.getNewTourneys(dbScheds, newPlans) + tournamentRepo.insert(tourneysToAdd).logFailure(logger) catch case e: Exception => logger.error(s"failed to schedule all: ${e.getMessage}") @@ -496,7 +465,8 @@ Thank you all, you rock!""".some, ).flatten.filter(_.schedule.at.isAfter(rightNow)) private def atTopOfHour(rightNow: LocalDateTime, hourDelta: Int): LocalDateTime = - rightNow.plusHours(hourDelta).withMinute(0) + val withHours = rightNow.plusHours(hourDelta) + LocalDateTime.of(withHours.date, LocalTime.of(withHours.getHour, 0)) private type ValidHour = 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 diff --git a/modules/tournament/src/test/PlanBuilderTest.scala b/modules/tournament/src/test/PlanBuilderTest.scala new file mode 100644 index 000000000000..700875cbe6ac --- /dev/null +++ b/modules/tournament/src/test/PlanBuilderTest.scala @@ -0,0 +1,148 @@ +package lila.tournament + +import java.time.LocalDateTime + +import Schedule.Freq.* +import Schedule.Speed.* +import chess.variant.* + +class PlanBuilderTest extends munit.FunSuite: + + import lila.core.i18n.* + given Translator = TranslatorStub + given play.api.i18n.Lang = defaultLang + + test("tourney building with stagger"): + // Test that tourneys are scheduled based on the startAt field of the plan. + val dt1 = LocalDateTime.of(2024, 9, 30, 12, 0) + dt1.instant + val plan1 = Schedule(Daily, Bullet, Standard, None, dt1).plan + + val instant2 = plan1.startsAt.plusHours(1) + val plan2 = plan1.copy(startsAt = instant2) + val tourney = plan2.build + assertEquals(tourney.startsAt, instant2) + assertEquals(tourney.schedule.get.at, dt1) + + val tourneyBsonHandler = BSONHandlers.tourHandler + val bsonEncoded = tourneyBsonHandler.write(tourney) + val tourney2 = tourneyBsonHandler.read(bsonEncoded) + + // Test that serialized & deserialized tourney maintains a startAt based on the plan, and + // maintains a schedule.at based on the original schedule. + assertEquals(tourney2.startsAt, instant2) + assertEquals(tourney2.schedule.get.at, dt1) + + test("plansWithStagger & getNewTourneys"): + val dt = LocalDateTime.of(2024, 9, 30, 12, 0) + val plans = List( + Schedule(Hourly, Bullet, Standard, None, dt).plan, + Schedule(Hourly, Blitz, Standard, None, dt).plan, + Schedule(Hourly, Rapid, Standard, None, dt).plan + ) + val plannedStart = dt.instant + // Test basic stagger function + assertEquals( + PlanBuilder.plansWithStagger(Nil, plans).map(_.startsAt), + List( + plannedStart, + plannedStart.plusSeconds(40), + plannedStart.plusSeconds(20) + ), + "plans are not staggered as expected!" + ) + + // Test that resulting tourneys are staggered in startsAt field but maintain the original schedule.at. + val tourneys = PlanBuilder.getNewTourneys(Nil, plans) + assertEquals( + tourneys.map(_.startsAt), + List( + plannedStart, + plannedStart.plusSeconds(40), + plannedStart.plusSeconds(20) + ), + "tourneys are not staggered as expected!" + ) + assertEquals( + tourneys.map(_.schedule.get.at), + List(dt, dt, dt), + "tourneys do not maintain original schedule.at!" + ) + + test("Plan overlap"): + val dt1 = LocalDateTime.of(2024, 9, 30, 12, 0) + val p1 = Schedule(Daily, Bullet, Standard, None, dt1).plan + val p2 = Schedule(Hourly, Bullet, Standard, None, dt1).plan + val t1 = PlanBuilder.getNewTourneys(Nil, List(p1, p2)) + assertEquals(t1.length, 1, "Expected exactly one tourney!") + assert(clue(t1.head.schedule.get).freq.isDaily) + + // Try building against with existing tourney. Nothing new should be created. + assert(clue(PlanBuilder.getNewTourneys(t1, List(p1, p2))).isEmpty) + + test("Overlap from stagger"): + val dt1 = LocalDateTime.of(2024, 9, 30, 12, 0) + val p1 = Schedule(Daily, Bullet, Standard, None, dt1).plan + val p1Staggered = p1.copy(startsAt = dt1.plusMinutes(1).instant) + val t1 = p1Staggered.build + assertEquals(t1.startsAt, p1Staggered.startsAt) + + val dt2 = dt1.plusHours(1) + val p2 = Schedule(Hourly, Bullet, Standard, None, dt2).plan + + // The original plan didn't conflict + assert(!clue(p1).conflictsWith(clue(p2))) + + // But the staggered plan (from which the tourney was built) DOES conflict. + assert(clue(p1Staggered).conflictsWith(clue(p2))) + + // Ensure that new tourney is created even if the stagger conflicts. + PlanBuilder.getNewTourneys(List(t1), List(p2)) match + case List(t2) => assert(clue(t2.startsAt).isBefore(clue(t1.finishesAt))) + case Nil => fail(s"Expected tourney to be created from $p2") + case _ => fail("Too many tourneys!") + + test("findMaxSpaceSlot"): + def assertSlotFull(low: Long, hi: Long, existing: Seq[Long], expected: Long) = + assertEquals( + PlanBuilder.findMinimalGoodSlot(low, hi, existing), + expected, + s"low=$low hi=$hi existing=$existing" + ) + + def assertSlot(existing: Seq[Long], expected: Long) = + assertSlotFull(0L, 100L, existing, expected) + + // Edge case: no existing slots (use low) + assertSlot(Nil, 0L) + + // lowest maximal gap slot is returned + assertSlot(Seq(50L), 0L) + assertSlot(Seq(40L, 60L), 0L) + + // Middle is prioritized over high when equiv + assertSlot(Seq(10L, 70L), 40L) + + // Middle is used if high and low are worse + assertSlot(Seq(20L, 80L), 50L) + + // Finds slot correctly. + assertSlot(Seq(0L), 100L) + assertSlot(Seq(100L), 0L) + assertSlot(Seq(40L), 100L) + assertSlot(Seq(0L, 100L), 50L) + assertSlot(Seq(0L, 50L, 100L), 25L) + assertSlot(Seq(0L, 25L, 50L, 100L), 75L) + assertSlot(Seq(0L, 25L, 50L, 75L, 100L), 12L) // Rounds down + assertSlot(Seq(0L, 25L, 75L), 50L) + + // Edge case: low == hi + assertSlotFull(0L, 0L, Nil, 0L) + assertSlotFull(0L, 0L, Seq(0L), 0L) + + // Unlikely edge case: negatives + assertSlotFull(-10L, -5L, Nil, -10L) + assertSlotFull(-10L, -5L, Seq(-10L), -5L) + assertSlotFull(-10L, -5L, Seq(-5L), -10L) + assertSlotFull(-10L, -5L, Seq(-10L, -5L), -8L) // Rounds down when negative + assertSlotFull(-1L, 2L, Seq(-1L, 2L), 0L) // Rounds down when positive diff --git a/modules/tournament/src/test/ScheduleTestHelpers.scala b/modules/tournament/src/test/ScheduleTestHelpers.scala index d67f122ff1c0..e112a5b4250b 100644 --- a/modules/tournament/src/test/ScheduleTestHelpers.scala +++ b/modules/tournament/src/test/ScheduleTestHelpers.scala @@ -1,5 +1,7 @@ package lila.tournament +import PlanBuilder.{ ScheduleWithInterval, SCHEDULE_DAILY_OVERLAP_MINS } + object ScheduleTestHelpers: def planSortKey(p: Schedule.Plan) = val s = p.schedule @@ -88,7 +90,7 @@ object ScheduleTestHelpers: existingSchedules.foreach { s => getAllHours(s).foreach { addToMap(_, s) } } possibleNewPlans - .foldLeft(List[A]()): (newPlans, p) => + .foldLeft(Nil): (newPlans, p) => val potentialConflicts = getConflictingHours(p).flatMap { hourMap.getOrElse(_, Nil) } if p.conflictsWithFailOnUsurp(potentialConflicts) then newPlans else diff --git a/modules/tournament/src/test/SchedulerTest.scala b/modules/tournament/src/test/SchedulerTest.scala index 443c30d613dc..c227c6389ca6 100644 --- a/modules/tournament/src/test/SchedulerTest.scala +++ b/modules/tournament/src/test/SchedulerTest.scala @@ -81,7 +81,7 @@ class SchedulerTest extends munit.FunSuite: ) test("pruneConflict methods produce identical results"): - val prescheduled = Schedule.pruneConflicts( + val prescheduled = PlanBuilder.pruneConflicts( List.empty, TournamentScheduler.allWithConflicts(LocalDateTime.of(2024, 7, 31, 23, 0)) ) @@ -91,7 +91,7 @@ class SchedulerTest extends munit.FunSuite: } assertEquals( ExperimentalPruner.pruneConflictsFailOnUsurp(prescheduled, allTourneys), - Schedule.pruneConflicts(prescheduled, allTourneys) + PlanBuilder.pruneConflicts(prescheduled, allTourneys) ) test("2024-09-05 - thursday, summer"):