From 21cea916548da33fb61d899cc4dd70dba98960c4 Mon Sep 17 00:00:00 2001 From: riperiperi Date: Mon, 20 May 2019 22:44:09 +0100 Subject: [PATCH] Feature/free vote (#149) * [Server, Client] Add Free Vote A mechanism that allows avatars in neighborhoods without elections to vote in an ongoing election of their choosing. * [Server] Free Vote, Github Releases, Restore Lot Tool, Test Utils Still need to fix an issue with the unauthenticated disconnect. * [Server] Improvements, Account Lockout - Too many failed password attempts can now cause an account lockout. - More reasonable CORS policy that doesn't spam warnings. * [UI] Add Free Vote Confirmation Text --- TSOClient/FSO.Server.Api.Core/Api.cs | 3 + .../Controllers/Admin/AdminHostsController.cs | 2 + .../Controllers/Admin/AdminOAuthController.cs | 26 ++ .../Admin/AdminShardsController.cs | 2 + .../Controllers/Admin/AdminTasksController.cs | 2 + .../Admin/AdminUpdatesController.cs | 6 +- .../Controllers/Admin/AdminUsersController.cs | 2 + .../Controllers/AuthLoginController.cs | 37 +- .../Controllers/AvatarDataController.cs | 2 + .../Controllers/CityJSONController.cs | 2 + .../Controllers/GameAPI/UpdateController.cs | 2 + .../Controllers/GithubController.cs | 79 ++++ .../Controllers/LotInfoController.cs | 4 +- .../Controllers/RegistrationController.cs | 3 + .../Controllers/ShardStatusController.cs | 2 + .../Controllers/ValuesController.cs | 45 --- .../FSO.Server.Api.Core.csproj | 2 + TSOClient/FSO.Server.Api.Core/Program.cs | 2 +- .../Services/AWSUpdateUploader.cs | 2 +- .../Services/GenerateUpdateService.cs | 8 +- .../Services/GithubUpdateUploader.cs | 48 +++ .../Services/IUpdateUploader.cs | 2 +- TSOClient/FSO.Server.Api.Core/Startup.cs | 23 +- .../FSO.Server.Common/Config/GithubConfig.cs | 22 ++ .../FSO.Server.Common.csproj | 1 + TSOClient/FSO.Server.Core/Program.cs | 4 + .../DA/Elections/DbElectionCycle.cs | 4 + .../DA/Elections/DbElectionFreeVote.cs | 17 + .../DA/Elections/DbElectionVote.cs | 1 + .../DA/Elections/IElections.cs | 7 +- .../DA/Elections/SqlElections.cs | 50 ++- .../FSO.Server.Database/DA/Lots/ILots.cs | 1 + .../FSO.Server.Database/DA/Lots/SqlLots.cs | 5 + .../DA/Users/DbAuthAttempt.cs | 19 + .../FSO.Server.Database/DA/Users/IUsers.cs | 6 + .../FSO.Server.Database/DA/Users/SqlUsers.cs | 62 ++++ .../DatabaseScripts/changes/0026_freevote.sql | 48 +++ .../DatabaseScripts/manifest.json | 8 + .../FSO.Server.Database.csproj | 5 + .../Electron/Packets/NhoodRequest.cs | 7 +- .../Electron/Packets/NhoodResponse.cs | 9 +- TSOClient/FSO.Server/FSO.Server.csproj | 1 + .../Framework/Aries/AbstractAriesServer.cs | 2 +- TSOClient/FSO.Server/Program.cs | 4 + TSOClient/FSO.Server/ProgramOptions.cs | 23 ++ .../FSO.Server/Servers/City/CityServer.cs | 2 +- .../Servers/City/CityServerConfiguration.cs | 16 + .../Servers/City/Domain/LotAllocations.cs | 5 +- .../Servers/City/Domain/Neighborhoods.cs | 56 ++- .../Servers/City/Handlers/MailHandler.cs | 8 +- .../Servers/City/Handlers/NhoodHandler.cs | 142 +++++++- TSOClient/FSO.Server/Servers/Lot/LotServer.cs | 2 +- .../Servers/Lot/LotServerConfiguration.cs | 1 + .../Servers/UserApi/ApiServerConfiguration.cs | 1 + TSOClient/FSO.Server/ToolRestoreLots.cs | 342 ++++++++++++++++++ TSOClient/FSO.Windows/FSO.Windows.csproj | 10 +- TSOClient/FSO.Windows/packages.config | 4 + .../NeighborhoodActionController.cs | 51 ++- .../UI/Panels/LotControls/UICheatHandler.cs | 25 +- .../UINominationSelectContainer.cs | 57 +-- .../tso.client/UI/Panels/UIMessageWindow.cs | 6 + .../english.dir/_f116_neighmailstrings.cst | 15 +- .../_f117_neighprotocolstrings.cst | 8 +- .../english.dir/_f118_votedialogstrings.cst | 8 + .../english.dir/_f119_specialemailstrings.cst | 3 +- .../_f121_bulletinprotocolstrings.cst | 2 + .../tso.files/Formats/tsodata/MessageItem.cs | 3 +- TSOClient/tso.simantics/FSO.SimAntics.csproj | 1 + .../Model/Routing/VMObstacleSet.cs | 12 + .../tso.simantics/Test/CollisionTestUtils.cs | 49 +++ 70 files changed, 1305 insertions(+), 136 deletions(-) create mode 100644 TSOClient/FSO.Server.Api.Core/Controllers/GithubController.cs delete mode 100644 TSOClient/FSO.Server.Api.Core/Controllers/ValuesController.cs create mode 100644 TSOClient/FSO.Server.Api.Core/Services/GithubUpdateUploader.cs create mode 100644 TSOClient/FSO.Server.Common/Config/GithubConfig.cs create mode 100644 TSOClient/FSO.Server.Database/DA/Elections/DbElectionFreeVote.cs create mode 100644 TSOClient/FSO.Server.Database/DA/Users/DbAuthAttempt.cs create mode 100644 TSOClient/FSO.Server.Database/DatabaseScripts/changes/0026_freevote.sql create mode 100644 TSOClient/FSO.Server/ToolRestoreLots.cs create mode 100644 TSOClient/FSO.Windows/packages.config create mode 100644 TSOClient/tso.simantics/Test/CollisionTestUtils.cs diff --git a/TSOClient/FSO.Server.Api.Core/Api.cs b/TSOClient/FSO.Server.Api.Core/Api.cs index 7151cd589..94732565b 100644 --- a/TSOClient/FSO.Server.Api.Core/Api.cs +++ b/TSOClient/FSO.Server.Api.Core/Api.cs @@ -1,6 +1,7 @@ using FSO.Server.Api.Core.Services; using FSO.Server.Api.Core.Utils; using FSO.Server.Common; +using FSO.Server.Common.Config; using FSO.Server.Database.DA; using FSO.Server.Domain; using FSO.Server.Protocol.Gluon.Model; @@ -27,6 +28,8 @@ public class Api : ApiAbstract public Shards Shards; public IGluonHostPool HostPool; public IUpdateUploader UpdateUploader; + public IUpdateUploader UpdateUploaderClient; + public GithubConfig Github; public Api() { diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminHostsController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminHostsController.cs index 2e0a153a1..0fe958f17 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminHostsController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminHostsController.cs @@ -6,9 +6,11 @@ using System.Web.Http; using System.Linq; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Cors; namespace FSO.Server.Api.Core.Controllers.Admin { + [EnableCors("AdminAppPolicy")] [Route("admin/hosts")] [ApiController] public class AdminHostsController : ControllerBase diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminOAuthController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminOAuthController.cs index 1e81d0de6..031b73a1e 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminOAuthController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminOAuthController.cs @@ -1,6 +1,7 @@ using FSO.Server.Api.Core.Utils; using FSO.Server.Common; using FSO.Server.Servers.Api.JsonWebToken; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; @@ -12,6 +13,7 @@ namespace FSO.Server.Api.Core.Controllers.Admin { + [EnableCors("AdminAppPolicy")] [Route("admin/oauth/token")] [ApiController] public class AdminOAuthController : ControllerBase @@ -35,6 +37,17 @@ public IActionResult Post([FromForm] AuthRequest auth) }); } + var ip = ApiUtils.GetIP(Request); + var accLock = da.Users.GetRemainingAuth(user.user_id, ip); + if (accLock != null && (accLock.active || accLock.count >= AuthLoginController.LockAttempts) && accLock.expire_time > Epoch.Now) + { + return ApiResponse.Json(System.Net.HttpStatusCode.OK, new OAuthError + { + error = "unauthorized_client", + error_description = "account_locked" + }); + } + var authSettings = da.Users.GetAuthenticationSettings(user.user_id); var isPasswordCorrect = PasswordHasher.Verify(auth.password, new PasswordHash { @@ -44,6 +57,17 @@ public IActionResult Post([FromForm] AuthRequest auth) if (!isPasswordCorrect) { + var durations = AuthLoginController.LockDuration; + var failDelay = 60 * durations[Math.Min(durations.Length - 1, da.Users.FailedConsecutive(user.user_id, ip))]; + if (accLock == null) + { + da.Users.NewFailedAuth(user.user_id, ip, (uint)failDelay); + } + else + { + var remaining = da.Users.FailedAuth(accLock.attempt_id, (uint)failDelay, AuthLoginController.LockAttempts); + } + return ApiResponse.Json(System.Net.HttpStatusCode.OK, new OAuthError { error = "unauthorized_client", @@ -51,6 +75,8 @@ public IActionResult Post([FromForm] AuthRequest auth) }); } + da.Users.SuccessfulAuth(user.user_id, ip); + JWTUser identity = new JWTUser(); identity.UserName = user.username; var claims = new List(); diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminShardsController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminShardsController.cs index 05060b24e..30acf7089 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminShardsController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminShardsController.cs @@ -1,6 +1,7 @@ using FSO.Server.Api.Core.Utils; using FSO.Server.Common; using FSO.Server.Protocol.Gluon.Model; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using System; using System.Net; @@ -10,6 +11,7 @@ namespace FSO.Server.Api.Core.Controllers.Admin { + [EnableCors("AdminAppPolicy")] [Route("admin/shards")] [ApiController] public class AdminShardsController : ControllerBase diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminTasksController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminTasksController.cs index 76de1e9a0..5f8c4952f 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminTasksController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminTasksController.cs @@ -9,9 +9,11 @@ using System.Linq; using System.Web.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Cors; namespace FSO.Server.Api.Core.Controllers.Admin { + [EnableCors("AdminAppPolicy")] [Route("admin/tasks")] [ApiController] public class AdminTasksController : ControllerBase diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminUpdatesController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminUpdatesController.cs index 4e8fe67ad..eb854d479 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminUpdatesController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminUpdatesController.cs @@ -10,6 +10,7 @@ using FSO.Server.Api.Core.Services; using FSO.Server.Api.Core.Utils; using FSO.Server.Database.DA.Updates; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Newtonsoft.Json; @@ -18,6 +19,7 @@ namespace FSO.Server.Api.Core.Controllers.Admin { + [EnableCors("AdminAppPolicy")] [Route("admin/updates")] public class AdminUpdatesController : ControllerBase { @@ -129,7 +131,7 @@ public async Task UploadAddon(AddonUploadModel upload) { await upload.clientAddon.CopyToAsync(file); } - info.addon_zip_url = await api.UpdateUploader.UploadFile($"addons/client{addonID}.zip", $"updateTemp/addons/client{reqID}.zip"); + info.addon_zip_url = await api.UpdateUploader.UploadFile($"addons/client{addonID}.zip", $"updateTemp/addons/client{reqID}.zip", $"addon-{addonID}"); System.IO.File.Delete($"updateTemp/addons/client{reqID}.zip"); } @@ -139,7 +141,7 @@ public async Task UploadAddon(AddonUploadModel upload) { await upload.serverAddon.CopyToAsync(file); } - info.server_zip_url = await api.UpdateUploader.UploadFile($"addons/server{addonID}.zip", $"updateTemp/addons/server{reqID}.zip"); + info.server_zip_url = await api.UpdateUploader.UploadFile($"addons/server{addonID}.zip", $"updateTemp/addons/server{reqID}.zip", $"addon-{addonID}"); System.IO.File.Delete($"updateTemp/addons/server{reqID}.zip"); } diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminUsersController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminUsersController.cs index 1c231b6e2..ef558ff9d 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminUsersController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/Admin/AdminUsersController.cs @@ -3,6 +3,7 @@ using FSO.Server.Common; using FSO.Server.Database.DA.Inbox; using FSO.Server.Database.DA.Users; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; @@ -13,6 +14,7 @@ namespace FSO.Server.Api.Core.Controllers.Admin { + [EnableCors("AdminAppPolicy")] [Route("admin/users")] [ApiController] public class AdminUsersController : ControllerBase diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/AuthLoginController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/AuthLoginController.cs index 9d263e443..d68726330 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/AuthLoginController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/AuthLoginController.cs @@ -22,6 +22,20 @@ public class AuthLoginController : ControllerBase private static Func ERROR_302 = printError("INV-302", "The game has experienced an internal error. Please try again."); private static Func ERROR_160 = printError("INV-160", "The server is currently down for maintainance. Please try again later."); private static Func ERROR_150 = printError("INV-150", "We're sorry, but your account has been suspended or cancelled."); + private static string LOCK_MESSAGE = "Your account has been locked due to too many incorrect login attempts. " + + "If you cannot remember your password, it can be reset at https://beta.freeso.org/forgot. Locked for: "; + + public static int LockAttempts = 5; + + public static int[] LockDuration = new int[] { + 5, + 15, + 30, + 60, + 120, + 720, + 1440 + }; // GET api/ [HttpGet] @@ -54,6 +68,14 @@ public IActionResult Get(string username, string password, string version, strin return ERROR_160(); } + var ip = ApiUtils.GetIP(Request); + + var accLock = db.Users.GetRemainingAuth(user.user_id, ip); + if (accLock != null && (accLock.active || accLock.count >= LockAttempts) && accLock.expire_time > Epoch.Now) + { + return printError("INV-170", LOCK_MESSAGE + Epoch.HMSRemaining(accLock.expire_time))(); + } + var authSettings = db.Users.GetAuthenticationSettings(user.user_id); var isPasswordCorrect = PasswordHasher.Verify(password, new PasswordHash { @@ -63,17 +85,26 @@ public IActionResult Get(string username, string password, string version, strin if (!isPasswordCorrect) { + var failDelay = 60 * LockDuration[Math.Min(LockDuration.Length - 1, db.Users.FailedConsecutive(user.user_id, ip))]; + if (accLock == null) + { + db.Users.NewFailedAuth(user.user_id, ip, (uint)failDelay); + } else + { + var remaining = db.Users.FailedAuth(accLock.attempt_id, (uint)failDelay, LockAttempts); + if (remaining == 0) + return printError("INV-170", LOCK_MESSAGE + Epoch.HMSRemaining(Epoch.Now + (uint)failDelay))(); + } return ERROR_110(); } - - var ip = ApiUtils.GetIP(Request); - + var ban = db.Bans.GetByIP(ip); if (ban != null) { return ERROR_110(); } + db.Users.SuccessfulAuth(user.user_id, ip); db.Users.UpdateClientID(user.user_id, clientid ?? "0"); /** Make a ticket **/ diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/AvatarDataController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/AvatarDataController.cs index 532dd8805..238695077 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/AvatarDataController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/AvatarDataController.cs @@ -2,6 +2,7 @@ using FSO.Server.Api.Core.Utils; using FSO.Server.Protocol.CitySelector; using FSO.Server.Servers.Api.JsonWebToken; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; @@ -12,6 +13,7 @@ namespace FSO.Server.Api.Core.Controllers { + [EnableCors] [Route("cityselector/app/AvatarDataServlet")] [ApiController] public class AvatarDataController : ControllerBase diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/CityJSONController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/CityJSONController.cs index 055fdf3b3..ed8e8e06d 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/CityJSONController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/CityJSONController.cs @@ -1,5 +1,6 @@ using FSO.Server.Api.Core.Utils; using FSO.Server.Common; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; @@ -10,6 +11,7 @@ namespace FSO.Server.Api.Core.Controllers { + [EnableCors] [ApiController] public class CityJSONController : ControllerBase { diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/GameAPI/UpdateController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/GameAPI/UpdateController.cs index 4f90858ca..9e6d2fe6d 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/GameAPI/UpdateController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/GameAPI/UpdateController.cs @@ -2,10 +2,12 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; namespace FSO.Server.Api.Core.Controllers.GameAPI { + [EnableCors] [Route("userapi/update")] public class UpdateController : ControllerBase { diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/GithubController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/GithubController.cs new file mode 100644 index 000000000..f7534a2d8 --- /dev/null +++ b/TSOClient/FSO.Server.Api.Core/Controllers/GithubController.cs @@ -0,0 +1,79 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Cors; +using Microsoft.AspNetCore.Mvc; +using Octokit; + +namespace FSO.Server.Api.Core.Controllers +{ + [EnableCors] + [ApiController] + public class GithubController : ControllerBase + { + readonly GitHubClient client = + new GitHubClient(new ProductHeaderValue(Api.INSTANCE.Github.AppName), new Uri("https://github.com/")); + + private string StoredToken; + private static string CSRF; + + // GET: // + [HttpGet] + [Route("github/")] + public IActionResult Index() + { + if (Api.INSTANCE.Github == null) return NotFound(); + if (Api.INSTANCE.Github.AccessToken != null) return NotFound(); + + return Redirect(GetOauthLoginUrl()); + } + + [HttpGet] + [Route("github/callback")] + public async Task Callback(string code, string state) + { + if (Api.INSTANCE.Github == null) return NotFound(); + if (Api.INSTANCE.Github.AccessToken != null) return NotFound(); + + if (!String.IsNullOrEmpty(code)) + { + var expectedState = CSRF; + if (state != expectedState) throw new InvalidOperationException("SECURITY FAIL!"); + //CSRF = null; + + var token = await client.Oauth.CreateAccessToken( + new OauthTokenRequest(Api.INSTANCE.Github.ClientID, Api.INSTANCE.Github.ClientSecret, code) + { + RedirectUri = new Uri("http://localhost:80/github/callback") + }); + StoredToken = token.AccessToken; + } + + return Ok(StoredToken); + } + + private string GetOauthLoginUrl() + { + var rngCsp = new RNGCryptoServiceProvider(); + string csrf = ""; + var random = new byte[24]; + rngCsp.GetBytes(random); + for (int i=0; i<24; i++) + { + csrf += (char)('?' + random[i]/4); + } + CSRF = csrf; + + // 1. Redirect users to request GitHub access + var request = new OauthLoginRequest(Api.INSTANCE.Github.ClientID) + { + Scopes = { "admin:org", "repo" }, + State = csrf + }; + var oauthLoginUrl = client.Oauth.GetGitHubLoginUrl(request); + return oauthLoginUrl.ToString(); + } + } +} diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/LotInfoController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/LotInfoController.cs index c2e0fbbdb..921db5e80 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/LotInfoController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/LotInfoController.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Memory; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Cors; namespace FSO.Server.Api.Core.Controllers { @@ -38,7 +39,8 @@ public static void Delete(string key) memoryCache.Remove(key); } } - + + [EnableCors] [ApiController] public class LotInfoController : ControllerBase { diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/RegistrationController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/RegistrationController.cs index 290700192..1ee748f86 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/RegistrationController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/RegistrationController.cs @@ -1,6 +1,7 @@ using FSO.Server.Api.Core.Utils; using FSO.Server.Common; using FSO.Server.Database.DA.EmailConfirmation; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using System.Linq; using System.Net; @@ -14,6 +15,8 @@ namespace FSO.Server.Api.Core.Controllers /// Controller for user registrations. /// Supports email confirmation if enabled in config.json. /// + + [EnableCors] [Route("userapi/registration")] [ApiController] public class RegistrationController : ControllerBase diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/ShardStatusController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/ShardStatusController.cs index 52aacd9f2..b679115d4 100644 --- a/TSOClient/FSO.Server.Api.Core/Controllers/ShardStatusController.cs +++ b/TSOClient/FSO.Server.Api.Core/Controllers/ShardStatusController.cs @@ -1,6 +1,7 @@ using FSO.Common.Utils; using FSO.Server.Api.Core.Utils; using FSO.Server.Protocol.CitySelector; +using Microsoft.AspNetCore.Cors; using Microsoft.AspNetCore.Mvc; using System; using System.Collections.Generic; @@ -10,6 +11,7 @@ namespace FSO.Server.Api.Core.Controllers { + [EnableCors] [Route("cityselector/shard-status.jsp")] [ApiController] public class ShardStatusController : ControllerBase diff --git a/TSOClient/FSO.Server.Api.Core/Controllers/ValuesController.cs b/TSOClient/FSO.Server.Api.Core/Controllers/ValuesController.cs deleted file mode 100644 index eb89f4774..000000000 --- a/TSOClient/FSO.Server.Api.Core/Controllers/ValuesController.cs +++ /dev/null @@ -1,45 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc; - -namespace FSO.Server.Api.Core.Controllers -{ - [Route("api/[controller]")] - [ApiController] - public class ValuesController : ControllerBase - { - // GET api/values - [HttpGet] - public ActionResult> Get() - { - return new string[] { "value1", "value2" }; - } - - // GET api/values/5 - [HttpGet("{id}")] - public ActionResult Get(int id) - { - return "value"; - } - - // POST api/values - [HttpPost] - public void Post([FromBody] string value) - { - } - - // PUT api/values/5 - [HttpPut("{id}")] - public void Put(int id, [FromBody] string value) - { - } - - // DELETE api/values/5 - [HttpDelete("{id}")] - public void Delete(int id) - { - } - } -} diff --git a/TSOClient/FSO.Server.Api.Core/FSO.Server.Api.Core.csproj b/TSOClient/FSO.Server.Api.Core/FSO.Server.Api.Core.csproj index 41421a7a3..a07301703 100644 --- a/TSOClient/FSO.Server.Api.Core/FSO.Server.Api.Core.csproj +++ b/TSOClient/FSO.Server.Api.Core/FSO.Server.Api.Core.csproj @@ -49,6 +49,8 @@ + + diff --git a/TSOClient/FSO.Server.Api.Core/Program.cs b/TSOClient/FSO.Server.Api.Core/Program.cs index 639ce10bb..b35ceac88 100644 --- a/TSOClient/FSO.Server.Api.Core/Program.cs +++ b/TSOClient/FSO.Server.Api.Core/Program.cs @@ -32,7 +32,7 @@ public static IWebHostBuilder CreateWebHostBuilder(string[] args) => .UseUrls(args[0]) .ConfigureLogging(x => { - x.SetMinimumLevel(LogLevel.Critical); + x.SetMinimumLevel(LogLevel.None); }) .UseKestrel(options => { diff --git a/TSOClient/FSO.Server.Api.Core/Services/AWSUpdateUploader.cs b/TSOClient/FSO.Server.Api.Core/Services/AWSUpdateUploader.cs index c67fdd62b..0f6358213 100644 --- a/TSOClient/FSO.Server.Api.Core/Services/AWSUpdateUploader.cs +++ b/TSOClient/FSO.Server.Api.Core/Services/AWSUpdateUploader.cs @@ -19,7 +19,7 @@ public AWSUpdateUploader(AWSConfig config) Config = config; } - public async Task UploadFile(string destPath, string fileName) + public async Task UploadFile(string destPath, string fileName, string groupName) { var region = Config.Region; var bucket = Config.Bucket; diff --git a/TSOClient/FSO.Server.Api.Core/Services/GenerateUpdateService.cs b/TSOClient/FSO.Server.Api.Core/Services/GenerateUpdateService.cs index afc3e1e91..d811f187f 100644 --- a/TSOClient/FSO.Server.Api.Core/Services/GenerateUpdateService.cs +++ b/TSOClient/FSO.Server.Api.Core/Services/GenerateUpdateService.cs @@ -250,15 +250,15 @@ public async Task BuildUpdate(UpdateGenerationStatus status) Directory.Delete(updateDir + "client/", true); status.UpdateStatus(UpdateGenerationStatusCode.PUBLISHING_CLIENT); - result.full_zip = await Api.INSTANCE.UpdateUploader.UploadFile($"{baseUpdateKey}client-{versionName}.zip", finalClientZip); + result.full_zip = await Api.INSTANCE.UpdateUploaderClient.UploadFile($"{baseUpdateKey}client-{versionName}.zip", finalClientZip, versionName); } status.UpdateStatus(UpdateGenerationStatusCode.PUBLISHING_CLIENT); if (diffZip != null) { - result.incremental_zip = await Api.INSTANCE.UpdateUploader.UploadFile($"{baseUpdateKey}incremental-{versionName}.zip", diffZip); + result.incremental_zip = await Api.INSTANCE.UpdateUploaderClient.UploadFile($"{baseUpdateKey}incremental-{versionName}.zip", diffZip, versionName); } await System.IO.File.WriteAllTextAsync(updateDir + "manifest.json", Newtonsoft.Json.JsonConvert.SerializeObject(manifest)); - result.manifest_url = await Api.INSTANCE.UpdateUploader.UploadFile($"{baseUpdateKey}{versionName}.json", updateDir + "manifest.json"); + result.manifest_url = await Api.INSTANCE.UpdateUploaderClient.UploadFile($"{baseUpdateKey}{versionName}.json", updateDir + "manifest.json", versionName); } if (serverArti != null && !request.contentOnly) @@ -290,7 +290,7 @@ public async Task BuildUpdate(UpdateGenerationStatus status) Directory.Delete(updateDir + "server/", true); status.UpdateStatus(UpdateGenerationStatusCode.PUBLISHING_SERVER); - result.server_zip = await Api.INSTANCE.UpdateUploader.UploadFile($"{baseUpdateKey}server-{versionName}.zip", finalServerZip); + result.server_zip = await Api.INSTANCE.UpdateUploader.UploadFile($"{baseUpdateKey}server-{versionName}.zip", finalServerZip, versionName); } else { result.server_zip = result.incremental_zip; //same as client, as server uses same content. diff --git a/TSOClient/FSO.Server.Api.Core/Services/GithubUpdateUploader.cs b/TSOClient/FSO.Server.Api.Core/Services/GithubUpdateUploader.cs new file mode 100644 index 000000000..9f9f9d015 --- /dev/null +++ b/TSOClient/FSO.Server.Api.Core/Services/GithubUpdateUploader.cs @@ -0,0 +1,48 @@ +using FSO.Server.Common.Config; +using Octokit; +using Octokit.Internal; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading.Tasks; + +namespace FSO.Server.Api.Core.Services +{ + public class GithubUpdateUploader : IUpdateUploader + { + private GithubConfig Config; + + private static string Description = "This is an automated client release produced by the master FreeSO server. " + + "These releases match up with a branch on GitHub, but with some addon content such as a custom catalog and splash. " + + "It can be downloaded and installed directly, but it is better to do so through the game's launcher/updater. \n\n" + + "The incremental update is applied by simply extracting the zip over the previous version of the game " + + "(though the updater still affords extra validation here)"; + + public GithubUpdateUploader(GithubConfig config) + { + Config = config; + } + + public async Task UploadFile(string destPath, string fileName, string groupName) + { + destPath = Path.GetFileName(destPath); + var credentials = new InMemoryCredentialStore(new Credentials(Config.AccessToken)); + + var client = new GitHubClient(new ProductHeaderValue(Config.AppName), credentials); + + Release release; + release = await client.Repository.Release.Get(Config.User, Config.Repository, groupName); + if (release == null) { + var newRel = new NewRelease(groupName); + newRel.Body = Description; + release = await client.Repository.Release.Create(Config.User, Config.Repository, newRel); + } + + using (var file = File.Open(fileName, System.IO.FileMode.Open, FileAccess.Read, FileShare.Read)) { + var asset = await client.Repository.Release.UploadAsset(release, new ReleaseAssetUpload(destPath, "application/zip", file, new TimeSpan(1, 0, 0))); + return asset.BrowserDownloadUrl; + } + } + } +} diff --git a/TSOClient/FSO.Server.Api.Core/Services/IUpdateUploader.cs b/TSOClient/FSO.Server.Api.Core/Services/IUpdateUploader.cs index 0bc571fbb..8facbdf8c 100644 --- a/TSOClient/FSO.Server.Api.Core/Services/IUpdateUploader.cs +++ b/TSOClient/FSO.Server.Api.Core/Services/IUpdateUploader.cs @@ -7,6 +7,6 @@ namespace FSO.Server.Api.Core.Services { public interface IUpdateUploader { - Task UploadFile(string destPath, string fileName); + Task UploadFile(string destPath, string fileName, string groupName); } } diff --git a/TSOClient/FSO.Server.Api.Core/Startup.cs b/TSOClient/FSO.Server.Api.Core/Startup.cs index f8eb73bb8..68da3a098 100644 --- a/TSOClient/FSO.Server.Api.Core/Startup.cs +++ b/TSOClient/FSO.Server.Api.Core/Startup.cs @@ -29,7 +29,21 @@ public Startup(IConfiguration configuration) // This method gets called by the runtime. Use this method to add services to the container. public void ConfigureServices(IServiceCollection services) { - services.AddCors().AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); + services.AddCors(options => + { + options.AddDefaultPolicy( + builder => + { + + builder.AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader().WithExposedHeaders("content-disposition"); + }); + + options.AddPolicy("AdminAppPolicy", + builder => + { + builder.WithOrigins("https://freeso.org", "http://localhost:8080").AllowAnyMethod().AllowAnyHeader().AllowCredentials().WithExposedHeaders("content-disposition"); + }); + }).AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_1); } // This method gets called by the runtime. Use this method to configure the HTTP request pipeline. @@ -43,12 +57,7 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env, IApplica { app.UseHsts(); } - app.UseCors(x => - { - x - .AllowAnyOrigin().AllowAnyMethod().AllowAnyHeader().AllowCredentials().WithExposedHeaders("content-disposition"); - //TODO: limit credentials passing to only trusted URLs. - }); + app.UseCors(); //app.UseHttpsRedirection(); app.UseMvc(); AppLifetime = appLifetime; diff --git a/TSOClient/FSO.Server.Common/Config/GithubConfig.cs b/TSOClient/FSO.Server.Common/Config/GithubConfig.cs new file mode 100644 index 000000000..8da9a8645 --- /dev/null +++ b/TSOClient/FSO.Server.Common/Config/GithubConfig.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace FSO.Server.Common.Config +{ + public class GithubConfig + { + public string AppName { get; set; } = "FreeSO"; + public string User { get; set; } = "riperiperi"; + public string Repository { get; set; } = "FreeSO"; + public string ClientID { get; set; } + public string ClientSecret { get; set; } + + /// + /// Must be generated by installing the app on your user account. Browse to the /github/ API endpoint when this is null. (yes, on this server) + /// + public string AccessToken { get; set; } + } +} diff --git a/TSOClient/FSO.Server.Common/FSO.Server.Common.csproj b/TSOClient/FSO.Server.Common/FSO.Server.Common.csproj index 3b87a33cd..c77667b65 100644 --- a/TSOClient/FSO.Server.Common/FSO.Server.Common.csproj +++ b/TSOClient/FSO.Server.Common/FSO.Server.Common.csproj @@ -65,6 +65,7 @@ + diff --git a/TSOClient/FSO.Server.Core/Program.cs b/TSOClient/FSO.Server.Core/Program.cs index 1f3df20d6..1d44948e8 100644 --- a/TSOClient/FSO.Server.Core/Program.cs +++ b/TSOClient/FSO.Server.Core/Program.cs @@ -1,6 +1,7 @@ using FSO.Server; using FSO.Server.Api.Core.Services; using FSO.Server.Common; +using FSO.Server.Common.Config; using FSO.Server.Servers.UserApi; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Builder; @@ -52,6 +53,9 @@ public static IAPILifetime StartWebApi(UserApi api, string url) var api2 = new FSO.Server.Api.Core.Api(); api2.Init(settings); if (userApiConfig.AwsConfig != null) api2.UpdateUploader = new AWSUpdateUploader(userApiConfig.AwsConfig); + if (userApiConfig.GithubConfig != null) api2.UpdateUploaderClient = new GithubUpdateUploader(userApiConfig.GithubConfig); + else api2.UpdateUploaderClient = api2.UpdateUploader; + api2.Github = userApiConfig.GithubConfig; api.SetupInstance(api2); api2.HostPool = api.GetGluonHostPool(); diff --git a/TSOClient/FSO.Server.Database/DA/Elections/DbElectionCycle.cs b/TSOClient/FSO.Server.Database/DA/Elections/DbElectionCycle.cs index 2cec1de5b..fd5e6e42e 100644 --- a/TSOClient/FSO.Server.Database/DA/Elections/DbElectionCycle.cs +++ b/TSOClient/FSO.Server.Database/DA/Elections/DbElectionCycle.cs @@ -13,6 +13,10 @@ public class DbElectionCycle public uint end_date { get; set; } public DbElectionCycleState current_state { get; set; } public DbElectionCycleType election_type { get; set; } + + //for free vote + public string name { get; set; } + public int nhood_id { get; set; } } public enum DbElectionCycleState : byte diff --git a/TSOClient/FSO.Server.Database/DA/Elections/DbElectionFreeVote.cs b/TSOClient/FSO.Server.Database/DA/Elections/DbElectionFreeVote.cs new file mode 100644 index 000000000..5f6638e21 --- /dev/null +++ b/TSOClient/FSO.Server.Database/DA/Elections/DbElectionFreeVote.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace FSO.Server.Database.DA.Elections +{ + public class DbElectionFreeVote + { + public uint avatar_id { get; set; } + public int neighborhood_id { get; set; } + public uint cycle_id { get; set; } + public uint date { get; set; } + public uint expire_date { get; set; } + } +} diff --git a/TSOClient/FSO.Server.Database/DA/Elections/DbElectionVote.cs b/TSOClient/FSO.Server.Database/DA/Elections/DbElectionVote.cs index 2e1a69645..de0cbaadf 100644 --- a/TSOClient/FSO.Server.Database/DA/Elections/DbElectionVote.cs +++ b/TSOClient/FSO.Server.Database/DA/Elections/DbElectionVote.cs @@ -13,6 +13,7 @@ public class DbElectionVote public DbElectionVoteType type { get; set; } public uint target_avatar_id { get; set; } public uint date { get; set; } + public int value { get; set; } } public enum DbElectionVoteType diff --git a/TSOClient/FSO.Server.Database/DA/Elections/IElections.cs b/TSOClient/FSO.Server.Database/DA/Elections/IElections.cs index 57bd78793..8319212ad 100644 --- a/TSOClient/FSO.Server.Database/DA/Elections/IElections.cs +++ b/TSOClient/FSO.Server.Database/DA/Elections/IElections.cs @@ -1,4 +1,5 @@ -using System; +using FSO.Server.Database.DA.Neighborhoods; +using System; using System.Collections.Generic; using System.Linq; using System.Text; @@ -10,6 +11,7 @@ public interface IElections { DbElectionCycle GetCycle(uint cycle_id); DbElectionCandidate GetCandidate(uint avatar_id, uint cycle_id, DbCandidateState state); + List GetActiveCycles(int shard_id); List GetCandidates(uint cycle_id, DbCandidateState state); List GetCandidates(uint cycle_id); List GetCycleVotes(uint cycle_id, DbElectionVoteType type); @@ -31,6 +33,9 @@ public interface IElections bool EmailRegistered(DbElectionCycleMail p); bool TryRegisterMail(DbElectionCycleMail p); + bool EnrollFreeVote(DbElectionFreeVote entry); + DbElectionFreeVote GetFreeVote(uint avatar_id); + DbElectionWin FindLastWin(uint avatar_id); } diff --git a/TSOClient/FSO.Server.Database/DA/Elections/SqlElections.cs b/TSOClient/FSO.Server.Database/DA/Elections/SqlElections.cs index 8d26e8f22..8647c67e6 100644 --- a/TSOClient/FSO.Server.Database/DA/Elections/SqlElections.cs +++ b/TSOClient/FSO.Server.Database/DA/Elections/SqlElections.cs @@ -19,19 +19,29 @@ public DbElectionCandidate GetCandidate(uint avatar_id, uint cycle_id, DbCandida { return Context.Connection.Query("SELECT * FROM fso_election_candidates WHERE candidate_avatar_id = @avatar_id " + "AND election_cycle_id = @cycle_id AND state = @state", - new { avatar_id = avatar_id, cycle_id = cycle_id, state = state.ToString() }).FirstOrDefault(); + new { avatar_id, cycle_id, state = state.ToString() }).FirstOrDefault(); } public List GetCandidates(uint cycle_id, DbCandidateState state) { return Context.Connection.Query("SELECT * FROM fso_election_candidates WHERE election_cycle_id = @cycle_id AND state = @state", - new { cycle_id = cycle_id, state = state.ToString() }).ToList(); + new { cycle_id, state = state.ToString() }).ToList(); } public List GetCandidates(uint cycle_id) { return Context.Connection.Query("SELECT * FROM fso_election_candidates WHERE election_cycle_id = @cycle_id", - new { cycle_id = cycle_id }).ToList(); + new { cycle_id }).ToList(); + } + + public List GetActiveCycles(int shard_id) + { + + return Context.Connection.Query("SELECT *, n.neighborhood_id AS 'nhood_id' " + + "FROM fso_election_cycles JOIN fso_neighborhoods n ON election_cycle_id = cycle_id " + + "WHERE current_state != 'shutdown' " + + "AND cycle_id IN (SELECT election_cycle_id FROM fso_neighborhoods WHERE shard_id = @shard_id)", + new { shard_id }).ToList(); } public DbElectionCycle GetCycle(uint cycle_id) @@ -231,6 +241,40 @@ public bool TryRegisterMail(DbElectionCycleMail p) } } + public bool EnrollFreeVote(DbElectionFreeVote entry) + { + try + { + return (Context.Connection.Execute("INSERT INTO fso_election_freevotes (avatar_id, neighborhood_id, cycle_id, date, expire_date) " + + "VALUES (@avatar_id, @neighborhood_id, @cycle_id, @date, @expire_date)", + entry) > 0); + } + catch + { + //already exists, or foreign key fails + return false; + } + } + + public DbElectionFreeVote GetFreeVote(uint avatar_id) + { + var result = Context.Connection.Query("SELECT * FROM fso_election_freevotes WHERE avatar_id = @avatar_id", + new { avatar_id }).FirstOrDefault(); + if (result != null && result.expire_date < Epoch.Now) + { + //outdated. delete and set null. + try + { + Context.Connection.Execute("DELETE FROM fso_election_freevotes WHERE avatar_id = @avatar_id", new { avatar_id }); + } + catch (Exception) + { + } + result = null; + } + return result; + } + public DbElectionWin FindLastWin(uint avatar_id) { return Context.Connection.Query("SELECT n.neighborhood_id AS nhood_id, n.name AS nhood_name " + diff --git a/TSOClient/FSO.Server.Database/DA/Lots/ILots.cs b/TSOClient/FSO.Server.Database/DA/Lots/ILots.cs index 1f665136c..52a9f6eb7 100644 --- a/TSOClient/FSO.Server.Database/DA/Lots/ILots.cs +++ b/TSOClient/FSO.Server.Database/DA/Lots/ILots.cs @@ -13,6 +13,7 @@ public interface ILots List GetLocationsInNhood(uint nhood_id); List GetCommunityLocations(int shard_id); List AllLocations(int shard_id); + DbLot GetByName(int shard_id, string name); DbLot GetByLocation(int shard_id, uint location); List GetAdjToLocation(int shard_id, uint location); DbLot GetByOwner(uint owner_id); diff --git a/TSOClient/FSO.Server.Database/DA/Lots/SqlLots.cs b/TSOClient/FSO.Server.Database/DA/Lots/SqlLots.cs index edea1d2b8..bcfe8043a 100644 --- a/TSOClient/FSO.Server.Database/DA/Lots/SqlLots.cs +++ b/TSOClient/FSO.Server.Database/DA/Lots/SqlLots.cs @@ -119,6 +119,11 @@ public List AllNames(int shard_id) return Context.Connection.Query("SELECT name FROM fso_lots WHERE shard_id = @shard_id", new { shard_id = shard_id }).ToList(); } + public DbLot GetByName(int shard_id, string name) + { + return Context.Connection.Query("SELECT * FROM fso_lots WHERE name = @name AND shard_id = @shard_id", new { name, shard_id = shard_id }).FirstOrDefault(); + } + public DbLot GetByLocation(int shard_id, uint location) { return Context.Connection.Query("SELECT * FROM fso_lots WHERE location = @location AND shard_id = @shard_id", new { location = location, shard_id = shard_id }).FirstOrDefault(); diff --git a/TSOClient/FSO.Server.Database/DA/Users/DbAuthAttempt.cs b/TSOClient/FSO.Server.Database/DA/Users/DbAuthAttempt.cs new file mode 100644 index 000000000..99111744a --- /dev/null +++ b/TSOClient/FSO.Server.Database/DA/Users/DbAuthAttempt.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace FSO.Server.Database.DA.Users +{ + public class DbAuthAttempt + { + public uint attempt_id { get; set; } + public string ip { get; set; } + public uint user_id { get; set; } + public uint expire_time { get; set; } + public int count { get; set; } + public bool active { get; set; } + public bool invalidated { get; set; } + } +} diff --git a/TSOClient/FSO.Server.Database/DA/Users/IUsers.cs b/TSOClient/FSO.Server.Database/DA/Users/IUsers.cs index 9cfa830ff..682e6edbb 100644 --- a/TSOClient/FSO.Server.Database/DA/Users/IUsers.cs +++ b/TSOClient/FSO.Server.Database/DA/Users/IUsers.cs @@ -22,5 +22,11 @@ public interface IUsers User GetByEmail(string email); void UpdateAuth(UserAuthenticate auth); void UpdateLastLogin(uint id, uint last_login); + + DbAuthAttempt GetRemainingAuth(uint user_id, string ip); + int FailedConsecutive(uint user_id, string ip); + int FailedAuth(uint attempt_id, uint delay, int failLimit); + void NewFailedAuth(uint user_id, string ip, uint delay); + void SuccessfulAuth(uint user_id, string ip); } } diff --git a/TSOClient/FSO.Server.Database/DA/Users/SqlUsers.cs b/TSOClient/FSO.Server.Database/DA/Users/SqlUsers.cs index c1f609783..72178a2b8 100644 --- a/TSOClient/FSO.Server.Database/DA/Users/SqlUsers.cs +++ b/TSOClient/FSO.Server.Database/DA/Users/SqlUsers.cs @@ -4,6 +4,7 @@ using System.Text; using System.Threading.Tasks; using Dapper; +using FSO.Server.Common; using FSO.Server.Database.DA.Utils; namespace FSO.Server.Database.DA.Users @@ -90,5 +91,66 @@ public void UpdateAuth(UserAuthenticate auth) new { auth.scheme_class, auth.data, auth.user_id } ); } + + public DbAuthAttempt GetRemainingAuth(uint user_id, string ip) + { + var result = Context.Connection.Query( + "SELECT * FROM fso_auth_attempts WHERE user_id = @user_id AND ip = @ip AND invalidated = 0 ORDER BY expire_time DESC", + new { user_id, ip } + ).FirstOrDefault(); + if (result != null && result.active && result.expire_time < Epoch.Now) return null; + else return result; + } + + public int FailedConsecutive(uint user_id, string ip) + { + return Context.Connection.Query( + "SELECT COUNT(*) FROM fso_auth_attempts WHERE user_id = @user_id AND ip = @ip AND active = 1 AND invalidated = 0", + new { user_id, ip } + ).First(); + } + + public int FailedAuth(uint attempt_id, uint delay, int failLimit) + { + Context.Connection.Execute( + "UPDATE fso_auth_attempts SET count = count + 1, expire_time = @time WHERE attempt_id = @attempt_id", + new { attempt_id, time = Epoch.Now + delay } + ); + var result = Context.Connection.Query( + "SELECT * FROM fso_auth_attempts WHERE attempt_id = @attempt_id", new { attempt_id }).FirstOrDefault(); + if (result != null) + { + if (result.count >= failLimit) + { + Context.Connection.Execute( + "UPDATE fso_auth_attempts SET active = 1 WHERE attempt_id = @attempt_id", + new { attempt_id }); + return 0; + } else + { + return failLimit - result.count; + } + } else + { + return failLimit; + } + } + + public void NewFailedAuth(uint user_id, string ip, uint delay) + { + //create a new entry + Context.Connection.Execute( + "INSERT INTO fso_auth_attempts (ip, user_id, expire_time, count) VALUES (@ip, @user_id, @time, 1)", + new { user_id, ip, time = Epoch.Now + delay } + ); + } + + public void SuccessfulAuth(uint user_id, string ip) + { + Context.Connection.Execute( + "UPDATE fso_auth_attempts SET invalidated = 1 WHERE user_id = @user_id AND ip = @ip", + new { user_id, ip } + ); + } } } diff --git a/TSOClient/FSO.Server.Database/DatabaseScripts/changes/0026_freevote.sql b/TSOClient/FSO.Server.Database/DatabaseScripts/changes/0026_freevote.sql new file mode 100644 index 000000000..1ca2d62f4 --- /dev/null +++ b/TSOClient/FSO.Server.Database/DatabaseScripts/changes/0026_freevote.sql @@ -0,0 +1,48 @@ +CREATE TABLE `fso_election_freevotes` ( + `avatar_id` INT UNSIGNED NOT NULL, + `neighborhood_id` INT NOT NULL, + `cycle_id` INT UNSIGNED NOT NULL, + `date` INT NOT NULL, + `expire_date` INT NOT NULL, + PRIMARY KEY (`avatar_id`), + INDEX `fso_freevote_cycle_idx` (`cycle_id` ASC), + INDEX `fso_freevote_nhood_idx` (`neighborhood_id` ASC), + CONSTRAINT `fso_freevote_avatar` + FOREIGN KEY (`avatar_id`) + REFERENCES `fso_avatars` (`avatar_id`) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT `fso_freevote_cycle` + FOREIGN KEY (`cycle_id`) + REFERENCES `fso_election_cycles` (`cycle_id`) + ON DELETE CASCADE + ON UPDATE CASCADE, + CONSTRAINT `fso_freevote_nhood` + FOREIGN KEY (`neighborhood_id`) + REFERENCES `fso_neighborhoods` (`neighborhood_id`) + ON DELETE CASCADE + ON UPDATE CASCADE) +COMMENT = 'When a neighborhood is ineligible for an election, its residents get to choose a neighborhood election to participate in. Entries in this table allow avatars to vote in an election when they do not live in its neighborhood. Entries in this table should expire when the linked cycle ends.'; + +ALTER TABLE `fso_election_freevotes` +CHANGE COLUMN `date` `date` INT(11) UNSIGNED NOT NULL , +CHANGE COLUMN `expire_date` `expire_date` INT(11) UNSIGNED NOT NULL ; + +ALTER TABLE `fso_election_votes` +ADD COLUMN `value` INT NOT NULL DEFAULT 1 COMMENT 'The value of this vote. Some votes can be worth more than others (eg. free votes are worth less than normal ones)' AFTER `date`; + +CREATE TABLE `fso`.`fso_auth_attempts` ( + `attempt_id` INT UNSIGNED NOT NULL AUTO_INCREMENT, + `ip` VARCHAR(100) NOT NULL, + `user_id` INT UNSIGNED NOT NULL, + `expire_time` INT UNSIGNED NOT NULL, + `count` INT NOT NULL DEFAULT 0, + `active` TINYINT UNSIGNED NOT NULL DEFAULT 0, + `invalidated` TINYINT UNSIGNED NOT NULL DEFAULT 0, + PRIMARY KEY (`attempt_id`), + INDEX `fk_user_attempt_idx` (`user_id` ASC), + CONSTRAINT `fk_user_attempt` + FOREIGN KEY (`user_id`) + REFERENCES `fso`.`fso_users` (`user_id`) + ON DELETE CASCADE + ON UPDATE CASCADE); \ No newline at end of file diff --git a/TSOClient/FSO.Server.Database/DatabaseScripts/manifest.json b/TSOClient/FSO.Server.Database/DatabaseScripts/manifest.json index 6f3122ec4..cae6da1fb 100644 --- a/TSOClient/FSO.Server.Database/DatabaseScripts/manifest.json +++ b/TSOClient/FSO.Server.Database/DatabaseScripts/manifest.json @@ -236,6 +236,14 @@ "id": "1da5c115-35e3-4117-a2be-b874d0017700", "script": "changes/0025_updates.sql", "idempotent": false + }, + { + "id": "64dfb448-1a52-45b7-be2f-056d8ddfd305", + "script": "changes/0026_freevote.sql", + "idempotent": false, + "requires": [ + "3b87e93a-5fae-437b-8ba5-1e46ebb4cd15" + ] } ] } \ No newline at end of file diff --git a/TSOClient/FSO.Server.Database/FSO.Server.Database.csproj b/TSOClient/FSO.Server.Database/FSO.Server.Database.csproj index 803fdde1b..e2ff9fb60 100644 --- a/TSOClient/FSO.Server.Database/FSO.Server.Database.csproj +++ b/TSOClient/FSO.Server.Database/FSO.Server.Database.csproj @@ -85,6 +85,9 @@ + + PreserveNewest + @@ -121,6 +124,7 @@ + @@ -193,6 +197,7 @@ + diff --git a/TSOClient/FSO.Server.Protocol/Electron/Packets/NhoodRequest.cs b/TSOClient/FSO.Server.Protocol/Electron/Packets/NhoodRequest.cs index 07311dfff..62dbaa98d 100644 --- a/TSOClient/FSO.Server.Protocol/Electron/Packets/NhoodRequest.cs +++ b/TSOClient/FSO.Server.Protocol/Electron/Packets/NhoodRequest.cs @@ -18,7 +18,7 @@ public class NhoodRequest : AbstractElectronPacket, IActionRequest public object OType => Type; public bool NeedsValidation => Type == NhoodRequestType.CAN_NOMINATE || Type == NhoodRequestType.CAN_RATE - || Type == NhoodRequestType.CAN_RUN || Type == NhoodRequestType.CAN_VOTE; + || Type == NhoodRequestType.CAN_RUN || Type == NhoodRequestType.CAN_VOTE || Type == NhoodRequestType.CAN_FREE_VOTE; public string Message = ""; //bulletin, rate public uint Value; //rate (stars), nomination_run (accept if >0) @@ -60,7 +60,10 @@ public enum NhoodRequestType : byte CAN_RATE, NOMINATION_RUN, CAN_RUN, - + + CAN_FREE_VOTE, + FREE_VOTE, + //moderator commands DELETE_RATE, FORCE_MAYOR, diff --git a/TSOClient/FSO.Server.Protocol/Electron/Packets/NhoodResponse.cs b/TSOClient/FSO.Server.Protocol/Electron/Packets/NhoodResponse.cs index 74ec8bb03..b0198cf33 100644 --- a/TSOClient/FSO.Server.Protocol/Electron/Packets/NhoodResponse.cs +++ b/TSOClient/FSO.Server.Protocol/Electron/Packets/NhoodResponse.cs @@ -72,7 +72,14 @@ public enum NhoodResponseCode : byte YOU_MOVED_RECENTLY = 0x13, CANDIDATE_NHOOD_GAMEPLAY_BAN = 0x14, MISSING_ENTITY = 0x15, //missing someone - + + //free vote + NHOOD_NO_ELECTION = 0x16, + ALREADY_ENROLLED_FOR_FREE_VOTE = 0x17, + FREE_VOTE_ALREADY_ELIGIBLE = 0x18, + FREE_VOTE_MOVE_DATE = 0x19, + FREE_VOTE_ELECTION_OVER = 0x1A, + CANCEL = 0xFE, UNKNOWN_ERROR = 0xFF }; diff --git a/TSOClient/FSO.Server/FSO.Server.csproj b/TSOClient/FSO.Server/FSO.Server.csproj index 80b2d53d8..411c1fca1 100644 --- a/TSOClient/FSO.Server/FSO.Server.csproj +++ b/TSOClient/FSO.Server/FSO.Server.csproj @@ -260,6 +260,7 @@ + diff --git a/TSOClient/FSO.Server/Framework/Aries/AbstractAriesServer.cs b/TSOClient/FSO.Server/Framework/Aries/AbstractAriesServer.cs index 936bcd680..53d2df057 100644 --- a/TSOClient/FSO.Server/Framework/Aries/AbstractAriesServer.cs +++ b/TSOClient/FSO.Server/Framework/Aries/AbstractAriesServer.cs @@ -215,7 +215,7 @@ public void MessageReceived(IoSession session, object message) if (!ariesSession.IsAuthenticated) { /** You can only use aries packets when anon **/ - if(!(message is IAriesPacket)) + if(!(message is IAriesPacket) && !(message is ClientByePDU)) { throw new Exception($"Voltron packets are forbidden before aries authentication has completed. \n" + $"(got {message.GetType().ToString()} on connection for {Config.Call_Sign})"); diff --git a/TSOClient/FSO.Server/Program.cs b/TSOClient/FSO.Server/Program.cs index 5faac97fb..97cfc14a6 100644 --- a/TSOClient/FSO.Server/Program.cs +++ b/TSOClient/FSO.Server/Program.cs @@ -40,6 +40,10 @@ public static int Main(string[] args) toolType = typeof(ToolImportNhood); toolOptions = subOptions; break; + case "restore-lots": + toolType = typeof(ToolRestoreLots); + toolOptions = subOptions; + break; default: Console.Write(options.GetUsage(verb)); break; diff --git a/TSOClient/FSO.Server/ProgramOptions.cs b/TSOClient/FSO.Server/ProgramOptions.cs index 532bfa978..9f0b5c08d 100644 --- a/TSOClient/FSO.Server/ProgramOptions.cs +++ b/TSOClient/FSO.Server/ProgramOptions.cs @@ -20,6 +20,10 @@ public class ProgramOptions HelpText = "Import the neighborhood stored in the given JSON file to the specified shard.")] public ImportNhoodOptions ImportNhoodVerb { get; set; } + [VerbOption("restore-lots", + HelpText = "Create lots in the database from FSOV saves in the specified folder. (with specified shard)")] + public RestoreLotsOptions RestoreLotsVerb { get; set; } + [HelpVerbOption] public string GetUsage(string verb) { @@ -45,4 +49,23 @@ public class ImportNhoodOptions [ValueOption(1)] public string JSON { get; set; } } + + public class RestoreLotsOptions + { + [ValueOption(0)] + public int ShardId { get; set; } + [ValueOption(1)] + public string RestoreFolder { get; set; } + + [Option('r', "report", DefaultValue = false, HelpText = "Report changes that would be made restoring the lot, " + + "eg. add/remove/reown of objects, lot positon (and if we can restore it) ")] + public bool Report { get; set; } + + [Option('o', "objects", DefaultValue = false, HelpText = "Create new database entries for objects when they are still owned. " + + "If 'safe' is enabled, then database entries will be created for objects on other lots, otherwise they will be created for all.")] + public bool Objects { get; set; } + + [Option('s', "safe", DefaultValue = false, HelpText = "Do not return objects that have been placed, only ones in inventories.")] + public bool Safe { get; set; } + } } diff --git a/TSOClient/FSO.Server/Servers/City/CityServer.cs b/TSOClient/FSO.Server/Servers/City/CityServer.cs index fb43c8a7b..76e489501 100644 --- a/TSOClient/FSO.Server/Servers/City/CityServer.cs +++ b/TSOClient/FSO.Server/Servers/City/CityServer.cs @@ -36,7 +36,7 @@ public class CityServer : AbstractAriesServer public CityServer(CityServerConfiguration config, IKernel kernel) : base(config, kernel) { this.UnexpectedDisconnectWaitSeconds = 30; - this.TimeoutIfNoAuth = true; + this.TimeoutIfNoAuth = config.Timeout_No_Auth; this.Config = config; VoltronSessions = Sessions.GetOrCreateGroup(Groups.VOLTRON); } diff --git a/TSOClient/FSO.Server/Servers/City/CityServerConfiguration.cs b/TSOClient/FSO.Server/Servers/City/CityServerConfiguration.cs index 24e40f5c3..221236721 100644 --- a/TSOClient/FSO.Server/Servers/City/CityServerConfiguration.cs +++ b/TSOClient/FSO.Server/Servers/City/CityServerConfiguration.cs @@ -10,6 +10,7 @@ namespace FSO.Server.Servers.City public class CityServerConfiguration : AbstractAriesServerConfig { public int ID; + public bool Timeout_No_Auth = true; public CityServerNhoodConfiguration Neighborhoods = new CityServerNhoodConfiguration(); public CityServerMaintenanceConfiguration Maintenance; @@ -62,6 +63,21 @@ public class CityServerNhoodConfiguration * If true, starts elections on the last monday in a month, rather than 7 days before the end of the month. */ public bool Election_Week_Align = true; + + /** + * If true, sims in areas without an election are offered a free vote. + */ + public bool Election_Free_Vote = true; + + /** + * The value of a vote/nomination made by a resident. + */ + public int Vote_Normal_Value = 2; + + /** + * The value of a vote/nomination made by a non-resident. + */ + public int Vote_Free_Value = 1; } public class CityServerMaintenanceConfiguration diff --git a/TSOClient/FSO.Server/Servers/City/Domain/LotAllocations.cs b/TSOClient/FSO.Server/Servers/City/Domain/LotAllocations.cs index 7ba3bc921..318681a7a 100644 --- a/TSOClient/FSO.Server/Servers/City/Domain/LotAllocations.cs +++ b/TSOClient/FSO.Server/Servers/City/Domain/LotAllocations.cs @@ -1,4 +1,5 @@ -using FSO.Common.Security; +using FSO.Common.Enum; +using FSO.Common.Security; using FSO.Server.Database.DA; using FSO.Server.Database.DA.Lots; using FSO.Server.Framework.Gluon; @@ -91,7 +92,7 @@ public void TryClose(int lotId, uint claimId) if (lot != null) { location = lot.location; - if (lot.owner_id == null) da.Lots.Delete(lotId); //this lot should no longer exist. + if (lot.owner_id == null && lot.category != LotCategory.community) da.Lots.Delete(lotId); //this lot should no longer exist. } } } diff --git a/TSOClient/FSO.Server/Servers/City/Domain/Neighborhoods.cs b/TSOClient/FSO.Server/Servers/City/Domain/Neighborhoods.cs index 7ca106702..9f8b5cece 100644 --- a/TSOClient/FSO.Server/Servers/City/Domain/Neighborhoods.cs +++ b/TSOClient/FSO.Server/Servers/City/Domain/Neighborhoods.cs @@ -94,7 +94,14 @@ public void BroadcastNhoodState(IDA da, MailHandler mail, DbNeighborhood nhood, var myLotID = da.Roommates.GetAvatarsLots(session.AvatarId).FirstOrDefault(); var myLot = (myLotID == null) ? null : da.Lots.Get(myLotID.lot_id); - if (myLot != null && myLot.neighborhood_id == nhood.neighborhood_id) + var free = da.Elections.GetFreeVote(session.AvatarId); + var nhoodID = (int)(myLot?.neighborhood_id ?? 0); + if (free != null) + { + nhoodID = free.neighborhood_id; //enrolled to a free vote. receive vote mail for that neighborhood + } + + if (myLot != null && nhoodID == nhood.neighborhood_id) SendStateEmail(da, mail, nhood, cycle, session.AvatarId); } } @@ -128,6 +135,14 @@ public void SendStateEmail(IDA da, MailHandler mail, DbNeighborhood nhood, DbEle 1, MessageSpecialType.Normal, endDate, avatarID, nhood.name); break; + case DbElectionCycleState.shutdown: + if (Context.Config.Neighborhoods.Election_Free_Vote) + { + mail.SendSystemEmail("f116", (int)NeighMailStrings.FreeVoteSubject, (int)NeighMailStrings.FreeVote, + 1, MessageSpecialType.FreeVote, endDate, avatarID, nhood.name, endDate.ToString()); + } + break; + case DbElectionCycleState.ended: var winner = da.Avatars.Get(nhood.mayor_id ?? 0)?.name; if (winner == null) return; @@ -141,7 +156,8 @@ public void SendStateEmail(IDA da, MailHandler mail, DbNeighborhood nhood, DbEle public bool StateHasEmail(DbElectionCycleState state) { return state == DbElectionCycleState.nomination || state == DbElectionCycleState.election - || state == DbElectionCycleState.ended || state == DbElectionCycleState.failsafe; + || state == DbElectionCycleState.ended || state == DbElectionCycleState.failsafe + || (state == DbElectionCycleState.shutdown && Context.Config.Neighborhoods.Election_Free_Vote); } public Task TickNeighborhoods() @@ -293,7 +309,7 @@ public async Task TickNeighborhoods(DateTime now) //update eligibility if ((nhood.flag & 2) > 0) { - //not eligibile for elections (temp) + //not eligibile for elections (right now, at least) //is our placement within bounds? if (placement != -1 && placement < config.Mayor_Elegibility_Limit) { @@ -302,9 +318,37 @@ public async Task TickNeighborhoods(DateTime now) nhoodDS.Neighborhood_Flag = nhood.flag; da.Neighborhoods.UpdateFlag((uint)nhood.neighborhood_id, nhood.flag); - SendBulletinPost(da, nhood.neighborhood_id, "f123", (int)NeighBulletinStrings.ElectionBeginSubject, (int)NeighBulletinStrings.ElectionBegin, + SendBulletinPost(da, nhood.neighborhood_id, "f123", (int)NeighBulletinStrings.ElectionBeginSubject, (int)NeighBulletinStrings.ElectionBegin, 0, nhood.name, config.Mayor_Elegibility_Limit.ToString()); } + else if (Context.Config.Neighborhoods.Election_Free_Vote) + { + //still ineligible for elections, but we need to tell resdients they are eligible for a free vote + var cycle = da.Elections.GetCycle(nhood.election_cycle_id ?? 0); + if (cycle == null || cycle.end_date < epochNow) + { + //free vote needs to start another shutdown cycle, so we can keep track of reminder emails sent to residents + //this will set stillActive to true for the rest of the election cycle (time to next month < 7), + //so we won't get back here til next cycle + var dbCycle = new DbElectionCycle + { + current_state = DbElectionCycleState.shutdown, + election_type = DbElectionCycleType.shutdown, + start_date = Epoch.FromDate(midnight), + end_date = Epoch.FromDate(endDate) + }; + + var cycleID = da.Elections.CreateCycle(dbCycle); + nhoodDS.Neighborhood_ElectionCycle = new ElectionCycle() + { + ElectionCycle_CurrentState = (byte)dbCycle.current_state, + ElectionCycle_ElectionType = (byte)dbCycle.election_type, + ElectionCycle_StartDate = dbCycle.start_date, + ElectionCycle_EndDate = dbCycle.end_date + }; + da.Neighborhoods.UpdateCycle((uint)nhood.neighborhood_id, cycleID); + } + } } else { @@ -421,7 +465,7 @@ public async Task ChangeElectionState(IDA da, DbNeighborhood nhood, DbElectionCy var toRemove = da.Elections.GetCandidates(cycle.cycle_id).ToDictionary(x => x.candidate_avatar_id); if (cycleNoms.Count > 0) { - var grouped = cycleNoms.GroupBy(x => x.target_avatar_id).OrderByDescending(x => x.Count()); + var grouped = cycleNoms.GroupBy(x => x.target_avatar_id).OrderByDescending(x => x.Sum(y => y.value)); var selected = 0; var candidates = new List>(); @@ -480,7 +524,7 @@ public async Task ChangeElectionState(IDA da, DbNeighborhood nhood, DbElectionCy var cycleVotes = da.Elections.GetCycleVotes(cycle.cycle_id, DbElectionVoteType.vote); if (cycleVotes.Count > 0) { - var grouped = cycleVotes.GroupBy(x => x.target_avatar_id).OrderByDescending(x => x.Count()).ToList(); + var grouped = cycleVotes.GroupBy(x => x.target_avatar_id).OrderByDescending(x => x.Sum(y => y.value)).ToList(); //verify the winner is still alive and still in this neighborhood string name = ""; diff --git a/TSOClient/FSO.Server/Servers/City/Handlers/MailHandler.cs b/TSOClient/FSO.Server/Servers/City/Handlers/MailHandler.cs index 5c87c52e5..de515a0b0 100644 --- a/TSOClient/FSO.Server/Servers/City/Handlers/MailHandler.cs +++ b/TSOClient/FSO.Server/Servers/City/Handlers/MailHandler.cs @@ -279,6 +279,12 @@ public enum NeighMailStrings : int Failsafe = 32, NominationNotAcceptedSubject = 33, - NominationNotAccepted = 34 + NominationNotAccepted = 34, + + FreeVoteSubject = 35, + FreeVote = 36, + + FreeVoteConfirmationSubject = 37, + FreeVoteConfirmation = 38 } } diff --git a/TSOClient/FSO.Server/Servers/City/Handlers/NhoodHandler.cs b/TSOClient/FSO.Server/Servers/City/Handlers/NhoodHandler.cs index be65702d3..84c92af58 100644 --- a/TSOClient/FSO.Server/Servers/City/Handlers/NhoodHandler.cs +++ b/TSOClient/FSO.Server/Servers/City/Handlers/NhoodHandler.cs @@ -69,8 +69,9 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) if (session.IsAnonymous) //CAS users can't do this. return; - var moveTime = Context.Config.Neighborhoods.Election_Move_Penalty * 24 * 60 * 60; //24 * 60 * 60 * 30; //must live in an nhood 30 days before participating in an election - var rateMoveTime = Context.Config.Neighborhoods.Rating_Move_Penalty * 24 * 60 * 60; //24 * 60 * 60 * 30; //must live in an nhood 30 days before participating in an election + var config = Context.Config.Neighborhoods; + var moveTime = config.Election_Move_Penalty * 24 * 60 * 60; //24 * 60 * 60 * 30; //must live in an nhood 30 days before participating in an election + var rateMoveTime = config.Rating_Move_Penalty * 24 * 60 * 60; //24 * 60 * 60 * 30; //must live in an nhood 30 days before participating in an election var mail = Kernel.Get(); try @@ -107,6 +108,14 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) var myLotID = da.Roommates.GetAvatarsLots(session.AvatarId).FirstOrDefault(); var myLot = (myLotID == null) ? null : da.Lots.Get(myLotID.lot_id); + var freeVote = da.Elections.GetFreeVote(session.AvatarId); + var freeNhood = (int)(myLot?.neighborhood_id ?? 0); + var voteValue = (freeVote == null) ? config.Vote_Normal_Value : config.Vote_Free_Value; + if (freeVote != null) + { + freeNhood = freeVote.neighborhood_id; + } + switch (packet.Type) { //user requests @@ -180,8 +189,8 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) case NhoodRequestType.CAN_VOTE: case NhoodRequestType.VOTE: { - - if (myLot == null || myLot.neighborhood_id != packet.TargetNHood) + if (freeVote != null) packet.TargetNHood = (uint)freeNhood; + if (myLot == null || freeNhood != packet.TargetNHood) { session.Write(Code(NhoodResponseCode.NOT_IN_NHOOD)); return; } @@ -191,7 +200,7 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) { session.Write(Code(NhoodResponseCode.YOU_MOVED_RECENTLY)); return; } - + //check if voting cycle in correct state var nhood = da.Neighborhoods.Get(packet.TargetNHood); if (nhood == null) @@ -220,7 +229,7 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) { session.Write(Code(NhoodResponseCode.INVALID_AVATAR)); return; } - var targLotID = da.Roommates.GetAvatarsLots(session.AvatarId).FirstOrDefault(); + var targLotID = da.Roommates.GetAvatarsLots(packet.TargetAvatar).FirstOrDefault(); var targLot = (targLotID == null) ? null : da.Lots.Get(targLotID.lot_id); if (targLot == null || targLot.neighborhood_id != packet.TargetNHood) @@ -255,7 +264,7 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) if (existing.from_avatar_id != session.AvatarId) session.Write(Code(NhoodResponseCode.ALREADY_VOTED_SAME_IP)); //couldn't vote due to relation else - session.Write(Code(NhoodResponseCode.ALREADY_VOTED)); + session.Write(Code(NhoodResponseCode.ALREADY_VOTED)); } if (packet.Type == NhoodRequestType.CAN_VOTE) @@ -264,7 +273,7 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) //send back the candidate list - + var sims = da.Elections.GetCandidates(cycle.cycle_id); var result = new List(); @@ -309,7 +318,8 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) election_cycle_id = cycle.cycle_id, from_avatar_id = session.AvatarId, target_avatar_id = packet.TargetAvatar, - type = DbElectionVoteType.vote + type = DbElectionVoteType.vote, + value = voteValue }); if (!success) { @@ -337,14 +347,14 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) if (packet.Type == NhoodRequestType.CAN_NOMINATE) { //we are allowed to nominate. - + //send back the list of sims in nhood var sims = da.Avatars.GetPossibleCandidatesNhood((uint)packet.TargetNHood); var result = sims.Select(x => new NhoodCandidate() { ID = x.avatar_id, Name = x.name, - Rating = (x.rating == null) ? uint.MaxValue: (uint)((x.rating / 2) * 100) + Rating = (x.rating == null) ? uint.MaxValue : (uint)((x.rating / 2) * 100) }); session.Write(new NhoodCandidateList() @@ -363,7 +373,8 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) election_cycle_id = cycle.cycle_id, from_avatar_id = session.AvatarId, target_avatar_id = packet.TargetAvatar, - type = DbElectionVoteType.nomination + type = DbElectionVoteType.nomination, + value = voteValue }); if (!success) { @@ -373,7 +384,7 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) //if >= 3 nominations, allow the player to run for election. var noms = da.Elections.GetCycleVotesForAvatar(packet.TargetAvatar, cycle.cycle_id, DbElectionVoteType.nomination); - if (noms.Count() >= Context.Config.Neighborhoods.Min_Nominations) + if (noms.Count() >= config.Min_Nominations) { var created = da.Elections.CreateCandidate(new DbElectionCandidate() { candidate_avatar_id = packet.TargetAvatar, @@ -430,7 +441,7 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) //have we been nominated the minimum number of times? (3) var noms = da.Elections.GetCycleVotesForAvatar(session.AvatarId, cycle.cycle_id, DbElectionVoteType.nomination); - if (noms.Count < Context.Config.Neighborhoods.Min_Nominations) + if (noms.Count < config.Min_Nominations) { session.Write(Code(NhoodResponseCode.NOBODY_NOMINATED_YOU_IDIOT)); return; } @@ -463,7 +474,108 @@ public async void Handle(IVoltronSession session, NhoodRequest packet) } return; } - //management + + case NhoodRequestType.CAN_FREE_VOTE: + case NhoodRequestType.FREE_VOTE: + { + //should live somewhere: + if (myLot == null) + { + session.Write(Code(NhoodResponseCode.MISSING_ENTITY)); return; + } + + //we shouldn't already be enrolled + if (freeNhood != myLot.neighborhood_id) + { + session.Write(Code(NhoodResponseCode.ALREADY_ENROLLED_FOR_FREE_VOTE)); return; + } + + //our nhood needs to be ineligible + var ourNhood = da.Neighborhoods.Get(myLot.neighborhood_id); + if (ourNhood == null) + { + session.Write(Code(NhoodResponseCode.MISSING_ENTITY)); return; + } + + var cycle = (ourNhood.election_cycle_id == null) ? null : da.Elections.GetCycle(ourNhood.election_cycle_id.Value); + if (cycle != null && cycle.current_state != DbElectionCycleState.shutdown) + { + session.Write(Code(NhoodResponseCode.FREE_VOTE_ALREADY_ELIGIBLE)); return; + } + + //get eligible nhoods + var eligible = da.Elections.GetActiveCycles(Context.ShardId); + + if (eligible.Count == 0) + { + session.Write(Code(NhoodResponseCode.FREE_VOTE_ELECTION_OVER)); return; + } + + //our last move needs to have been before the nhood cycle started + var now = Epoch.Now; + if (eligible.All(x => x.start_date < myAva.move_date)) + { + session.Write(Code(NhoodResponseCode.FREE_VOTE_MOVE_DATE)); return; + } + + if (packet.Type == NhoodRequestType.CAN_FREE_VOTE) + { + //send the eligible nhoods + var result = eligible.Select(x => new NhoodCandidate() + { + ID = (uint)x.nhood_id, + Name = x.name, + Rating = uint.MaxValue + }); + + session.Write(new NhoodCandidateList() + { + NominationMode = true, + Candidates = result.ToList() + }); + + session.Write(Code(NhoodResponseCode.SUCCESS)); + return; + } + + //the target nhood needs to be eligible, and have an ongoing election + var targ = da.Neighborhoods.Get(packet.TargetNHood); + if (targ == null || targ.election_cycle_id == null) + { + session.Write(Code(NhoodResponseCode.MISSING_ENTITY)); + return; + } + + var targCycle = da.Elections.GetCycle((uint)targ.election_cycle_id); + if (targCycle == null || targCycle.current_state == DbElectionCycleState.shutdown) + { + session.Write(Code(NhoodResponseCode.NHOOD_NO_ELECTION)); + return; + } + + var fVote = new DbElectionFreeVote() { + avatar_id = session.AvatarId, + cycle_id = targCycle.cycle_id, + neighborhood_id = targ.neighborhood_id, + date = Epoch.Now, + expire_date = targCycle.end_date + }; + if (!da.Elections.EnrollFreeVote(fVote)) + { + session.Write(Code(NhoodResponseCode.ALREADY_ENROLLED_FOR_FREE_VOTE)); + return; + } + + mail.SendSystemEmail("f116", (int)NeighMailStrings.FreeVoteConfirmationSubject, (int)NeighMailStrings.FreeVoteConfirmation, + 1, MessageSpecialType.Normal, targCycle.end_date, session.AvatarId, targ.name); + + Nhoods.SendStateEmail(da, mail, targ, targCycle, session.AvatarId); + + session.Write(Code(NhoodResponseCode.SUCCESS)); + return; + } + + // ======= management ======= case NhoodRequestType.DELETE_RATE: var beforeDelete = da.Elections.GetRating(packet.Value); if (da.Elections.DeleteRating(packet.Value)) diff --git a/TSOClient/FSO.Server/Servers/Lot/LotServer.cs b/TSOClient/FSO.Server/Servers/Lot/LotServer.cs index 31211895b..5c85a4379 100644 --- a/TSOClient/FSO.Server/Servers/Lot/LotServer.cs +++ b/TSOClient/FSO.Server/Servers/Lot/LotServer.cs @@ -33,7 +33,7 @@ public LotServer(LotServerConfiguration config, Ninject.IKernel kernel) : base(c { this.Config = config; this.UnexpectedDisconnectWaitSeconds = 30; - this.TimeoutIfNoAuth = true; + this.TimeoutIfNoAuth = config.Timeout_No_Auth; Kernel.Bind().ToConstant(Config); Kernel.Bind().To().InSingletonScope(); diff --git a/TSOClient/FSO.Server/Servers/Lot/LotServerConfiguration.cs b/TSOClient/FSO.Server/Servers/Lot/LotServerConfiguration.cs index d3adbbe55..3bf654279 100644 --- a/TSOClient/FSO.Server/Servers/Lot/LotServerConfiguration.cs +++ b/TSOClient/FSO.Server/Servers/Lot/LotServerConfiguration.cs @@ -13,6 +13,7 @@ public class LotServerConfiguration : AbstractAriesServerConfig public string SimNFS; public int RingBufferSize = 10; + public bool Timeout_No_Auth = true; //Which cities to provide lot hosting for public LotServerConfigurationCity[] Cities; diff --git a/TSOClient/FSO.Server/Servers/UserApi/ApiServerConfiguration.cs b/TSOClient/FSO.Server/Servers/UserApi/ApiServerConfiguration.cs index e2113eacc..f1825206a 100644 --- a/TSOClient/FSO.Server/Servers/UserApi/ApiServerConfiguration.cs +++ b/TSOClient/FSO.Server/Servers/UserApi/ApiServerConfiguration.cs @@ -49,6 +49,7 @@ public class ApiServerConfiguration public bool UseProxy { get; set; } = true; public AWSConfig AwsConfig { get; set; } + public GithubConfig GithubConfig { get; set; } } public enum ApiServerControllers diff --git a/TSOClient/FSO.Server/ToolRestoreLots.cs b/TSOClient/FSO.Server/ToolRestoreLots.cs new file mode 100644 index 000000000..58d212569 --- /dev/null +++ b/TSOClient/FSO.Server/ToolRestoreLots.cs @@ -0,0 +1,342 @@ +using FSO.Common.Enum; +using FSO.Server.Database.DA; +using FSO.Server.Database.DA.Lots; +using FSO.Server.Database.DA.Objects; +using FSO.SimAntics; +using FSO.SimAntics.Marshals; +using FSO.SimAntics.Model.TSOPlatform; +using NLog; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace FSO.Server +{ + public class ToolRestoreLots : ITool + { + private static Logger LOG = LogManager.GetCurrentClassLogger(); + private IDAFactory DAFactory; + private RestoreLotsOptions Options; + private ServerConfiguration Config; + + // avatar ids that we have verified exist + private Dictionary AvatarIDs = new Dictionary(); + private Dictionary PersistRemap = new Dictionary(); + private uint OwnerID; + + public ToolRestoreLots(ServerConfiguration config, RestoreLotsOptions options, IDAFactory factory) + { + this.Options = options; + this.DAFactory = factory; + this.Config = config; + } + + private void Done() + { + if (!Options.Report) Console.WriteLine("Done!"); + else Console.WriteLine("(skipped)"); + } + + private void ReplaceOBJID(List entities, uint oldPID, uint newPID) + { + foreach (var obj in entities) + { + if (obj.PersistID == oldPID) obj.PersistID = newPID; + } + } + + private uint RemapAvatarID(IDA da, uint avatarID) + { + if (avatarID == 0) return 0; + uint remapped; + if (!AvatarIDs.TryGetValue(avatarID, out remapped)) + { + var ava = da.Avatars.Get(avatarID); + if (ava == null) + { + Console.WriteLine($"(could not find avatar {avatarID}, replacing with owner id {OwnerID})"); + AvatarIDs[avatarID] = OwnerID; + remapped = OwnerID; + } + else + { + AvatarIDs[avatarID] = avatarID; + remapped = avatarID; + } + } + return remapped; + } + + private void CreateDbObject(IDA da, VMEntityMarshal entity, DbLot lot) + { + var ownerID = ((VMTSOObjectState)entity.PlatformState).OwnerID; + var obj = new DbObject() + { + budget = (int)((VMTSOEntityState)entity.PlatformState).Budget.Value, + type = (entity.MasterGUID == 0) ? entity.GUID : entity.MasterGUID, + lot_id = lot.lot_id, + owner_id = (ownerID == 0) ? (uint?)null : ownerID, + shard_id = lot.shard_id, + dyn_obj_name = "", //get from multitile? + value = 0 //get from multitile? + }; + if (!Options.Report) + { + uint id = da.Objects.Create(obj); + PersistRemap[entity.PersistID] = id; + entity.PersistID = id; + } + } + + public int Run() + { + if (Options.RestoreFolder == null) + { + Console.WriteLine("Please pass: "); + return 1; + } + Console.WriteLine("Scanning content, please wait..."); + + VMContext.InitVMConfig(false); + Content.Content.Init(Config.GameLocation, Content.ContentMode.SERVER); + + Console.WriteLine($"Starting property restore - scanning { Options.RestoreFolder }..."); + + if (!Directory.Exists(Options.RestoreFolder)) + { + Console.WriteLine($"Could not find the given directory: { Options.RestoreFolder }"); + return 1; + } + + var files = Directory.EnumerateFiles(Options.RestoreFolder).Where(x => x.ToLowerInvariant().EndsWith(".fsov")).ToList(); + + if (files.Count == 0) + { + Console.WriteLine($"Specified folder did not contain any lot saves (*.fsov). Note that blueprint .xmls are not supported."); + return 1; + } + + using (var da = DAFactory.Get()) + { + foreach (var file in files) + { + Console.WriteLine($"===== { Path.GetFileName(file) } ====="); + + var data = File.ReadAllBytes(file); + + var vm = new VMMarshal(); + VMTSOLotState state; + try + { + using (var mem = new MemoryStream(data)) + { + vm.Deserialize(new BinaryReader(mem)); + } + state = (VMTSOLotState)vm.PlatformState; + } + catch (Exception e) + { + Console.WriteLine($"Could not read FSOV. ({e.Message}) Continuing..."); + continue; + } + + var lot = new DbLot(); + lot.name = state.Name; + lot.location = state.LotID; + lot.description = "Restored from FSOV"; + if (state.PropertyCategory == 255) state.PropertyCategory = 11; + lot.category = (LotCategory)state.PropertyCategory; + lot.owner_id = RemapAvatarID(da, state.OwnerID); + lot.neighborhood_id = state.NhoodID; + lot.ring_backup_num = 0; + lot.shard_id = Options.ShardId; + lot.skill_mode = state.SkillMode; + var random = new Random(); + + OwnerID = lot.owner_id ?? 0; + if (lot.owner_id == 0) lot.owner_id = null; + + Console.WriteLine($"Attempting to restore '{state.Name}', at location {lot.location}."); + var originalName = lot.name; + int addedOffset = 1; + var existingName = da.Lots.GetByName(lot.shard_id, lot.name); + while (existingName != null) + { + lot.name = originalName + " (" + (addedOffset++) + ")"; + Console.WriteLine($"Lot already exists with name {originalName}. Trying with name {lot.name}."); + existingName = da.Lots.GetByName(lot.shard_id, lot.name); + } + + var existingLocation = da.Lots.GetByLocation(Options.ShardId, lot.location); + while (existingLocation != null) + { + lot.location = (uint)(random.Next(512) | (random.Next(512) << 16)); + Console.WriteLine($"Lot already exists at location {existingLocation.location}. Placing at random location {lot.location}."); + existingLocation = da.Lots.GetByLocation(Options.ShardId, lot.location); + } + + var objectFromInventory = 0; + var objectFromLot = 0; + var objectCreate = 0; + var objectIgnore = 0; + + string lotFolder = "./"; + if (!Options.Report) + { + Console.WriteLine($"Creating database entry for lot..."); + try + { + lot.lot_id = (int)da.Lots.Create(lot); + Console.WriteLine($"Database entry for lot created! (ID {lot.lot_id})"); + } + catch (Exception e) + { + Console.WriteLine("FATAL! Could not create lot in database."); + Console.WriteLine(e.ToString()); + continue; + } + + Console.WriteLine($"Creating and populating data folder for lot..."); + try + { + lotFolder = Path.Combine(Config.SimNFS, $"Lots/{lot.lot_id.ToString("x8")}/"); + Directory.CreateDirectory(lotFolder); + } + catch (Exception e) + { + Console.WriteLine("FATAL! Could not create lot data in NFS."); + Console.WriteLine(e.ToString()); + continue; + } + } + + foreach (var obj in vm.Entities) + { + var estate = obj.PlatformState as VMTSOObjectState; + if (estate != null) + { + estate.OwnerID = RemapAvatarID(da, estate.OwnerID); //make sure the owners exist + } + } + + //check the objects + var processed = new HashSet(); + foreach (var obj in vm.Entities) + { + if (obj.PersistID == 0 || processed.Contains(obj.PersistID) || obj is VMAvatarMarshal) + { + if (PersistRemap.ContainsKey(obj.PersistID)) + { + obj.PersistID = PersistRemap[obj.PersistID]; + } + continue; + } + processed.Add(obj.PersistID); + + try + { + //does this object exist in the database? + var dbObj = da.Objects.Get(obj.PersistID); + var guid = (obj.MasterGUID == 0) ? obj.GUID : obj.MasterGUID; + if (dbObj == null) + { + Console.Write("++"); + Console.Write(guid); + Console.Write(": Does not exist in DB. Creating new entry..."); + objectCreate++; + CreateDbObject(da, obj, lot); + Done(); + } + else + { + if (dbObj.lot_id != null) + { + Console.Write("!!"); + Console.Write(dbObj.dyn_obj_name ?? dbObj.type.ToString()); + Console.Write(": In another property! "); + if (Options.Safe || Options.Objects) + { + if (Options.Objects) + { + Console.Write("Creating a new entry..."); + objectCreate++; + CreateDbObject(da, obj, lot); + Done(); + } + else + { + Console.WriteLine("Object will be ignored."); + objectIgnore++; + } + } + else + { + Console.Write("Taking the object back..."); + objectFromLot++; + if (!Options.Report) da.Objects.SetInLot(obj.PersistID, (uint)lot.lot_id); + Done(); + } + } + else + { + Console.Write("~~"); + Console.Write(dbObj.dyn_obj_name ?? dbObj.type.ToString()); + Console.Write(": In a user's inventory. "); + if (dbObj.type != guid) Console.Write("(WRONG GUID - MAKING NEW OBJECT) "); + if (Options.Objects || dbObj.type != guid) + { + Console.Write("Creating a new entry..."); + objectCreate++; + CreateDbObject(da, obj, lot); + Done(); + } + else + { + Console.Write("Taking the object back..."); + objectFromInventory++; + if (!Options.Report) da.Objects.SetInLot(obj.PersistID, (uint)lot.lot_id); + Done(); + } + } + } + } catch (Exception e) + { + Console.WriteLine($"Failed - {e.Message}. Continuing..."); + } + } + + Console.WriteLine($"Objects created: {objectCreate}, Objects from inventory: {objectFromInventory}, Objects from other lot: {objectFromLot}, Objects ignored: {objectIgnore}"); + Console.WriteLine($"Object/lot owner avatars missing (replaced with lot owner): {AvatarIDs.Count(x => x.Key != x.Value)}"); + Console.WriteLine("Object scan complete! Serializing restored state..."); + byte[] newData; + using (var mem = new MemoryStream()) + { + using (var writer = new BinaryWriter(mem)) + { + vm.SerializeInto(writer); + newData = mem.ToArray(); + } + } + Console.WriteLine("New FSOV created. Finalizing restore..."); + + if (!Options.Report) + { + File.WriteAllBytes(Path.Combine(lotFolder, "state_0.fsov"), newData); + Console.WriteLine($"Restoring {Path.GetFileName(file)} complete!"); + da.Lots.UpdateRingBackup(lot.lot_id, 0); + } + else + { + Console.WriteLine($"Report for {Path.GetFileName(file)} complete!"); + } + } + } + Console.WriteLine("All properties processed. Press any key to exit."); + Console.ReadKey(); + return 0; + } + } +} diff --git a/TSOClient/FSO.Windows/FSO.Windows.csproj b/TSOClient/FSO.Windows/FSO.Windows.csproj index 8154e7755..f3998173c 100644 --- a/TSOClient/FSO.Windows/FSO.Windows.csproj +++ b/TSOClient/FSO.Windows/FSO.Windows.csproj @@ -72,6 +72,10 @@ true + + ..\packages\MonoGame.Framework.Portable.3.6.0.1625\lib\portable-net45+win8+wpa81\MonoGame.Framework.dll + False + @@ -100,6 +104,7 @@ Resources.resx True + SettingsSingleFileGenerator Settings.Designer.cs @@ -116,11 +121,6 @@ - - {6d75e618-19ca-4c51-9546-f10965fbc0b8} - MonoGame.Framework.WindowsGL - True - {635e68fa-3905-4943-b4f5-d463a8c02e87} FSO.Client diff --git a/TSOClient/FSO.Windows/packages.config b/TSOClient/FSO.Windows/packages.config new file mode 100644 index 000000000..78caa928a --- /dev/null +++ b/TSOClient/FSO.Windows/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/TSOClient/tso.client/Controllers/NeighborhoodActionController.cs b/TSOClient/tso.client/Controllers/NeighborhoodActionController.cs index a17c7c352..e8a93e8be 100644 --- a/TSOClient/tso.client/Controllers/NeighborhoodActionController.cs +++ b/TSOClient/tso.client/Controllers/NeighborhoodActionController.cs @@ -135,6 +135,17 @@ public void PretendDate(uint date, Callback callback) Callbacks.Add(callback); } + public void BeginFreeVote(uint nhoodID, Callback callback) + { + if (Blocked) return; + Blocked = true; + ConnectionReg.MakeRequest(new NhoodRequest() + { + Type = NhoodRequestType.CAN_FREE_VOTE + }); + Callbacks.Add(callback); + } + private void ResolveCallbacks(NhoodResponseCode code) { GameThread.InUpdate(() => @@ -169,7 +180,7 @@ private void Regulator_OnError(object data) else if (response.Code == NhoodResponseCode.UNKNOWN_ERROR) { errorTitle = GameFacade.Strings.GetString("f117", "1"); - errorBody = GameFacade.Strings.GetString("f117", "23"); + errorBody = GameFacade.Strings.GetString("f117", "28"); if (response.Message != "") { errorBody += "\n\n" + response.Message; @@ -349,6 +360,44 @@ private void Regulator_OnTransition(string state, object data) BlockingDialog.Opacity = 1; break; + + case NhoodRequestType.CAN_FREE_VOTE: + //free vote dialog + + var freeCont = new UINominationSelectContainer(ConnectionReg.CandidateList, true); + BlockingDialog = UIScreen.GlobalShowAlert(new UIAlertOptions() + { + Title = GameFacade.Strings.GetString("f118", "25"), + Message = GameFacade.Strings.GetString("f118", "26"), + Width = 440, + GenericAddition = freeCont, + Buttons = new UIAlertButton[] { + new UIAlertButton(UIAlertButtonType.OK, (btn2) => { + var newReq = freeCont.GetRequest(req); + if (newReq != null) { + UIAlert.YesNo( + GameFacade.Strings.GetString("f118", "27"), + GameFacade.Strings.GetString("f118", "28", new string[] { freeCont.SelectedCandidate.Name }), + true, + (result) => + { + if (result) ConnectionReg.MakeRequest(newReq); + } + ); + } + else + { + //tell user they should select a neighborhood + } + }, GameFacade.Strings.GetString("f118", "29")), + new UIAlertButton(UIAlertButtonType.Cancel, (btn2) => { + ConnectionReg.AsyncReset(); + }) + } + }, false); + BlockingDialog.Opacity = 1; + break; + default: //something went terribly wrong - we don't have any dialog to handle this. ConnectionReg.AsyncReset(); diff --git a/TSOClient/tso.client/UI/Panels/LotControls/UICheatHandler.cs b/TSOClient/tso.client/UI/Panels/LotControls/UICheatHandler.cs index 1792c20a7..4875eee14 100644 --- a/TSOClient/tso.client/UI/Panels/LotControls/UICheatHandler.cs +++ b/TSOClient/tso.client/UI/Panels/LotControls/UICheatHandler.cs @@ -1,11 +1,13 @@ using FSO.Client.UI.Framework; using FSO.Common; using FSO.Common.Rendering.Framework.Model; +using FSO.Common.Utils; using FSO.LotView.LMap; using FSO.LotView.Model; using FSO.SimAntics; using FSO.SimAntics.NetPlay.Model; using FSO.SimAntics.NetPlay.Model.Commands; +using FSO.SimAntics.Test; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using System; @@ -27,6 +29,8 @@ private VM vm private UpdateState LastState; private Texture2D DebugTexture; + private GameThreadInterval CollisionTestInterval; + private float tod = 0; public UICheatHandler(UILotControl owner) { @@ -135,9 +139,9 @@ public void SubmitCommand(string msg) { case "roomat": //!roomat - LotTilePos targetrPos = new LotTilePos((short)(tilePos.X*16), (short)(tilePos.Y*16), vm.Context.World.State.Level); + LotTilePos targetrPos = new LotTilePos((short)(tilePos.X * 16), (short)(tilePos.Y * 16), vm.Context.World.State.Level); var room = vm.Context.GetRoomAt(targetrPos); - response += "Room at (" + targetrPos.TileX + ", " + targetrPos.TileY + ", " + targetrPos.Level + ") is "+room+"\r\n"; + response += "Room at (" + targetrPos.TileX + ", " + targetrPos.TileY + ", " + targetrPos.Level + ") is " + room + "\r\n"; var roomInfo = vm.Context.RoomInfo[room]; foreach (var obj in roomInfo.Room.AdjRooms) { @@ -151,7 +155,7 @@ public void SubmitCommand(string msg) LotTilePos targetPos = LotTilePos.FromBigTile((short)tilePos.X, (short)tilePos.Y, vm.Context.World.State.Level); if (args == "oow") targetPos = LotTilePos.OUT_OF_WORLD; var objs = vm.Context.ObjectQueries.GetObjectsAt(targetPos); - response += "Objects at (" + targetPos.TileX + ", " + targetPos.TileY + ", " + targetPos.Level + ")\r\n"; + response += "Objects at (" + targetPos.TileX + ", " + targetPos.TileY + ", " + targetPos.Level + ")\r\n"; foreach (var obj in objs) { response += ObjectSummary(obj); @@ -172,6 +176,21 @@ public void SubmitCommand(string msg) SimAntics.Engine.VMRoutingFrame.DEBUG_DRAW = on; response += "Debug Routes Set: " + on; break; + case "vc": + case "validcollision": + if (CollisionTestInterval != null) + { + CollisionTestInterval.Clear(); + CollisionTestInterval = null; + response += "Collision validator is now disabled."; + } + else + { + var collisionValidator = new CollisionTestUtils(); + CollisionTestInterval = GameThread.SetInterval(() => { collisionValidator.VerifyAllCollision(vm); }, 1000); + response += "Collision validator is now running every second. An exception will be thrown if collision state is inconsistent."; + } + break; default: response += "Unknown command."; break; diff --git a/TSOClient/tso.client/UI/Panels/Neighborhoods/UINominationSelectContainer.cs b/TSOClient/tso.client/UI/Panels/Neighborhoods/UINominationSelectContainer.cs index 83e26323c..eefc68501 100644 --- a/TSOClient/tso.client/UI/Panels/Neighborhoods/UINominationSelectContainer.cs +++ b/TSOClient/tso.client/UI/Panels/Neighborhoods/UINominationSelectContainer.cs @@ -25,6 +25,7 @@ public class UINominationSelectContainer : UIContainer public UITextBox SearchBox; private NhoodCandidateList Candidates; + private bool NonPerson; public NhoodCandidate SelectedCandidate { @@ -33,9 +34,14 @@ public NhoodCandidate SelectedCandidate return RoommateListBox.SelectedItem?.Data as NhoodCandidate; } } + public UINominationSelectContainer(NhoodCandidateList candidates) : this(candidates, false) + { + + } - public UINominationSelectContainer(NhoodCandidateList candidates) + public UINominationSelectContainer(NhoodCandidateList candidates, bool nonPerson) { + NonPerson = nonPerson; var ui = RenderScript("fsodonatorlist.uis"); var listBg = ui.Create("ListBoxBackground"); AddAt(0, listBg); @@ -43,18 +49,6 @@ public UINominationSelectContainer(NhoodCandidateList candidates) listBg.Width += 110; listBg.Height += 50; - /* - Dropdown = ui.Create("PullDownMenuSetup"); - Dropdown.OnSearch += (query) => - { - FindController()?.Search(query, false, (results) => - { - Dropdown.SetResults(results); - }); - }; - Dropdown.OnSelect += AddDonator; - Add(Dropdown);*/ - RoommateListSlider.AttachButtons(RoommateListScrollUpButton, RoommateScrollDownButton, 1); RoommateListBox.AttachSlider(RoommateListSlider); RoommateListBox.Columns[1].Alignment = Framework.TextAlignment.Left | Framework.TextAlignment.Middle; @@ -98,12 +92,23 @@ public override Rectangle GetBounds() public NhoodRequest GetRequest(NhoodRequest initial) { if (SelectedCandidate == null) return null; - return new NhoodRequest() + if (NonPerson) { - Type = NhoodRequestType.NOMINATE, - TargetNHood = initial.TargetNHood, - TargetAvatar = SelectedCandidate.ID - }; + return new NhoodRequest() + { + Type = NhoodRequestType.FREE_VOTE, + TargetNHood = SelectedCandidate.ID + }; + } + else + { + return new NhoodRequest() + { + Type = NhoodRequestType.NOMINATE, + TargetNHood = initial.TargetNHood, + TargetAvatar = SelectedCandidate.ID + }; + } } public void UpdateCandidateList(NhoodCandidateList candidates) @@ -113,12 +118,16 @@ public void UpdateCandidateList(NhoodCandidateList candidates) if (SearchBox.CurrentText != "") sims = sims.Where(x => x.Name.ToLowerInvariant().Contains(searchString)); RoommateListBox.Items = sims.OrderBy(x => x.Name).Select(x => { - var personBtn = new UIPersonButton() + UIPersonButton personBtn = null; + if (!NonPerson) { - AvatarId = x.ID, - FrameSize = UIPersonButtonSize.SMALL - }; - personBtn.LogicalParent = this; + personBtn = new UIPersonButton() + { + AvatarId = x.ID, + FrameSize = UIPersonButtonSize.SMALL + }; + personBtn.LogicalParent = this; + } UIRatingDisplay rating = null; if (x.Rating != uint.MaxValue) @@ -130,7 +139,7 @@ public void UpdateCandidateList(NhoodCandidateList candidates) } return new UIListBoxItem( x, - personBtn, + (object)personBtn ?? "", x.Name, "", (object)rating ?? "" diff --git a/TSOClient/tso.client/UI/Panels/UIMessageWindow.cs b/TSOClient/tso.client/UI/Panels/UIMessageWindow.cs index 0ae2116d7..19fc819a1 100644 --- a/TSOClient/tso.client/UI/Panels/UIMessageWindow.cs +++ b/TSOClient/tso.client/UI/Panels/UIMessageWindow.cs @@ -186,6 +186,9 @@ private void SpecialButton_OnButtonClick(UIElement button) case MessageSpecialType.AcceptNomination: controller.NeighborhoodProtocol.AcceptNominations(nhoodID, SpecialResult); break; + case MessageSpecialType.FreeVote: + controller.NeighborhoodProtocol.BeginFreeVote(nhoodID, SpecialResult); + break; } }); } @@ -208,6 +211,9 @@ private void SpecialResult(NhoodResponseCode code) case MessageSpecialType.AcceptNomination: message = GameFacade.Strings.GetString("f118", "19"); break; + case MessageSpecialType.FreeVote: + message = GameFacade.Strings.GetString("f118", "30"); + break; } UIAlert.Alert(title, message, true); diff --git a/TSOClient/tso.content/Content/UI/uitext/english.dir/_f116_neighmailstrings.cst b/TSOClient/tso.content/Content/UI/uitext/english.dir/_f116_neighmailstrings.cst index b7cd30c03..74c6c9955 100644 --- a/TSOClient/tso.content/Content/UI/uitext/english.dir/_f116_neighmailstrings.cst +++ b/TSOClient/tso.content/Content/UI/uitext/english.dir/_f116_neighmailstrings.cst @@ -65,4 +65,17 @@ Until next month, have fun!^ //player nominated but not accepted [sent once candidates are chosen] (nhood name, end date) 33 ^Nomination not Accepted!^ -34 ^You were nominated multiple times, but did not accept to run for mayor of %s in time. You can still vote for any of the candidates, and help decide who will become Mayor on %s. ^ \ No newline at end of file +34 ^You were nominated multiple times, but did not accept to run for mayor of %s in time. You can still vote for any of the candidates, and help decide who will become Mayor on %s. ^ + +//free-vote info +35 ^Free Vote Enrollment^ (nhood name) +36 ^Unfortunately, your neighborhood %s was not popular enough to have elections of its own... However, this means that your neighborhood is entirely neutral, meaning you are entitled to a Free Vote! + +A Free Vote allows you to vote for the mayor in one election, from any eligible neighborhood. This could be an election in a neighborhood that you visit often, or for a specific person you want to control a town hall for events and gatherings! Your vote won't count as much as someone who lives in that neighborhood, but it could still be enough to tip the scales in a candidate's favor, or get them on the ballot at all. Isn't democracy exciting? + +Click the button below to choose a neighborhood to free-vote in. Once you are signed up to a neighborhood's election, you will receive nomination and election ballots for that neighborhood. Note that you cannot change your election neighborhood until the next voting cycle!^ + +37 ^Free Vote Confirmation^ (nhood name) +38 ^Thank you for signing up for the Free Vote! Democracy would offer you a firm handshake, if it existed in any physical form. + +Nominations and Votes you cast in this election cycle will be for the neighborhood %s. Choose your candidates wisely!^ \ No newline at end of file diff --git a/TSOClient/tso.content/Content/UI/uitext/english.dir/_f117_neighprotocolstrings.cst b/TSOClient/tso.content/Content/UI/uitext/english.dir/_f117_neighprotocolstrings.cst index 6dcef78ef..744478421 100644 --- a/TSOClient/tso.content/Content/UI/uitext/english.dir/_f117_neighprotocolstrings.cst +++ b/TSOClient/tso.content/Content/UI/uitext/english.dir/_f117_neighprotocolstrings.cst @@ -30,4 +30,10 @@ Reason: %s^ 21 ^The sim you are attempting to vote for is currently unable to participate in elections.^ 22 ^An entity required by this action could not be found - perhaps something was deleted while we were working. Try again later.^ -23 ^An unknown error has occured. This has been logged on the server, so you should probably report the issue and what you were requesting so we can look into it.^ \ No newline at end of file +23 ^The neighborhood you selected does not have an ongoing election. Please try again.^ +24 ^You are already enrolled for a free vote in this election cycle. Check your confirmation email for more information!^ +25 ^Your neighborhood has its own elections, so you cannot perform a free vote.^ +26 ^You are not allowed to enroll for a free vote if you've moved neighborhood after the election cycle started. Sorry!^ +27 ^There are no elections you can use a Free Vote in. Please try again later.^ + +28 ^An unknown error has occured. This has been logged on the server, so you should probably report the issue and what you were requesting so we can look into it.^ \ No newline at end of file diff --git a/TSOClient/tso.content/Content/UI/uitext/english.dir/_f118_votedialogstrings.cst b/TSOClient/tso.content/Content/UI/uitext/english.dir/_f118_votedialogstrings.cst index 3826377c3..240662a29 100644 --- a/TSOClient/tso.content/Content/UI/uitext/english.dir/_f118_votedialogstrings.cst +++ b/TSOClient/tso.content/Content/UI/uitext/english.dir/_f118_votedialogstrings.cst @@ -54,3 +54,11 @@ Before running, please review the rules on http://freeso.org/nhoodrules . Specif //ratings dialog 23 ^Ratings for %s^ 24 ^Listed are ratings for avatar %s, from best to worst. Ratings were given when this user was mayor of a neighborhood, either now or any time in the past.^ + +//free vote nhood dialog +25 ^Choose Neighborhood for Free Vote^ +26 ^You will participate in the chosen neighborhood's election for the rest of the voting cycle. Note that sims from the same real-world household cannot vote and nominate in the same election, and you cannot change your Free Vote neighborhood until the end of the election.^ +27 ^Confirm Free Vote Neighborhood^ +28 ^Are you sure you want to vote in the election for %s? It might be a good idea to think of which residents you might vote for before confirming.^ +29 ^Select^ +30 ^You have successfully enrolled for the free vote! You should recieve a confirmation email shortly.^ \ No newline at end of file diff --git a/TSOClient/tso.content/Content/UI/uitext/english.dir/_f119_specialemailstrings.cst b/TSOClient/tso.content/Content/UI/uitext/english.dir/_f119_specialemailstrings.cst index 4cc2248cf..17d14433f 100644 --- a/TSOClient/tso.content/Content/UI/uitext/english.dir/_f119_specialemailstrings.cst +++ b/TSOClient/tso.content/Content/UI/uitext/english.dir/_f119_specialemailstrings.cst @@ -1,4 +1,5 @@ // buttons that appear in emails with special types. see MessageSpecialType in MessageItem.cs 1 ^Submit Nomination^ 2 ^Submit Vote^ -3 ^Run for Mayor^ \ No newline at end of file +3 ^Run for Mayor^ +4 ^Choose Free Vote Neighborhood^ \ No newline at end of file diff --git a/TSOClient/tso.content/Content/UI/uitext/english.dir/_f121_bulletinprotocolstrings.cst b/TSOClient/tso.content/Content/UI/uitext/english.dir/_f121_bulletinprotocolstrings.cst index 1e59b9987..c362ca9ec 100644 --- a/TSOClient/tso.content/Content/UI/uitext/english.dir/_f121_bulletinprotocolstrings.cst +++ b/TSOClient/tso.content/Content/UI/uitext/english.dir/_f121_bulletinprotocolstrings.cst @@ -15,4 +15,6 @@ 13 ^You cannot perform this action because you are not the Mayor of this neighborhood. Perhaps the Mayorship recently passed to someone else?^ 14 ^You cannot delete this post - it doesn't belong to you. Nice try though.^ 15 ^Calm down! This post has already been promoted to the Mayor section, you don't need to do it again.^ +16 ^You are not allowed to perform this action.^ + 16 ^You are not allowed to perform this action.^ \ No newline at end of file diff --git a/TSOClient/tso.files/Formats/tsodata/MessageItem.cs b/TSOClient/tso.files/Formats/tsodata/MessageItem.cs index a8a201eb1..75d4334b4 100644 --- a/TSOClient/tso.files/Formats/tsodata/MessageItem.cs +++ b/TSOClient/tso.files/Formats/tsodata/MessageItem.cs @@ -86,6 +86,7 @@ public enum MessageSpecialType Nominate = 1, Vote = 2, - AcceptNomination = 3 + AcceptNomination = 3, + FreeVote = 4 } } diff --git a/TSOClient/tso.simantics/FSO.SimAntics.csproj b/TSOClient/tso.simantics/FSO.SimAntics.csproj index a31c689ea..3ebc66bbd 100644 --- a/TSOClient/tso.simantics/FSO.SimAntics.csproj +++ b/TSOClient/tso.simantics/FSO.SimAntics.csproj @@ -384,6 +384,7 @@ + diff --git a/TSOClient/tso.simantics/Model/Routing/VMObstacleSet.cs b/TSOClient/tso.simantics/Model/Routing/VMObstacleSet.cs index de0d85889..3521f41dc 100644 --- a/TSOClient/tso.simantics/Model/Routing/VMObstacleSet.cs +++ b/TSOClient/tso.simantics/Model/Routing/VMObstacleSet.cs @@ -102,6 +102,18 @@ private void Reclaim(int index) else FreeList.Add(index); } + public List All() + { + var result = new List(); + var free = new HashSet(FreeList); + for (int i=0; i= Nodes.Length && FreeList.Count == 0) InitNodes(Nodes.Length * 2); diff --git a/TSOClient/tso.simantics/Test/CollisionTestUtils.cs b/TSOClient/tso.simantics/Test/CollisionTestUtils.cs new file mode 100644 index 000000000..7a4516809 --- /dev/null +++ b/TSOClient/tso.simantics/Test/CollisionTestUtils.cs @@ -0,0 +1,49 @@ +using FSO.SimAntics.Model.Routing; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace FSO.SimAntics.Test +{ + public class CollisionTestUtils + { + public void VerifyAllCollision(VM vm) + { + // verifies that static and dynamic obstacles are in a valid state + var context = vm.Context; + var allRooms = context.RoomInfo; + foreach (var room in allRooms) + { + var obs = room.StaticObstacles.All(); + var ents = new HashSet(room.Entities); + foreach (var node in obs) + { + var rect = node.Rect as VMEntityObstacle; + if (rect != null) + { + var ent = rect.Parent; + ents.Remove(rect.Parent); + + var footprint = ent.Footprint; + if (footprint != null) + { + if (rect.x1 != footprint.x1 || rect.x2 != footprint.x2 || rect.y1 != footprint.y1 || rect.y2 != footprint.y2) + { + throw new Exception("Static footprint mismatch with .Footprint on object!"); + } + } + + if (rect != ent.Footprint) throw new Exception("Out of date footprint in static!"); + if (!ent.StaticFootprint) throw new Exception("Object with dynamic footprint still present in static!"); + } + } + foreach (var ent in ents) + { + if (ent.StaticFootprint && ent.Footprint != null) throw new Exception("Object with static footprint missing from static list."); + } + } + } + } +}