import 'dart:async'; import 'dart:io'; import 'package:libac_dart/packets/packets.dart'; import 'package:libac_dart/utils/IOTools.dart'; import 'package:libac_dart/utils/TimeUtils.dart'; import 'package:servermanager/game.dart'; import 'package:servermanager/structs/SessionData.dart'; import 'package:servermanager/structs/discordHookHelper.dart'; import 'package:servermanager/structs/mod.dart'; import 'package:servermanager/structs/settings.dart'; import 'package:servermanager/wine.dart'; enum States { Idle, // For when the state machine is waiting for a state change PreStart, // Backup task Starting, // For when the server is starting up ModUpdateCheck, // For when the mods are being checked in the jail folder against the live mods WarnPlayersNonIntrusive, // Gives a non-instrusive warning about the upcoming restart WarnPlayersIntrusive, // Sends a message intrusively about the upcoming restart FullStop, // Intends to set the inactive state, and immediately stops the server Inactive // The inactive state will be used for when the state machine is not supposed to be doing anything. } enum WarnType { Intrusive, NonIntrusive } enum WarnIntervals { FIVE_SECONDS(seconds: 5, type: WarnType.Intrusive, warning: "Tiiiiiimber!"), TEN_SECONDS( seconds: 10, type: WarnType.Intrusive, warning: "The server has 10 seconds until restart"), TWENTY_SECONDS( seconds: 20, type: WarnType.NonIntrusive, warning: "The server is about to go down in 20 seconds"), THIRTY_SECONDS( seconds: 30, type: WarnType.NonIntrusive, warning: "The server will restart in 30 seconds"), ONE_MINUTE( seconds: 60, type: WarnType.Intrusive, warning: "The server will restart in 1 minute"), FIVE_MIN( seconds: (5 * 60), type: WarnType.NonIntrusive, warning: "The server will restart in 5 minutes"), TEN_MIN( seconds: (10 * 60), type: WarnType.Intrusive, warning: "The server will restart in 10 minutes"), ONE_HOUR( seconds: (1 * 60 * 60), type: WarnType.Intrusive, warning: "The server will restart in 1 hour"), TWO_HOURS( seconds: (2 * 60 * 60), type: WarnType.NonIntrusive, warning: "The server will restart in 2 hours"), ELEVEN_HOURS( seconds: (11 * 60 * 60), type: WarnType.NonIntrusive, warning: "The server will restart in 11 hours"), NONE( seconds: 2147483647, type: WarnType.NonIntrusive, warning: ""); // int32 max value should never be possible, is more seconds than in a single day. final int seconds; final WarnType type; final String warning; const WarnIntervals( {required this.seconds, required this.type, required this.warning}); } class StateMachine { static Process? PROC; static Completer DeadProcKillswitch = Completer(); void resetKillswitch() { DeadProcKillswitch = Completer(); } static Future monitorProcess() async { Settings settings = Settings(); // Ping RCON. If we can connect, the server is alive. // Only start pinging once a minute, after the first 10 minutes. if (SessionData.operating_time.getTotalSeconds() > Time(minutes: 10, hours: 0, seconds: 0).getTotalSeconds() && !SessionData.canPingServer) { SessionData.canPingServer = true; } if (SessionData.canPingServer) { SessionData.timeSinceLastPing.tickUp(); if (SessionData.timeSinceLastPing.getTotalSeconds() > 60) { SessionData.timeSinceLastPing.apply(0); } } } var _currentState = States.Inactive; StreamController _stateController = StreamController.broadcast(); Stream get stateChanges => _stateController.stream; States get currentState => _currentState; void changeState(States n) { _currentState = n; _stateController.add(n); } Future runTask() async { if (currentState == States.Idle) { return; // Nothing to do here } else if (currentState == States.FullStop) { Settings settings = Settings(); print("Sending shutdown command to server"); await settings.sendRconCommand("shutdown"); Timer.periodic(Duration(seconds: 30), (timer) { timer.cancel(); print("Sending killswitch to server"); PROC!.kill(ProcessSignal.sigkill); PacketServer.socket!.close(); }); DiscordHookHelper.sendWebHook( settings.inst!.discord, DiscordHookProps.OFFLINE_ALERT, "Server is now offline", "The server is shutting down"); changeState(States.Inactive); } else if (currentState == States.Starting) { // Server startup in progress Settings settings = Settings(); //await settings.RunUpdate(valid: false); //await doDownloadMods(false); //settings.inst!.mods = await doScanMods(false); settings.Write(); await settings.writeOutModListFile(); var conanArgs = [ "-RconEnabled=1", "-RconPassword=${settings.inst!.serverSettings.RconPassword}", "-RconPort=${settings.inst!.serverSettings.RconPort}", "-Port=${settings.inst!.serverSettings.GamePort}", "-QueryPort=${settings.inst!.serverSettings.QueryPort}", "-log", "-console" ]; // Start the server now String executable = PathHelper.builder(settings.getServerPath()) .resolve("ConanSandbox") .resolve("Binaries") .resolve("Win64") .resolve("ConanSandboxServer-Win64-Shipping.exe") .build(); if (Platform.isWindows) { PROC = await Process.start(executable, conanArgs, workingDirectory: settings.getServerPath()); } else { runDetachedWine(executable, conanArgs, settings.getServerPath()); } Timer.periodic(Duration(seconds: 20), (timer) async { File logFile = File(PathHelper.builder(settings.getServerPath()) .resolve("ConanSandbox") .resolve("Saved") .resolve("Logs") .resolve("ConanSandbox.log") .build()); await logFile.create(recursive: true); tailAndPrint(logFile); timer.cancel(); }); changeState(States.Idle); } else if (currentState == States.PreStart) { // Perform Backup Task // Finally, allow the server to start up changeState(States.Starting); } } StateMachine() { stateChanges.listen((event) async { await runTask(); }); } Timer? task; Future pollModUpdates() async { await doDownloadMods(false); await doScanMods(false); } /// You should only start this task once the server is ready to be started. /// This task will start the server on first-start /// /// It will monitor the states for shutdown, it will also monitor the process handle /// /// This is what runs the automatic restart timers as well. All state change logic that should happen automatically is contained in here. Future startScheduler() async { Settings settings = Settings(); SessionData.timer = settings.inst!.timer.time.copy(); changeState(States.PreStart); DiscordHookHelper.sendWebHook( settings.inst!.discord, DiscordHookProps.ONLINE_ALERT, "Server is now starting up", "The server is starting up now, it should appear on the server list in a few minutes"); resetKillswitch(); SessionData.enableRestartTimer = settings.inst!.timer.enabled; // Schedule the server task task = Timer.periodic(Duration(seconds: 1), (timer) async { switch (currentState) { case States.Inactive: { timer.cancel(); // Check if we should perform a world restore if (SessionData.isWorldRestore) { await DiscordHookHelper.sendWebHook( settings.inst!.discord, DiscordHookProps.OFFLINE_ALERT, "RESTORE", "Restoring backup file: ${SessionData.snapshotToRestore}"); // Now restore the backup file File backup = File(SessionData.snapshotToRestore); await backup.copy(settings.getWorldGameDB()); } //Settings settings = Settings(); if (settings.inst!.pterodactylMode) { // Shut down the server processes now PacketServer.socket!.close(); exit(0); } else { resetKillswitch(); SessionData.timer = settings.inst!.timer.time.copy(); await pollModUpdates(); changeState(States.PreStart); SessionData.enableRestartTimer = settings.inst!.timer.enabled; } break; } case States.Idle: { // Restart timers and such SessionData.timer.tickDown(); SessionData.operating_time.tickUp(); SessionData.timeSinceLastPing.tickUp(); SessionData.bumpModUpdateChecker(); // Check if we should send an alert int sec = SessionData.timer.getTotalSeconds(); WarnIntervals current = SessionData.CURRENT_INTERVAL; bool send = false; for (WarnIntervals WI in WarnIntervals.values) { if (WI == WarnIntervals.NONE) continue; if (WI.seconds >= sec && (current == WarnIntervals.NONE || WI.seconds <= current.seconds) && WI != current) { current = WI; send = true; break; } } // Ping the server to check if it is alive /* if (SessionData.timeSinceLastPing.getTotalSeconds() > 60 && SessionData.operating_time.getTotalSeconds() > (10 * 60)) { SessionData.timeSinceLastPing.apply(0); int pid = PROC!.pid; try { Socket sock = await Socket.connect( "127.0.0.1", settings.inst!.serverSettings.QueryPort); await sock.close(); } catch (E, stack) { print("Dead process checker caught: ${E}\n\n${stack}\n\n"); DeadProcKillswitch.complete(); } }*/ if (send && SessionData.enableRestartTimer) { // Send the alert message SessionData.CURRENT_INTERVAL = current; int alertColor = 0; if (current.type == WarnType.Intrusive) { print("Sending alert '${current.warning}'"); settings.sendRconCommand("broadcast ${current.warning}"); // Set discord alert color alertColor = DiscordHookProps.ALERT_INTRUSIVE; } else if (current.type == WarnType.NonIntrusive) { print("Sending chat message '${current.warning}'"); //settings.sendRconCommand( // "ast chat \"global\" \"${current.warning}\""); // Set discord alert color alertColor = DiscordHookProps.ALERT; } DiscordHookHelper.sendWebHook(settings.inst!.discord, alertColor, "Server Restart Alert", current.warning); } // Check Shutdown Pending if (SessionData.shutdownMessage.isNotEmpty) { settings .sendRconCommand("broadcast ${SessionData.shutdownMessage}"); SessionData.shutdownMessage = ""; } if (SessionData.shutdownPending) { // Shut down the server changeState(States.FullStop); SessionData.shutdownPending = false; } // Check mod updates if (SessionData.shouldCheckModUpdates()) { print("Scheduling mod update checker..."); SessionData.resetModUpdateChecker(); Timer.periodic(Duration(seconds: 10), (timer) async { timer.cancel(); await doDownloadMods(true); List currentMods = await doScanMods(true); List updatedMods = []; for (int i = 0; i < currentMods.length; i++) { Mod currentMod = settings.inst!.mods[i]; Mod scannedMod = currentMods[i]; if (currentMod.mod_hash == scannedMod.mod_hash) { // Mod is ok } else { // Mod is not ok updatedMods.add(scannedMod.mod_name); } } if (updatedMods.isNotEmpty) { settings.sendRconCommand( "broadcast The server will be going down for a restart in 5 minutes. The following mods have been updated: ${updatedMods.join(', ')}"); SessionData.timer.apply((5 * 60)); print( "Scheduling restart for mod updates: ${updatedMods.join(', ')}"); SessionData.enableRestartTimer = true; // Send discord alert! DiscordHookHelper.sendWebHook( settings.inst!.discord, DiscordHookProps.ALERT_INTRUSIVE, "Mods have been updated", "The server is going to restart because the following mods have been updated: \n${updatedMods.join(', ')}"); DiscordHookHelper.sendWebHook( settings.inst!.discord, DiscordHookProps.ALERT, "Server Restart Alert", "The server will restart in 5 minutes"); } }); } // Check Total Seconds if (SessionData.timer.getTotalSeconds() == 0 && SessionData.enableRestartTimer) { print("Shutdown is pending, restart timer has hit zero"); SessionData.shutdownPending = true; } // Check Dead Process if (DeadProcKillswitch.isCompleted) { // Switch state print("Dead process detected - Entering restart loop"); changeState(States.FullStop); // This has the stop logic } break; } default: { break; } } }); } }