11import path from 'path' ;
22import { LRUCache } from 'lru-cache' ;
33import {
4- avatar , ContestModel , Context , fs , getAlphabeticId , ObjectId , PERM ,
5- RecordDoc , Schema , STATUS , Tdoc , Types , UserModel ,
4+ avatar , ContestModel , Context , fs , getAlphabeticId , Logger , ObjectId ,
5+ PERM , RecordDoc , Schema , STATUS , superagent , Tdoc , Types , UserModel ,
66} from 'hydrooj' ;
77
8+ const logger = new Logger ( 'scoreboard-xcpcio' ) ;
9+
810const file = fs . readFileSync ( path . join ( __dirname , 'public/assets/board.html' ) , 'utf8' ) ;
911const indexJs = file . match ( / i n d e x - ( [ \w - ] + ) \. j s " / ) ?. [ 1 ] ;
1012const indexCss = file . match ( / i n d e x - ( [ \w - ] + ) \. c s s " / ) ?. [ 1 ] ;
@@ -88,11 +90,28 @@ async function loadContestState(tdoc: Tdoc, realtime: boolean) {
8890}
8991
9092export const name = 'scoreboard-xcpcio' ;
93+
94+ const PublishConfig = Schema . object ( {
95+ domainId : Schema . string ( ) . required ( ) ,
96+ contestId : Schema . string ( ) . required ( ) ,
97+ publishToken : Schema . string ( ) . required ( ) ,
98+ publishPath : Schema . string ( ) . required ( ) ,
99+ publishEndpoint : Schema . string ( ) . default ( 'https://scoreboard.hydrooj.com/_publish' ) ,
100+ medals : Schema . object ( {
101+ gold : Schema . number ( ) . required ( ) ,
102+ silver : Schema . number ( ) . required ( ) ,
103+ bronze : Schema . number ( ) . required ( ) ,
104+ } ) ,
105+ banner : Schema . string ( ) . default ( '' ) ,
106+ badge : Schema . boolean ( ) . default ( false ) ,
107+ override : Schema . any ( ) . default ( { } ) . description ( 'Scoreboard contest override' ) ,
108+ } ) ;
91109export const Config = Schema . object ( {
92110 cacheTTL : Schema . number ( ) . default ( 0 ) . description ( 'Cache TTL in milliseconds' ) ,
93111 cacheSize : Schema . number ( ) . default ( 100 ) . description ( 'Cache size' ) ,
94112 asDefault : Schema . boolean ( ) . default ( false ) . description ( 'As default scoreboard' ) ,
95113 override : Schema . any ( ) . default ( { } ) . description ( 'Scoreboard contest override' ) ,
114+ publish : Schema . array ( PublishConfig ) . description ( 'Scoreboard publish config' ) ,
96115} ) ;
97116
98117export async function apply ( ctx : Context , config : ReturnType < typeof Config > ) {
@@ -138,6 +157,80 @@ export async function apply(ctx: Context, config: ReturnType<typeof Config>) {
138157 } ) ;
139158 }
140159
160+ const getJson = async ( tdoc , realtime : boolean , cfg : Partial < ReturnType < typeof PublishConfig > > ) => {
161+ const isLocked = ContestModel . isLocked ( tdoc ) ;
162+ const cacheKey = `${ tdoc . docId . toHexString ( ) } /${ ( isLocked && realtime ) ? 'realtime' : 'public' } ` ;
163+ const state = lru . get ( cacheKey ) || await loadContestState ( tdoc , realtime ) ;
164+ if ( cfg . cacheTTL ) lru . set ( cacheKey , state ) ;
165+ const relatedGroups = state . teams . flatMap ( ( i ) => i . group ) ;
166+ return {
167+ contest : {
168+ contest_name : tdoc . title ,
169+ start_time : Math . floor ( tdoc . beginAt . getTime ( ) / 1000 ) ,
170+ end_time : Math . floor ( tdoc . endAt . getTime ( ) / 1000 ) ,
171+ frozen_time : tdoc . lockAt ? Math . floor ( ( tdoc . endAt . getTime ( ) - tdoc . lockAt . getTime ( ) ) / 1000 ) : 0 ,
172+ penalty : 1200 ,
173+ problem_quantity : tdoc . pids . length ,
174+ problem_id : tdoc . pids . map ( ( i , idx ) => getAlphabeticId ( idx ) ) ,
175+ group : {
176+ official : '正式队伍' ,
177+ unofficial : '打星队伍' ,
178+ ...Object . fromEntries ( cfg . groups ?. filter ( ( i ) => relatedGroups . includes ( i . name ) ) . map ( ( i ) => [ i . name , i . name ] ) || [ ] ) ,
179+ } ,
180+ ...( cfg . badge ? { badge : 'Badge' } : { } ) ,
181+ organization : 'School' ,
182+ status_time_display : {
183+ correct : true ,
184+ incorrect : true ,
185+ pending : true ,
186+ } ,
187+ medal : {
188+ official : cfg . medals ,
189+ } ,
190+ balloon_color : tdoc . balloon
191+ ? tdoc . pids . filter ( ( i ) => tdoc . balloon [ i ] ) . map ( ( i ) => ( {
192+ color : '#000' ,
193+ background_color : typeof tdoc . balloon [ i ] === 'string' ? tdoc . balloon [ i ] : tdoc . balloon [ i ] . color ,
194+ } ) )
195+ : [ ] ,
196+ logo : {
197+ preset : 'ICPC' ,
198+ } ,
199+ ...( cfg . banner ? { banner : { url : 'banner.jpg' } } : { } ) ,
200+ options : {
201+ submission_timestamp_unit : 'millisecond' ,
202+ } ,
203+ ...( typeof cfg . override === 'object' ? cfg . override || { } : { } ) ,
204+ } ,
205+ ...state ,
206+ } ;
207+ } ;
208+
209+ if ( config . publish ?. length && process . env . NODE_APP_INSTANCE === '0' ) {
210+ const done = [ ] ;
211+ const unlocked = [ ] ;
212+ logger . debug ( 'Will publish scoreboards' , config . publish ) ;
213+ ctx . effect ( ( ) => ctx . setInterval ( ( ) => {
214+ Promise . allSettled ( config . publish . map ( async ( i ) => {
215+ const key = `${ i . domainId } /${ i . contestId } ` ;
216+ if ( unlocked . includes ( key ) ) return ;
217+ const tdoc = await ContestModel . get ( i . domainId , new ObjectId ( i . contestId ) ) ;
218+ if ( ContestModel . isDone ( tdoc ) && ContestModel . isLocked ( tdoc ) && done . includes ( key ) ) return ;
219+ if ( ContestModel . isDone ( tdoc ) && ! ContestModel . isLocked ( tdoc ) ) unlocked . push ( key ) ;
220+ if ( ContestModel . isDone ( tdoc ) ) done . push ( key ) ;
221+ const groups = await UserModel . listGroup ( i . domainId ) ;
222+ const json = await getJson ( tdoc , false , { ...i , groups } ) ;
223+ logger . info ( `Publishing scoreboard ${ i . domainId } /${ i . contestId } to ${ i . publishEndpoint } ` ) ;
224+ const res = await superagent . post ( i . publishEndpoint ) . send ( {
225+ path : i . publishPath ,
226+ token : i . publishToken ,
227+ json,
228+ } ) ;
229+ logger . info ( `Published scoreboard ${ i . domainId } /${ i . contestId } to ${ i . publishEndpoint } ` , res . body ) ;
230+ } ) ) . catch ( console . error ) ;
231+ } , 30000 ) ) ;
232+ }
233+
141234 ctx . inject ( [ 'scoreboard' ] , ( { scoreboard } ) => {
142235 scoreboard . addView ( 'xcpcio' , 'XCPCIO' , {
143236 tdoc : 'tdoc' ,
@@ -155,56 +248,7 @@ export async function apply(ctx: Context, config: ReturnType<typeof Config>) {
155248 } ) {
156249 if ( realtime && ! this . user . own ( tdoc ) ) this . checkPerm ( PERM . PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD ) ;
157250 if ( json || this . request . json ) {
158- const isLocked = ContestModel . isLocked ( tdoc ) ;
159- const cacheKey = `${ tdoc . docId . toHexString ( ) } /${ ( isLocked && realtime ) ? 'realtime' : 'public' } ` ;
160- const state = lru . get ( cacheKey ) || await loadContestState ( tdoc , realtime ) ;
161- if ( config . cacheTTL ) lru . set ( cacheKey , state ) ;
162- const relatedGroups = state . teams . flatMap ( ( i ) => i . group ) ;
163- this . response . body = {
164- contest : {
165- contest_name : tdoc . title ,
166- start_time : Math . floor ( tdoc . beginAt . getTime ( ) / 1000 ) ,
167- end_time : Math . floor ( tdoc . endAt . getTime ( ) / 1000 ) ,
168- frozen_time : tdoc . lockAt ? Math . floor ( ( tdoc . endAt . getTime ( ) - tdoc . lockAt . getTime ( ) ) / 1000 ) : 0 ,
169- penalty : 1200 ,
170- problem_quantity : tdoc . pids . length ,
171- problem_id : tdoc . pids . map ( ( i , idx ) => getAlphabeticId ( idx ) ) ,
172- group : {
173- official : '正式队伍' ,
174- unofficial : '打星队伍' ,
175- ...Object . fromEntries ( groups . filter ( ( i ) => relatedGroups . includes ( i . name ) ) . map ( ( i ) => [ i . name , i . name ] ) ) ,
176- } ,
177- ...( badge ? { badge : 'Badge' } : { } ) ,
178- organization : 'School' ,
179- status_time_display : {
180- correct : true ,
181- incorrect : true ,
182- pending : true ,
183- } ,
184- medal : {
185- official : {
186- gold,
187- silver,
188- bronze,
189- } ,
190- } ,
191- balloon_color : tdoc . balloon
192- ? tdoc . pids . filter ( ( i ) => tdoc . balloon [ i ] ) . map ( ( i ) => ( {
193- color : '#000' ,
194- background_color : typeof tdoc . balloon [ i ] === 'string' ? tdoc . balloon [ i ] : tdoc . balloon [ i ] . color ,
195- } ) )
196- : [ ] ,
197- logo : {
198- preset : 'ICPC' ,
199- } ,
200- ...( banner ? { banner : { url : 'banner.jpg' } } : { } ) ,
201- options : {
202- submission_timestamp_unit : 'millisecond' ,
203- } ,
204- ...( typeof config . override === 'object' ? config . override || { } : { } ) ,
205- } ,
206- ...state ,
207- } ;
251+ this . response . body = await getJson ( tdoc , realtime , { badge, banner, groups, medals : { gold, silver, bronze } } ) ;
208252 } else {
209253 this . response . template = 'xcpcio_board.html' ;
210254 let query = '' ;
0 commit comments