diff --git a/.gitignore b/.gitignore index f2e5b33e2..325476c99 100644 --- a/.gitignore +++ b/.gitignore @@ -2,13 +2,16 @@ /Distribution/ModernUO /Distribution/ModernUO.* /Distribution/Assemblies +/Distribution/bsdtar /Distribution/Configuration/modernuo.json /Distribution/Configuration/email-settings.json /Distribution/Configuration/throttles.json /Distribution/Logs +/Distribution/Archives /Distribution/Backups /Distribution/Saves /Distribution/docs +/Distribution/temp /Distribution/*.dylib /Distribution/*.so /Distribution/*.dll diff --git a/Distribution/Data/timezones.json b/Distribution/Data/timezones.json new file mode 100644 index 000000000..e61680c1e --- /dev/null +++ b/Distribution/Data/timezones.json @@ -0,0 +1,1157 @@ +/* Generated from https://github.com/unicode-org/cldr/blob/main/common/supplemental/windowsZones.xml */ +[ + { + "timezone": "Dateline Standard Time", + "regions": [ + "Etc/GMT+12" + ] + }, + { + "timezone": "UTC-11", + "regions": [ + "Etc/GMT+11", + "Pacific/Pago_Pago", + "Pacific/Niue", + "Pacific/Midway" + ] + }, + { + "timezone": "Aleutian Standard Time", + "regions": [ + "America/Adak" + ] + }, + { + "timezone": "Hawaiian Standard Time", + "regions": [ + "Pacific/Honolulu", + "Pacific/Rarotonga", + "Pacific/Tahiti", + "Pacific/Johnston", + "Etc/GMT+10" + ] + }, + { + "timezone": "Marquesas Standard Time", + "regions": [ + "Pacific/Marquesas" + ] + }, + { + "timezone": "Alaskan Standard Time", + "regions": [ + "America/Anchorage", + "America/Juneau", + "America/Metlakatla", + "America/Nome", + "America/Sitka", + "America/Yakutat" + ] + }, + { + "timezone": "UTC-09", + "regions": [ + "Etc/GMT+9", + "Pacific/Gambier" + ] + }, + { + "timezone": "Pacific Standard Time (Mexico)", + "regions": [ + "America/Tijuana", + "America/Santa_Isabel" + ] + }, + { + "timezone": "UTC-08", + "regions": [ + "Etc/GMT+8", + "Pacific/Pitcairn" + ] + }, + { + "timezone": "Pacific Standard Time", + "regions": [ + "America/Los_Angeles", + "America/Vancouver", + "PST8PDT" + ] + }, + { + "timezone": "US Mountain Standard Time", + "regions": [ + "America/Phoenix", + "America/Creston", + "America/Dawson_Creek", + "America/Fort_Nelson", + "America/Hermosillo", + "Etc/GMT+7" + ] + }, + { + "timezone": "Mountain Standard Time (Mexico)", + "regions": [ + "America/Chihuahua", + "America/Mazatlan" + ] + }, + { + "timezone": "Mountain Standard Time", + "regions": [ + "America/Denver", + "America/Edmonton", + "America/Cambridge_Bay", + "America/Inuvik", + "America/Yellowknife", + "America/Ojinaga", + "America/Boise", + "MST7MDT" + ] + }, + { + "timezone": "Yukon Standard Time", + "regions": [ + "America/Whitehorse", + "America/Dawson" + ] + }, + { + "timezone": "Central America Standard Time", + "regions": [ + "America/Guatemala", + "America/Belize", + "America/Costa_Rica", + "Pacific/Galapagos", + "America/Tegucigalpa", + "America/Managua", + "America/El_Salvador", + "Etc/GMT+6" + ] + }, + { + "timezone": "Central Standard Time", + "regions": [ + "America/Chicago", + "America/Winnipeg", + "America/Rainy_River", + "America/Rankin_Inlet", + "America/Resolute", + "America/Matamoros", + "America/Indiana/Knox", + "America/Indiana/Tell_City", + "America/Menominee", + "America/North_Dakota/Beulah", + "America/North_Dakota/Center", + "America/North_Dakota/New_Salem", + "CST6CDT" + ] + }, + { + "timezone": "Easter Island Standard Time", + "regions": [ + "Pacific/Easter" + ] + }, + { + "timezone": "Central Standard Time (Mexico)", + "regions": [ + "America/Mexico_City", + "America/Bahia_Banderas", + "America/Merida", + "America/Monterrey" + ] + }, + { + "timezone": "Canada Central Standard Time", + "regions": [ + "America/Regina", + "America/Swift_Current" + ] + }, + { + "timezone": "SA Pacific Standard Time", + "regions": [ + "America/Bogota", + "America/Rio_Branco", + "America/Eirunepe", + "America/Coral_Harbour", + "America/Guayaquil", + "America/Jamaica", + "America/Cayman", + "America/Panama", + "America/Lima", + "Etc/GMT+5" + ] + }, + { + "timezone": "Eastern Standard Time (Mexico)", + "regions": [ + "America/Cancun" + ] + }, + { + "timezone": "Eastern Standard Time", + "regions": [ + "America/New_York", + "America/Nassau", + "America/Toronto", + "America/Iqaluit", + "America/Montreal", + "America/Nipigon", + "America/Pangnirtung", + "America/Thunder_Bay", + "America/Detroit", + "America/Indiana/Petersburg", + "America/Indiana/Vincennes", + "America/Indiana/Winamac", + "America/Kentucky/Monticello", + "America/Louisville", + "EST5EDT" + ] + }, + { + "timezone": "Haiti Standard Time", + "regions": [ + "America/Port-au-Prince" + ] + }, + { + "timezone": "Cuba Standard Time", + "regions": [ + "America/Havana" + ] + }, + { + "timezone": "US Eastern Standard Time", + "regions": [ + "America/Indianapolis", + "America/Indiana/Marengo", + "America/Indiana/Vevay" + ] + }, + { + "timezone": "Turks And Caicos Standard Time", + "regions": [ + "America/Grand_Turk" + ] + }, + { + "timezone": "Paraguay Standard Time", + "regions": [ + "America/Asuncion" + ] + }, + { + "timezone": "Atlantic Standard Time", + "regions": [ + "America/Halifax", + "Atlantic/Bermuda", + "America/Glace_Bay", + "America/Goose_Bay", + "America/Moncton", + "America/Thule" + ] + }, + { + "timezone": "Venezuela Standard Time", + "regions": [ + "America/Caracas" + ] + }, + { + "timezone": "Central Brazilian Standard Time", + "regions": [ + "America/Cuiaba", + "America/Campo_Grande" + ] + }, + { + "timezone": "SA Western Standard Time", + "regions": [ + "America/La_Paz", + "America/Antigua", + "America/Anguilla", + "America/Aruba", + "America/Barbados", + "America/St_Barthelemy", + "America/Kralendijk", + "America/Manaus", + "America/Boa_Vista", + "America/Porto_Velho", + "America/Blanc-Sablon", + "America/Curacao", + "America/Dominica", + "America/Santo_Domingo", + "America/Grenada", + "America/Guadeloupe", + "America/Guyana", + "America/St_Kitts", + "America/St_Lucia", + "America/Marigot", + "America/Martinique", + "America/Montserrat", + "America/Puerto_Rico", + "America/Lower_Princes", + "America/Port_of_Spain", + "America/St_Vincent", + "America/Tortola", + "America/St_Thomas", + "Etc/GMT+4" + ] + }, + { + "timezone": "Pacific SA Standard Time", + "regions": [ + "America/Santiago" + ] + }, + { + "timezone": "Newfoundland Standard Time", + "regions": [ + "America/St_Johns" + ] + }, + { + "timezone": "Tocantins Standard Time", + "regions": [ + "America/Araguaina" + ] + }, + { + "timezone": "E. South America Standard Time", + "regions": [ + "America/Sao_Paulo" + ] + }, + { + "timezone": "SA Eastern Standard Time", + "regions": [ + "America/Cayenne", + "Antarctica/Rothera", + "Antarctica/Palmer", + "America/Fortaleza", + "America/Belem", + "America/Maceio", + "America/Recife", + "America/Santarem", + "Atlantic/Stanley", + "America/Paramaribo", + "Etc/GMT+3" + ] + }, + { + "timezone": "Argentina Standard Time", + "regions": [ + "America/Buenos_Aires", + "America/Argentina/La_Rioja", + "America/Argentina/Rio_Gallegos", + "America/Argentina/Salta", + "America/Argentina/San_Juan", + "America/Argentina/San_Luis", + "America/Argentina/Tucuman", + "America/Argentina/Ushuaia", + "America/Catamarca", + "America/Cordoba", + "America/Jujuy", + "America/Mendoza" + ] + }, + { + "timezone": "Greenland Standard Time", + "regions": [ + "America/Godthab" + ] + }, + { + "timezone": "Montevideo Standard Time", + "regions": [ + "America/Montevideo" + ] + }, + { + "timezone": "Magallanes Standard Time", + "regions": [ + "America/Punta_Arenas" + ] + }, + { + "timezone": "Saint Pierre Standard Time", + "regions": [ + "America/Miquelon" + ] + }, + { + "timezone": "Bahia Standard Time", + "regions": [ + "America/Bahia" + ] + }, + { + "timezone": "UTC-02", + "regions": [ + "Etc/GMT+2", + "America/Noronha", + "Atlantic/South_Georgia" + ] + }, + { + "timezone": "Azores Standard Time", + "regions": [ + "Atlantic/Azores", + "America/Scoresbysund" + ] + }, + { + "timezone": "Cape Verde Standard Time", + "regions": [ + "Atlantic/Cape_Verde", + "Etc/GMT+1" + ] + }, + { + "timezone": "UTC", + "regions": [ + "Etc/UTC", + "Etc/GMT" + ] + }, + { + "timezone": "GMT Standard Time", + "regions": [ + "Europe/London", + "Atlantic/Canary", + "Atlantic/Faeroe", + "Europe/Guernsey", + "Europe/Dublin", + "Europe/Isle_of_Man", + "Europe/Jersey", + "Europe/Lisbon", + "Atlantic/Madeira" + ] + }, + { + "timezone": "Greenwich Standard Time", + "regions": [ + "Atlantic/Reykjavik", + "Africa/Ouagadougou", + "Africa/Abidjan", + "Africa/Accra", + "America/Danmarkshavn", + "Africa/Banjul", + "Africa/Conakry", + "Africa/Bissau", + "Africa/Monrovia", + "Africa/Bamako", + "Africa/Nouakchott", + "Atlantic/St_Helena", + "Africa/Freetown", + "Africa/Dakar", + "Africa/Lome" + ] + }, + { + "timezone": "Sao Tome Standard Time", + "regions": [ + "Africa/Sao_Tome" + ] + }, + { + "timezone": "Morocco Standard Time", + "regions": [ + "Africa/Casablanca", + "Africa/El_Aaiun" + ] + }, + { + "timezone": "W. Europe Standard Time", + "regions": [ + "Europe/Berlin", + "Europe/Andorra", + "Europe/Vienna", + "Europe/Zurich", + "Europe/Busingen", + "Europe/Gibraltar", + "Europe/Rome", + "Europe/Vaduz", + "Europe/Luxembourg", + "Europe/Monaco", + "Europe/Malta", + "Europe/Amsterdam", + "Europe/Oslo", + "Europe/Stockholm", + "Arctic/Longyearbyen", + "Europe/San_Marino", + "Europe/Vatican" + ] + }, + { + "timezone": "Central Europe Standard Time", + "regions": [ + "Europe/Budapest", + "Europe/Tirane", + "Europe/Prague", + "Europe/Podgorica", + "Europe/Belgrade", + "Europe/Ljubljana", + "Europe/Bratislava" + ] + }, + { + "timezone": "Romance Standard Time", + "regions": [ + "Europe/Paris", + "Europe/Brussels", + "Europe/Copenhagen", + "Europe/Madrid", + "Africa/Ceuta" + ] + }, + { + "timezone": "Central European Standard Time", + "regions": [ + "Europe/Warsaw", + "Europe/Sarajevo", + "Europe/Zagreb", + "Europe/Skopje" + ] + }, + { + "timezone": "W. Central Africa Standard Time", + "regions": [ + "Africa/Lagos", + "Africa/Luanda", + "Africa/Porto-Novo", + "Africa/Kinshasa", + "Africa/Bangui", + "Africa/Brazzaville", + "Africa/Douala", + "Africa/Algiers", + "Africa/Libreville", + "Africa/Malabo", + "Africa/Niamey", + "Africa/Ndjamena", + "Africa/Tunis", + "Etc/GMT-1" + ] + }, + { + "timezone": "Jordan Standard Time", + "regions": [ + "Asia/Amman" + ] + }, + { + "timezone": "GTB Standard Time", + "regions": [ + "Europe/Bucharest", + "Asia/Nicosia", + "Asia/Famagusta", + "Europe/Athens" + ] + }, + { + "timezone": "Middle East Standard Time", + "regions": [ + "Asia/Beirut" + ] + }, + { + "timezone": "Egypt Standard Time", + "regions": [ + "Africa/Cairo" + ] + }, + { + "timezone": "E. Europe Standard Time", + "regions": [ + "Europe/Chisinau" + ] + }, + { + "timezone": "Syria Standard Time", + "regions": [ + "Asia/Damascus" + ] + }, + { + "timezone": "West Bank Standard Time", + "regions": [ + "Asia/Hebron", + "Asia/Gaza" + ] + }, + { + "timezone": "South Africa Standard Time", + "regions": [ + "Africa/Johannesburg", + "Africa/Bujumbura", + "Africa/Gaborone", + "Africa/Lubumbashi", + "Africa/Maseru", + "Africa/Blantyre", + "Africa/Maputo", + "Africa/Kigali", + "Africa/Mbabane", + "Africa/Lusaka", + "Africa/Harare", + "Etc/GMT-2" + ] + }, + { + "timezone": "FLE Standard Time", + "regions": [ + "Europe/Kiev", + "Europe/Mariehamn", + "Europe/Sofia", + "Europe/Tallinn", + "Europe/Helsinki", + "Europe/Vilnius", + "Europe/Riga", + "Europe/Uzhgorod", + "Europe/Zaporozhye" + ] + }, + { + "timezone": "Israel Standard Time", + "regions": [ + "Asia/Jerusalem" + ] + }, + { + "timezone": "South Sudan Standard Time", + "regions": [ + "Africa/Juba" + ] + }, + { + "timezone": "Kaliningrad Standard Time", + "regions": [ + "Europe/Kaliningrad" + ] + }, + { + "timezone": "Sudan Standard Time", + "regions": [ + "Africa/Khartoum" + ] + }, + { + "timezone": "Libya Standard Time", + "regions": [ + "Africa/Tripoli" + ] + }, + { + "timezone": "Namibia Standard Time", + "regions": [ + "Africa/Windhoek" + ] + }, + { + "timezone": "Arabic Standard Time", + "regions": [ + "Asia/Baghdad" + ] + }, + { + "timezone": "Turkey Standard Time", + "regions": [ + "Europe/Istanbul" + ] + }, + { + "timezone": "Arab Standard Time", + "regions": [ + "Asia/Riyadh", + "Asia/Bahrain", + "Asia/Kuwait", + "Asia/Qatar", + "Asia/Aden" + ] + }, + { + "timezone": "Belarus Standard Time", + "regions": [ + "Europe/Minsk" + ] + }, + { + "timezone": "Russian Standard Time", + "regions": [ + "Europe/Moscow", + "Europe/Kirov", + "Europe/Simferopol" + ] + }, + { + "timezone": "E. Africa Standard Time", + "regions": [ + "Africa/Nairobi", + "Antarctica/Syowa", + "Africa/Djibouti", + "Africa/Asmera", + "Africa/Addis_Ababa", + "Indian/Comoro", + "Indian/Antananarivo", + "Africa/Mogadishu", + "Africa/Dar_es_Salaam", + "Africa/Kampala", + "Indian/Mayotte", + "Etc/GMT-3" + ] + }, + { + "timezone": "Iran Standard Time", + "regions": [ + "Asia/Tehran" + ] + }, + { + "timezone": "Arabian Standard Time", + "regions": [ + "Asia/Dubai", + "Asia/Muscat", + "Etc/GMT-4" + ] + }, + { + "timezone": "Astrakhan Standard Time", + "regions": [ + "Europe/Astrakhan", + "Europe/Ulyanovsk" + ] + }, + { + "timezone": "Azerbaijan Standard Time", + "regions": [ + "Asia/Baku" + ] + }, + { + "timezone": "Russia Time Zone 3", + "regions": [ + "Europe/Samara" + ] + }, + { + "timezone": "Mauritius Standard Time", + "regions": [ + "Indian/Mauritius", + "Indian/Reunion", + "Indian/Mahe" + ] + }, + { + "timezone": "Saratov Standard Time", + "regions": [ + "Europe/Saratov" + ] + }, + { + "timezone": "Georgian Standard Time", + "regions": [ + "Asia/Tbilisi" + ] + }, + { + "timezone": "Volgograd Standard Time", + "regions": [ + "Europe/Volgograd" + ] + }, + { + "timezone": "Caucasus Standard Time", + "regions": [ + "Asia/Yerevan" + ] + }, + { + "timezone": "Afghanistan Standard Time", + "regions": [ + "Asia/Kabul" + ] + }, + { + "timezone": "West Asia Standard Time", + "regions": [ + "Asia/Tashkent", + "Antarctica/Mawson", + "Asia/Oral", + "Asia/Aqtau", + "Asia/Aqtobe", + "Asia/Atyrau", + "Indian/Maldives", + "Indian/Kerguelen", + "Asia/Dushanbe", + "Asia/Ashgabat", + "Asia/Samarkand", + "Etc/GMT-5" + ] + }, + { + "timezone": "Ekaterinburg Standard Time", + "regions": [ + "Asia/Yekaterinburg" + ] + }, + { + "timezone": "Pakistan Standard Time", + "regions": [ + "Asia/Karachi" + ] + }, + { + "timezone": "Qyzylorda Standard Time", + "regions": [ + "Asia/Qyzylorda" + ] + }, + { + "timezone": "India Standard Time", + "regions": [ + "Asia/Calcutta" + ] + }, + { + "timezone": "Sri Lanka Standard Time", + "regions": [ + "Asia/Colombo" + ] + }, + { + "timezone": "Nepal Standard Time", + "regions": [ + "Asia/Katmandu" + ] + }, + { + "timezone": "Central Asia Standard Time", + "regions": [ + "Asia/Almaty", + "Antarctica/Vostok", + "Asia/Urumqi", + "Indian/Chagos", + "Asia/Bishkek", + "Asia/Qostanay", + "Etc/GMT-6" + ] + }, + { + "timezone": "Bangladesh Standard Time", + "regions": [ + "Asia/Dhaka", + "Asia/Thimphu" + ] + }, + { + "timezone": "Omsk Standard Time", + "regions": [ + "Asia/Omsk" + ] + }, + { + "timezone": "Myanmar Standard Time", + "regions": [ + "Asia/Rangoon", + "Indian/Cocos" + ] + }, + { + "timezone": "SE Asia Standard Time", + "regions": [ + "Asia/Bangkok", + "Antarctica/Davis", + "Indian/Christmas", + "Asia/Jakarta", + "Asia/Pontianak", + "Asia/Phnom_Penh", + "Asia/Vientiane", + "Asia/Saigon", + "Etc/GMT-7" + ] + }, + { + "timezone": "Altai Standard Time", + "regions": [ + "Asia/Barnaul" + ] + }, + { + "timezone": "W. Mongolia Standard Time", + "regions": [ + "Asia/Hovd" + ] + }, + { + "timezone": "North Asia Standard Time", + "regions": [ + "Asia/Krasnoyarsk", + "Asia/Novokuznetsk" + ] + }, + { + "timezone": "N. Central Asia Standard Time", + "regions": [ + "Asia/Novosibirsk" + ] + }, + { + "timezone": "Tomsk Standard Time", + "regions": [ + "Asia/Tomsk" + ] + }, + { + "timezone": "China Standard Time", + "regions": [ + "Asia/Shanghai", + "Asia/Hong_Kong", + "Asia/Macau" + ] + }, + { + "timezone": "North Asia East Standard Time", + "regions": [ + "Asia/Irkutsk" + ] + }, + { + "timezone": "Singapore Standard Time", + "regions": [ + "Asia/Singapore", + "Asia/Brunei", + "Asia/Makassar", + "Asia/Kuala_Lumpur", + "Asia/Kuching", + "Asia/Manila", + "Etc/GMT-8" + ] + }, + { + "timezone": "W. Australia Standard Time", + "regions": [ + "Australia/Perth" + ] + }, + { + "timezone": "Taipei Standard Time", + "regions": [ + "Asia/Taipei" + ] + }, + { + "timezone": "Ulaanbaatar Standard Time", + "regions": [ + "Asia/Ulaanbaatar", + "Asia/Choibalsan" + ] + }, + { + "timezone": "Aus Central W. Standard Time", + "regions": [ + "Australia/Eucla" + ] + }, + { + "timezone": "Transbaikal Standard Time", + "regions": [ + "Asia/Chita" + ] + }, + { + "timezone": "Tokyo Standard Time", + "regions": [ + "Asia/Tokyo", + "Asia/Jayapura", + "Pacific/Palau", + "Asia/Dili", + "Etc/GMT-9" + ] + }, + { + "timezone": "North Korea Standard Time", + "regions": [ + "Asia/Pyongyang" + ] + }, + { + "timezone": "Korea Standard Time", + "regions": [ + "Asia/Seoul" + ] + }, + { + "timezone": "Yakutsk Standard Time", + "regions": [ + "Asia/Yakutsk", + "Asia/Khandyga" + ] + }, + { + "timezone": "Cen. Australia Standard Time", + "regions": [ + "Australia/Adelaide", + "Australia/Broken_Hill" + ] + }, + { + "timezone": "AUS Central Standard Time", + "regions": [ + "Australia/Darwin" + ] + }, + { + "timezone": "E. Australia Standard Time", + "regions": [ + "Australia/Brisbane", + "Australia/Lindeman" + ] + }, + { + "timezone": "AUS Eastern Standard Time", + "regions": [ + "Australia/Sydney", + "Australia/Melbourne" + ] + }, + { + "timezone": "West Pacific Standard Time", + "regions": [ + "Pacific/Port_Moresby", + "Antarctica/DumontDUrville", + "Pacific/Truk", + "Pacific/Guam", + "Pacific/Saipan", + "Etc/GMT-10" + ] + }, + { + "timezone": "Tasmania Standard Time", + "regions": [ + "Australia/Hobart", + "Australia/Currie", + "Antarctica/Macquarie" + ] + }, + { + "timezone": "Vladivostok Standard Time", + "regions": [ + "Asia/Vladivostok", + "Asia/Ust-Nera" + ] + }, + { + "timezone": "Lord Howe Standard Time", + "regions": [ + "Australia/Lord_Howe" + ] + }, + { + "timezone": "Bougainville Standard Time", + "regions": [ + "Pacific/Bougainville" + ] + }, + { + "timezone": "Russia Time Zone 10", + "regions": [ + "Asia/Srednekolymsk" + ] + }, + { + "timezone": "Magadan Standard Time", + "regions": [ + "Asia/Magadan" + ] + }, + { + "timezone": "Norfolk Standard Time", + "regions": [ + "Pacific/Norfolk" + ] + }, + { + "timezone": "Sakhalin Standard Time", + "regions": [ + "Asia/Sakhalin" + ] + }, + { + "timezone": "Central Pacific Standard Time", + "regions": [ + "Pacific/Guadalcanal", + "Antarctica/Casey", + "Pacific/Ponape", + "Pacific/Kosrae", + "Pacific/Noumea", + "Pacific/Efate", + "Etc/GMT-11" + ] + }, + { + "timezone": "Russia Time Zone 11", + "regions": [ + "Asia/Kamchatka", + "Asia/Anadyr" + ] + }, + { + "timezone": "New Zealand Standard Time", + "regions": [ + "Pacific/Auckland", + "Antarctica/McMurdo" + ] + }, + { + "timezone": "UTC+12", + "regions": [ + "Etc/GMT-12", + "Pacific/Tarawa", + "Pacific/Majuro", + "Pacific/Kwajalein", + "Pacific/Nauru", + "Pacific/Funafuti", + "Pacific/Wake", + "Pacific/Wallis" + ] + }, + { + "timezone": "Fiji Standard Time", + "regions": [ + "Pacific/Fiji" + ] + }, + { + "timezone": "Chatham Islands Standard Time", + "regions": [ + "Pacific/Chatham" + ] + }, + { + "timezone": "UTC+13", + "regions": [ + "Etc/GMT-13", + "Pacific/Enderbury", + "Pacific/Fakaofo" + ] + }, + { + "timezone": "Tonga Standard Time", + "regions": [ + "Pacific/Tongatapu" + ] + }, + { + "timezone": "Samoa Standard Time", + "regions": [ + "Pacific/Apia" + ] + }, + { + "timezone": "Line Islands Standard Time", + "regions": [ + "Pacific/Kiritimati", + "Etc/GMT-14" + ] + } +] diff --git a/Projects/Server/AssemblyHandler.cs b/Projects/Server/AssemblyHandler.cs index 2cccc222a..00e8d7e53 100644 --- a/Projects/Server/AssemblyHandler.cs +++ b/Projects/Server/AssemblyHandler.cs @@ -181,6 +181,20 @@ namespace Server return path; } + + public static void EnsureDirectory(FileInfo fi) + { + var dir = fi.DirectoryName; + if (dir != null) + { + Directory.CreateDirectory(dir); + } + } + + public static void EnsureDirectory(DirectoryInfo di) + { + Directory.CreateDirectory(di.FullName); + } } public class TypeCache diff --git a/Projects/Server/Comparers/DescendingComparer.cs b/Projects/Server/Comparers/DescendingComparer.cs new file mode 100644 index 000000000..4a53e9f32 --- /dev/null +++ b/Projects/Server/Comparers/DescendingComparer.cs @@ -0,0 +1,24 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2021 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: DescendingComparer.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; + +namespace Server +{ + public class DescendingComparer : IComparer where T : IComparable { + public int Compare(T x, T y) => y?.CompareTo(x) ?? 1; + } +} diff --git a/Projects/Server/Configuration/ServerConfiguration.cs b/Projects/Server/Configuration/ServerConfiguration.cs index f2e785b1f..7463aadff 100644 --- a/Projects/Server/Configuration/ServerConfiguration.cs +++ b/Projects/Server/Configuration/ServerConfiguration.cs @@ -169,6 +169,17 @@ namespace Server return value; } + public static void SetSetting(string key, TimeSpan value) => SetSetting(key, value.ToString()); + + public static void SetSetting(string key, int value) => SetSetting(key, value.ToString()); + + public static void SetSetting(string key, long value) => SetSetting(key, value.ToString()); + + public static void SetSetting(string key, bool value) => SetSetting(key, value.ToString()); + + public static void SetSetting(string key, T value) where T : struct, Enum => + SetSetting(key, value.ToString()); + public static void SetSetting(string key, string value) { m_Settings.Settings[key] = value; diff --git a/Projects/Server/Json/Converters/GuidConverter.cs b/Projects/Server/Json/Converters/GuidConverter.cs index 3f61742b8..b6c373094 100644 --- a/Projects/Server/Json/Converters/GuidConverter.cs +++ b/Projects/Server/Json/Converters/GuidConverter.cs @@ -14,7 +14,6 @@ *************************************************************************/ using System; -using System.Net; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/Projects/Server/Json/Converters/GuidConverterFactory.cs b/Projects/Server/Json/Converters/GuidConverterFactory.cs index 92acf4183..e0ba7c4ce 100644 --- a/Projects/Server/Json/Converters/GuidConverterFactory.cs +++ b/Projects/Server/Json/Converters/GuidConverterFactory.cs @@ -14,7 +14,6 @@ *************************************************************************/ using System; -using System.Net; using System.Text.Json; using System.Text.Json.Serialization; diff --git a/Projects/Server/Main.cs b/Projects/Server/Main.cs index 41bc0f858..1d56edbb6 100644 --- a/Projects/Server/Main.cs +++ b/Projects/Server/Main.cs @@ -494,7 +494,7 @@ namespace Server try { #if DEBUG - var idleCPU = true; + const bool idleCPU = true; #else var idleCPU = ServerConfiguration.GetOrUpdateSetting("core.enableIdleCPU", false); #endif diff --git a/Projects/Server/TimeZones/TimeZoneHandler.cs b/Projects/Server/TimeZones/TimeZoneHandler.cs new file mode 100644 index 000000000..5e9450f02 --- /dev/null +++ b/Projects/Server/TimeZones/TimeZoneHandler.cs @@ -0,0 +1,80 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2021 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: TimeZoneHandler.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System; +using System.Collections.Generic; +using System.IO; +using System.Runtime.CompilerServices; +using Server.Json; + +namespace Server +{ + /** + * TimeZoneHandler provides a cross platform compatible way to handle system timezones. + * + * By default the system timezone is used. + * To manually configure the local timezone add the following setting to modernuo.json: + * "system.localTimeZone": "Pacific Standard Time" + * All valid timezones can be found in the Distribution/Data/timezones.json file. + * + * Notes for timezones.json: + * By default, Windows will use the values in the "timezone" property. + * By default, Unix/MacOS will use the values in the "region" property. + * system.localTimeZone can be either timezone or a region. + * + * Example: + * "system.localTimeZone": "Europe/Lisbon" + */ + public static class TimeZoneHandler + { + private static readonly Dictionary _timeZoneById = new(); + public static TimeZoneInfo SystemTimeZone { get; private set; } + + static TimeZoneHandler() + { + var timezones = JsonConfig.Deserialize(Path.Combine(Core.BaseDirectory, "Data/timezones.json")); + foreach (var tz in timezones) + { + // Get the timezone if we are on Windows + var tzInfo = Core.IsWindows ? TimeZoneInfo.FindSystemTimeZoneById(tz.TimeZone) : null; + + foreach (var region in tz.Regions) + { + // Get the timezone by region if we are on linux + tzInfo ??= TimeZoneInfo.FindSystemTimeZoneById(region); + _timeZoneById[region] = tzInfo; + } + + _timeZoneById[tz.TimeZone] = tzInfo; + } + } + + public static void Configure() + { + var tzId = ServerConfiguration.GetSetting("system.localTimeZone", TimeZoneInfo.Local.Id); + SystemTimeZone = FindTimeZoneById(tzId); + } + + /** + * Cross platform version of TimeZoneInfo.FindSystemTimeZoneById + */ + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static TimeZoneInfo FindTimeZoneById(string id) => _timeZoneById[id]; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static DateTime ToSystemLocalTime(this DateTime date) => + TimeZoneInfo.ConvertTimeFromUtc(date, SystemTimeZone); + } +} diff --git a/Projects/Server/TimeZones/TimeZoneWithRegions.cs b/Projects/Server/TimeZones/TimeZoneWithRegions.cs new file mode 100644 index 000000000..4c19a0086 --- /dev/null +++ b/Projects/Server/TimeZones/TimeZoneWithRegions.cs @@ -0,0 +1,28 @@ +/************************************************************************* + * ModernUO * + * Copyright 2019-2021 - ModernUO Development Team * + * Email: hi@modernuo.com * + * File: TimeZoneWithRegions.cs * + * * + * This program is free software: you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation, either version 3 of the License, or * + * (at your option) any later version. * + * * + * You should have received a copy of the GNU General Public License * + * along with this program. If not, see . * + *************************************************************************/ + +using System.Text.Json.Serialization; + +namespace Server +{ + public record TimeZoneWithRegions + { + [JsonPropertyName("timezone")] + public string TimeZone { get; init; } + + [JsonPropertyName("regions")] + public string[] Regions { get; init; } + } +} diff --git a/Projects/Server/Timer/Timer.cs b/Projects/Server/Timer/Timer.cs index 176cf5748..5ed8ac3d9 100644 --- a/Projects/Server/Timer/Timer.cs +++ b/Projects/Server/Timer/Timer.cs @@ -35,9 +35,9 @@ namespace Server private Timer _nextTimer; private Timer _prevTimer; - public Timer(TimeSpan delay) : this(delay, TimeSpan.Zero, 1) - { - } + public Timer(TimeSpan delay) => Init(delay, TimeSpan.Zero, 1); + + public Timer(TimeSpan interval, int count) => Init(interval, interval, count); public Timer(TimeSpan delay, TimeSpan interval, int count = 0) => Init(delay, interval, count); diff --git a/Projects/Server/Utilities/Utility.cs b/Projects/Server/Utilities/Utility.cs index 430dd0907..1ecdc755d 100644 --- a/Projects/Server/Utilities/Utility.cs +++ b/Projects/Server/Utilities/Utility.cs @@ -927,14 +927,11 @@ namespace Server return outputList; } - public static bool ToBoolean(string value) - { -#pragma warning disable CA1806 // Do not ignore method results - bool.TryParse(value, out var b); -#pragma warning restore CA1806 // Do not ignore method results - - return b; - } + public static bool ToBoolean(string value) => + bool.TryParse(value, out var b) && b || + value.InsensitiveEquals("enabled") || + value.InsensitiveEquals("on") || + !value.InsensitiveEquals("disabled") && !value.InsensitiveEquals("off"); public static double ToDouble(string value) { @@ -1484,7 +1481,10 @@ namespace Server } [MethodImpl(MethodImplOptions.AggressiveInlining)] - public static string GetTimeStamp() => Core.Now.ToString("yyyy-MM-dd-HH-mm-ss"); + public static string GetTimeStamp() => Core.Now.ToTimeStamp(); + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static string ToTimeStamp(this DateTime dt) => dt.ToString("yyyy-MM-dd-HH-mm-ss"); [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void Add(ref List list, T value) @@ -1639,5 +1639,26 @@ namespace Server dict.Clear(); dict = null; } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void DateToComponents( + DateTime date, + out int year, + out int month, + out int day, + out DayOfWeek dayOfWeek, + out int hour, + out int min, + out int sec + ) + { + year = date.Year; + month = date.Month; + day = date.Day; + dayOfWeek = date.DayOfWeek; + hour = date.Hour; + min = date.Minute; + sec = date.Second; + } } } diff --git a/Projects/Server/World/World.cs b/Projects/Server/World/World.cs index 0120d2991..cb18a5396 100644 --- a/Projects/Server/World/World.cs +++ b/Projects/Server/World/World.cs @@ -131,7 +131,7 @@ namespace Server public static WorldState WorldState { get; private set; } public static bool Saving => WorldState == WorldState.Saving; - public static bool Running => WorldState != WorldState.Loading && WorldState != WorldState.Initial; + public static bool Running => WorldState == WorldState.Running; public static bool Loading => WorldState == WorldState.Loading; public static Dictionary Mobiles { get; private set; } diff --git a/Projects/UOContent/Commands/Handlers.cs b/Projects/UOContent/Commands/Handlers.cs index 03dc060af..867ed68d6 100644 --- a/Projects/UOContent/Commands/Handlers.cs +++ b/Projects/UOContent/Commands/Handlers.cs @@ -11,6 +11,7 @@ using Server.Misc; using Server.Mobiles; using Server.Multis; using Server.Network; +using Server.Saves; using Server.Spells; using Server.Targeting; using Server.Targets; @@ -44,8 +45,6 @@ namespace Server.Commands Register("Help", AccessLevel.Player, Help_OnCommand); - Register("Save", AccessLevel.Administrator, Save_OnCommand); - Register("Move", AccessLevel.GameMaster, Move_OnCommand); Register("Client", AccessLevel.Counselor, Client_OnCommand); @@ -459,12 +458,6 @@ namespace Server.Commands e.Mobile.Target = new PickMoveTarget(); } - [Usage("Save"), Description("Saves the world.")] - private static void Save_OnCommand(CommandEventArgs e) - { - AutoSave.Save(); - } - private static bool FixMap(ref Map map, ref Point3D loc, Item item) => map != null && map != Map.Internal || item.RootParent is Mobile m && FixMap(ref map, ref loc, m); diff --git a/Projects/UOContent/Compression/TarArchive.cs b/Projects/UOContent/Compression/TarArchive.cs new file mode 100755 index 000000000..05d2d222b --- /dev/null +++ b/Projects/UOContent/Compression/TarArchive.cs @@ -0,0 +1,113 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.IO.Compression; +using System.Net; +using Server.Buffers; + +namespace Server.Compression +{ + public static class TarArchive + { + private const string _libArchiveWindowsUrl = @"https://www.libarchive.org/downloads/libarchive-v3.5.2-win64.zip"; + private static string _pathToTar; + + private static string GetPathToTar() + { + if (!Core.IsWindows || File.Exists(@"C:\Windows\system32\tar.exe")) + { + return "tar"; + } + + var tarPath = Path.Combine(Core.BaseDirectory, "bsdtar/bsdtar.exe"); + return File.Exists(tarPath) ? tarPath : DownloadTarForWindows(); + } + + private static int RunTar(string arguments, string compressionProgramPath) + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = _pathToTar, + Arguments = arguments + } + }; + + if (compressionProgramPath != null) + { + var envPaths = $"{process.StartInfo.EnvironmentVariables["PATH"]}:{compressionProgramPath}"; + process.StartInfo.EnvironmentVariables["PATH"] = envPaths; + } + + process.Start(); + process.WaitForExit(); + + return process.ExitCode; + } + + private static string DownloadTarForWindows() + { + var tempDir = Path.Combine(Core.BaseDirectory, "temp"); + AssemblyHandler.EnsureDirectory("temp"); + + var libarchiveFile = Path.Combine(tempDir, "libarchive.zip"); + using WebClient wc = new WebClient(); + wc.DownloadFile (new Uri(_libArchiveWindowsUrl), libarchiveFile); + + ZipFile.ExtractToDirectory(libarchiveFile, tempDir); + var libArchivePath = Path.Combine(tempDir, "libarchive"); + Directory.Move(Path.Combine(libArchivePath, "bin"), "bsdtar"); + Directory.Delete(libArchivePath, true); + File.Delete(libarchiveFile); + + return Path.Combine(Core.BaseDirectory, "bsdtar/bsdtar.exe"); + } + + public static bool ExtractToDirectory( + string fileNamePath, + string outputDirectory, + string compressCommand = null, + string compressionProgramPath = null + ) + { + _pathToTar ??= GetPathToTar(); + + var tarFlags = compressCommand == null ? "-axf" : "-xf"; + var useExternalCompression = compressCommand != null ? $"--use-compress-program \"{compressCommand}\" " : ""; + var arguments = $"{useExternalCompression}{tarFlags} \"{fileNamePath}\" -C \"{outputDirectory}\""; + + return RunTar(arguments, compressionProgramPath) == 0; + } + + public static bool CreateFromPaths( + List paths, + string destinationArchiveFileName, + string compressCommand = null, + string compressionProgramPath = null + ) + { + _pathToTar ??= GetPathToTar(); + + AssemblyHandler.EnsureDirectory(new FileInfo(destinationArchiveFileName)); + var di = new DirectoryInfo(paths[0]); + var directory = di.Parent!.FullName; + + using var builder = new ValueStringBuilder(); + for (var i = 0; i < paths.Count; i++) + { + var path = paths[i]; + builder.Append($"{(i > 0 ? " " : "")}\"{Path.GetRelativePath(directory, path)}\""); + } + var pathsToCompress = builder.ToString(); + + var tarFlags = compressCommand == null ? "-acf" : "-cf"; + var useExternalCompression = compressCommand != null ? $"--use-compress-program \"{compressCommand}\" " : ""; + var arguments = $"{useExternalCompression}{tarFlags} \"{destinationArchiveFileName}\" -C \"{directory}\" {pathsToCompress}"; + + return RunTar(arguments, compressionProgramPath) == 0; + } + } +} diff --git a/Projects/UOContent/Compression/ZstdArchive.cs b/Projects/UOContent/Compression/ZstdArchive.cs new file mode 100755 index 000000000..66c02f2af --- /dev/null +++ b/Projects/UOContent/Compression/ZstdArchive.cs @@ -0,0 +1,95 @@ +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Reflection; + +namespace Server.Compression +{ + public static class ZstdArchive + { + private static readonly string _pathToZstd = new FileInfo(Assembly.GetExecutingAssembly().Location).Directory?.FullName; + + public static bool ExtractToDirectory(string fileNamePath, string outputDirectory) + { + // bsdtar has a bug and hangs, so we are doing it in two steps. + if (Core.IsWindows) + { + var tempTarArchive = Path.Combine(Core.BaseDirectory, "temp/temp-file.tar"); + + try + { + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = _pathToZstd, + Arguments = $"--no-progress -d \"{fileNamePath}\" -o \"${tempTarArchive}\"" + } + }; + + process.Start(); + process.WaitForExit(); + + return process.ExitCode == 0 && TarArchive.ExtractToDirectory(tempTarArchive, outputDirectory); + } + catch + { + return false; + } + finally + { + File.Delete(tempTarArchive); + } + } + + return TarArchive.ExtractToDirectory(fileNamePath, outputDirectory, "zstd -d", _pathToZstd); + } + + public static bool CreateFromPaths( + List paths, + string destinationArchiveFileName, + int compressionLevel = 10 + ) + { + Debug.Assert(compressionLevel is >= 1 and <= 22, $"{nameof(compressionLevel)} must be between 1 and 22"); + + // bsdtar has a bug and hangs, so we are doing it in two steps. + if (Core.IsWindows) + { + var tempTarArchive = Path.Combine(Core.BaseDirectory, "temp/temp-file.tar"); + + try + { + if (!TarArchive.CreateFromPaths(paths, tempTarArchive)) + { + return false; + } + + var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = $"{_pathToZstd}\\zstd.exe", + Arguments = $"--no-progress -10 \"{tempTarArchive}\" -o \"{destinationArchiveFileName}\"" + } + }; + + process.Start(); + process.WaitForExit(); + + return process.ExitCode == 0; + } + catch + { + return false; + } + finally + { + File.Delete(tempTarArchive); + } + } + + return TarArchive.CreateFromPaths(paths, destinationArchiveFileName, $"zstd -10", _pathToZstd); + } + } +} diff --git a/Projects/UOContent/Misc/AutoRestart.cs b/Projects/UOContent/Misc/AutoRestart.cs index db881e4b7..b281b9349 100644 --- a/Projects/UOContent/Misc/AutoRestart.cs +++ b/Projects/UOContent/Misc/AutoRestart.cs @@ -1,4 +1,5 @@ using System; +using Server.Saves; namespace Server.Misc { @@ -12,15 +13,15 @@ namespace Server.Misc get => _enabled; set { + _enabled = value; + if (value) { - _enabled = true; _autoRestart ??= new AutoRestart(); _autoRestart.Start(); } else { - _enabled = false; _autoRestart?.Stop(); _autoRestart = null; } @@ -29,21 +30,20 @@ namespace Server.Misc private static readonly TimeSpan RestartTime = TimeSpan.FromHours(2.0); // time of day at which to restart - private static readonly TimeSpan - RestartDelay = - TimeSpan.Zero; // how long the server should remain active before restart (period of 'server wars') + // how long the server should remain active before restart (period of 'server wars') + private static readonly TimeSpan RestartDelay = TimeSpan.Zero; - private static readonly TimeSpan - WarningDelay = TimeSpan.FromMinutes(1.0); // at what interval should the shutdown message be displayed? + // at what interval should the shutdown message be displayed? + private static readonly TimeSpan WarningDelay = TimeSpan.FromMinutes(1.0); private static DateTime m_RestartTime; public AutoRestart() : base(TimeSpan.FromSeconds(1.0), TimeSpan.FromSeconds(1.0)) { - m_RestartTime = DateTime.UtcNow.Date + RestartTime; + m_RestartTime = Core.Now.Date + RestartTime; - if (m_RestartTime < DateTime.UtcNow) + if (m_RestartTime < Core.Now) { m_RestartTime += TimeSpan.FromDays(1.0); } @@ -71,7 +71,7 @@ namespace Server.Misc { e.Mobile.SendMessage("You have initiated server shutdown."); Enabled = true; - m_RestartTime = DateTime.UtcNow; + m_RestartTime = Core.Now; } } @@ -92,7 +92,7 @@ namespace Server.Misc return; } - if (DateTime.UtcNow < m_RestartTime) + if (Core.Now < m_RestartTime) { return; } diff --git a/Projects/UOContent/Misc/AutoSave.cs b/Projects/UOContent/Misc/AutoSave.cs deleted file mode 100644 index b35774716..000000000 --- a/Projects/UOContent/Misc/AutoSave.cs +++ /dev/null @@ -1,114 +0,0 @@ -using System; -using System.IO; -using Server.Logging; - -namespace Server.Misc -{ - public class AutoSave : Timer - { - private static readonly ILogger logger = LogFactory.GetLogger(typeof(Persistence)); - - public static TimeSpan Delay { get; private set; } - public static TimeSpan Warning { get; private set; } - public static string BackupPath { get; private set; } - - public AutoSave() : base(Delay - Warning, Delay) - { - } - - public static bool SavesEnabled { get; set; } = true; - - public static void Configure() - { - BackupPath = ServerConfiguration.GetOrUpdateSetting("autosave.backupPath", "Backups/Automatic"); - Delay = ServerConfiguration.GetOrUpdateSetting("autosave.saveDelay", TimeSpan.FromMinutes(5.0)); - Warning = ServerConfiguration.GetOrUpdateSetting("autosave.warningDelay", TimeSpan.Zero); - } - - public static void Initialize() - { - new AutoSave().Start(); - CommandSystem.Register("SetSaves", AccessLevel.Administrator, SetSaves_OnCommand); - - EventSink.WorldSavePostSnapshot += Backup; - } - - [Usage("SetSaves "), Description("Enables or disables automatic shard saving.")] - public static void SetSaves_OnCommand(CommandEventArgs e) - { - if (e.Length == 1) - { - SavesEnabled = e.GetBoolean(0); - e.Mobile.SendMessage("Saves have been {0}.", SavesEnabled ? "enabled" : "disabled"); - } - else - { - e.Mobile.SendMessage("Format: SetSaves "); - } - } - - protected override void OnTick() - { - if (!SavesEnabled || AutoRestart.Restarting) - { - return; - } - - if (Warning == TimeSpan.Zero) - { - Save(); - } - else - { - var s = (int)Warning.TotalSeconds; - var m = s / 60; - s %= 60; - - if (m > 0 && s > 0) - { - World.Broadcast( - 0x35, - true, - "The world will save in {0} minute{1} and {2} second{3}.", - m, - m != 1 ? "s" : "", - s, - s != 1 ? "s" : "" - ); - } - else if (m > 0) - { - World.Broadcast(0x35, true, "The world will save in {0} minute{1}.", m, m != 1 ? "s" : ""); - } - else - { - World.Broadcast(0x35, true, "The world will save in {0} second{1}.", s, s != 1 ? "s" : ""); - } - - StartTimer(Warning, Save); - } - } - - public static void Save() - { - if (!AutoRestart.Restarting && World.WorldState == WorldState.Running) - { - World.Save(); - } - } - - private static void Backup(WorldSavePostSnapshotEventArgs args) - { - if (!Directory.Exists(args.OldSavePath)) - { - return; - } - - var backupPath = Path.Combine(BackupPath, Utility.GetTimeStamp()); - AssemblyHandler.EnsureDirectory(BackupPath); - Directory.Move(args.OldSavePath, backupPath); - - logger.Information($"Created backup at {backupPath}"); - } - } -} diff --git a/Projects/UOContent/UOContent.csproj b/Projects/UOContent/UOContent.csproj index 2dd10a7f8..00117bb55 100755 --- a/Projects/UOContent/UOContent.csproj +++ b/Projects/UOContent/UOContent.csproj @@ -30,6 +30,9 @@ + + + @@ -40,6 +43,7 @@ + diff --git a/Projects/UOContent/World Saves/AutoArchive.cs b/Projects/UOContent/World Saves/AutoArchive.cs new file mode 100755 index 000000000..c0ec356e1 --- /dev/null +++ b/Projects/UOContent/World Saves/AutoArchive.cs @@ -0,0 +1,382 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Runtime.CompilerServices; +using System.Threading; +using Server.Compression; +using Server.Logging; + +namespace Server.Saves +{ + public enum ArchivePeriod + { + Hourly, + Daily, + Monthly + } + + public enum CompressionFormat + { + None, + Zip, + GZip, + Zstd, + } + + public static class AutoArchive + { + private static readonly ILogger logger = LogFactory.GetLogger(typeof(AutoArchive)); + + private static int _isArchiving; + private static DateTime _nextHourlyArchive; + private static DateTime _nextDailyArchive; + private static DateTime _nextMonthlyArchive; + private static CompressionFormat _compressionFormat; + private static bool _enablePruning; + + public static Action Archive { get; set; } + public static Action Prune { get; set; } + public static string ArchivePath { get; private set; } + public static string BackupPath { get; private set; } + + public static void Configure() + { + BackupPath = ServerConfiguration.GetOrUpdateSetting("autoArchive.backupPath", "Backups/Automatic"); + ArchivePath = ServerConfiguration.GetOrUpdateSetting("autoArchive.archivePath", "Archives"); + _compressionFormat = ServerConfiguration.GetOrUpdateSetting("autoArchive.compressionFormat", CompressionFormat.Zstd); + + var useLocalArchives = ServerConfiguration.GetOrUpdateSetting("autoArchive.archiveLocally", true); + _enablePruning = ServerConfiguration.GetOrUpdateSetting("autoArchive.enableArchivePruning", true); + + if (useLocalArchives) + { + Archive = AutoArchiveLocally; + } + + if (_enablePruning) + { + Prune = PruneBackups; + } + + // Restores an archive file placed in the Saves folder. Supports all compression formats. + RestoreFromArchive(); + + DateTimeOffset now = Core.Now; + var date = now.Date; + _nextHourlyArchive = date.AddHours(now.Hour); + _nextDailyArchive = date; + _nextMonthlyArchive = date.AddDays(1 - now.Day); + + // Do a local archive rollup & prune + AutoArchiveLocally(); + } + + public static void Initialize() + { + EventSink.WorldSavePostSnapshot += Backup; + } + + private static void Backup(WorldSavePostSnapshotEventArgs args) + { + if (!Directory.Exists(args.OldSavePath)) + { + return; + } + + var backupPath = Path.Combine(BackupPath, Utility.GetTimeStamp()); + AssemblyHandler.EnsureDirectory(BackupPath); + Directory.Move(args.OldSavePath, backupPath); + + logger.Information($"Created backup at {backupPath}"); + + Archive?.Invoke(); + } + + private static void RestoreFromArchive() + { + var savePath = Path.Combine(Core.BaseDirectory, ServerConfiguration.GetSetting("world.savePath", "Saves")); + + var files = Directory.Exists(savePath) ? Directory.GetFiles(savePath, "????-??-??-??-??-??.*") : null; + + if (files == null || files.Length == 0) + { + return; + } + + var latestFiles = GetInRange(files, DateTime.MinValue, DateTime.MaxValue, true); + + foreach (var file in latestFiles) + { + if (RestoreFromFile(file, savePath)) + { + break; + } + } + } + + private static bool RestoreFromFile(string file, string savePath) + { + var fi = new FileInfo(file); + var fileName = fi.Name; + + if (!TryGetDate(fileName[..fileName.IndexOfOrdinal(".")], out _)) + { + return false; + } + + logger.Information($"Restoring latest world save from archive {fileName}"); + + var tempFolder = Path.Combine(Core.BaseDirectory, "temp"); + AssemblyHandler.EnsureDirectory(tempFolder); + bool successful; + + if (fileName.EndsWithOrdinal(".tar.zst")) + { + successful = ZstdArchive.ExtractToDirectory(fi.FullName, tempFolder); + } + else + { + TarArchive.ExtractToDirectory(fi.FullName, tempFolder); + successful = true; + } + + if (!successful) + { + logger.Information($"Failed to extract {fi.Name}"); + return false; + } + + var allFolders = Directory.EnumerateDirectories(tempFolder, "????-??-??-??-??-??"); + var worldSaveFolders = GetInRange(allFolders, DateTime.MinValue, DateTime.MaxValue); + var first = true; + foreach (var folder in worldSaveFolders) + { + if (first) + { + first = false; + Directory.Delete(savePath, true); + var dirInfo = new DirectoryInfo(folder); + logger.Information($"Restoring backup {dirInfo.Name}"); + Directory.Move(folder, savePath); + } + else + { + Directory.Delete(folder, true); + } + } + + return true; + } + + public static void PruneBackups() + { + // Maintain a total of 66 of the most recent archives + PruneLocalArchives(ArchivePeriod.Hourly, 24); + PruneLocalArchives(ArchivePeriod.Daily, 30); + PruneLocalArchives(ArchivePeriod.Monthly, 12); + + var allFolders = Directory.EnumerateDirectories(BackupPath, "????-??-??-??-??-??", SearchOption.AllDirectories); + var latestBackupFolders = GetInRange(allFolders, DateTime.MinValue, Core.Now.AddMonths(-1)); + + foreach (var folder in latestBackupFolders) + { + logger.Information($"Pruning backup {folder}"); + Directory.Delete(folder, true); + } + } + + private static void PruneLocalArchives(ArchivePeriod period, int minRetained) + { + var periodStr = period.ToString(); + var path = Path.Combine(ArchivePath, periodStr); + var allFiles = Directory.EnumerateFiles(path, "????-??-??-??-??-??.*"); + var archives = GetInRange(allFiles, DateTime.MinValue, DateTime.MaxValue, true); + + var periodLowerStr = periodStr.ToLowerInvariant(); + foreach (var archive in archives) + { + if (minRetained > 0) + { + minRetained--; + continue; + } + + var fi = new FileInfo(archive); + logger.Information($"Pruning {periodLowerStr} archive {fi.Name}"); + File.Delete(archive); + } + } + + public static void AutoArchiveLocally() + { + var date = Core.Now; + + if (date < _nextHourlyArchive && date < _nextDailyArchive && date < _nextMonthlyArchive) + { + return; + } + + if (Interlocked.CompareExchange(ref _isArchiving, 0, 1) == 1) + { + return; + } + + ThreadPool.UnsafeQueueUserWorkItem( + _ => + { + if (date >= _nextHourlyArchive) + { + var lastHour = date.Date.AddHours(date.Hour); + Rollup(ArchivePeriod.Hourly, lastHour.AddHours(-1), lastHour.ToTimeStamp()); + _nextHourlyArchive = _nextHourlyArchive.AddHours(2); + } + + if (date >= _nextDailyArchive) + { + var yesterday = date.Date.AddDays(-1); + Rollup(ArchivePeriod.Daily, yesterday, yesterday.ToTimeStamp()); + _nextDailyArchive = _nextDailyArchive.AddDays(2); + } + + if (date >= _nextMonthlyArchive) + { + var lastMonth = new DateTime(date.Year, date.Month, 1).AddMonths(-1); + Rollup(ArchivePeriod.Monthly, lastMonth, lastMonth.ToTimeStamp()); + _nextMonthlyArchive = _nextMonthlyArchive.AddMonths(2); + } + + Prune?.Invoke(); + + _isArchiving = 0; + }, + null + ); + } + + private static void Rollup(ArchivePeriod archivePeriod, DateTime rangeStart, string archiveNameNoExtension) + { + var archivePeriodStr = archivePeriod.ToString(); + var archivePeriodStrLower = archivePeriodStr.ToLowerInvariant(); + + var stopWatch = new Stopwatch(); + stopWatch.Start(); + var allFolders = Directory.EnumerateDirectories(BackupPath, "????-??-??-??-??-??"); + + var rangeEnd = archivePeriod switch + { + ArchivePeriod.Monthly => rangeStart.AddMonths(1), + ArchivePeriod.Daily => rangeStart.AddDays(1), + _ => rangeStart.AddHours(1), + }; + + var latestFolders = GetInRange(allFolders, rangeStart, rangeEnd).ToList(); + + // We don't want to re-archive a single save. This usually means we rebooted and found the one we purposefully left behind + if (latestFolders.Count <= 1) + { + return; + } + + var extension = _compressionFormat.GetFileExtension(); + var archiveFilePath = Path.Combine(ArchivePath, archivePeriodStr, $"{archiveNameNoExtension}{extension}"); + + logger.Information($"Creating {archivePeriodStrLower} archive"); + var archiveCreated = _compressionFormat == CompressionFormat.Zstd + ? ZstdArchive.CreateFromPaths(latestFolders, archiveFilePath) + : TarArchive.CreateFromPaths(latestFolders, archiveFilePath); + + if (archiveCreated) + { + stopWatch.Stop(); + var elapsed = stopWatch.Elapsed.TotalSeconds; + logger.Information($"Created {archivePeriodStrLower} archive at {archiveFilePath} ({elapsed:F2} seconds)"); + + // Keep the latest one, but delete the rest. + for (var i = 1; i < latestFolders.Count; i++) + { + Directory.Delete(latestFolders[i], true); + } + } + else + { + logger.Warning($"Failed to create {archivePeriodStrLower} archive"); + } + } + + private static IEnumerable GetInRange( + IEnumerable allItems, + DateTime rangeStart, + DateTime rangeEnd, + bool files = false + ) + { + var items = new SortedDictionary(new DescendingComparer()); + foreach (var item in allItems) + { + string name; + if (files) + { + var fileName = new FileInfo(item).Name; + name = fileName[..fileName.IndexOfOrdinal(".")]; + } + else + { + name = new DirectoryInfo(item).Name; + } + + if (IsInRange(name, rangeStart, rangeEnd, out var date)) + { + items.Add(date, item); + } + } + + return items.Values; + } + + private static bool IsInRange(string name, DateTime start, DateTime end, out DateTime date) => + TryGetDate(name, out date) && start <= date && end >= date; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static bool TryGetDate(string value, out DateTime date) + { + // Expecting YYYY-MM-DD-HH-mm-ss + var split = value.Split("-"); + if (split.Length != 6) + { + date = DateTime.MinValue; + return false; + } + + try + { + date = new DateTime( + int.Parse(split[0]), + int.Parse(split[1]), + int.Parse(split[2]), + int.Parse(split[3]), + int.Parse(split[4]), + int.Parse(split[5]) + ); + } + catch + { + date = DateTime.MinValue; + return false; + } + + return true; + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private static string GetFileExtension(this CompressionFormat compressionFormat) => + compressionFormat switch + { + CompressionFormat.Zip => ".zip", + CompressionFormat.GZip => ".tar.gz", + CompressionFormat.Zstd => ".tar.zst", + _ => ".tar" + }; + } +} diff --git a/Projects/UOContent/World Saves/AutoSave.cs b/Projects/UOContent/World Saves/AutoSave.cs new file mode 100644 index 000000000..a97446d89 --- /dev/null +++ b/Projects/UOContent/World Saves/AutoSave.cs @@ -0,0 +1,132 @@ +using System; +using Server.Misc; + +namespace Server.Saves +{ + public class AutoSave : Timer + { + private static Timer _autoSave; + private static bool _enabled; + private static DateTime _nextSave; + + public static TimeSpan Delay { get; private set; } + public static TimeSpan Warning { get; private set; } + + public static bool SavesEnabled + { + get => _enabled; + set + { + _enabled = value; + + if (value) + { + SetNextSave(Core.Now.ToSystemLocalTime()); + _autoSave ??= new AutoSave(); + _autoSave.Start(); + } + else + { + _autoSave?.Stop(); + _autoSave = null; + } + } + } + + public static void Configure() + { + Delay = ServerConfiguration.GetOrUpdateSetting("autosave.saveDelay", TimeSpan.FromMinutes(5.0)); + Warning = ServerConfiguration.GetOrUpdateSetting("autosave.warningDelay", TimeSpan.Zero); + } + + public static void Initialize() + { + SavesEnabled = true; + } + + public static void ResetAutoSave(TimeSpan saveDelay, TimeSpan warningDelay) + { + if (saveDelay != Delay || warningDelay != Warning) + { + Delay = saveDelay; + Warning = warningDelay; + + ServerConfiguration.SetSetting("autosave.saveDelay", Delay); + ServerConfiguration.SetSetting("autosave.warningDelay", Warning); + } + + SavesEnabled = true; + } + + private static void SetNextSave(DateTime now) + { + var timeOfDay = now.TimeOfDay; + var delay = Delay.Ticks; + + _nextSave = (now + TimeSpan.FromTicks(delay - timeOfDay.Ticks % delay) - Warning).ToUniversalTime(); + } + + public AutoSave() : base(TimeSpan.FromSeconds(1.0), TimeSpan.FromSeconds(1.0)) + { + } + + protected override void OnTick() + { + if (_nextSave > Core.Now) + { + return; + } + + if (SavesEnabled && !AutoRestart.Restarting && !Core.Closing && World.Running) + { + if (Warning > TimeSpan.Zero) + { + BroadcastWarning(); + StartTimer(Warning, Save); + } + else + { + Save(); + } + } + + _nextSave += Delay; + } + + private static void BroadcastWarning() + { + var s = (int)Warning.TotalSeconds; + var m = s / 60; + s %= 60; + + if (m > 0 && s > 0) + { + World.Broadcast( + 0x35, + true, + "The world will save in {0} minute{1} and {2} second{3}.", + m, + m != 1 ? "s" : "", + s, + s != 1 ? "s" : "" + ); + } + else if (m > 0) + { + World.Broadcast(0x35, true, "The world will save in {0} minute{1}.", m, m != 1 ? "s" : ""); + } + else + { + World.Broadcast(0x35, true, "The world will save in {0} second{1}.", s, s != 1 ? "s" : ""); + } + } + + public static void Save() + { + if (!AutoRestart.Restarting && World.Running) + { + World.Save(); + } + } + } +} diff --git a/Projects/UOContent/World Saves/SaveCommands.cs b/Projects/UOContent/World Saves/SaveCommands.cs new file mode 100644 index 000000000..811239a94 --- /dev/null +++ b/Projects/UOContent/World Saves/SaveCommands.cs @@ -0,0 +1,60 @@ +using Server.Saves; + +namespace Server.Commands +{ + public static class SaveCommands + { + public static void Initialize() + { + CommandSystem.Register("Save", AccessLevel.Administrator, Save_OnCommand); + CommandSystem.Register("SetSaves", AccessLevel.Administrator, SetAutoSaves_OnCommand); + CommandSystem.Register("AutoSave", AccessLevel.Administrator, SetAutoSaves_OnCommand); + CommandSystem.Register("SaveFrequency", AccessLevel.Administrator, SetSaveFrequency_OnCommand); + CommandSystem.Register("PruneArchives", AccessLevel.Administrator, PruneArchives_OnCommand); + } + + [Usage("PruneArchives"), Description("Prunes archives folder.")] + private static void PruneArchives_OnCommand(CommandEventArgs e) + { + AutoArchive.PruneBackups(); + } + + [Usage("Save"), Description("Saves the world.")] + private static void Save_OnCommand(CommandEventArgs e) + { + AutoSave.Save(); + } + + [Usage("AutoSave "), Description("Enables or disables automatic shard saving.")] + public static void SetAutoSaves_OnCommand(CommandEventArgs e) + { + if (e.Length == 1) + { + + var enabled = AutoSave.SavesEnabled = e.GetBoolean(0); + e.Mobile.SendMessage("Saves have been {0}.", enabled ? "enabled" : "disabled"); + } + else + { + e.Mobile.SendMessage("Format: AutoSave "); + } + } + + [Usage("SaveFrequency [warning duration]"), Description("Sets the save frequency starting at midnight local to the server.")] + public static void SetSaveFrequency_OnCommand(CommandEventArgs e) + { + if (e.Length < 1) + { + e.Mobile.SendMessage("Format: SaveFrequency [warning duration]"); + return; + } + + var saveDelay = e.GetTimeSpan(0); + var warningDelay = e.Length >= 2 ? e.GetTimeSpan(1) : AutoSave.Warning; + + AutoSave.ResetAutoSave(saveDelay, warningDelay); + + e.Mobile.SendMessage("Save frequency has been updated."); + } + } +}