import { ResourceManager } from "./resources" ; import { state } from "./state" ; import { BestTimes , StorageManager } from "./storage" ; import { Util } from "./util" ; import { executeOnWorker } from "./worker" ; /** Stores and handles operations on the online leaderboard. */ export abstract class Leaderboard { /** The scores for each mission. */ static scores = new Map < string , [ string , number ] [ ] > ( ) ; /** Whether a mission's scores are currently loading. */ static loading = new Set < string > ( ) ; /** The latest score timestamp received from the user. Will be used to get any scores newer than this. */ static latestTimestamp : number = null ; static async init ( ) { await this . syncLeaderboard ( ) ; } /** Loads the scores of all missions in the vicinity of the current mission. */ static loadLocal ( ) { let missionPaths = new Set < string > ( ) ; let currentLevelArray = state . menu . levelSelect . currentMissionArray ; for ( let i = - 5 ; i <= 5 ; i ++ ) { let index = state . menu . levelSelect . getCycleMissionIndex ( i ) ; let mission = currentLevelArray [ index ] ; if ( mission ) missionPaths . add ( mission . path ) ; } for ( let mission of state . menu . levelSelect . getNextShuffledMissions ( ) ) missionPaths . add ( mission . path ) ; this . loadForMissions ( [ ... missionPaths ] ) ; } /** Loads all scores for the given missions. */ static async loadForMissions ( missionPaths ) { missionPaths = missionPaths . filter ( x => ! this . loading . has ( x ) && ! this . scores . has ( x ) ) ; if ( missionPaths . length === 0 ) return ; missionPaths . forEach ( x => this . loading . add ( x ) ) ; this . registerLeaderboardChange ( missionPaths ) ; let blob = await ResourceManager . retryFetch ( './api/scores' , { method : 'POST' , body : JSON . stringify ( { missions : missionPaths } ) } ) ; let data = await ResourceManager . readBlobAsJson ( blob ) ; for ( let missionPath in data ) { this . scores . set ( missionPath , data [ missionPath ] ) ; this . loading . delete ( missionPath ) ; } this . registerLeaderboardChange ( missionPaths ) ; } static isLoading ( missionPath ) { return this . loading . has ( missionPath ) ; } /** Submits a new personal best time to the leaderboard. */ static async submitBestTime ( missionPath , score ) { StorageManager . data . bestTimeSubmissionQueue [ missionPath ] = score ; StorageManager . store ( ) ; this . syncLeaderboard ( ) ; } /** Synchronizes the leaderboard: Uploads new personal best times and gets all new/changed online scores and updates the leaderboard accordingly. */ static async syncLeaderboard ( ) { let queue = StorageManager . data . bestTimeSubmissionQueue ; let payloadBestTimes = { } ; let payloadReplays = { } ; for ( let missionPath in queue ) { let score = queue [ missionPath ] ; payloadBestTimes [ missionPath ] = [ score [ 0 ] , score [ 1 ] ] ; let onlineScore = this . scores . get ( missionPath ) ?. [ 0 ] ; if ( ( ! onlineScore || ( score [ 1 ] < onlineScore [ 1 ] ) ) && ! missionPath . includes ( 'custom/' ) ) { let replayData = await StorageManager . databaseGet ( 'replays' , score [ 2 ] ) as ArrayBuffer ; if ( ! replayData ) continue ; let base64 = await Util . arrayBufferToBase64 ( replayData ) ; payloadReplays [ missionPath ] = base64 ; } } let payload = { randomId : StorageManager . data . randomId , bestTimes : Object . keys ( queue ) . length ? await Util . arrayBufferToBase64 ( await executeOnWorker ( 'compress' , JSON . stringify ( payloadBestTimes ) ) ) : null , latestTimestamp : this . latestTimestamp , replays : payloadReplays } ; let blob = await ResourceManager . retryFetch ( './api/submit' , { method : 'POST' , body : JSON . stringify ( payload ) } ) ; let data = await ResourceManager . readBlobAsJson ( blob ) ; this . latestTimestamp = data . latestTimestamp ; for ( let missionPath in data . scores ) { this . scores . set ( missionPath , data . scores [ missionPath ] ) ; } this . registerLeaderboardChange ( Object . keys ( data . scores ) ) ; StorageManager . data . bestTimeSubmissionQueue = { } ; StorageManager . store ( ) ; } /** Communicates that the given missions' leaderboards have changed somehow. Causes a visual update to the leaderboard if the missions are currently being viewed. */ static registerLeaderboardChange ( changedMissions ) { let localScoreRemoved = false ; for ( let missionPath in changedMissions ) { let onlineScore = this . scores . get ( missionPath ) ?. [ 0 ] ; let localScore = StorageManager . data . bestTimes [ missionPath ] ?. [ 0 ] ; if ( ! onlineScore || ! localScore ) continue ; while ( localScore && localScore [ 1 ] < Number ( onlineScore [ 1 ] ) && localScore [ 3 ] === 0 ) { StorageManager . data . bestTimes [ missionPath ] . splice ( 0 , 1 ) ; localScore = StorageManager . data . bestTimes [ missionPath ] [ 0 ] ; localScoreRemoved = true ; } if ( StorageManager . data . bestTimes [ missionPath ] . length === 0 ) delete StorageManager . data . bestTimes [ missionPath ] ; } if ( localScoreRemoved ) StorageManager . storeBestTimes ( ) ; let currentMission = state . menu . levelSelect . currentMission ; if ( changedMissions . includes ( currentMission ?. path ) ) state . menu . levelSelect . displayBestTimes ( ) ; } }