VRisingServerManager.psm1

# module parameters
$ErrorActionPreference = 'Stop'

class VRisingServer {
    # static variables
    static hidden [hashtable] $_config
    static hidden [string] $_configFilePath
    static hidden [string] $_serverFileDir
    static hidden [string] $DATA_DIR_NAME = 'Data'
    static hidden [string] $SAVES_DIR_NAME = 'Saves'
    static hidden [string] $INSTALL_DIR_NAME = 'Install'
    static hidden [string] $LOG_DIR_NAME = 'Log'

    # static constructor
    static VRisingServer() {
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName ShortName `
            -MemberType ScriptProperty `
            -Value { return $this._properties.ReadProperty('ShortName') } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName SaveName `
            -MemberType ScriptProperty `
            -Value { return $this._settings.GetHostSetting('SaveName') } `
            -SecondValue { param($value) $this.SetHostSetting('SaveName',  $value) } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName DisplayName `
            -MemberType ScriptProperty `
            -Value { return $this._settings.GetHostSetting('Name') } `
            -SecondValue { param($value) $this.SetHostSetting('Name',  $value) } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName Status `
            -MemberType ScriptProperty `
            -Value { return $this._processMonitor.GetStatus() } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName Monitor `
            -MemberType ScriptProperty `
            -Value { return $this._processMonitor.GetMonitorStatus() } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName Command `
            -MemberType ScriptProperty `
            -Value { return $this._processMonitor.GetNextCommandName() } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName LastUpdate `
            -MemberType ScriptProperty `
            -Value { return $this._processMonitor.GetUpdateStatus() } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName Uptime `
            -MemberType ScriptProperty `
            -Value { return $this._processMonitor.GetUptime() } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName Enabled `
            -MemberType ScriptProperty `
            -Value { return $this._properties.ReadProperty('Enabled') } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName UpdateOnStartup `
            -MemberType ScriptProperty `
            -Value { return $this._properties.ReadProperty('UpdateOnStartup') } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName InstallDir `
            -MemberType ScriptProperty `
            -Value { return $this._properties.ReadProperty('InstallDir') } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName DataDir `
            -MemberType ScriptProperty `
            -Value { return $this._properties.ReadProperty('DataDir') } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName SettingsDir `
            -MemberType ScriptProperty `
            -Value { return $this.GetSettingsDirPath() } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName SavesDir `
            -MemberType ScriptProperty `
            -Value { return $this.GetSavesDirPath() } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName LogDir `
            -MemberType ScriptProperty `
            -Value { return $this._properties.ReadProperty('LogDir') } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName LogFile `
            -MemberType ScriptProperty `
            -Value { return $this._properties.GetLogFilePath([VRisingServerLogType]::Server) } `
            -Force
        Update-TypeData `
            -TypeName "VRisingServer" `
            -MemberName FilePath `
            -MemberType ScriptProperty `
            -Value { return $this._filePath } `
            -Force
        [VRisingServer]::_configFilePath = Join-Path `
                -Path ([Environment]::GetEnvironmentVariable('ProgramData')) `
                -ChildPath 'edgetools' |
            Join-Path -ChildPath 'VRisingServerManager' |
            Join-Path -ChildPath 'config.json'
        [VRisingServer]::_serverFileDir = Join-Path `
                -Path ([Environment]::GetEnvironmentVariable('ProgramData')) `
                -ChildPath 'edgetools' |
            Join-Path -ChildPath 'VRisingServerManager' |
            Join-Path -ChildPath 'Servers'
        [VRisingServer]::_config = @{
            SkipNewVersionCheck = $false
            DefaultServerDir = 'D:\VRisingServers'
            SteamCmdPath = $null
        }
        [VRisingServer]::LoadConfigFile()
        if (($null -eq [VRisingServer]::_config['SteamCmdPath']) -or ($true -eq ([string]::IsNullOrWhiteSpace([VRisingServer]::_config['SteamCmdPath'])))) {
            [VRisingServerLog]::Warning("SteamCmdPath is unset -- resolve using: Set-VRisingServerManagerConfigOption SteamCmdPath 'path/to/steamcmd.exe'")
        }
    }

    static hidden [psobject[]] GetConfigValue([string]$configKey) {
        return [VRisingServer]::_config[$configKey]
    }

    static hidden [void] SetConfigValue([string]$configKey, [PSObject]$configValue) {
        if ($false -eq ($configKey -in [VRisingServer]::_config.Keys)) {
            throw [VRisingServerException]::New("Config key '$configKey' unrecognized -- valid keys: $([VRisingServer]::_config.Keys)")
        }
        [VRisingServer]::_config[$configKey] = $configValue
        [VRisingServer]::SaveConfigFile()
        [VRisingServerLog]::Info("Updated $configKey")
    }

    static hidden [string[]] GetConfigKeys() {
        return [VRisingServer]::_config.Keys
    }

    static hidden [void] LoadConfigFile() {
        if ($false -eq (Test-Path -LiteralPath ([VRisingServer]::_configFilePath) -PathType Leaf)) {
            return
        }
        $configFileContents = Get-Content -Raw -LiteralPath ([VRisingServer]::_configFilePath) | ConvertFrom-Json
        if ($true -eq ($configFileContents.PSObject.Properties.Name -contains 'SkipNewVersionCheck')) {
            [VRisingServer]::_config['SkipNewVersionCheck'] = $configFileContents.SkipNewVersionCheck
        }
        if ($true -eq ($configFileContents.PSObject.Properties.Name -contains 'DefaultServerDir')) {
            [VRisingServer]::_config['DefaultServerDir'] = $configFileContents.DefaultServerDir
        }
        if ($true -eq ($configFileContents.PSObject.Properties.Name -contains 'SteamCmdPath')) {
            [VRisingServer]::_config['SteamCmdPath'] = $configFileContents.SteamCmdPath
        }
    }

    static hidden [void] SaveConfigFile() {
        # get dir for path
        $configFileDir = [VRisingServer]::_configFilePath | Split-Path -Parent
        # check if dir exists
        if ($false -eq (Test-Path -LiteralPath $configFileDir -PathType Container)) {
            # create it
            New-Item -Path $configFileDir -ItemType Directory | Out-Null
        }
        $configFile = [PSCustomObject]@{
            SkipNewVersionCheck = [VRisingServer]::_config['SkipNewVersionCheck']
            DefaultServerDir = [VRisingServer]::_config['DefaultServerDir']
            SteamCmdPath = [VRisingServer]::_config['SteamCmdPath']
        }
        $configFileJson = ConvertTo-Json -InputObject $configFile -Depth 5
        $configFileJson | Out-File -LiteralPath ([VRisingServer]::_configFilePath)
        [VRisingServerLog]::Verbose("Saved main config file")
    }

    static hidden [VRisingServer[]] FindServers([string[]]$searchKeys) {
        $servers = [VRisingServer]::LoadServers()
        $foundServers = [System.Collections.ArrayList]::New()
        if (($null -eq $searchKeys) -or ($searchKeys.Count -eq 0)) {
            $searchKeys = @('*')
        }
        foreach ($searchKey in $searchKeys) {
            $serversForKey = [VRisingServer]::FindServers($searchKey, $servers)
            if ($null -ne $serversForKey) {
                $foundServers.AddRange($serversForKey)
            }
        }
        return $foundServers.ToArray([VRisingServer])
    }

    static hidden [VRisingServer] GetServer([string]$shortName) {
        return [VRisingServer]::LoadServers() | Where-Object { $_._properties.ReadProperty('ShortName') -eq $shortName }
    }

    static hidden [VRisingServer[]] FindServers([string]$searchKey, [VRisingServer[]]$servers) {
        if ([string]::IsNullOrWhiteSpace($searchKey)) {
            $searchKey = '*'
        }
        return $servers | Where-Object { $_._properties.ReadProperty('ShortName') -like $searchKey }
    }

    static hidden [VRisingServer[]] FindServers([string]$searchKey) {
        return [VRisingServer]::FindServers($searchKey, [VRisingServer]::LoadServers())
    }

    static hidden [string[]] GetShortNames() {
        return [VRisingServer]::LoadServers() | ForEach-Object { $_._properties.ReadProperty('ShortName') }
    }

    static hidden [bool] ServerFileDirExists() {
        return Test-Path -LiteralPath ([VRisingServer]::_serverFileDir) -PathType Container
    }

    static hidden [string] GetServerFilePath([string]$ShortName) {
        return Join-Path -Path ([VRisingServer]::_serverFileDir) -ChildPath "$ShortName.json"
    }

    static hidden [System.IO.FileInfo[]] GetServerFiles() {
        return Get-ChildItem `
            -Path $(Join-Path -Path ([VRisingServer]::_serverFileDir) -ChildPath '*.json') `
            -File
    }

    static hidden [VRisingServer[]] LoadServers() {
        # check if servers dir exists
        if ($false -eq ([VRisingServer]::ServerFileDirExists())) {
            return $null
        }
        $servers = [System.Collections.ArrayList]::New()
        $serverFiles = [VRisingServer]::GetServerFiles()
        foreach ($serverFile in $serverFiles) {
            $server = [VRisingServer]::LoadServer($serverFile.FullName)
            if ($null -ne $server) {
                $servers.Add($server)
            }
        }
        return $servers.ToArray([VRisingServer])
    }

    static hidden [VRisingServer] LoadServer([string]$filePath) {
        $serverFileContents = Get-Content -Raw -LiteralPath $filePath | ConvertFrom-Json
        if (($false -eq ($serverFileContents.PSObject.Properties.Name -contains 'ShortName')) -or
                ([string]::IsNullOrWhiteSpace($serverFileContents.ShortName))) {
            throw [VRisingServerException]::New("Failed loading server file at $filePath -- ShortName is missing or empty")
        }
        $server = [VRisingServer]::New($filePath, $serverFileContents.ShortName)
        [VRisingServerLog]::Verbose("[$($serverFileContents.ShortName)] server loaded from $filePath")
        return $server
    }

