public async Task QueueRequest(ConnectedUser user, MatchMakerQueueRequest cmd) { var banTime = BannedSeconds(user.Name); if (banTime != null) { await UpdatePlayerStatus(user.Name); await user.Respond($"Please rest and wait for {banTime}s because you refused previous match"); return; } //assure people don't rejoin (possibly accidentally) directly after starting a game if (server.Battles.Values.Any(x => x.IsInGame && DateTime.UtcNow.Subtract(x.RunningSince ?? DateTime.UtcNow).TotalMinutes < DynamicConfig.Instance.MmMinimumMinutesBetweenGames && x.spring.LobbyStartContext.Players.Count(p => !p.IsSpectator) > 1 && x.spring.LobbyStartContext.Players.Any(p => !p.IsSpectator && p.Name == user.Name))) { await UpdatePlayerStatus(user.Name); await user.Respond($"You have recently started a match. Please play for at least {DynamicConfig.Instance.MmMinimumMinutesBetweenGames} minutes before starting another match"); return; } DateTime player; lastTimePlayerDeniedMatch.TryRemove(user.Name, out player); //this player might be interested in suggestive MM games after all var wantedQueueNames = cmd.Queues?.ToList() ?? new List <string>(); var wantedQueues = PossibleQueues.Where(x => wantedQueueNames.Contains(x.Name)).ToList(); await AddOrUpdateUser(user, wantedQueues); }
public async Task ProcessPartyInviteResponse(ConnectedUser usr, PartyInviteResponse response) { RemoveOldInvites(); if (response.Accepted) { var inv = partyInvites.FirstOrDefault(x => x.PartyID == response.PartyID); if ((inv != null) && (inv.Invitee == usr.Name)) { var inviteeUser = usr; var inviterUser = server.ConnectedUsers.Get(inv.Inviter); if (inviterUser != null) { var targetBattle = inviterUser.MyBattle ?? inviteeUser.MyBattle; // join inviter user's battle, if its empty join invitee user's battle if (targetBattle != null) { if (inviteeUser.MyBattle != targetBattle) { await server.ForceJoinBattle(inviteeUser.Name, targetBattle); } if (inviterUser.MyBattle != targetBattle) { await server.ForceJoinBattle(inviterUser.Name, targetBattle); } } } var inviterParty = parties.FirstOrDefault(x => x.PartyID == response.PartyID); var inviteeParty = parties.FirstOrDefault(x => x.UserNames.Contains(usr.Name)); Party party = null; if ((inviterParty == null) && (inviteeParty != null)) { party = inviteeParty; } if ((inviterParty == null) && (inviteeParty == null)) { party = new Party(inv.PartyID); parties.Add(party); } if ((inviterParty != null) && (inviteeParty == null)) { party = inviterParty; } if ((inviterParty != null) && (inviteeParty != null)) { await RemoveFromParty(inviterParty, inv.Invitee); party = inviterParty; } await AddToParty(party, inv.Invitee, inv.Inviter); } } }
public async Task AreYouReadyResponse(ConnectedUser user, AreYouReadyResponse response) { PlayerEntry entry; if (players.TryGetValue(user.Name, out entry)) { if (entry.InvitedToPlay) { if (response.Ready) { entry.LastReadyResponse = true; } else { entry.LastReadyResponse = false; await RemoveUser(user.Name, true); } var invitedPeople = players.Values.Where(x => x?.InvitedToPlay == true).ToList(); if (invitedPeople.Count <= 1) { foreach (var p in invitedPeople) { p.LastReadyResponse = true; } // if we are doing tick because too few people, make sure we count remaining people as readied to not ban them OnTick(); } else if (invitedPeople.All(x => x.LastReadyResponse)) { OnTick(); } else { var readyCounts = CountQueuedPeople(invitedPeople.Where(x => x.LastReadyResponse)); var proposedBattles = ProposeBattles(invitedPeople.Where(x => x.LastReadyResponse)); await Task.WhenAll(invitedPeople.Select(async(p) => { var invitedBattle = invitationBattles?.FirstOrDefault(x => x.Players.Contains(p)); await server.SendToUser(p.Name, new AreYouReadyUpdate() { QueueReadyCounts = readyCounts, ReadyAccepted = p.LastReadyResponse == true, LikelyToPlay = proposedBattles.Any(y => y.Players.Contains(p)), YourBattleSize = invitedBattle?.Size, YourBattleReady = invitedPeople.Count(x => x.LastReadyResponse && (invitedBattle?.Players.Contains(x) == true)) }); })); } } } }
public async Task ProcessLeaveParty(ConnectedUser usr, LeaveParty msg) { var party = parties.FirstOrDefault(x => x.PartyID == msg.PartyID); if (party != null) { await RemoveFromParty(party, usr.Name); } }
public async Task OnLoginAccepted(ConnectedUser conus) { await conus.SendCommand(new MatchMakerSetup() { PossibleQueues = PossibleQueues }); await UpdatePlayerStatus(conus.Name); }
private async Task AddOrUpdateUser(ConnectedUser user, List <MatchMakerSetup.Queue> wantedQueues) { var party = server.PartyManager.GetParty(user.Name); if (party != null) { foreach (var p in party.UserNames) { var conUs = server.ConnectedUsers.Get(p); if (conUs != null) { players.AddOrUpdate(p, (str) => new PlayerEntry(conUs.User, wantedQueues, party), (str, usr) => { usr.UpdateTypes(wantedQueues); usr.Party = party; return(usr); }); } } } else { players.AddOrUpdate(user.Name, (str) => new PlayerEntry(user.User, wantedQueues, null), (str, usr) => { usr.UpdateTypes(wantedQueues); usr.Party = null; return(usr); }); } // if nobody is invited, we can do tick now to speed up things bool doUpdates = false; lock (tickLock) {//wait for running tick to finish first if (invitationBattles?.Any() != true) { OnTick(); } else { doUpdates = true; } } if (doUpdates) { await UpdateAllPlayerStatuses(); // else we just send statuses } }
public async Task ProcessInviteToParty(ConnectedUser usr, InviteToParty msg) { ConnectedUser target; if (server.ConnectedUsers.TryGetValue(msg.UserName, out target)) { if (target.Ignores.Contains(usr.Name)) { return; } var myParty = GetParty(usr.Name); var targetParty = GetParty(target.Name); if ((myParty != null) && (myParty == targetParty)) { return; } // if i dont have battle but target has, join him if (myParty == null && usr.MyBattle == null && target.MyBattle != null && !target.MyBattle.IsPassworded) { await server.ForceJoinBattle(usr.Name, target.MyBattle); } RemoveOldInvites(); var partyInvite = partyInvites.FirstOrDefault(x => (x.Inviter == usr.Name) && (x.Invitee == target.Name)); if (partyInvite == null) { partyInvite = new PartyInvite() { PartyID = myParty?.PartyID ?? Interlocked.Increment(ref partyCounter), Inviter = usr.Name, Invitee = target.Name }; partyInvites.Add(partyInvite); } await target.SendCommand(new OnPartyInvite() { PartyID = partyInvite.PartyID, UserNames = myParty?.UserNames?.ToList() ?? new List <string>() { usr.Name }, TimeoutSeconds = inviteTimeoutSeconds }); } }
public bool IsAdmin(ServerBattle battle, string userName) { UserBattleStatus ubs = null; battle.Users.TryGetValue(userName, out ubs); if (ubs != null) { return(ubs.LobbyUser.IsAdmin); } ConnectedUser con = null; battle.server.ConnectedUsers.TryGetValue(userName, out con); return(con?.User?.IsAdmin ?? false); }
public static bool HasSeen(ConnectedUser uWatcher, ConnectedUser uWatched) { if (uWatched == null || uWatcher == null) { return(true); } int lastSync; var newSync = uWatched.User.SyncVersion; if (!uWatcher.HasSeenUserVersion.TryGetValue(uWatched.Name, out lastSync) || lastSync != newSync) { uWatcher.HasSeenUserVersion[uWatched.Name] = newSync; return(false); } return(true); }
public async Task RequestConnectSpring(ConnectedUser conus, string joinPassword) { UserBattleStatus ubs; if (!Users.TryGetValue(conus.Name, out ubs) && !(IsInGame && spring.LobbyStartContext.Players.Any(x => x.Name == conus.Name))) { if (IsPassworded && (Password != joinPassword)) { await conus.Respond("Invalid password"); return; } } var pwd = GenerateClientScriptPassword(conus.Name); spring.AddUser(conus.Name, pwd, conus.User); await conus.SendCommand(GetConnectSpringStructure(pwd)); }
public async Task QueueRequest(ConnectedUser user, MatchMakerQueueRequest cmd) { var banTime = BannedSeconds(user.Name); if (banTime != null) { await UpdatePlayerStatus(user.Name); await user.Respond($"Please rest and wait for {banTime}s because you refused previous match"); return; } // already invited ignore requests PlayerEntry entry; if (players.TryGetValue(user.Name, out entry) && entry.InvitedToPlay) { await UpdatePlayerStatus(user.Name); return; } var wantedQueueNames = cmd.Queues?.ToList() ?? new List <string>(); var wantedQueues = possibleQueues.Where(x => wantedQueueNames.Contains(x.Name)).ToList(); var party = server.PartyManager.GetParty(user.Name); if (party != null) { wantedQueues = wantedQueues.Where(x => x.MaxSize / 2 >= party.UserNames.Count).ToList(); // if is in party keep only queues where party fits } if (wantedQueues.Count == 0) // delete { await RemoveUser(user.Name, true); return; } await AddOrUpdateUser(user, wantedQueues); }
public async Task Process(Login login) { var ret = await Task.Run(() => server.LoginChecker.DoLogin(login, RemoteEndpointIP, login.Dlc)); if (ret.LoginResponse.ResultCode == LoginResponse.Code.Ok) { var user = ret.User; //Trace.TraceInformation("{0} login: {1}", this, response.ResultCode.Description()); await this.SendCommand(user); // send self to self first connectedUser = server.ConnectedUsers.GetOrAdd(user.Name, (n) => new ConnectedUser(server, user)); connectedUser.User = user; connectedUser.Connections.TryAdd(this, true); // close other connections foreach (var otherConnection in connectedUser.Connections.Keys.Where(x => x != null && x != this).ToList()) { otherConnection.RequestClose(); bool oth; connectedUser.Connections.TryRemove(otherConnection, out oth); } server.SessionTokens[ret.LoginResponse.SessionToken] = user.AccountID; await SendCommand(ret.LoginResponse); // login accepted connectedUser.ResetHasSeen(); foreach (var b in server.Battles.Values.Where(x => x != null)) { await SendCommand(new BattleAdded() { Header = b.GetHeader() }); } // mutually syncs users based on visibility rules await server.TwoWaySyncUsers(Name, server.ConnectedUsers.Keys); server.OfflineMessageHandler.SendMissedMessagesAsync(this, SayPlace.User, Name, user.AccountID); var defChans = await server.ChannelManager.GetDefaultChannels(user.AccountID); defChans.AddRange(server.Channels.Where(x => x.Value.Users.ContainsKey(user.Name)).Select(x => x.Key)); // add currently connected channels to list too foreach (var chan in defChans.ToList().Distinct()) { await connectedUser.Process(new JoinChannel() { ChannelName = chan, Password = null }); } foreach (var bat in server.Battles.Values.Where(x => x != null && x.IsInGame)) { var s = bat.spring; if (s.LobbyStartContext.Players.Any(x => !x.IsSpectator && x.Name == Name) && !s.Context.ActualPlayers.Any(x => x.Name == Name && x.LoseTime != null)) { await SendCommand(new RejoinOption() { BattleID = bat.BattleID }); } } await SendCommand(new FriendList() { Friends = connectedUser.FriendEntries.ToList() }); await SendCommand(new IgnoreList() { Ignores = connectedUser.Ignores.ToList() }); await server.MatchMaker.OnLoginAccepted(connectedUser); await server.PlanetWarsMatchMaker.OnLoginAccepted(connectedUser); await SendCommand(server.NewsListManager.GetCurrentNewsList()); await SendCommand(server.LadderListManager.GetCurrentLadderList()); await SendCommand(server.ForumListManager.GetCurrentForumList(user.AccountID)); using (var db = new ZkDataContext()) { var acc = db.Accounts.Find(user.AccountID); if (acc != null) { await server.PublishUserProfileUpdate(acc); } } } else { await SendCommand(ret.LoginResponse); if (ret.LoginResponse.ResultCode == LoginResponse.Code.Banned) { await Task.Delay(500); // this is needed because socket writes are async and might not be queued yet await transport.Flush(); transport.RequestClose(); } } }
private async Task AddOrUpdateUser(ConnectedUser user, List <MatchMakerSetup.Queue> wantedQueues, bool massJoin = false) { // already invited ignore requests PlayerEntry entry; if (players.TryGetValue(user.Name, out entry) && entry.InvitedToPlay) { await UpdatePlayerStatus(user.Name); return; } var party = server.PartyManager.GetParty(user.Name); if (party != null) { wantedQueues = wantedQueues.Where(x => x.MaxSize / 2 >= party.UserNames.Count).ToList(); // if is in party keep only queues where party fits } if (wantedQueues.Count == 0) // delete { if (entry?.QueueTypes?.Count > 0 && entry?.QuickPlay == false) { await server.UserLogSay($"{user.Name} has left the matchmaker."); } await RemoveUser(user.Name, true); return; } if (party != null) { foreach (var p in party.UserNames) { var conUs = server.ConnectedUsers.Get(p); if (conUs != null) { players.AddOrUpdate(p, (str) => new PlayerEntry(conUs.User, wantedQueues, party), (str, usr) => { usr.UpdateTypes(wantedQueues); usr.Party = party; return(usr); }); } } } else { players.AddOrUpdate(user.Name, (str) => new PlayerEntry(user.User, wantedQueues, null), (str, usr) => { usr.UpdateTypes(wantedQueues); usr.Party = null; return(usr); }); } //if many people are joined simultaneously, wait until join is completed before sending updates or trying to create battles. if (massJoin) { return; } await server.UserLogSay($"{user.Name} has joined the following queues: {wantedQueues.Select(q => q.Name).StringJoin()}."); // if nobody is invited, we can do tick now to speed up things if (invitationBattles?.Any() != true) { OnTick(); } else { await UpdateAllPlayerStatuses(); // else we just send statuses } }
public async Task Process(Login login) { var ret = await Task.Run(() => server.LoginChecker.DoLogin(login, RemoteEndpointIP)); if (ret.LoginResponse.ResultCode == LoginResponse.Code.Ok) { var user = ret.User; //Trace.TraceInformation("{0} login: {1}", this, response.ResultCode.Description()); await this.SendCommand(user); // send self to self first connectedUser = server.ConnectedUsers.GetOrAdd(user.Name, (n) => new ConnectedUser(server, user)); connectedUser.User = user; connectedUser.Connections.TryAdd(this, true); server.SessionTokens[ret.LoginResponse.SessionToken] = user.AccountID; await SendCommand(ret.LoginResponse); // login accepted connectedUser.ResetHasSeen(); foreach (var b in server.Battles.Values.Where(x => x != null)) { await SendCommand(new BattleAdded() { Header = b.GetHeader() }); } // mutually syncs users based on visibility rules await server.TwoWaySyncUsers(Name, server.ConnectedUsers.Keys); server.OfflineMessageHandler.SendMissedMessagesAsync(this, SayPlace.User, Name, user.AccountID); var defChans = await server.ChannelManager.GetDefaultChannels(user.AccountID); defChans.AddRange(server.Channels.Where(x => x.Value.Users.ContainsKey(user.Name)).Select(x => x.Key)); // add currently connected channels to list too foreach (var chan in defChans.ToList().Distinct()) { await connectedUser.Process(new JoinChannel() { ChannelName = chan, Password = null }); } foreach (var bat in server.Battles.Values.Where(x => x != null && x.IsInGame)) { var s = bat.spring; if (s.LobbyStartContext.Players.Any(x => !x.IsSpectator && x.Name == Name) && !s.Context.ActualPlayers.Any(x => x.Name == Name && x.LoseTime != null)) { await SendCommand(new RejoinOption() { BattleID = bat.BattleID }); } } await SendCommand(new FriendList() { Friends = connectedUser.FriendEntries.ToList() }); await SendCommand(new IgnoreList() { Ignores = connectedUser.Ignores.ToList() }); await server.MatchMaker.OnLoginAccepted(connectedUser); await server.PlanetWarsMatchMaker.OnLoginAccepted(connectedUser); } else { await SendCommand(ret.LoginResponse); if (ret.LoginResponse.ResultCode == LoginResponse.Code.Banned) { transport.RequestClose(); } } }
public virtual async Task ProcessPlayerJoin(ConnectedUser user, string joinPassword) { if (IsPassworded && (Password != joinPassword)) { await user.Respond("Invalid password"); return; } if (IsKicked(user.Name)) { await KickFromBattle(user.Name, "Banned for five minutes"); return; } if ((user.MyBattle != null) && (user.MyBattle != this)) { await user.Process(new LeaveBattle()); } UserBattleStatus ubs; if (!Users.TryGetValue(user.Name, out ubs)) { ubs = new UserBattleStatus(user.Name, user.User, GenerateClientScriptPassword(user.Name)); Users[user.Name] = ubs; } ValidateBattleStatus(ubs); user.MyBattle = this; await server.TwoWaySyncUsers(user.Name, Users.Keys); // mutually sync user statuses await server.SyncUserToAll(user); await RecalcSpectators(); await user.SendCommand(new JoinBattleSuccess() { BattleID = BattleID, Players = Users.Values.Select(x => x.ToUpdateBattleStatus()).ToList(), Bots = Bots.Values.Select(x => x.ToUpdateBotStatus()).ToList(), Options = ModOptions }); await server.Broadcast(Users.Keys.Where(x => x != user.Name), ubs.ToUpdateBattleStatus()); // send my UBS to others in battle if (spring.IsRunning) { spring.AddUser(ubs.Name, ubs.ScriptPassword, ubs.LobbyUser); var started = DateTime.UtcNow.Subtract(spring.IngameStartTime ?? RunningSince ?? DateTime.UtcNow); started = new TimeSpan((int)started.TotalHours, started.Minutes, started.Seconds); await SayBattle($"THIS GAME IS CURRENTLY IN PROGRESS, PLEASE WAIT UNTIL IT ENDS! Running for {started}", ubs.Name); await SayBattle("If you say !notify, I will message you when the current game ends.", ubs.Name); } try { var ret = PlayerJoinHandler.AutohostPlayerJoined(GetContext(), ubs.LobbyUser.AccountID); if (ret != null) { if (!IsNullOrEmpty(ret.PrivateMessage)) { await SayBattle(ret.PrivateMessage, ubs.Name); } if (!IsNullOrEmpty(ret.PublicMessage)) { await SayBattle(ret.PublicMessage); } } } catch (Exception ex) { Trace.TraceError(ex.ToString()); await SayBattle("ServerManage error: " + ex); } }
public async Task AreYouReadyResponse(ConnectedUser user, AreYouReadyResponse response) { PlayerEntry entry; if (players.TryGetValue(user.Name, out entry)) { if (entry.InvitedToPlay) { if (response.Ready) { entry.LastReadyResponse = true; if (entry.QuickPlay) { await server.UserLogSay($"{user.Name} accepted his quickplay MM invitation"); } else { await server.UserLogSay($"{user.Name} accepted his pop-up MM invitation"); } } else { if (entry.QuickPlay) { await server.UserLogSay($"{user.Name} rejected his quickplay MM invitation"); entry.InvitedToPlay = false; //don't ban quickplayers } else { await server.UserLogSay($"{user.Name} rejected his pop-up MM invitation"); } lastTimePlayerDeniedMatch[entry.Name] = DateTime.UtcNow; //store that this player is probably not interested in suggestive MM games entry.LastReadyResponse = false; await RemoveUser(user.Name, true); } var invitedPeople = players.Values.Where(x => x?.InvitedToPlay == true).ToList(); if (invitedPeople.Count <= 1) { await server.UserLogSay($"Aborting MM invitations because only {invitedPeople.Count} invitations outstanding."); foreach (var p in invitedPeople) { p.LastReadyResponse = true; } // if we are doing tick because too few people, make sure we count remaining people as readied to not ban them OnTick(); } else if (invitedPeople.All(x => x.LastReadyResponse)) { await server.UserLogSay($"All {invitedPeople.Count} invitations have been accepted, doing tick."); OnTick(); } else { var readyCounts = CountQueuedPeople(invitedPeople.Where(x => x.LastReadyResponse)); var proposedBattles = ProposeBattles(invitedPeople.Where(x => x.LastReadyResponse), false); await Task.WhenAll(invitedPeople.Select(async(p) => { var invitedBattle = invitationBattles?.FirstOrDefault(x => x.Players.Contains(p)); await server.SendToUser(p.Name, new AreYouReadyUpdate() { QueueReadyCounts = readyCounts, ReadyAccepted = p.LastReadyResponse == true, LikelyToPlay = proposedBattles.Any(y => y.Players.Contains(p)), YourBattleSize = invitedBattle?.Size, YourBattleReady = invitedPeople.Count(x => x.LastReadyResponse && (invitedBattle?.Players.Contains(x) == true)) }); })); } } } }
public bool CanUserSee(ConnectedUser uWatcher, ConnectedUser uWatched) { if (uWatched == null || uWatcher == null) { return(false); } if (uWatched.Name == uWatcher.Name) { return(true); } // admins always visible if (uWatched.User?.IsAdmin == true) { return(true); } // friends see each other if (uWatcher.FriendNames.Contains(uWatched.Name)) { return(true); } // already seen, cannot be unseen if (uWatcher.HasSeenUserVersion.ContainsKey(uWatched.Name)) { return(true); } // clanmates see each other if (uWatcher.User?.Clan != null && uWatcher.User?.Clan == uWatched.User?.Clan) { return(true); } // people in same battle see each other if (uWatcher.MyBattle != null && uWatcher.MyBattle == uWatched.MyBattle) { return(true); } // people in same party see each other if (uWatcher.User?.PartyID != null && uWatcher.User.PartyID == uWatched.User?.PartyID) { return(true); } // people in same non "zk" channel see each other foreach (var chan in Channels.Values.Where(x => x != null)) { if (chan.Users.ContainsKey(uWatcher.Name)) // my channel { if (chan.IsDeluge) { if (GlobalConst.DelugeChannelDisplayUsers > 0) { var myEffectiveElo = uWatcher?.User?.EffectiveElo ?? 1200; var channelUsersBySkill = chan.Users.Keys.Select(x => ConnectedUsers.Get(x)).Where(x => x != null) .OrderBy(x => Math.Abs((x.User?.EffectiveMmElo ?? 1200) - myEffectiveElo)).Select(x => x.Name).Take(GlobalConst.DelugeChannelDisplayUsers); if (channelUsersBySkill.Contains(uWatched.Name)) { return(true); } } } else { if (chan.Users.ContainsKey(uWatched.Name)) { return(true); } } } } return(false); }
public async Task SyncUserToAll(ConnectedUser changer) { await Broadcast(ConnectedUsers.Values.Where(x => x != null).Where(x => CanUserSee(x, changer) && !HasSeen(x, changer)), changer.User); }
public async Task Process(Login login) { var user = new User(); var response = await Task.Run(() => state.LoginChecker.Login(user, login, this)); if (response.ResultCode == LoginResponse.Code.Ok) { connectedUser = state.ConnectedUsers.GetOrAdd(user.Name, (n) => new ConnectedUser(state, user)); connectedUser.Connections.TryAdd(this, true); connectedUser.User = user; Trace.TraceInformation("{0} login: {1}", this, response.ResultCode.Description()); await state.Broadcast(state.ConnectedUsers.Values, connectedUser.User); // send self to all await SendCommand(response); // login accepted foreach (var c in state.ConnectedUsers.Values.Where(x => x != connectedUser)) { await SendCommand(c.User); // send others to self } foreach (var b in state.Battles.Values) { if (b != null) { await SendCommand(new BattleAdded() { Header = new BattleHeader() { BattleID = b.BattleID, Engine = b.EngineVersion, Game = b.ModName, Founder = b.Founder.Name, Map = b.MapName, Ip = b.Ip, Port = b.HostPort, Title = b.Title, SpectatorCount = b.SpectatorCount, MaxPlayers = b.MaxPlayers, Password = b.Password != null ? "?" : null } }); foreach (var u in b.Users.Values.Select(x => x.ToUpdateBattleStatus()).ToList()) { await SendCommand(new JoinedBattle() { BattleID = b.BattleID, User = u.Name }); } } } await state.OfflineMessageHandler.SendMissedMessages(this, SayPlace.User, Name, user.AccountID); foreach (var chan in await state.ChannelManager.GetDefaultChannels(user.AccountID)) { await connectedUser.Process(new JoinChannel() { ChannelName = chan, Password = null }); } } else { await SendCommand(response); if (response.ResultCode == LoginResponse.Code.Banned) { transport.RequestClose(); } } }