diff --git a/src/cds/src/main/kotlin/org/icpclive/api/Scoreboard.kt b/src/cds/src/main/kotlin/org/icpclive/api/Scoreboard.kt index 3c9a92569..c639d3e15 100644 --- a/src/cds/src/main/kotlin/org/icpclive/api/Scoreboard.kt +++ b/src/cds/src/main/kotlin/org/icpclive/api/Scoreboard.kt @@ -2,8 +2,7 @@ package org.icpclive.api import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable -import org.icpclive.util.DurationInMillisecondsSerializer -import org.icpclive.util.DurationInMinutesSerializer +import org.icpclive.util.* import kotlin.time.Duration @Serializable @@ -26,7 +25,7 @@ data class ScoreboardRow( val teamId: Int, val rank: Int, val totalScore: Double, - @Serializable(with = DurationInMinutesSerializer::class) + @Serializable(with = DurationInSecondsSerializer::class) val penalty: Duration, val lastAccepted: Long, val medalType: String?, diff --git a/src/cds/src/main/kotlin/org/icpclive/cds/atcoder/AtcoderDataSource.kt b/src/cds/src/main/kotlin/org/icpclive/cds/atcoder/AtcoderDataSource.kt new file mode 100644 index 000000000..0e749c275 --- /dev/null +++ b/src/cds/src/main/kotlin/org/icpclive/cds/atcoder/AtcoderDataSource.kt @@ -0,0 +1,140 @@ +package org.icpclive.cds.atcoder + +import kotlinx.datetime.* +import kotlinx.serialization.Serializable +import org.icpclive.api.* +import org.icpclive.cds.common.* +import org.icpclive.cds.common.ContestParseResult +import org.icpclive.cds.common.FullReloadContestDataSource +import org.icpclive.cds.common.jsonLoader +import org.icpclive.cds.settings.AtcoderSettings +import org.icpclive.util.* +import kotlin.time.Duration +import kotlin.time.Duration.Companion.ZERO +import kotlin.time.Duration.Companion.minutes +import kotlin.time.Duration.Companion.nanoseconds +import kotlin.time.Duration.Companion.seconds + +@Serializable +internal class AtcoderTask( + val Assignment: String, + val TaskName: String, + val TaskScreenName: String, +) + +@Serializable +internal class AtcoderTaskResult( + val Count: Int, + val Failure: Int, + val Penalty: Int, + val Score: Int, + val Elapsed: Long, + val Pending: Boolean, +) + +@Serializable +internal class AtcoderTeam( + val UserScreenName: String, + val TaskResults: Map +) + +@Serializable +internal class ContestData( + val TaskInfo: List, + val StandingsData: List +) + +internal class AtcoderDataSource(val settings: AtcoderSettings) : FullReloadContestDataSource(5.seconds) { + val teamIds = Enumerator() + val problemIds = Enumerator() + private val loader = jsonLoader(settings.network, ClientAuth.CookieAuth("REVEL_SESSION", settings.sessionCookie)) { "https://atcoder.jp/contests/${settings.contestId}/standings/json" } + + var submissionId: Int = 1 + val runs = mutableMapOf, List>() + + fun addNewRuns(teamId: Int, problemId: Int, result: AtcoderTaskResult) : List { + val oldRuns = (runs[teamId to problemId] ?: emptyList()).toMutableList() + repeat(result.Count - oldRuns.size) { + oldRuns.add( + RunInfo( + id = submissionId++, + result = null, + percentage = 0.0, + problemId = problemId, + teamId = teamId, + time = minOf(settings.contestLength, Clock.System.now() - settings.startTime) + ) + ) + } + while (oldRuns.count { (it.result as? IOIRunResult)?.wrongVerdict != null } < result.Penalty) { + val fst = oldRuns.indexOfFirst { it.result == null } + oldRuns[fst] = oldRuns[fst].copy(result = IOIRunResult(score = listOf(0.0), wrongVerdict = Verdict.Rejected)) + if (result.Elapsed.nanoseconds != ZERO && oldRuns[fst].time > result.Elapsed.nanoseconds) { + oldRuns[fst] = oldRuns[fst].copy(time = result.Elapsed.nanoseconds) + } + } + if (result.Score > 0) { + if (oldRuns.mapNotNull { it.result as? IOIRunResult }.maxOfOrNull { it.score[0] }?.toInt() != result.Score / 100 && !result.Pending) { + val fst = oldRuns.indexOfFirst { it.result == null } + oldRuns[fst] = oldRuns[fst].copy(result = IOIRunResult(score = listOf(result.Score / 100.0)), time = result.Elapsed.nanoseconds) + } + } + return oldRuns + } + + override suspend fun loadOnce(): ContestParseResult { + val data = loader.load() + val problems = data.TaskInfo.mapIndexed { index, task -> + ProblemInfo( + letter = task.Assignment, + name = task.TaskName, + id = problemIds[task.TaskScreenName], + ordinal = index, + contestSystemId = task.TaskScreenName, + minScore = 0.0, + maxScore = (data.StandingsData.maxOfOrNull { it.TaskResults[task.TaskScreenName]?.Score ?: 0 } ?: 0) / 100.0, + scoreMergeMode = ScoreMergeMode.LAST_OK + ) + } + val teams = data.StandingsData.map { + TeamInfo( + id = teamIds[it.UserScreenName], + fullName = it.UserScreenName, + displayName = it.UserScreenName, + contestSystemId = it.UserScreenName, + groups = emptyList(), + hashTag = null, + medias = emptyMap(), + isHidden = false, + isOutOfContest = false, + organizationId = null, + ) + } + val info = ContestInfo( + name = "", + status = ContestStatus.byCurrentTime(settings.startTime, settings.contestLength), + resultType = ContestResultType.IOI, + startTime = settings.startTime, + contestLength = settings.contestLength, + freezeTime = settings.contestLength, + problemList = problems, + teamList = teams, + groupList = emptyList(), + organizationList = emptyList(), + penaltyRoundingMode = PenaltyRoundingMode.LAST, + penaltyPerWrongAttempt = 5.minutes, + ) + val newRuns = buildList { + for (teamResult in data.StandingsData) { + val teamId = teamIds[teamResult.UserScreenName] + for ((problemCdsId, problemResult) in teamResult.TaskResults) { + val problemId = problemIds[problemCdsId] + runs[teamId to problemId] = addNewRuns(teamId, problemId, problemResult).also { + addAll(it) + } + } + } + } + return ContestParseResult(info, newRuns, emptyList()) + } +} \ No newline at end of file diff --git a/src/cds/src/main/kotlin/org/icpclive/cds/common/NetworkUtils.kt b/src/cds/src/main/kotlin/org/icpclive/cds/common/NetworkUtils.kt index b03846afd..e2941371d 100644 --- a/src/cds/src/main/kotlin/org/icpclive/cds/common/NetworkUtils.kt +++ b/src/cds/src/main/kotlin/org/icpclive/cds/common/NetworkUtils.kt @@ -7,6 +7,7 @@ import io.ktor.client.plugins.auth.* import io.ktor.client.plugins.auth.providers.* import io.ktor.client.request.* import org.icpclive.cds.settings.NetworkSettings +import io.ktor.http.* import java.security.cert.X509Certificate import javax.net.ssl.X509TrustManager @@ -16,6 +17,8 @@ internal sealed class ClientAuth { class OAuth(val token: String) : ClientAuth() + class CookieAuth(val name: String, val value: String): ClientAuth() + companion object { fun BasicOrNull(login: String?, password: String?) = if (login != null && password != null) { Basic(login, password) @@ -38,7 +41,13 @@ internal fun HttpClientConfig<*>.setupAuth(auth: ClientAuth) { is ClientAuth.OAuth -> { defaultRequest { - header("Authorization", "OAuth ${auth.token}") + header(HttpHeaders.Authorization, "OAuth ${auth.token}") + } + } + + is ClientAuth.CookieAuth -> { + defaultRequest { + header(HttpHeaders.Cookie, "${auth.name}=${auth.value}") } } } diff --git a/src/cds/src/main/kotlin/org/icpclive/cds/settings/CDSSettings.kt b/src/cds/src/main/kotlin/org/icpclive/cds/settings/CDSSettings.kt index c22e0f457..7ff5be911 100644 --- a/src/cds/src/main/kotlin/org/icpclive/cds/settings/CDSSettings.kt +++ b/src/cds/src/main/kotlin/org/icpclive/cds/settings/CDSSettings.kt @@ -12,6 +12,7 @@ import kotlinx.serialization.properties.decodeFromStringMap import org.icpclive.api.ContestResultType import org.icpclive.cds.ContestUpdate import org.icpclive.cds.adapters.toEmulationFlow +import org.icpclive.cds.atcoder.AtcoderDataSource import org.icpclive.cds.cats.CATSDataSource import org.icpclive.cds.clics.ClicsDataSource import org.icpclive.cds.clics.FeedVersion @@ -27,6 +28,7 @@ import org.icpclive.cds.yandex.YandexDataSource import org.icpclive.util.* import java.io.InputStream import java.nio.file.Path +import kotlin.time.Duration // I'd like to have them in cds files, but then serializing would be much harder @@ -222,6 +224,21 @@ class CodeDrillsSettings( override fun toDataSource(creds: Map) = CodeDrillsDataSource(this, creds) } +@SerialName("atcoder") +@Serializable +class AtcoderSettings( + val contestId: String, + val sessionCookie: String, + @Serializable(with = HumanTimeSerializer::class) + val startTime: Instant, + @Serializable(with = DurationInSecondsSerializer::class) + @SerialName("contestLengthSeconds") val contestLength: Duration, + override val emulation: EmulationSettings? = null, + override val network: NetworkSettings? = null +) : CDSSettings() { + override fun toDataSource(creds: Map) = AtcoderDataSource(this) +} + @OptIn(ExperimentalSerializationApi::class) fun parseFileToCdsSettings(path: Path) : CDSSettings { val file = path.toFile() diff --git a/src/cds/src/main/kotlin/org/icpclive/scoreboard/IOIScoreboardCalculator.kt b/src/cds/src/main/kotlin/org/icpclive/scoreboard/IOIScoreboardCalculator.kt index 5abd7d49c..ea9b4cbfd 100644 --- a/src/cds/src/main/kotlin/org/icpclive/scoreboard/IOIScoreboardCalculator.kt +++ b/src/cds/src/main/kotlin/org/icpclive/scoreboard/IOIScoreboardCalculator.kt @@ -5,6 +5,7 @@ import org.icpclive.api.* internal class IOIScoreboardCalculator : AbstractScoreboardCalculator() { override val comparator: Comparator = compareBy( { -it.totalScore }, + { it.penalty } ) override fun ContestInfo.getScoreboardRow( diff --git a/src/frontend/overlay/src/components/atoms/ContestCells.js b/src/frontend/overlay/src/components/atoms/ContestCells.js index a968fa5ed..b6710666b 100644 --- a/src/frontend/overlay/src/components/atoms/ContestCells.js +++ b/src/frontend/overlay/src/components/atoms/ContestCells.js @@ -24,6 +24,21 @@ export const formatScore = (score, digits = 2) => { return score?.toFixed((score - Math.floor(score)) > 0 ? digits : 0); }; +export const formatPenalty = (contestInfo, penalty) => { + if (penalty === undefined) { + return ""; + } + let mode = contestInfo.penaltyRoundingMode; + if (mode === "sum_in_seconds" || mode === "last") { + return Math.floor(penalty / 60) + ":" + (penalty % 60 < 10 ? "0" : "") + (penalty % 60); + } else { + return Math.floor(penalty / 60); + } +}; + +export const needPenalty = (contestInfo) => contestInfo?.penaltyRoundingMode !== "zero"; + + export const ProblemCellWrap = styled(Cell)` border-bottom: ${props => props.probColor} ${CELL_PROBLEM_LINE_WIDTH} solid; `; diff --git a/src/frontend/overlay/src/components/organisms/holder/TeamViewHolder.js b/src/frontend/overlay/src/components/organisms/holder/TeamViewHolder.js index 7fe2c3915..4c52834aa 100644 --- a/src/frontend/overlay/src/components/organisms/holder/TeamViewHolder.js +++ b/src/frontend/overlay/src/components/organisms/holder/TeamViewHolder.js @@ -13,7 +13,7 @@ import { import { SCOREBOARD_TYPES } from "../../../consts"; import { pushLog } from "../../../redux/debug"; import { Cell } from "../../atoms/Cell"; -import { ProblemCell, RankCell, TextShrinkingCell } from "../../atoms/ContestCells"; +import { formatPenalty, needPenalty, ProblemCell, RankCell, TextShrinkingCell } from "../../atoms/ContestCells"; import { StarIcon } from "../../atoms/Star"; import { formatScore } from "../../atoms/ContestCells"; import { ScoreboardIOITaskCell } from "../widgets/Scoreboard"; @@ -201,10 +201,10 @@ export const TeamInfo = ({ teamId }) => { {scoreboardData === null ? null : formatScore(scoreboardData?.totalScore, 1)} - {contestInfo?.resultType !== "IOI" && - - {scoreboardData?.penalty} - } + {needPenalty(contestInfo) && + + {scoreboardData === null ? null : formatPenalty(contestInfo, scoreboardData.penalty)} + } ; }; diff --git a/src/frontend/overlay/src/components/organisms/widgets/PVP.js b/src/frontend/overlay/src/components/organisms/widgets/PVP.js index 22abb4388..5ce0302f3 100644 --- a/src/frontend/overlay/src/components/organisms/widgets/PVP.js +++ b/src/frontend/overlay/src/components/organisms/widgets/PVP.js @@ -11,7 +11,7 @@ import { } from "../../../config"; import { SCOREBOARD_TYPES } from "../../../consts"; import { Cell } from "../../atoms/Cell"; -import { formatScore, RankCell, TextShrinkingCell } from "../../atoms/ContestCells"; +import { formatPenalty, formatScore, needPenalty, RankCell, TextShrinkingCell } from "../../atoms/ContestCells"; import { StarIcon } from "../../atoms/Star"; import { ScoreboardIOITaskCell } from "./Scoreboard"; import { TeamWebRTCProxyVideoWrapper, TeamWebRTCGrabberVideoWrapper } from "../holder/TeamViewHolder"; @@ -231,9 +231,9 @@ const TeamInfo = ({ teamId }) => { {scoreboardData === null ? null : formatScore(scoreboardData?.totalScore, 1)} - {contestData.resultType !== "IOI" && + {needPenalty(contestData) && - {scoreboardData?.penalty} + {scoreboardData === null ? null : formatPenalty(contestData, scoreboardData.penalty)} } ; diff --git a/src/frontend/overlay/src/components/organisms/widgets/Scoreboard.js b/src/frontend/overlay/src/components/organisms/widgets/Scoreboard.js index 11264588c..69762fcc9 100644 --- a/src/frontend/overlay/src/components/organisms/widgets/Scoreboard.js +++ b/src/frontend/overlay/src/components/organisms/widgets/Scoreboard.js @@ -20,7 +20,14 @@ import { VERDICT_UNKNOWN } from "../../../config"; import { Cell } from "../../atoms/Cell"; -import { formatScore, ProblemCell, RankCell, TextShrinkingCell } from "../../atoms/ContestCells"; +import { + formatPenalty, + formatScore, + needPenalty, + ProblemCell, + RankCell, + TextShrinkingCell +} from "../../atoms/ContestCells"; import { StarIcon } from "../../atoms/Star"; @@ -221,8 +228,8 @@ export const ScoreboardRow = ({ teamId, hideTasks, rankWidth, nameWidth, sumPenW {scoreboardData === null ? null : formatScore(scoreboardData.totalScore)} - {contestData?.resultType === "ICPC" && - {scoreboardData?.penalty} + {needPenalty(contestData) && + {scoreboardData === null ? null : formatPenalty(contestData, scoreboardData.penalty)} } {!hideTasks && scoreboardData?.problemResults.map((resultsData, i) => @@ -244,7 +251,7 @@ const ScoreboardHeader = ({ problems, rowHeight, name }) => { return {nameTable[name]} STANDINGS Σ - {contestInfo?.resultType === "ICPC" && PEN} + {needPenalty(contestInfo) && PEN} {problems && problems.map((probData) =>