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.");
+ }
+ }
+}