    static hidden [void] CreateServer([string]$ShortName) {
        if ($false -eq ($ShortName -match '^[0-9A-Za-z-_]+$')) {
            throw [VRisingServerException]::New("Server '$ShortName' is not a valid name -- allowed characters: [A-Z] [a-z] [0-9] [-] [_]")
        }
        if ([VRisingServer]::GetShortNames() -contains $ShortName) {
            throw [VRisingServerException]::New("Server '$ShortName' already exists")
        }
        $server = [VRisingServer]::New([VRisingServer]::GetServerFilePath($ShortName), $ShortName)
        $serverProperties = @{
            ShortName = $ShortName

            Enabled = $false
            UpdateOnStartup = $true

            DataDir = Join-Path -Path ([VRisingServer]::_config['DefaultServerDir']) -ChildPath $ShortName |
                Join-Path -ChildPath ([VRisingServer]::DATA_DIR_NAME)
            InstallDir = Join-Path -Path ([VRisingServer]::_config['DefaultServerDir']) -ChildPath $ShortName |
                Join-Path -ChildPath ([VRisingServer]::INSTALL_DIR_NAME)
            LogDir = Join-Path -Path ([VRisingServer]::_config['DefaultServerDir']) -ChildPath $ShortName |
                Join-Path -ChildPath ([VRisingServer]::LOG_DIR_NAME)

            LastExitCode = 0
            ProcessId = 0
            StdoutLogFile = $null
            StderrLogFile = $null

            UpdateLastExitCode = 0
            UpdateProcessId = 0
            UpdateStdoutLogFile = $null
            UpdateStderrLogFile = $null

            CommandType = $null
            CommandProcessId = 0
            CommandStdoutLogFile = $null
            CommandStderrLogFile = $null
            CommandFinished = $null
        }
        $server._properties.WriteProperties($serverProperties)
        [VRisingServerLog]::Info("[$($ShortName)] server created")
    }

    static hidden [void] DeleteServer([VRisingServer]$server, [bool]$force) {
        if (($true -eq $server._processMonitor.MonitorIsRunning()) -and
                ($true -eq $server._processMonitor.QueueIsBusy()) -and
                ($false -eq $force)) {
            [VRisingServerLog]::Error("[$($server._properties.ReadProperty('ShortName'))] cannot remove server while it is busy with $($server._processMonitor.GetActiveCommand().Name) -- wait for command to complete first or use force to override")
        }
        if (($true -eq $server._processMonitor.ServerIsRunning()) -and ($false -eq $force)) {
            [VRisingServerLog]::Error("[$($server._properties.ReadProperty('ShortName'))] cannot remove server while it is running -- stop first or use force to override")
        }
        if (($true -eq $server._processMonitor.UpdateIsRunning()) -and ($false -eq $force)) {
            [VRisingServerLog]::Error("[$($server._properties.ReadProperty('ShortName'))] cannot remove server while it is updating -- wait for update to complete or use force to override")
        }
        $shortName = $($server._properties.ReadProperty('ShortName'))
        if ($true -eq (Test-Path -LiteralPath $server._filePath -PathType Leaf)) {
            Remove-Item -LiteralPath $server._filePath
        }
        [VRisingServerLog]::Info("[$shortName] server removed")
    }

    # instance variables
    hidden [VRisingServerProperties] $_properties
    hidden [VRisingServerSettings] $_settings
    hidden [VRisingServerProcessMonitor] $_processMonitor

    # instance constructors
    VRisingServer([string]$filePath, [string]$shortName) {
        $this._properties = [VRisingServerProperties]::New($filePath)
        $this._settings = [VRisingServerSettings]::New($this._properties)
        $this._processMonitor = [VRisingServerProcessMonitor]::New($this._properties)
    }

    # instance methods
    [void] Start() {
        $this._processMonitor.Start()
    }

    [void] Stop([bool]$force) {
        $this._processMonitor.Stop($force)
    }

    [void] Update() {
        $this._processMonitor.Update()
    }

    [void] Restart([bool]$force) {
        $this._processMonitor.Restart()
    }

    [void] Enable() {
        $this._processMonitor.Enable()
    }

    [void] Disable() {
        $this._processMonitor.Disable()
    }

    hidden [string] GetCommandStatus() {
        return 'TODO: REMOVE'
        # if ($true -eq $this.CommandIsRunning()) {
        # return 'Executing'
        # } elseif ($this._properties.ReadProperty('CommandFinished') -eq $true) {
        # return 'OK'
        # } elseif ($this._properties.ReadProperty('CommandFinished') -eq $false) {
        # return 'Error'
        # } else {
        # return 'Unknown'
        # }
    }

    hidden [string] GetSavesDirPath() {
        return Join-Path -Path $this._properties.ReadProperty('DataDir') -ChildPath ([VRisingServer]::SAVES_DIR_NAME)
    }
}

class VRisingServerException : Exception {
    VRisingServerException()
            : base("an error occurred") {}

    VRisingServerException([string]$message)
            : base($message) {}

    VRisingServerException([string]$message, [Exception]$innerException)
            : base($message, $innerException) {}
}

class VRisingServerLog {
    static [bool] $ShowDateTime = $false

    static [void] Info([PSCustomObject[]]$toLog) {
        $toLog | ForEach-Object { Write-Information $([VRisingServerLog]::WithPrefix($_)) -InformationAction 'Continue' }
    }

    static [void] Verbose([PSCustomObject[]]$toLog) {
        $toLog | ForEach-Object { Write-Verbose $([VRisingServerLog]::WithPrefix($_)) }
    }

    static [void] Debug([PSCustomObject[]]$toLog) {
        $toLog | ForEach-Object { Write-Debug $([VRisingServerLog]::WithPrefix($_)) }
    }

    static [void] Warning([PSCustomObject[]]$toLog) {
        $toLog | ForEach-Object { Write-Warning $([VRisingServerLog]::WithPrefix($_)) }
    }

    static [void] Error([PSCustomObject[]]$toLog) {
        $toLog | ForEach-Object { Write-Error $([VRisingServerLog]::WithPrefix($_)) }
    }

    static hidden [string] WithPrefix([PSCustomObject]$toLog) {
        $prefixString = ''
        if ($true -eq [VRisingServerLog]::ShowDateTime) {
            $prefixString += Get-Date -Format '[yyyy-MM-dd HH:mm:ss] '
        }
        $prefixString += '(VRisingServer) '
        $prefixString += $toLog.ToString()
        return $prefixString
    }
}

enum VRisingServerLogType {
    Server
    Output
    Error
    Update
    UpdateError
    Monitor
    MonitorError
}

class VRisingServerProcessMonitor {
    static hidden [int] $STEAM_APP_ID = 1829350

    hidden [VRisingServerProperties] $_properties

    hidden [System.Threading.Mutex] $_processMutex
    hidden [System.Threading.Mutex] $_queueMutex

    hidden [int] $_defaultPollingRate = 1

    VRisingServerProcessMonitor([VRisingServerProperties]$properties) {
        $this._properties = $properties
    }

    [void] Run() {
        $processQueueItems = $true
        $properties = $null
        try {
            [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] monitor is starting")
            while ($true -eq $processQueueItems) {
                $properties = $this._properties.ReadProperties(@(
                    'ShortName',
                    'ProcessMonitorEnabled'
                ))
                if ($false -eq $properties.ProcessMonitorEnabled) {
                    $processQueueItems = $false
                    [VRisingServerLog]::Info("[$($properties.ShortName)] monitor is disabled")
                    continue
                }
                $activeCommand = $this.PopCommandQueueItem()
                if ($null -ne $activeCommand) {
                    $this._properties.WriteProperty('ProcessMonitorActiveCommand', $activeCommand)
                    [VRisingServerLog]::Info("[$($properties.ShortName)] processing command: $($activeCommand.Name)")
                    switch ($activeCommand.Name) {
                        'Start' {
                            $this.LaunchServer()
                            # TODO wait for server to start and stabilize for... 30 seconds?
                            break
                        }
                        'Stop' {
                            $this.KillServer($activeCommand.Force)
                            # TODO wait for server to exit and capture exit code
                            break
                        }
                        'Update' {
                            $this.UpdateServer()
                            break
                        }
                    }
                    $this._properties.WriteProperty('ProcessMonitorActiveCommand', $null)
                    [VRisingServerLog]::Info("[$($properties.ShortName)] command processed: $($activeCommand.Name)")
                }
                if ($this.GetQueueDepth() -eq 0) {
                    # TODO ? - don't exit the monitor unless nothing is running
                    # or do we even need to monitor running processes?
                    $processQueueItems = $false
                    [VRisingServerLog]::Info("[$($properties.ShortName)] command queue empty")
                    continue
                }
                Start-Sleep -Seconds $this.GetPollingRate()
            }
        } finally {
            $this._properties.WriteProperty('ProcessMonitorActiveCommand', $null)
            [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] monitor is exiting")
        }
    }

    [void] Start() {
        if ($false -eq $this.IsEnabled()) {
            [VRisingServerLog]::Error("[$($this._properties.ReadProperty('ShortName'))] cannot send Start command - server is disabled")
        }
        $this.AddCommandQueueItem(
            [pscustomobject]@{
                Name = 'Start'
            }
        )
        $this.LaunchMonitor()
        [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] sent Start command")
    }

    [void] Stop([bool]$force) {
        if ($false -eq $this.IsEnabled()) {
            [VRisingServerLog]::Error("[$($this._properties.ReadProperty('ShortName'))] cannot send Stop command - server is disabled")
        }
        $this.AddCommandQueueItem(
            [pscustomobject]@{
                Name = 'Stop'
                Force = $force
            }
        )
        $this.LaunchMonitor()
        [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] sent Stop command")
    }

    [void] Update() {
        if ($false -eq $this.IsEnabled()) {
            [VRisingServerLog]::Error("[$($this._properties.ReadProperty('ShortName'))] cannot send Update command - server is disabled")
        }
        $this.AddCommandQueueItem(
            [pscustomobject]@{
                Name = 'Update'
            }
        )
        $this.LaunchMonitor()
        [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] sent Update command")
    }

    [bool] IsEnabled() {
        return $this._properties.ReadProperty('ProcessMonitorEnabled') -eq $true
    }

    [void] Enable() {
        $this.GetProcessMutex().WaitOne()
        try {
            $this._properties.WriteProperty('ProcessMonitorEnabled', $true)
            [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] server enabled")
        } finally {
            $this.GetProcessMutex().ReleaseMutex()
        }
        $this.LaunchMonitor()
    }

    [void] Disable() {
        $this.GetProcessMutex().WaitOne()
        try {
            $this._properties.WriteProperty('ProcessMonitorEnabled', $false)
            [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] server disabled")
        } finally {
            $this.GetProcessMutex().ReleaseMutex()
        }
    }

    [void] KillMonitor([bool]$force) {
        if ($false -eq $this.MonitorIsRunning()) {
            [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] monitor already stopped")
            return
        }
        if ($true -eq $force) {
            [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] forcefully killing monitor process")
        } else {
            [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] gracefully killing monitor process")
        }
        & taskkill.exe '/PID' $this._properties.ReadProperty('ProcessMonitorProcessId') $(if ($true -eq $force) { '/F' })
    }

