/** * EnduroCup mode */ #Extends "Modes/Nadeo/Trackmania/Base/TrackmaniaRoundsBase.Script.txt" #Const CompatibleMapTypes "TrackMania\\TM_Race,TM_Race" #Const Version "2024-12-23" #Const ScriptName "Modes/TM2020-Gamemodes/TM_EnduroCup.Script.txt" // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // Libraries // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // #Include "TextLib" as TL #Include "MathLib" as ML #Include "Libs/Nadeo/CMGame/Utils/Semver.Script.txt" as Semver #Include "Libs/Nadeo/Trackmania/MainMenu/Constants.Script.txt" as MenuConsts #Include "Libs/Nadeo/Trackmania/Modes/Rounds/StateManager.Script.txt" as StateMgr #Include "Libs/Nadeo/TMGame/Utils/Tracking.Script.txt" as Tracking #Include "Libs/Nadeo/TMGame/Modes/Base/UIModules/Checkpoint_Server.Script.txt" as UIModules_Checkpoint #Include "Libs/Nadeo/TMGame/Modes/Base/UIModules/PauseMenuOnline_Server.Script.txt" as UIModules_PauseMenu_Online // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // Settings // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // #Setting S_CheckpointsWithoutElimination -1 as "Number of checkpoint without elimination. Negative value = Nb of Laps" #Setting S_TimeToReachCheckpoint 30000 as "Default time to reach checkpoint in ms" #Setting S_TimeReductionEveryCP 500 as "Reduction of time in ms applied to each checkpoint" #Setting S_PointsLimit 0 as _("Points limit") #Setting S_RoundsPerMap 4 as _("Number of rounds per track") ///< Number of round to play on one map before going to the next one #Setting S_MapsPerMatch 1 as _("Number of tracks per match") ///< Number of maps to play before finishing the match #Setting S_UseTieBreak False as _("Use tie-break") ///< Continue to play the map until the tie is broken #Setting S_WarmUpNb 0 as _("Number of warm up") #Setting S_WarmUpDuration 0 as _("Duration of one warm up") #Setting S_WarmUpTimeout -1 as _("Warm up timeout") // Default settings that sould be ignored #Setting S_InfiniteLaps True #Setting S_ForceLapsNb 0 // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // Constants // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // #Const C_ModeName "EnduroCup" //L16N [Rounds] Description of the mode rules #Const Description "" // TODO CHANGE #Const C_ManiaAppUrl "file://Media/ManiaApps/Nadeo/Trackmania/Modes/Rounds.Script.txt" //< Url of the mania app #Const C_FakeUsersNb 0 // Server can allow up to 500ms of latency from the clients, so the double should be enough #Const C_LatencyCompensation 1000 #Const C_CleanCheckDelay 1000 #Const C_ScriptName_Timer "EnduroCup_Timer" #Const C_PointsLimit_NotReached 0 #Const C_PointsLimit_Reached 1 #Const C_PointsLimit_Tie 2 #Const C_UploadRecord True #Const C_DisplayRecordGhost False #Const C_DisplayRecordMedal False #Const C_CelebrateRecordGhost True #Const C_CelebrateRecordMedal True #Struct K_CheckpointState { Integer RaceTime; Integer TimeToReach; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // Extends // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // ***Match_LogVersions*** *** Log::RegisterScript(ScriptName, Version); Log::RegisterScript(Semver::ScriptName, Semver::Version); Log::RegisterScript(ModeUtils::ScriptName, ModeUtils::Version); Log::RegisterScript(StateMgr::ScriptName, StateMgr::Version); *** ***Match_LoadLibraries*** *** StateMgr::Load(); *** ***Match_UnloadLibraries*** *** StateMgr::Unload(); *** ***Match_Settings*** *** MB_Settings_UseDefaultHud = True; *** ***Match_AfterLoadHud*** *** ClientManiaAppUrl = C_ManiaAppUrl; Race::SortScores(Race::C_Sort_TotalPoints); UIModules_ScoresTable::SetScoreMode(UIModules_ScoresTable::C_Mode_Points); UIModules_Checkpoint::SetVisibilityTimeDiff(False); UIModules_Checkpoint::SetRankMode(UIModules_Checkpoint::C_RankMode_CurrentRace); UIModules_PauseMenu_Online::SetHelp(Description); UIModules_Sign16x9Small::SetScoreMode(UIModules_Sign16x9Small::C_ScoreMode_Points); // Hide SM Overlay UIManager.UIAll.OverlayHideSpectatorInfos = True; UIManager.UIAll.OverlayHideCountdown = True; // Unload default UI UIModules::UnloadModules(["UIModule_Rounds_SmallScoresTable"]); CreateTimerUI(); *** ***Match_Yield*** *** foreach (Event in PendingEvents) { switch (Event.Type) { // Initialize players when they join the server case CSmModeEvent::EType::OnPlayerAdded: { StateMgr::InitializePlayer(Event.Player); CarRank::InitializePlayer(Event.Player); } } } StateMgr::Yield(); *** ***Match_StartServer*** *** // Initialize mode Clans::SetClansNb(0); Scores::SaveInScore(Scores::C_Points_Match); StateMgr::ForcePlayersStates([StateMgr::C_State_Waiting]); WarmUp::SetAvailability(True); Race::SetupRecord( MenuConsts::C_ScopeType_Season, MenuConsts::C_ScopeType_PersonalBest, MenuConsts::C_GameMode_Rounds, "", C_UploadRecord, C_DisplayRecordGhost, C_DisplayRecordMedal, C_CelebrateRecordGhost, C_CelebrateRecordMedal ); declare netwrite K_CheckpointState[Integer] Net_EnduroCup_Checkpoints for Teams[0] = []; Net_EnduroCup_Checkpoints = []; *** ***Match_InitMap*** *** declare Integer Map_ValidRoundsNb; declare Boolean Map_Skipped; declare Integer Map_PointsLimit = S_PointsLimit; declare Integer Map_RoundsPerMap = S_RoundsPerMap; declare Integer Map_MapsPerMatch = S_MapsPerMatch; UpdateScoresTableFooter(S_PointsLimit, S_RoundsPerMap, S_MapsPerMatch, Map_ValidRoundsNb); *** ***Match_StartMap*** *** // Set inifinite laps (ignoring server settings) Race::SetLapsSettings(True, 0); Map_Skipped = True; CarRank::Reset(); // Warm up UIModules_ScoresTable::SetFooterInfo(_("Warm up")); MB_WarmUp(S_WarmUpNb, S_WarmUpDuration * 1000, S_WarmUpTimeout * 1000); *** ***Match_InitRound*** *** declare Integer Round_CheckpointsWithoutElimination; declare netwrite K_CheckpointState[Integer] Net_EnduroCup_Checkpoints for Teams[0] = []; declare Integer Last_CheckAlivePlayers = 0; while (Players.count < 2 && MB_MapIsRunning()) { MB_Yield(); } *** ***Match_StartRound*** *** if (S_CheckpointsWithoutElimination < 0) { Round_CheckpointsWithoutElimination = (Map::GetCheckpointsCount() + 1) * ML::Abs(Round_CheckpointsWithoutElimination); } else { Round_CheckpointsWithoutElimination = S_CheckpointsWithoutElimination; } UpdateScoresTableFooter(S_PointsLimit, S_RoundsPerMap, S_MapsPerMatch, Map_ValidRoundsNb); StateMgr::ForcePlayersStates([StateMgr::C_State_Playing]); *** ***Rounds_PlayerSpawned*** *** CarRank::ThrottleUpdate(CarRank::C_SortCriteria_CurrentRace); *** ***Rounds_AfterSpawningPlayers*** *** // Prevent late players to spawn foreach (Score in Scores) { Rounds_SetCanSpawn(Score, False); } *** ***Match_PlayLoop*** *** // Manage race events foreach (Event in Race::GetPendingEvents()) { Race::ValidEvent(Event); if (Event.Player == Null) continue; // Waypoint if (Event.Type == Events::C_Type_Waypoint) { CarRank::ThrottleUpdate(CarRank::C_SortCriteria_CurrentRace); if (Event.IsEndLap) { Scores::UpdatePlayerBestLapIfBetter(Event.Player); } // Update the PrevRace at each checkpoint to be able to sort scores even if someone disconnect or something Scores::UpdatePlayerPrevRace(Event.Player); declare Integer NumberOfCP = Event.Player.RaceWaypointTimes.count; if (Round_CheckpointsWithoutElimination < NumberOfCP) { // Player is first if (!Net_EnduroCup_Checkpoints.existskey(NumberOfCP) || Net_EnduroCup_Checkpoints[NumberOfCP].RaceTime > Event.RaceTime) { Net_EnduroCup_Checkpoints[NumberOfCP] = K_CheckpointState { RaceTime = Event.RaceTime, TimeToReach = S_TimeToReachCheckpoint - S_TimeReductionEveryCP * (NumberOfCP - Round_CheckpointsWithoutElimination) }; if (Last_CheckAlivePlayers == 0) Last_CheckAlivePlayers = Now + C_CleanCheckDelay; } else if (Net_EnduroCup_Checkpoints[NumberOfCP].RaceTime + Net_EnduroCup_Checkpoints[NumberOfCP].TimeToReach < Event.RaceTime) { EliminatePlayer(Event.Player); } } } else if (Event.Type == Events::C_Type_GiveUp) { EliminatePlayer(Event.Player); } } // Manage mode events foreach (Event in PendingEvents) { if (Event.HasBeenPassed || Event.HasBeenDiscarded) continue; Events::Invalid(Event); } // Check if players need to be unspawned if (Last_CheckAlivePlayers > 0 && Last_CheckAlivePlayers < Now) { Last_CheckAlivePlayers = 0; foreach (Player in Players) { if (Player.SpawnStatus != CSmPlayer::ESpawnStatus::Spawned) continue; // Use get because it's more optimized declare K_CheckpointState NextCheckpointState = Net_EnduroCup_Checkpoints.get(Player.RaceWaypointTimes.count + 1, K_CheckpointState{}); if (NextCheckpointState.RaceTime == 0) continue; if (NextCheckpointState.RaceTime + NextCheckpointState.TimeToReach + C_LatencyCompensation < Player.CurrentRaceTime) { EliminatePlayer(Player); } } } // Server info change if ( Map_PointsLimit != S_PointsLimit || Map_RoundsPerMap != S_RoundsPerMap || Map_MapsPerMatch != S_MapsPerMatch ) { Map_PointsLimit = S_PointsLimit; Map_RoundsPerMap = S_RoundsPerMap; Map_MapsPerMatch = S_MapsPerMatch; UpdateScoresTableFooter(S_PointsLimit, S_RoundsPerMap, S_MapsPerMatch, Map_ValidRoundsNb); } *** ***Rounds_CheckStopRound*** *** // End the round // If All players finished if (Players.count > 0 && PlayersNbAlive <= 1) { MB_StopRound(); Round_Skipped = False; } // If forced end round or round skipped after pause if (Round_ForceEndRound || Round_SkipPauseRound) { MB_StopRound(); Round_Skipped = False; } *** ***Match_EndRound*** *** Race::StopSkipOutroAll(); StateMgr::ForcePlayersStates([StateMgr::C_State_Waiting]); CarRank::Update(CarRank::C_SortCriteria_CurrentRace); Net_EnduroCup_Checkpoints = []; if (Semver::Compare(XmlRpc::GetApiVersion(), ">=", "2.1.1")) { Scores::XmlRpc_SendScores(Scores::C_Section_PreEndRound, ""); } if (Round_ForceEndRound || Round_SkipPauseRound || Round_Skipped) { // Cancel points foreach (Score in Scores) { Scores::SetPlayerRoundPoints(Score, 0); } // Do not launch the forced end round sequence after a pause if (!Round_SkipPauseRound) { ForcedEndRoundSequence(); } MB_SetValidRound(False); } else { Map_ValidRoundsNb += 1; // Get the last round points ComputePoints(); Race::SortScores(Race::C_Sort_TotalPoints); UIManager.UIAll.ScoreTableVisibility = CUIConfig::EVisibility::ForcedVisible; UIManager.UIAll.UISequence = CUIConfig::EUISequence::EndRound; MB_Sleep(5000); // Add them to the total scores ComputeScores(); Race::SortScores(Race::C_Sort_TotalPoints); MB_Sleep(3000); UIManager.UIAll.ScoreTableVisibility = CUIConfig::EVisibility::Normal; UIManager.UIAll.UISequence = CUIConfig::EUISequence::Playing; if (MapIsOver(S_UseTieBreak, S_PointsLimit, Map_ValidRoundsNb, S_RoundsPerMap)) { Map_Skipped = False; MB_StopMap(); } } *** ***Match_EndMap*** *** if (MatchIsOver(S_UseTieBreak, S_PointsLimit, MB_GetMapCount(), S_MapsPerMatch, S_RoundsPerMap, Map_Skipped)) MB_StopMatch(); if (!MB_MapIsRunning() && MB_MatchIsRunning()) MB_SkipPodiumSequence(); Race::SortScores(Race::C_Sort_TotalPoints); declare CSmScore Winner <=> Scores::GetBestPlayer(Scores::C_Sort_MatchPoints); Scores::SetPlayerWinner(Winner); if (!MB_MatchIsRunning()) { // Compute ranking for tracking declare Integer PreviousPoints = 0; declare Integer Rank = 0; foreach (Key => Score in Scores) { if (Key == 0 || Scores::GetPlayerMatchPoints(Score) < PreviousPoints) { PreviousPoints = Scores::GetPlayerMatchPoints(Score); Rank = Key + 1; } Tracking::SendPlayerMatchResult(UIManager, Score.User, Rank, Winner == Score && Scores.count > 1); } } *** // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // Functions // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /** Update the scores table footer text * * @param _PointsLimit The points limit * @param _RoundsPerMap The number of rounds per map * @param _MapsPerMatch The number of maps per match * @param _ValidRoundsNb Number of valid rounds played */ Void UpdateScoresTableFooter(Integer _PointsLimit, Integer _RoundsPerMap, Integer _MapsPerMatch, Integer _ValidRoundsNb) { declare Text[] Parts; declare Text Message = ""; if (_PointsLimit > 0) { if (Parts.count > 0) Message ^= "\n"; Message ^= """%{{{Parts.count + 1}}}{{{_PointsLimit}}}"""; //L16N [Rounds] Number of points to reach to win the match. Parts.add(_("Points limit : ")); } if (_RoundsPerMap > 0) { if (Parts.count > 0) Message ^= "\n"; Message ^= """%{{{Parts.count + 1}}}{{{ML::Min(_ValidRoundsNb+1, _RoundsPerMap)}}}/{{{_RoundsPerMap}}}"""; //L16N [Rounds] Number of rounds played during the track. Parts.add(_("Rounds : ")); } if (_MapsPerMatch > 0) { if (Parts.count > 0) Message ^= "\n"; Message ^= """%{{{Parts.count + 1}}}{{{MB_GetMapCount()}}}/{{{_MapsPerMatch}}}"""; //L16N [Rounds] Number of tracks played during the match. Parts.add(_("Tracks : ")); } switch (Parts.count) { case 0: UIModules_ScoresTable::SetFooterInfo(Message); case 1: UIModules_ScoresTable::SetFooterInfo(TL::Compose(Message, Parts[0])); case 2: UIModules_ScoresTable::SetFooterInfo(TL::Compose(Message, Parts[0], Parts[1])); case 3: UIModules_ScoresTable::SetFooterInfo(TL::Compose(Message, Parts[0], Parts[1], Parts[2])); } } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /// Compute round points of the prev race Void ComputePoints() { declare Ident[][Integer][Integer] Ranking = []; foreach (Score in Scores) { declare Integer NumberOfCP = Score.PrevRaceTimes.count; declare Integer LastTime = 0; if (NumberOfCP > 0) LastTime = Score.PrevRaceTimes[-1]; if (!Ranking.existskey(NumberOfCP)) Ranking[NumberOfCP] = []; if (!Ranking[NumberOfCP].existskey(LastTime)) Ranking[NumberOfCP][LastTime] = []; Ranking[NumberOfCP][LastTime].add(Score.Id); } declare Integer[] PointsRepartition = PointsRepartition::GetPointsRepartition(); declare Integer Index = 0; foreach (Times in Ranking.sortkeyreverse()) { foreach (ScoresId in Times.sortkey()) { foreach (ScoreId in ScoresId) { if (!Scores.existskey(ScoreId)) continue; declare CSmScore Score <=> Scores[ScoreId]; declare Integer Points = 0; if (PointsRepartition.count > 0) { if (PointsRepartition.existskey(Index)) { Points = PointsRepartition[Index]; } else { Points = PointsRepartition[-1]; } } Scores::SetPlayerRoundPoints(Score, Points); Index += 1; } } } } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /// Compute the map scores Void ComputeScores() { Scores::EndRound(); } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /** Eliminate Player * * @param _Player Player to eliminate */ Void EliminatePlayer(CSmPlayer _Player) { Race::StopSkipOutro(_Player); if (_Player.User != Null) { UIManager.UIAll.SendChat("$f00" ^ _Player.User.Name ^" elimitated"); } } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /** Check if the points limit was reached * * @param _UseTieBreak Prevent ties or not * @param _PointsLimit Number of points to get to win the match * * @return C_PointsLimit_Reached if the points limit is reached * C_PointsLimit_Tie if there is a tie * C_PointsLimit_NotReached if the points limit is not reached */ Integer PointsLimitReached(Boolean _UseTieBreak, Integer _PointsLimit) { declare Integer MaxScore = -1; declare Boolean Tie = False; foreach (Score in Scores) { declare Integer Points = Scores::GetPlayerMatchPoints(Score); if (Points > MaxScore) { MaxScore = Points; Tie = False; } else if (Points == MaxScore) { Tie = True; } } if (_UseTieBreak && Tie) return C_PointsLimit_Tie; //< There is a tie and it is not allowed if (_PointsLimit > 0 && MaxScore >= _PointsLimit) return C_PointsLimit_Reached; //< There is a points limit and it is reached return C_PointsLimit_NotReached; //< There is no points limit or the points limit is not reached } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /** Check if we should go to the next map * * @param _UseTieBreak Prevent ties or not * @param _PointsLimit Number of points to get to win the match * @param _ValidRoundsNb Number of valid rounds played * @param _RoundsPerMap Number of rounds to play to complete the map * * @return True if it is the case, false otherwise */ Boolean MapIsOver(Boolean _UseTieBreak, Integer _PointsLimit, Integer _ValidRoundsNb, Integer _RoundsPerMap) { declare Integer PointsLimitReached = PointsLimitReached(_UseTieBreak, _PointsLimit); Log::Log("""[Rounds] MapIsOver() > _UseTieBreak: {{{_UseTieBreak}}} | _PointsLimit: {{{_PointsLimit}}} | _ValidRoundsNb: {{{_ValidRoundsNb}}} | _RoundsPerMap: {{{_RoundsPerMap}}} | PointsLimitReached: {{{PointsLimitReached}}}"""); if (PointsLimitReached == C_PointsLimit_Reached) return True; //< There is a points limit and it is reached if (_RoundsPerMap > 0 && _ValidRoundsNb >= _RoundsPerMap) return True; //< There is a rounds limit and it is reached return False; } // ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // /** Check if we should go to the next match * * @param _UseTieBreak Prevent ties or not * @param _PointsLimit Number of points to get to win the match * @param _MapsPerMatch Number of maps to play to complete a match * @param _RoundsPerMap Number of rounds to play to complete the map * * @return True if it is the case, false otherwise */ Boolean MatchIsOver(Boolean _UseTieBreak, Integer _PointsLimit, Integer _MapCount, Integer _MapsPerMatch, Integer _RoundsPerMap, Boolean _MapSkipped) { declare Integer PointsLimitReached = PointsLimitReached(_UseTieBreak, _PointsLimit); Log::Log("""[Rounds] MatchIsOver() > _UseTieBreak: {{{_UseTieBreak}}} | _PointsLimit: {{{_PointsLimit}}} | _MapCount: {{{_MapCount}}} | _MapsPerMatch: {{{_MapsPerMatch}}} | _RoundsPerMap: {{{_RoundsPerMap}}} | PointsLimitReached: {{{PointsLimitReached}}} | _MapSkipped : {{{_MapSkipped}}}"""); // If there is a point limit and it is reached, stop the match if (PointsLimitReached == C_PointsLimit_Reached) { return True; } // If there is an explicit maps limit ... else if (_MapsPerMatch >= 1) { if ( (_MapCount >= _MapsPerMatch && PointsLimitReached != C_PointsLimit_Tie) || //< ... stop the match if the maps limit is reached and the match is not a tie (_MapSkipped && _MapsPerMatch == 1 && _MapCount >= _MapsPerMatch) //< ... stop the match if the map was skipped and the match is played on only one map ) { return True; } } // If there is a rounds limit but no maps limit, continue to play until another limit is reached else if (_RoundsPerMap >= 1) { return False; } // If there is neither a points limit nor a rounds limit, always stop the match at the end of the first map, even if there is a tie else { return True; } // In all other cases continue to play return False; } Void CreateTimerUI() { declare Text Manialink = """ """; Layers::Create(C_ScriptName_Timer, Manialink); Layers::SetType(C_ScriptName_Timer, CUILayer::EUILayerType::Normal); Layers::Attach(C_ScriptName_Timer); }