    [void] KillServer([bool]$force) {
        if ($false -eq $this.ServerIsRunning()) {
            [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] server already stopped")
            return
        }
        if ($true -eq $force) {
            [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] forcefully killing server process")
        } else {
            [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] gracefully killing server process")
        }
        & taskkill.exe '/PID' $this._properties.ReadProperty('ServerProcessId') $(if ($true -eq $force) { '/F' })
    }

    [string] GetNextCommandName() {
        if ($false -eq [string]::IsNullOrEmpty($this.GetActiveCommand().Name)) {
            return $this.GetActiveCommand().Name
        } elseif ($this.GetQueueDepth() -gt 0) {
            return $this._properties.ReadProperty('ProcessMonitorCommandQueue')[0].Name
        } else {
            return $null
        }
    }

    [int] GetQueueDepth() {
        return $this._properties.ReadProperty('ProcessMonitorCommandQueue').Count
    }

    [bool] QueueIsBusy() {
        if (($null -ne $this.GetActiveCommand()) -or
                ($this.GetQueueDepth() -gt 0)) {
            return $true
        } else {
            return $false
        }
    }

    [string] GetUptime() {
        $process = $this.GetServerProcess()
        if ($null -eq $process) {
            return $null
        } elseif ($true -eq $process.HasExited) {
            return $null
        } else {
            $uptime = (Get-Date) - $process.StartTime
            $uptimeString = $null
            if ($uptime.Days -gt 0) {
                $uptimeString += "$(($uptime.TotalDays -split '\.')[0])d"
            } elseif ($uptime.Hours -gt 0) {
                $uptimeString += "$(($uptime.TotalHours -split '\.')[0])h"
            } elseif ($uptime.Minutes -gt 0) {
                $uptimeString += "$(($uptime.TotalMinutes -split '\.')[0])m"
            } else {
                $uptimeString += "$(($uptime.TotalSeconds -split '\.')[0])s"
            }
            return $uptimeString
        }
    }

    [string] GetStatus() {
        if ($true -eq $this.ServerIsRunning()) {
            return 'Running'
        } elseif ($true -eq $this.UpdateIsRunning()) {
            return 'Updating'
        } elseif ($false -eq $this.IsEnabled()) {
            return 'Disabled'
        } elseif ($this._properties.ReadProperty('LastExitCode') -ne 0) {
            return 'Error'
        } else {
            return 'Stopped'
        }
    }

    [string] GetMonitorStatus() {
        if ($true -eq $this.MonitorIsRunning()) {
            if ($true -eq $this.QueueIsBusy()) {
                return 'Busy'
            } else {
                return 'Idle'
            }
        } else {
            return 'Stopped'
        }
    }

    [string] GetUpdateStatus() {
        if ($true -eq $this.UpdateIsRunning()) {
            return 'InProgress'
        } elseif ($this._properties.ReadProperty('UpdateSuccess') -eq $false) {
            return 'Failed'
        } elseif ($this._properties.ReadProperty('UpdateSuccess') -eq $true) {
            return 'OK'
        } else {
            return 'Unknown'
        }
    }

    hidden [void] SetPollingRate([int]$pollingRate) {
        if ($pollingRate -gt 0) {
            $this._properties.WriteProperty('ProcessMonitorPollingRate', $pollingRate)
        }
    }

    hidden [int] GetPollingRate() {
        $pollingRate = $this._properties.ReadProperty('ProcessMonitorPollingRate')
        if ($pollingRate -gt 0) {
            return $pollingRate
        } else {
            return $this._defaultPollingRate
        }
    }

    hidden [pscustomobject] PopCommandQueueItem() {
        $this.GetCommandQueueMutex().WaitOne()
        try {
            $currentQueue = $this._properties.ReadProperty('ProcessMonitorCommandQueue')
            if ($currentQueue.Count -gt 0) {
                $queueItem = $currentQueue[0]
                $remainingQueue = $currentQueue[1..($currentQueue.Length)]
                $this._properties.WriteProperty('ProcessMonitorCommandQueue', $remainingQueue)
                return $queueItem
            } else {
                return $null
            }
        } finally {
            $this.GetCommandQueueMutex().ReleaseMutex()
        }
    }

    hidden [void] AddCommandQueueItem([pscustomobject]$queueItem) {
        $this.GetCommandQueueMutex().WaitOne()
        try {
            $currentQueue = $this._properties.ReadProperty('ProcessMonitorCommandQueue')
            if ($null -eq $currentQueue) {
                $updatedQueue = @($queueItem)
            } else {
                $updatedQueue = $currentQueue + $queueItem
            }
            $this._properties.WriteProperty('ProcessMonitorCommandQueue', $updatedQueue)
        } finally {
            $this.GetCommandQueueMutex().ReleaseMutex()
        }
    }

    hidden [psobject] GetActiveCommand() {
        return $this._properties.ReadProperty('ProcessMonitorActiveCommand')
    }

    hidden [void] EnsureDirPathExists([string]$dirPath) {
        if ($false -eq (Test-Path -LiteralPath $dirPath -PathType Container)) {
            New-Item -Path $dirPath -ItemType Directory | Out-Null
        }
    }

    hidden [System.Threading.Mutex] GetProcessMutex() {
        if ($null -eq $this._processMutex) {
            $this._processMutex = [System.Threading.Mutex]::New($false, "VRisingServerProcessMonitorProcess-$($this._properties.ReadProperty('ShortName'))")
        }
        return $this._processMutex
    }

    hidden [System.Threading.Mutex] GetCommandQueueMutex() {
        if ($null -eq $this._queueMutex) {
            $this._queueMutex = [System.Threading.Mutex]::New($false, "VRisingServerProcessMonitorCommandQueue-$($this._properties.ReadProperty('ShortName'))")
        }
        return $this._queueMutex
    }

    # start the update process
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Scope='Function')]
    # must disable this warning due to https://stackoverflow.com/a/23797762
    # $handle is declared but not used to cause $process to cache it
    # ensures that $process can access $process.ExitCode
    hidden [void] UpdateServer() {
        if ($true -eq $this.UpdateIsRunning()) {
            [VRisingServerLog]::Error("[$($this._properties.ReadProperty('ShortName'))] server update is already running")
        }
        if ($true -eq $this.ServerIsRunning()) {
            [VRisingServerLog]::Error("[$($this._properties.ReadProperty('ShortName'))] server must be stopped before updating")
        }
        $properties = $this._properties.ReadProperties(@(
            'ShortName',
            'LogDir',
            'InstallDir'
        ))
        $this.EnsureDirPathExists($properties.LogDir)
        $stdoutLogFile = Join-Path -Path $properties.LogDir -ChildPath "VRisingServer.LastUpdate.Info.log"
        $stderrLogFile = Join-Path -Path $properties.LogDir -ChildPath "VRisingServer.LastUpdate.Error.log"
        $process = $null
        $updateSucceeded = $false
        $updateDate = Get-Date -Format 'yyyy-MM-ddTHH:mm:ss'
        try {
            [VRisingServerLog]::Info("[$($properties.ShortName)] starting update")
            $process = Start-Process `
                -FilePath ([VRisingServer]::_config['SteamCmdPath']) `
                -ArgumentList @(
                    '+force_install_dir', $properties.InstallDir,
                    '+login', 'anonymous',
                    '+app_update', [VRisingServerProcessMonitor]::STEAM_APP_ID,
                    '+quit'
                ) `
                -WindowStyle Hidden `
                -RedirectStandardOutput $stdoutLogFile `
                -RedirectStandardError $stderrLogFile `
                -PassThru
            $handle = $process.Handle
            $this._properties.WriteProperties(@{
                UpdateProcessName = $process.Name
                UpdateProcessId = $process.Id
                UpdateStdoutLogFile = $stdoutLogFile
                UpdateStderrLogFile = $stderrLogFile
            })
            $process.WaitForExit()
            if ($process.ExitCode -ne 0) {
                [VRisingServerLog]::Error("[$($properties.ShortName)] update process exited with non-zero code: $($process.ExitCode)")
            } else {
                $updateSucceeded = $true
                [VRisingServerLog]::Info("[$($properties.ShortName)] update completed successfully")
            }
        } catch [InvalidOperationException] {
            [VRisingServerLog]::Error("[$($properties.ShortName)] failed starting update: $($_.Exception.Message)")
        } finally {
            if ($null -ne $process) {
                $process.Close()
            }
            $properties = @{
                UpdateProcessName = $null
                UpdateProcessId = 0
                UpdateSuccess = $updateSucceeded
            }
            if ($true -eq $updateSucceeded) {
                $properties['UpdateSuccessDate'] = $updateDate
            }
            $this._properties.WriteProperties($properties)
        }
    }

    # start the server process
    hidden [void] LaunchServer() {
        if ($true -eq $this.ServerIsRunning()) {
            [VRisingServerLog]::Error("[$($this._properties.ReadProperty('ShortName'))] server already running")
        }
        if ($true -eq $this.UpdateIsRunning()) {
            [VRisingServerLog]::Error("[$($this._properties.ReadProperty('ShortName'))] server is currently updating and cannot be started")
        }
        $this._properties.WriteProperties(@{
            ServerProcessName = $null
            ServerProcessId = 0
        })
        $properties = $this._properties.ReadProperties(@(
            'ShortName',
            'LogDir',
            'InstallDir',
            'DataDir'
        ))
        $this.EnsureDirPathExists($properties.LogDir)
        $logFile = $this._properties.GetLogFilePath([VRisingServerLogType]::Server)
        $stdoutLogFile = Join-Path -Path $properties.LogDir -ChildPath "VRisingServer.LastRun.Info.log"
        $stderrLogFile = Join-Path -Path $properties.LogDir -ChildPath "VRisingServer.LastRun.Error.log"
        $serverExePath = Join-Path -Path $properties.InstallDir -ChildPath 'VRisingServer.exe'
        try {
            $process = Start-Process `
                -WindowStyle Hidden `
                -RedirectStandardOutput $stdoutLogFile `
                -RedirectStandardError $stderrLogFile `
                -FilePath $serverExePath `
                -ArgumentList "-persistentDataPath `"$($properties.DataDir)`" -logFile `"$logFile`"" `
                -PassThru
        } catch [System.IO.DirectoryNotFoundException] {
            throw [VRisingServerException]::New("[$($properties.ShortName)] server failed to start due to missing directory -- try running update first")
        } catch [InvalidOperationException] {
            throw [VRisingServerException]::New("[$($properties.ShortName)] server failed to start: $($_.Exception.Message)")
        }
        $this._properties.WriteProperties(@{
            ServerProcessName = $process.Name
            ServerProcessId = $process.Id
            ServerStdoutLogFile = $stdoutLogFile
            ServerStderrLogFile = $stderrLogFile
        })
        [VRisingServerLog]::Info("[$($properties.shortName)] server launched")
    }

    # start the background process
    hidden [void] LaunchMonitor() {
        # check before locking the mutex (so we don't unnecessarily block the thread)
        if ($true -eq $this.MonitorIsRunning()) {
            return
        }
        $this.GetProcessMutex().WaitOne()
        try {
            # check again just in case it was launched between checking the last statement and locking the mutex
            if ($true -eq $this.MonitorIsRunning()) {
                return
            }
            $this._properties.WriteProperties(@{
                ProcessMonitorProcessName = $null
                ProcessMonitorProcessId = 0
                ProcessMonitorEnabled = $true
            })
            $properties = $this._properties.ReadProperties(@(
                'ShortName',
                'LogDir'
            ))
            $this.EnsureDirPathExists($properties.LogDir)
            $stdoutLogFile = Join-Path -Path $properties.LogDir -ChildPath "VRisingServer.ProcessMonitor.Info.log"
            $stderrLogFile = Join-Path -Path $properties.LogDir -ChildPath "VRisingServer.ProcessMonitor.Error.log"
            $argumentList = @"
-Command "& {
    `$ErrorActionPreference = 'Stop';
    if (`$null -eq `$script:VRisingServerManagerFlags) {
        `$script:VRisingServerManagerFlags = @{};
    }
    `$script:VRisingServerManagerFlags['SkipNewVersionCheck'] = `$true;
    `$script:VRisingServerManagerFlags['ShowDateTime'] = `$true;
    `$server = Get-VRisingServer -ShortName '$($properties.shortName)';
    `$server._processMonitor.Run();
}"
"@

            $process = Start-Process `
                -FilePath 'powershell' `
                -ArgumentList $argumentList `
                -WindowStyle Hidden `
                -RedirectStandardOutput $stdoutLogFile `
                -RedirectStandardError $stderrLogFile `
                -PassThru
            $this._properties.WriteProperties(@{
                ProcessMonitorProcessName = $process.Name
                ProcessMonitorProcessId = $process.Id
                ProcessMonitorStdoutLogFile = $stdoutLogFile
                ProcessMonitorStderrLogFile = $stderrLogFile
            })
            [VRisingServerLog]::Info("[$($properties.shortName)] process monitor launched")
        } finally {
            $this.GetProcessMutex().ReleaseMutex()
        }
    }

    hidden [bool] MonitorIsRunning() {
        return $this.ProcessIsRunning($this.GetMonitorProcess())
    }

    hidden [bool] ServerIsRunning() {
        return $this.ProcessIsRunning($this.GetServerProcess())
    }

    hidden [bool] UpdateIsRunning() {
        return $this.ProcessIsRunning($this.GetUpdateProcess())
    }

    hidden [bool] ProcessIsRunning([System.Diagnostics.Process]$process) {
        if ($null -eq $process) {
            return $false
        } elseif ($false -eq $process.HasExited) {
            return $true
        } else {
            return $false
        }
    }

    hidden [System.Diagnostics.Process] GetMonitorProcess() {
        return $this.GetProcessByProperties('ProcessMonitorProcessId', 'ProcessMonitorProcessName')
    }

    hidden [System.Diagnostics.Process] GetServerProcess() {
        return $this.GetProcessByProperties('ServerProcessId', 'ServerProcessName')
    }

    hidden [System.Diagnostics.Process] GetUpdateProcess() {
        return $this.GetProcessByProperties('UpdateProcessId', 'UpdateProcessName')
    }

    hidden [System.Diagnostics.Process] GetProcessByProperties([string]$processIdKey, [string]$processNameKey) {
        $properties = $this._properties.ReadProperties(@($processIdKey, $processNameKey))
        if ($properties.$processIdKey -gt 0) {
            $process = $this.GetProcessById($properties.$processIdKey)
            if ($properties.$processNameKey -eq $process.ProcessName) {
                return $process
            } else {
                return $null
            }
        } else {
            return $null
        }
    }

    hidden [System.Diagnostics.Process] GetProcessById([int]$processId) {
        try {
            return Get-Process -Id $processId
        } catch [Microsoft.PowerShell.Commands.ProcessCommandException] {
            if ('NoProcessFoundForGivenId' -eq ($_.FullyQualifiedErrorid -split ',')[0]) {
                return $null
            } else {
                throw $_
            }
        }
    }
}

class VRisingServerProperties {
    hidden [string] $_filePath
    hidden [System.Threading.Mutex] $_propertiesFileMutex

    VRisingServerProperties([string]$filePath) {
        $this._filePath = $filePath
        $fileName = ($filePath -split '\\')[-1]
        $this._propertiesFileMutex = [System.Threading.Mutex]::New($false, "VRisingServerProperties-$fileName")
    }

    [bool] IsEnabled() {
        return $this.ReadProperty('Enabled') -eq $true
    }

    [string] GetFilePath() {
        return $this._filePath
    }

    [string] GetLogFilePath([VRisingServerLogType]$logType) {
        switch ($logType) {
            ([VRisingServerLogType]::Server) {
                return Join-Path -Path $this.ReadProperty('LogDir') -ChildPath 'VRisingServer.log'
            }
            ([VRisingServerLogType]::Output) {
                return $this.ReadProperty('StdoutLogFile')
            }
            ([VRisingServerLogType]::Error) {
                return $this.ReadProperty('StderrLogFile')
            }
            ([VRisingServerLogType]::Update) {
                return $this.ReadProperty('UpdateStdoutLogFile')
            }
            ([VRisingServerLogType]::UpdateError) {
                return $this.ReadProperty('UpdateStderrLogFile')
            }
            ([VRisingServerLogType]::Monitor) {
                return $this.ReadProperty('ProcessMonitorStdoutLogFile')
            }
            ([VRisingServerLogType]::MonitorError) {
                return $this.ReadProperty('ProcessMonitorStderrLogFile')
            }
        }
        return $null
    }

    hidden [psobject] ReadProperty([string]$name) {
        return $this.ReadProperties(@($name)).$name
    }

    hidden [psobject] ReadProperties([string[]]$names) {
        if ($false -eq (Test-Path -LiteralPath $this._filePath -PathType Leaf)) {
            return $null
        }
        $fileContent = Get-Content -Raw -LiteralPath $this._filePath | ConvertFrom-Json
        $properties = [hashtable]@{}
        foreach ($name in $names) {
            if ($fileContent.PSObject.Properties.Name -contains $name) {
                $properties[$name] = $fileContent.$name
            }
        }
        return [pscustomobject]$properties
    }

    hidden [void] WriteProperty([string]$name, [psobject]$value) {
        # deal with PS5.1 ETS System.Array
        if (($null -ne $value) -and
                ('Object[]' -eq ($value.GetType().Name))) {
            $this.WriteProperties(@{
                $name=[psobject[]]$value
            })
        } else {
            $this.WriteProperties(@{
                $name=$value
            })
        }
    }

    hidden [void] WriteProperties([hashtable]$nameValues) {
        # get dir for path
        $serverFileDir = $this._filePath | Split-Path -Parent
        # check if server dir exists
        if ($false -eq (Test-Path -LiteralPath $serverFileDir -PathType Container)) {
            # create it
            New-Item -Path $serverFileDir -ItemType Directory | Out-Null
        }
        try {
            $this._propertiesFileMutex.WaitOne()
            # check if file exists
            if ($true -eq (Test-Path -LiteralPath $this._filePath -PathType Leaf)) {
                $fileContent = Get-Content -Raw -LiteralPath $this._filePath | ConvertFrom-Json
            } else {
                $fileContent = [PSCustomObject]@{}
            }
            foreach ($nameValue in $nameValues.GetEnumerator()) {
                if ($fileContent.PSObject.Properties.Name -contains $nameValue.Name) {
                    $fileContent.$($nameValue.Name) = $nameValue.Value
                } else {
                    $fileContent | Add-Member -MemberType NoteProperty -Name $nameValue.Name -Value $nameValue.Value
                }
            }
            $fileContentJson = ConvertTo-Json -InputObject $fileContent -Depth 5
            $fileContentJson | Out-File -LiteralPath $this._filePath
        } finally {
            $this._propertiesFileMutex.ReleaseMutex()
        }
    }
}

class VRisingServerSettings {
    static hidden [string] $SETTINGS_DIR_NAME = 'Settings'

    hidden [System.Threading.Mutex] $_settingsFileMutex
    hidden [VRisingServerProperties] $_properties

    VRisingServerSettings([VRisingServerProperties] $properties) {
        $this._properties = $properties
        $this._settingsFileMutex = [System.Threading.Mutex]::New($false, "VRisingServerSettings-$($properties.ReadProperty('ShortName'))")
    }

    [psobject] GetHostSetting([string]$settingName) {
        return $this.GetSettingsTypeValue([VRisingServerSettingsType]::Host, $settingName)
    }

    [psobject] GetGameSetting([string]$settingName) {
        return $this.GetSettingsTypeValue([VRisingServerSettingsType]::Game, $settingName)
    }

    [psobject] GetVoipSetting([string]$settingName) {
        return $this.GetSettingsTypeValue([VRisingServerSettingsType]::Voip, $settingName)
    }

    [void] SetHostSetting([string]$settingName, [psobject]$settingValue, [bool]$resetToDefault) {
        $this.SetSettingsTypeValue([VRisingServerSettingsType]::Host, $settingName, $settingValue, $resetToDefault)
        [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] Host Setting '$settingName' modified")
    }

    [void] SetGameSetting([string]$settingName, [psobject]$settingValue, [bool]$resetToDefault) {
        $this.SetSettingsTypeValue([VRisingServerSettingsType]::Game, $settingName, $settingValue, $resetToDefault)
        [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] Game Setting '$settingName' modified")
    }

    [void] SetVoipSetting([string]$settingName, [psobject]$settingValue, [bool]$resetToDefault) {
        $this.SetSettingsTypeValue([VRisingServerSettingsType]::Voip, $settingName, $settingValue, $resetToDefault)
        [VRisingServerLog]::Info("[$($this._properties.ReadProperty('ShortName'))] Voip Setting '$settingName' modified")
    }

    hidden [void] SetSettingsTypeValue([VRisingServerSettingsType]$settingsType, [string]$settingName, [psobject]$settingValue, [bool]$resetToDefault) {
        if ($true -eq [string]::IsNullOrWhiteSpace($settingName)) {
            throw [VRisingServerException]::New("settingName cannot be null or empty", [System.ArgumentNullException]::New('settingName'))
        }
        try {
            $this._settingsFileMutex.WaitOne()
            # skip getting default value if it's being reset
            $defaultValue = $null
            if ($false -eq $resetToDefault) {
                $defaultSettings = $null
                switch ($settingsType) {
                    ([VRisingServerSettingsType]::Host) {
                        $defaultSettings = $this.GetDefaultHostSettingsFile()
                        break
                    }
                    ([VRisingServerSettingsType]::Game) {
                        $defaultSettings = $this.GetDefaultGameSettingsFile()
                        break
                    }
                    ([VRisingServerSettingsType]::Voip) {
                        $defaultSettings = $this.GetDefaultVoipSettingsFile()
                        break
                    }
                }
                # get default value
                $defaultValue = $this.GetSetting($defaultSettings, $settingName)
                # if default value matches suggested value, reset = true
                if ($true -eq $this.ObjectsAreEqual($defaultValue, $settingValue)) {
                    $resetToDefault = $true
                }
            }

            # read the file
            $explicitSettings = $null
            $settingsFilePath = $null
            switch ($settingsType) {
                ([VRisingServerSettingsType]::Host) {
                    $explicitSettings = $this.GetHostSettingsFile()
                    $settingsFilePath = $this.GetHostSettingsFilePath()
                    break
                }
                ([VRisingServerSettingsType]::Game) {
                    $explicitSettings = $this.GetGameSettingsFile()
                    $settingsFilePath = $this.GetGameSettingsFilePath()
                    break
                }
                ([VRisingServerSettingsType]::Voip) {
                    $explicitSettings = $this.GetVoipSettingsFile()
                    $settingsFilePath = $this.GetVoipSettingsFilePath()
                    break
                }
            }

            # reset or modify the value
            if ($true -eq $resetToDefault) {
                $this.DeleteSetting($explicitSettings, $settingName)
            } else {
                $explicitSettings = $this.SetSetting($explicitSettings, $settingName, $settingValue)
            }

            # write the file
            $explicitSettingsJson = ConvertTo-Json -InputObject $explicitSettings -Depth 5
            $explicitSettingsJson | Out-File -LiteralPath $settingsFilePath
        } finally {
            # unlock mutex
            $this._settingsFileMutex.ReleaseMutex()
        }
    }

    hidden [psobject] GetSettingsTypeValue([VRisingServerSettingsType]$settingsType, [string]$settingName) {
        $defaultSettings = $null
        $explicitSettings = $null
        switch ($settingsType) {
            ([VRisingServerSettingsType]::Host) {
                $defaultSettings = $this.GetDefaultHostSettingsFile()
                $explicitSettings = $this.GetHostSettingsFile()
                break
            }
            ([VRisingServerSettingsType]::Game) {
                $defaultSettings = $this.GetDefaultGameSettingsFile()
                $explicitSettings = $this.GetGameSettingsFile()
                break
            }
            ([VRisingServerSettingsType]::Voip) {
                $defaultSettings = $this.GetDefaultVoipSettingsFile()
                $explicitSettings = $this.GetVoipSettingsFile()
                break
            }
        }
        $this.MergePSObjects($defaultSettings, $explicitSettings)
        if ([string]::IsNullOrWhiteSpace($settingName)) {
            return $defaultSettings
        } elseif ($settingName.Contains('*')) {
            $matchedSettings = $this.GetSettingsKeys($defaultSettings, $null) -like $settingName
            $filteredSettings = $this.FilterSettings($defaultSettings, $matchedSettings)
            return $filteredSettings
        } else {
            return $this.GetSetting($defaultSettings, $settingName)
        }
    }

    hidden [string[]] FindSettingsTypeKeys([VRisingServerSettingsType]$settingsType, [string]$searchKey) {
        $settings = $null
        switch ($settingsType) {
            ([VRisingServerSettingsType]::Host) {
                $settings = $this.GetDefaultHostSettingsFile()
                break
            }
            ([VRisingServerSettingsType]::Game) {
                $settings = $this.GetDefaultGameSettingsFile()
                break
            }
            ([VRisingServerSettingsType]::Voip) {
                $settings = $this.GetDefaultVoipSettingsFile()
                break
            }
        }
        return $this.GetSettingsKeys($settings, $null) -like $searchKey
    }

    hidden [string[]] GetSettingsKeys($settings, [string]$prefix) {
        $keys = [System.Collections.ArrayList]::new()
        foreach ($property in $settings.PSObject.Properties) {
            if ($property.TypeNameOfValue -eq 'System.Management.Automation.PSCustomObject') {
                $keys.AddRange($this.GetSettingsKeys($property.Value, "$($property.Name)."))
            } else {
                $keys.Add($property.Name)
            }
        }
        for ($i = 0; $i -lt $keys.Count; $i++) {
            $keys[$i] = $prefix + $keys[$i]
        }
        return $keys.ToArray([string])
    }

    # take a source object and merge an overlay on top of it
    # does not clone, modifies any objects passed by reference
    hidden [void] MergePSObjects([psobject]$sourceObject, [psobject]$overlay) {
        if ($null -eq $overlay) {
            return
        }
        if ($null -eq $sourceObject) {
            $sourceObject = $overlay
        }
        # iterate through the properties on the overlay
        $overlay.PSObject.Properties | ForEach-Object {
            $currentProperty = $_
            # if the sourceobject does NOT contain that property, just assign it from the overlay
            if ($sourceObject.PSObject.Properties.Name -notcontains $currentProperty.Name) {
                $sourceObject | Add-Member `
                    -MemberType NoteProperty `
                    -Name $currentProperty.Name `
                    -Value $currentProperty.Value
            }
            # if the sourceobject DOES contain that property, check first if it's a container (psobject)
            switch ($currentProperty.TypeNameOfValue) {
                'System.Management.Automation.PSCustomObject' {
                    # if it's a container, call this function on those subobjects (recursive)
                    $this.MergePSObjects($sourceObject.PSObject.Properties[$currentProperty.Name].Value, $currentProperty.Value)
                    break
                }
                Default {
                    # if it's NOT a container, just overlay the value directly on top of it
                    $sourceObject | Add-Member `
                        -MemberType NoteProperty `
                        -Name $currentProperty.Name `
                        -Value $currentProperty.Value `
                        -Force
                    break
                }
            }
        }
    }

    hidden [bool] ObjectsAreEqual([psobject]$a, [psobject]$b) {
        # simple dumbed down recursive comparison function
        # specifically for comparing values inside the [pscustomobject] from a loaded settings file
        # does not handle complex types
        $a_type = $a.GetType().Name
        $b_type = $b.GetType().Name
        if ($a_type -ne $b_type) {
            return $false
        }
        switch ($a_type) {
            'PSCustomObject' {
                foreach ($property in $a.PSObject.Properties.GetEnumerator()) {
                    if ($b.PSObject.Properties.Name -notcontains $property.Name) {
                        return $false
                    }
                    $a_value = $property.Value
                    $b_value = $b.PSObject.Properties[$property.Name].Value
                    $expectedComparables = @(
                        'System.Boolean',
                        'System.Int32',
                        'System.Decimal',
                        'System.String')
                    switch ($property.TypeNameOfValue) {
                        'System.Object' {
                            if ($false -eq $this.ObjectsAreEqual($a_value, $b_value)) {
                                return $false
                            }
                            continue
                        }
                        { $_ -in $expectedComparables } {
                            if ($a_value -cne $b_value) {
                                return $false
                            }
                            continue
                        }
                        Default {
                            throw [VRisingServerException]::New("ObjectsAreEqual unexpected type: $_")
                        }
                    }
                }
                continue
            }
            'Object[]' {
                if ($a.Count -ne $b.Count) {
                    return $false
                }
                for ($i=0; $i -lt $a.Count; $i++) {
                    if ($false -eq $this.ObjectsAreEqual($a[$i], $b[$i])) {
                        return $false
                    }
                    continue
                }
                continue
            }
            Default {
                return $a -eq $b
            }
        }
        return $true
    }

    hidden [psobject] FilterSettings([psobject]$settings, [string[]]$settingNameFilter) {
        if (($null -eq $settings) -or
                ([string]::IsNullOrWhiteSpace($settingNameFilter))) {
            return $null
        }
        $filteredSettings = $null
        foreach ($settingName in $settingNameFilter) {
            $settingValue = $this.GetSetting($settings, $settingName)
            $filteredSettings = $this.SetSetting($filteredSettings, $settingName, $settingValue)
        }
        return $filteredSettings
    }

    hidden [psobject] GetSetting([psobject]$settings, [string]$settingName) {
        if ($null -eq $settings) {
            return $null
        }
        $settingNameSegments = $settingName -split '\.'
        $settingContainer = $settings
        # loop into the object
        # based on the number of segments to the path
        for ($i = 0; $i -lt $settingNameSegments.Count; $i++) {
            if ($i -eq ($settingNameSegments.Count - 1)) {
                # last item
                if ($settingContainer.PSObject.Properties.Name -notcontains $settingNameSegments[$i]) {
                    return $null
                }
            } else {
                if ($null -eq $settingContainer) {
                    # parent pointed to a null value
                    return $null
                }
                if ($settingContainer.PSObject.Properties.Name -notcontains $settingNameSegments[$i]) {
                    # missing sub key (given foo.bar, foo does not exist)
                    return $null
                }
                $settingContainer = $settingContainer.PSObject.Properties[$settingNameSegments[$i]].Value
            }
        }
        return $settingContainer.PSObject.Properties[$settingNameSegments[-1]].Value
    }

    hidden [void] DeleteSetting([psobject]$settings, [string]$settingName) {
        if ($null -eq $settings) {
            return
        }
        $settingNameSegments = $settingName -split '\.'
        $settingContainer = $settings
        # loop into the object
        # based on the number of segments to the path
        for ($i = 0; $i -lt $settingNameSegments.Count; $i++) {
            if ($i -eq ($settingNameSegments.Count - 1)) {
                # last item
                if ($settingContainer.PSObject.Properties.Name -contains $settingNameSegments[$i]) {
                    $settingContainer.PSObject.Properties.Remove($settingNameSegments[$i])
                }
            } else {
                if ($null -eq $settingContainer) {
                    # parent pointed to a null value
                    return
                }
                if ($settingContainer.PSObject.Properties.Name -notcontains $settingNameSegments[$i]) {
                    # missing sub key (given foo.bar, foo does not exist)
                    return
                }
                $settingContainer = $settingContainer.PSObject.Properties[$settingNameSegments[$i]].Value
            }
        }
        return
    }

    # returns the modified (or new) settings object
    hidden [psobject] SetSetting([psobject]$settings, [string]$settingName, [psobject]$settingValue) {
        if ($null -eq $settings) {
            $settings = [PSCustomObject]@{}
        }
        # deal with PS5.1 ETS System.Array
        if (($null -ne $settingValue) -and
                ('Object[]' -eq ($settingValue.GetType().Name))) {
            $preparedValue = [psobject[]]$settingValue
        } else {
            $preparedValue = $settingValue
        }
        $settingNameSegments = $settingName -split '\.'
        $settingContainer = $settings
        # loop into the object
        # based on the number of segments to the path
        for ($i = 0; $i -lt $settingNameSegments.Count; $i++) {
            if ($i -eq ($settingNameSegments.Count - 1)) {
                # last item
                if ($settingContainer.PSObject.Properties.Name -notcontains $settingNameSegments[$i]) {
                    $settingContainer | Add-Member `
                        -MemberType NoteProperty `
                        -Name $settingNameSegments[$i] `
                        -Value $preparedValue
                } else {
                    $settingContainer.PSObject.Properties[$settingNameSegments[$i]].Value = $preparedValue
                }
            } else {
                if ($settingContainer.PSObject.Properties.Name -notcontains $settingNameSegments[$i]) {
                    # missing sub key (given foo.bar, foo does not exist)
                    # add the missing key
                    $settingContainer | Add-Member `
                        -MemberType NoteProperty `
                        -Name $settingNameSegments[$i] `
                        -Value ([PSCustomObject]@{})
                }
                $settingContainer = $settingContainer.PSObject.Properties[$settingNameSegments[$i]].Value
            }
        }
        return $settings
    }

    hidden [PSCustomObject] ReadSettingsFile([string]$filePath) {
        if ($true -eq (Test-Path -LiteralPath $filePath -PathType Leaf)) {
            return Get-Content $filePath | ConvertFrom-Json
        }
        return $null
    }

    hidden [PSCustomObject] GetHostSettingsFile() {
        return $this.ReadSettingsFile($this.GetHostSettingsFilePath())
    }

    hidden [PSCustomObject] GetGameSettingsFile() {
        return $this.ReadSettingsFile($this.GetGameSettingsFilePath())
    }

    hidden [PSCustomObject] GetVoipSettingsFile() {
        return $this.ReadSettingsFile($this.GetVoipSettingsFilePath())
    }

    hidden [string] GetDefaultHostSettingsFilePath() {
        return Join-Path -Path $this.GetDefaultSettingsDirPath() -ChildPath 'ServerHostSettings.json'
    }

    hidden [string] GetDefaultGameSettingsFilePath() {
        return Join-Path -Path $this.GetDefaultSettingsDirPath() -ChildPath 'ServerGameSettings.json'
    }

    hidden [string] GetHostSettingsFilePath() {
        return Join-Path -Path $this.GetSettingsDirPath() -ChildPath 'ServerHostSettings.json'
    }

    hidden [string] GetGameSettingsFilePath() {
        return Join-Path -Path $this.GetSettingsDirPath() -ChildPath 'ServerGameSettings.json'
    }

    hidden [string] GetVoipSettingsFilePath() {
        return Join-Path -Path $this.GetSettingsDirPath() -ChildPath 'ServerVoipSettings.json'
    }

    hidden [PSCustomObject] GetDefaultHostSettingsFile() {
        return $this.ReadSettingsFile($this.GetDefaultHostSettingsFilePath())
    }

    hidden [PSCustomObject] GetDefaultGameSettingsFile() {
        return $this.ReadSettingsFile($this.GetDefaultGameSettingsFilePath())
    }

    hidden [string] GetDefaultSettingsDirPath() {
        return Join-Path -Path $this._properties.ReadProperty('InstallDir') -ChildPath 'VRisingServer_Data' |
            Join-Path -ChildPath 'StreamingAssets' |
            Join-Path -ChildPath 'Settings'
    }

    hidden [string] GetSettingsDirPath() {
        return Join-Path -Path $this._properties.ReadProperty('DataDir') -ChildPath ([VRisingServerSettings]::SETTINGS_DIR_NAME)
    }

    hidden [PSCustomObject] GetDefaultVoipSettingsFile() {
        return [PSCustomObject]@{
            VOIPEnabled = $false
            VOIPIssuer = $null
            VOIPSecret = $null
            VOIPAppUserId = $null
            VOIPAppUserPwd = $null
            VOIPVivoxDomain = $null
            VOIPAPIEndpoint = $null
            VOIPConversationalDistance = $null
            VOIPAudibleDistance = $null
            VOIPFadeIntensity = $null
        }
    }
}

class VRisingServerSettingsMap {
    static [hashtable] $_map

    static VRisingServerSettingsMap() {
        [VRisingServerSettingsMap]::_map = @{
            Host = @{
                Secure = [bool]
                ListOnMasterServer = [bool]
                AdminOnlyDebugEvents = [bool]
                DisableDebugEvents = [bool]
                Rcon = @{
                    Enabled = [bool]
                }
            }
            Game = @{
                GameModeType = @('PvP', 'PvE')
                CastleDamageMode = @('Always', 'Never', 'TimeRestricted')
                SiegeWeaponHealth = @('VeryLow', 'Low', 'Normal', 'High', 'VeryHigh')
                PlayerDamageMode = @('Always', 'TimeRestricted')
                CastleHeartDamageMode = @('CanBeDestroyedOnlyWhenDecaying', 'CanBeDestroyedByPlayers', 'CanBeSeizedOrDestroyedByPlayers')
                PvPProtectionMode = @('Disabled', 'VeryShort', 'Short', 'Medium', 'Long')
                DeathContainerPermission = @('Anyone', 'ClanMembers', 'OnlySelf')
                RelicSpawnType = @('Unique', 'Plentiful')
                CanLootEnemyContainers = [bool]
                BloodBoundEquipment = [bool]
                TeleportBoundItems = [bool]
                AllowGlobalChat = [bool]
                AllWaypointsUnlocked = [bool]
                FreeCastleClaim = [bool]
                FreeCastleDestroy = [bool]
                InactivityKillEnabled = [bool]
                DisableDisconnectedDeadEnabled = [bool]
                AnnounceSiegeWeaponSpawn = [bool]
                ShowSiegeWeaponMapIcon = [bool]
                # "VBloodUnitSettings": [] # TODO UNKNOWN CONTENTS
                # "UnlockedAchievements": [] # TODO UNKNOWN CONTENTS
                # "UnlockedResearchs": [] # TODO UNKNOWN CONTENTS
                PlayerInteractionSettings = @{
                    TimeZone = @('Local', 'UTC', 'PST', 'CET', 'CST')
                }
            }
            Voip = @{
                VOIPEnabled = [bool]
            }
        }
    }

    static [psobject[]] Get([string]$settingsType, [string]$settingName) {
        # split on dots
        $splitSettingName = $settingName -split '\.'
        $settingLeafName = $splitSettingName[-1]
        $mappedSettings = $null
        if ([string]::IsNullOrEmpty($splitSettingName)) {
            return $null
        } elseif ($splitSettingName.Count -eq 1) {
            $mappedSettings = [VRisingServerSettingsMap]::_map[$settingsType]
        } elseif ($splitSettingName.Count -gt 1) {
            # loop into the settings object
            # based on the number of segments to the search key
            $subSettings = [VRisingServerSettingsMap]::_map[$settingsType]
            for ($i = 0; $i -lt $splitSettingName.Count; $i++) {
                if ($i -eq ($splitSettingName.Count - 1)) {
                    # last item
                } else {
                    if ($false -eq $subSettings.ContainsKey($splitSettingName[$i])) {
                        # invalid sub key (given foo.bar, foo does not exist)
                        return $null
                    }
                    $subSettings = $subSettings[$splitSettingName[$i]]
                }
            }
            $mappedSettings = $subSettings
        }
        if ($false -eq $mappedSettings.ContainsKey($settingLeafName)) {
            return $null
        } else {
            if ($mappedSettings[$settingLeafName] -is [System.Reflection.TypeInfo]) {
                switch ($mappedSettings[$settingLeafName].Name) {
                    'Boolean' {
                        return @($true,$false)
                    }
                    Default {
                        return $null
                    }
                }
            } elseif ($mappedSettings[$settingLeafName] -is [array]) {
                return $mappedSettings[$settingLeafName]
            }
        }
        return $null
    }
}

enum VRisingServerSettingsType {
    Host
    Game
    Voip
}

function ExportAliases {
    $commandAliases = @{
        vrget = 'Get-VRisingServer'
        vrcreate = 'New-VRisingServer'
        vrdelete = 'Remove-VRisingServer'
        vrmget = 'Get-VRisingServerManagerConfigOption'
        vrmset = 'Set-VRisingServerManagerConfigOption'
        vrstart = 'Start-VRisingServer'
        vrstop = 'Stop-VRisingServer'
        vrupdate = 'Update-VRisingServer'
        vrlog = 'Read-VRisingServerLog'
        vrenable = 'Enable-VRisingServer'
        vrdisable = 'Disable-VRisingServer'
        vrrestart = 'Restart-VRisingServer'
        vrset = 'Set-VRisingServer'
    }
    foreach ($commandAlias in $commandAliases.GetEnumerator()) {
        New-Alias -Value $commandAlias.Value -Name $commandAlias.Key -Scope Script
    }
}

function LoadServerManagerFlags {
    # had to stash this behavior into a function to be able to suppress the global warning from PSScriptAnalyzer
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', Scope='Function')]
    param()
    if ($null -eq $Global:VRisingServerManagerFlags) {
        return @{}
    } else {
        return $Global:VRisingServerManagerFlags
    }
}

function ServerManagerOptionArgumentCompleter {
    [OutputType([System.Management.Automation.CompletionResult])]
    param(
        [string] $CommandName,
        [string] $ParameterName,
        [string] $WordToComplete,
        [System.Management.Automation.Language.CommandAst] $CommandAst,
        [System.Collections.IDictionary] $FakeBoundParameters
    )
    $serverManagerOptions = [VRisingServer]::GetConfigKeys() -like "$WordToComplete*"
    foreach ($serverManagerOption in $serverManagerOptions) {
        [System.Management.Automation.CompletionResult]::New($serverManagerOption)
    }
}

function ServerSettingsFileArgumentCompleter {
    [OutputType([System.Management.Automation.CompletionResult])]
    param(
        [string] $CommandName,
        [string] $ParameterName,
        [string] $WordToComplete,
        [System.Management.Automation.Language.CommandAst] $CommandAst,
        [System.Collections.IDictionary] $FakeBoundParameters
    )

    if ($FakeBoundParameters.ShortName.Count -gt 1) {
        $shortName = $FakeBoundParameters.ShortName[0]
    } else {
        $shortName = $FakeBoundParameters.ShortName
    }

    switch ($ParameterName) {
        'SettingName' {
            # Foo ->
            # <- FooBar
            # <- FooBaz
            # Foo. ->
            # <- Foo.Foo
            # <- Foo.Bar
            # Foo.F ->
            # <- Foo.Foo
            # Foo.F.Foo ->
            # <- (null) -- don't fuzzy search on middle
            if ($false -eq $FakeBoundParameters.ContainsKey('ShortName')) {
                return
            }
            $server = Get-VRisingServer -ShortName $shortName
            if ($null -eq $server) {
                return
            }
            if ($false -eq $FakeBoundParameters.ContainsKey('SettingsType')) {
                return
            }
            $serverSettingsKeys = $server._settings.FindSettingsTypeKeys($FakeBoundParameters.SettingsType, "*$WordToComplete*")
            foreach ($settingsKey in $serverSettingsKeys) {
                [System.Management.Automation.CompletionResult]::New($settingsKey)
            }
            return
        }
        'SettingValue' {
            if ($false -eq $FakeBoundParameters.ContainsKey('SettingsType')) {
                return
            }
            if ($false -eq $FakeBoundParameters.ContainsKey('SettingName')) {
                return
            }
            # take type
            # lookup name in type map
            # if value type (from map) is a collection type (has multiple known values):
            # - return collection matching input, sorted preferring input
            # - e.g. Host ListOnMasterServer '' -> True / False
            # Host ListOnMasterServer 'Fa' -> False / True
            # if value type is not a collection type (has multiple known values):
            # - reach into settings
            # - extract the current or default value
            # - e.g. Host AutoSaveCount '' -> 50
            $suggestedValues = $null
            $mapResults = [VRisingServerSettingsMap]::Get(
                $FakeBoundParameters.SettingsType,
                $FakeBoundParameters.SettingName)
            if (($null -ne $mapResults) -and ($mapResults.Count -gt 0)) {
                # sort array results by those like result
                $sortedMapResults = [System.Collections.ArrayList]::New()
                foreach ($mapResult in $mapResults) {
                    if ($mapResult -like "$WordToComplete*") {
                        $sortedMapResults.Insert(0, $mapResult)
                    } else {
                        $sortedMapResults.Add($mapResult)
                    }
                }
                $suggestedValues = $sortedMapResults.ToArray()
            }
            # value does not have known values, try to grab current value from server instead
            if ($false -eq [string]::IsNullOrWhiteSpace($FakeBoundParameters.ShortName)) {
                $server = [VRisingServer]::GetServer($FakeBoundParameters.ShortName)
                $suggestedValues = $server._settings.GetSettingsTypeValue(
                    $FakeBoundParameters.SettingsType,
                    $FakeBoundParameters.SettingName)
            }
            foreach ($suggestedValue in $suggestedValues) {
                if ($suggestedValue -is [System.String]) {
                    [System.Management.Automation.CompletionResult]::New("`"$suggestedValue`"")
                } else {
                    [System.Management.Automation.CompletionResult]::New($suggestedValue)
                }
            }
            return
        }
        Default {
            return
        }
    }
}

function ServerShortNameArgumentCompleter {
    [OutputType([System.Management.Automation.CompletionResult])]
    param(
        [string] $CommandName,
        [string] $ParameterName,
        [string] $WordToComplete,
        [System.Management.Automation.Language.CommandAst] $CommandAst,
        [System.Collections.IDictionary] $FakeBoundParameters
    )
    $serverShortNames = [VRisingServer]::GetShortNames() -like "$WordToComplete*"
    foreach ($serverShortName in $serverShortNames) {
        [System.Management.Automation.CompletionResult]::New($serverShortName)
    }
}

function Disable-VRisingServer {
    [CmdletBinding(DefaultParameterSetName='ByShortName')]
    param (
        [Parameter(Position=0, ParameterSetName='ByShortName')]
        [string[]] $ShortName,

        [Parameter(ParameterSetName='ByServer', ValueFromPipeline=$true)]
        [VRisingServer] $Server
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ByShortName') {
            $servers = [VRisingServer]::FindServers($ShortName)
        } else {
            $servers = @($Server)
        }
        foreach ($serverItem in $servers) {
            try {
                $serverItem.Disable()
            } catch [VRisingServerException] {
                Write-Error $_.Exception
                continue
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Disable-VRisingServer -ParameterName ShortName -ScriptBlock $function:ServerShortNameArgumentCompleter

function Enable-VRisingServer {
    [CmdletBinding(DefaultParameterSetName='ByShortName')]
    param (
        [Parameter(Position=0, ParameterSetName='ByShortName')]
        [string[]] $ShortName,

        [Parameter(ParameterSetName='ByServer', ValueFromPipeline=$true)]
        [VRisingServer] $Server
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ByShortName') {
            $servers = [VRisingServer]::FindServers($ShortName)
        } else {
            $servers = @($Server)
        }
        foreach ($serverItem in $servers) {
            try {
                $serverItem.Enable()
            } catch [VRisingServerException] {
                Write-Error $_.Exception
                continue
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Enable-VRisingServer -ParameterName ShortName -ScriptBlock $function:ServerShortNameArgumentCompleter

function Get-VRisingServer {
    [CmdletBinding()]
    param (
        [Parameter(Position=0)]
        [string[]] $ShortName,

        [Parameter(Position=1)]
        [VRisingServerSettingsType] $SettingsType,

        [Parameter(Position=2)]
        [string] $SettingName
    )

    if ($null -ne $SettingsType) {
        $servers = [VRisingServer]::FindServers($ShortName)
        foreach ($server in $servers) {
            switch ($SettingsType) {
                ([VRisingServerSettingsType]::Host) {
                    $server._settings.GetHostSetting($SettingName)
                }
                ([VRisingServerSettingsType]::Game) {
                    $server._settings.GetGameSetting($SettingName)
                }
                ([VRisingServerSettingsType]::Voip) {
                    $server._settings.GetVoipSetting($SettingName)
                }
            }
        }
    } else {
        return [VRisingServer]::FindServers($ShortName)
    }
}

Register-ArgumentCompleter -CommandName Get-VRisingServer -ParameterName ShortName -ScriptBlock $function:ServerShortNameArgumentCompleter

Register-ArgumentCompleter -CommandName Get-VRisingServer -ParameterName SettingName -ScriptBlock $function:ServerSettingsFileArgumentCompleter

function Get-VRisingServerManagerConfigOption {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$true, Position=0)]
        $Option
    )
    [VRisingServer]::GetConfigValue($Option)
}

Register-ArgumentCompleter -CommandName Get-VRisingServerManagerConfigOption -ParameterName Option -ScriptBlock $function:ServerManagerOptionArgumentCompleter

# run this command instead of VRisingServer.exe directly
# this will know how to save the lastexitcode when it exits

function Invoke-VRisingServer {
    param (
        [string]$ShortName,
        [string]$ExePath,
        [string]$DataDir,
        [string]$LogFile
    )

    Register-EngineEvent PowerShell.Exiting -Action {
        [VRisingServerLog]::Info("[!!!!] caught exiting event")
    }

    try {
        $server = Get-VRisingServer $ShortName
        [VRisingServerLog]::Info("[$shortName] starting server")
        & $ExePath -persistentDataPath $DataDir -logFile $LogFile
    } finally {
        $exitCode = $LastExitCode
        $server._properties.WriteProperty('LastExitCode', [int]$exitCode)
        if ($exitCode -ne 0) {
            [VRisingServerLog]::Error("[$shortName] server exited with code $($exitCode)")
        }
        [VRisingServerLog]::Info("[$shortName] server exited with code $($exitCode)")
    }
}

# -ArgumentList "-persistentDataPath `"$($properties.DataDir)`" -logFile `"$logFile`"" `

# run this command instead of steamcmd.exe directly
# this will know how to save the lastexitcode when it exits
function New-VRisingServer {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Mandatory=$true)]
        [string] $ShortName
    )

    if ($true -eq $PSCmdlet.ShouldProcess($ShortName)) {
        [VRisingServer]::CreateServer($ShortName)
    }
}

function Read-VRisingServerLog {
    [CmdletBinding(DefaultParameterSetName='ByShortName')]
    param(
        [Parameter(Position=1, ParameterSetName='ByShortName')]
        [Parameter(Position=1, ParameterSetName='ByServer')]
        [VRisingServerLogType] $LogType = [VRisingServerLogType]::Server,

        [Parameter(Position=0, ParameterSetName='ByShortName')]
        [string[]] $ShortName,

        [Parameter(Position=0, ParameterSetName='ByServer', ValueFromPipeline=$true)]
        [VRisingServer] $Server,

        [Parameter()]
        [Alias('Tail')]
        [int]$Last,

        [Parameter()]
        [switch]$Follow
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ByShortName') {
            $servers = [VRisingServer]::FindServers($ShortName)
        } else {
            $servers = @($Server)
        }
        foreach ($serverItem in $servers) {
            $logFile = $serverItem._properties.GetLogFilePath($LogType)
            if ($false -eq [string]::IsNullOrWhiteSpace($logFile)) {
                $shortName = $($serverItem._properties.ReadProperty('ShortName'))
                $getContentParams = @{
                    LiteralPath = $logFile
                }
                if ($Last -gt 0) {
                    $getContentParams['Last'] = $Last
                }
                if ($true -eq $Follow) {
                    $getContentParams['Wait'] = $true
                    $keepFollowing = $true
                    while ($true -eq $keepFollowing) {
                        try {
                            Get-Content @getContentParams | ForEach-Object { "[$shortName] $_" }
                            $keepFollowing = $false
                        } catch [System.IO.FileNotFoundException],[System.Management.Automation.ItemNotFoundException] {
                            # allow following a log file that doesn't exist yet
                            # or that gets rotated out from under it while being watched
                            Start-Sleep -Seconds 1
                            continue
                        }
                    }
                } else {
                    Get-Content @getContentParams | ForEach-Object { "[$shortName] $_" }
                }
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Read-VRisingServerLog -ParameterName ShortName -ScriptBlock $function:ServerShortNameArgumentCompleter

function Remove-VRisingServer {
    [CmdletBinding(DefaultParameterSetName='ByShortName', SupportsShouldProcess, ConfirmImpact='High')]
    param (
        [Parameter(Position=0, ParameterSetName='ByShortName')]
        [string[]] $ShortName,

        [Parameter(ParameterSetName='ByServer', ValueFromPipeline=$true)]
        [VRisingServer] $Server,

        [Parameter()]
        [switch] $Force
    )

    process {
        if ($Force){
            $ConfirmPreference = 'None'
        }
        if ($PSCmdlet.ParameterSetName -eq 'ByShortName') {
            $servers = [VRisingServer]::FindServers($ShortName)
        } else {
            $servers = @($Server)
        }
        foreach ($serverItem in $servers) {
            try {
                if ($true -eq $PSCmdlet.ShouldProcess($serverItem.ShortName)) {
                    [VRisingServer]::DeleteServer($serverItem, $Force)
                }
            } catch [VRisingServerException] {
                Write-Error $_.Exception
                continue
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Remove-VRisingServer -ParameterName ShortName -ScriptBlock $function:ServerShortNameArgumentCompleter

function Restart-VRisingServer {
    [CmdletBinding(DefaultParameterSetName='ByShortName', SupportsShouldProcess)]
    param (
        [Parameter(Position=0, ParameterSetName='ByShortName')]
        [string[]] $ShortName,

        [Parameter(Position=0, ParameterSetName='ByServer', ValueFromPipeline=$true)]
        [VRisingServer] $Server,

        [Parameter()]
        [switch] $Force
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ByShortName') {
            $servers = [VRisingServer]::FindServers($ShortName)
        } else {
            $servers = @($Server)
        }
        foreach ($serverItem in $servers) {
            try {
                if ($PSCmdlet.ShouldProcess($serverItem.ShortName)) {
                    $serverItem.Restart($Force)
                }
            } catch [VRisingServerException] {
                Write-Error $_.Exception
                continue
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Restart-VRisingServer -ParameterName ShortName -ScriptBlock $function:ServerShortNameArgumentCompleter

function Set-VRisingServer {
    [CmdletBinding(SupportsShouldProcess)]
    param (
        [Parameter(Position=0, ParameterSetName='ByShortName')]
        [string[]] $ShortName,

        [Parameter(ParameterSetName='ByServer')]
        [VRisingServer] $Server,

        [Parameter(Position=1)]
        [VRisingServerSettingsType] $SettingsType,

        [Parameter(Position=2)]
        [string] $SettingName,

        [Parameter(Position=3)]
        [psobject] $SettingValue,

        [Parameter()]
        [switch] $Default
    )

    if ($PSCmdlet.ParameterSetName -eq 'ByShortName') {
        $servers = [VRisingServer]::FindServers($ShortName)
    } else {
        $servers = @($Server)
    }

    foreach ($server in $servers) {
        if ($PSCmdlet.ShouldProcess($server.ShortName)) {
            switch ($SettingsType) {
                ([VRisingServerSettingsType]::Host) {
                    $server._settings.SetHostSetting($SettingName, $SettingValue, $Default)
                }
                ([VRisingServerSettingsType]::Game) {
                    $server._settings.SetGameSetting($SettingName, $SettingValue, $Default)
                }
                ([VRisingServerSettingsType]::Voip) {
                    $server._settings.SetVoipSetting($SettingName, $SettingValue, $Default)
                }
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Set-VRisingServer -ParameterName ShortName -ScriptBlock $function:ServerShortNameArgumentCompleter

Register-ArgumentCompleter -CommandName Set-VRisingServer -ParameterName SettingName -ScriptBlock $function:ServerSettingsFileArgumentCompleter

Register-ArgumentCompleter -CommandName Set-VRisingServer -ParameterName SettingValue -ScriptBlock $function:ServerSettingsFileArgumentCompleter

function Set-VRisingServerManagerConfigOption {
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [Parameter(Mandatory=$true, Position=0)]
        $Option,

        [Parameter(Position=1)]
        [psobject]$Value
    )
    if ($PSCmdlet.ShouldProcess($Option)) {
        [VRisingServer]::SetConfigValue($Option, $Value)
    }
}

Register-ArgumentCompleter -CommandName Set-VRisingServerManagerConfigOption -ParameterName Option -ScriptBlock $function:ServerManagerOptionArgumentCompleter

function Start-VRisingServer {
    [CmdletBinding(DefaultParameterSetName='ByShortName', SupportsShouldProcess)]
    param (
        [Parameter(Position=0, ParameterSetName='ByShortName')]
        [string[]] $ShortName,

        [Parameter(ParameterSetName='ByServer', ValueFromPipeline=$true)]
        [VRisingServer] $Server
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ByShortName') {
            $servers = [VRisingServer]::FindServers($ShortName)
        } else {
            $servers = @($Server)
        }
        foreach ($serverItem in $servers) {
            try {
                if ($PSCmdlet.ShouldProcess($serverItem.ShortName)) {
                    $serverItem.Start()
                }
            } catch [VRisingServerException] {
                Write-Error $_.Exception
                continue
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Start-VRisingServer -ParameterName ShortName -ScriptBlock $function:ServerShortNameArgumentCompleter

function Stop-VRisingServer {
    [CmdletBinding(DefaultParameterSetName='ByShortName', SupportsShouldProcess)]
    param (
        [Parameter(Position=0, ParameterSetName='ByShortName')]
        [string[]] $ShortName,

        [Parameter(Position=0, ParameterSetName='ByServer', ValueFromPipeline=$true)]
        [VRisingServer] $Server,

        [Parameter()]
        [switch] $Force
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ByShortName') {
            $servers = [VRisingServer]::FindServers($ShortName)
        } else {
            $servers = @($Server)
        }
        foreach ($serverItem in $servers) {
            try {
                if ($PSCmdlet.ShouldProcess($serverItem.ShortName)) {
                    $serverItem.Stop($Force)
                }
            } catch [VRisingServerException] {
                Write-Error $_.Exception
                continue
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Stop-VRisingServer -ParameterName ShortName -ScriptBlock $function:ServerShortNameArgumentCompleter

function Update-VRisingServer {
    [CmdletBinding(DefaultParameterSetName='ByShortName', SupportsShouldProcess)]
    param (
        [Parameter(Position=0, ParameterSetName='ByShortName')]
        [string[]] $ShortName,

        [Parameter(Position=0, ParameterSetName='ByServer', ValueFromPipeline=$true)]
        [VRisingServer] $Server
    )

    process {
        if ($PSCmdlet.ParameterSetName -eq 'ByShortName') {
            $servers = [VRisingServer]::FindServers($ShortName)
        } else {
            $servers = @($Server)
        }
        foreach ($serverItem in $servers) {
            try {
                if ($PSCmdlet.ShouldProcess($serverItem.ShortName)) {
                    $serverItem.Update()
                }
            } catch [VRisingServerException] {
                Write-Error $_.Exception
                continue
            }
        }
    }
}

Register-ArgumentCompleter -CommandName Update-VRisingServer -ParameterName ShortName -ScriptBlock $function:ServerShortNameArgumentCompleter

ExportAliases

$Global:PSDefaultParameterValues['Start-VRisingServer:ErrorAction'] = 'Continue'
$Global:PSDefaultParameterValues['Stop-VRisingServer:ErrorAction'] = 'Continue'
$Global:PSDefaultParameterValues['Update-VRisingServer:ErrorAction'] = 'Continue'
$Global:PSDefaultParameterValues['Remove-VRisingServer:ErrorAction'] = 'Continue'
$Global:PSDefaultParameterValues['Enable-VRisingServer:ErrorAction'] = 'Continue'
$Global:PSDefaultParameterValues['Disable-VRisingServer:ErrorAction'] = 'Continue'
$Global:PSDefaultParameterValues['Restart-VRisingServer:ErrorAction'] = 'Continue'

# custom formatters
Update-FormatData -AppendPath "$PSScriptRoot\VRisingServerManager.Format.ps1xml"

# Load Flags
$script:VRisingServerManagerFlags = LoadServerManagerFlags

# Optionally Enable Log Timestamps
if ($true -eq $script:VRisingServerManagerFlags.ShowDateTime) {
    [VRisingServerLog]::ShowDateTime = $true
}

# Get Module Version
$script:ModuleVersion = (Import-PowerShellDataFile -Path (Join-Path -Path $PSScriptRoot -ChildPath 'VRisingServerManager.psd1')).ModuleVersion
[VRisingServerLog]::Info("VRisingServerManager v$ModuleVersion")

# List Flags
$enabledFlags = $script:VRisingServerManagerFlags.GetEnumerator() | Where-Object { $_.Value -eq $true } | Select-Object -ExpandProperty Name
if ($enabledFlags.Count -gt 0) {
    [VRisingServerLog]::Info("Using VRisingServerManagerFlags: $enabledFlags")
}

# check for new version
$skipNewVersionCheck = [VRisingServer]::GetConfigValue('SkipNewVersionCheck')
if (($true -ne $script:VRisingServerManagerFlags.SkipNewVersionCheck) -and
        ($true -ne $skipNewVersionCheck)) {
    $private:latestModule = Find-Module `
        -Name 'VRisingServerManager' `
        -Repository PSGallery `
        -MinimumVersion $ModuleVersion
    if ($ModuleVersion -ne $private:latestModule.Version) {
        $releaseNotesList = $private:latestModule.ReleaseNotes.Split(@("`r`n", "`r", "`n"), [System.StringSplitOptions]::None)
        [VRisingServerLog]::Warning('-- New Version Available! --')
        [VRisingServerLog]::Warning('')
        [VRisingServerLog]::Warning("Current Version: $ModuleVersion")
        [VRisingServerLog]::Warning(" Latest Version: $($private:latestModule.Version)")
        [VRisingServerLog]::Warning('')
        [VRisingServerLog]::Warning('To update, run:')
        [VRisingServerLog]::Warning(' Update-Module -Name VRisingServerManager')
        [VRisingServerLog]::Warning('')
        [VRisingServerLog]::Warning('To disable checking for new versions, run:')
        [VRisingServerLog]::Warning(' vrmset SkipNewVersionCheck $true')
        [VRisingServerLog]::Warning('')
        [VRisingServerLog]::Warning("-- Release Notes --")
        [VRisingServerLog]::Warning($releaseNotesList)
    } else {
        [VRisingServerLog]::Info("You are using the latest version -- run: `'vrmset SkipNewVersionCheck `$true' to disable checking for new versions")
    }
} else {
    [VRisingServerLog]::Info("Skipped new version check -- run: `'vrmset SkipNewVersionCheck `$false' to enable checking for new versions")
}