animal racing rewritten to be isolated. Please hunt bugs.

This commit is contained in:
Master Kwoth 2017-08-01 00:11:36 +02:00
parent 3097ef88a7
commit 82aac891dd
11 changed files with 326 additions and 333 deletions

View File

@ -3,16 +3,13 @@ using Discord.Commands;
using Discord.WebSocket;
using NadekoBot.Extensions;
using NadekoBot.Services;
using NLog;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using NadekoBot.Common;
using NadekoBot.Common.Attributes;
using NadekoBot.Services.Impl;
using NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
using NadekoBot.Modules.Gambling.Common.AnimalRacing;
namespace NadekoBot.Modules.Gambling
{
@ -24,7 +21,7 @@ namespace NadekoBot.Modules.Gambling
private readonly IBotConfigProvider _bc;
private readonly CurrencyService _cs;
private readonly DiscordSocketClient _client;
public static ConcurrentDictionary<ulong, AnimalRace> AnimalRaces { get; } = new ConcurrentDictionary<ulong, AnimalRace>();
@ -35,326 +32,118 @@ namespace NadekoBot.Modules.Gambling
_client = client;
}
private IUserMessage raceMessage = null;
[NadekoCommand, Usage, Description, Aliases]
[RequireContext(ContextType.Guild)]
public async Task Race()
public Task Race()
{
var ar = new AnimalRace(Context.Guild.Id, (ITextChannel)Context.Channel, Prefix,
_bc, _cs, _client,_localization, _strings);
var ar = new AnimalRace(_cs, _bc.BotConfig.RaceAnimals.Shuffle().ToArray());
if (!AnimalRaces.TryAdd(Context.Guild.Id, ar))
return Context.Channel.SendErrorAsync(GetText("animal_race"), GetText("animal_race_already_started"));
ar.Initialize();
if (ar.Fail)
await ReplyErrorLocalized("race_failed_starting").ConfigureAwait(false);
ar.OnStartingFailed += Ar_OnStartingFailed;
ar.OnStateUpdate += Ar_OnStateUpdate;
ar.OnEnded += Ar_OnEnded;
ar.OnStarted += Ar_OnStarted;
return Context.Channel.SendConfirmAsync(GetText("animal_race"), GetText("animal_race_starting"),
footer: GetText("animal_race_join_instr", Prefix));
}
private Task Ar_OnStarted(AnimalRace race)
{
if(race.Users.Length == race.MaxUsers)
return Context.Channel.SendConfirmAsync(GetText("animal_race"), GetText("animal_race_full"));
else
return Context.Channel.SendConfirmAsync(GetText("animal_race"), GetText("animal_race_starting_with_x", race.Users.Length));
}
private Task Ar_OnEnded(AnimalRace race)
{
AnimalRaces.TryRemove(Context.Guild.Id, out _);
var winner = race.FinishedUsers[0];
if (race.FinishedUsers[0].Bet > 0)
{
return Context.Channel.SendConfirmAsync(GetText("animal_race"),
GetText("animal_race_won_money", Format.Bold(winner.Username),
winner.Animal.Icon, (race.FinishedUsers[0].Bet * (race.Users.Length - 1)) + _bc.BotConfig.CurrencySign));
}
else
{
return Context.Channel.SendConfirmAsync(GetText("animal_race"),
GetText("animal_race_won", Format.Bold(winner.Username), winner.Animal.Icon));
}
}
private async Task Ar_OnStateUpdate(AnimalRace race)
{
var text = $@"|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|
{String.Join("\n", race.Users.Select(p =>
{
var index = race.FinishedUsers.IndexOf(p);
var extra = (index == -1 ? "" : $"#{index + 1} {(index == 0 ? "🏆" : "")}");
return $"{(int)(p.Progress / 60f * 100),-2}%|{new string('‣', p.Progress) + p.Animal.Icon + extra}";
}))}
|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|";
if (raceMessage == null)
raceMessage = await Context.Channel.SendConfirmAsync(text)
.ConfigureAwait(false);
else
await raceMessage.ModifyAsync(x => x.Embed = new EmbedBuilder()
.WithTitle(GetText("animal_race"))
.WithDescription(text)
.WithOkColor()
.Build())
.ConfigureAwait(false);
}
private Task Ar_OnStartingFailed(AnimalRace race)
{
AnimalRaces.TryRemove(Context.Guild.Id, out _);
return ReplyErrorLocalized("animal_race_failed");
}
[NadekoCommand, Usage, Description, Aliases]
[RequireContext(ContextType.Guild)]
public async Task JoinRace(int amount = 0)
{
if (amount < 0)
amount = 0;
AnimalRace ar;
if (!AnimalRaces.TryGetValue(Context.Guild.Id, out ar))
if (!AnimalRaces.TryGetValue(Context.Guild.Id, out var ar))
{
await ReplyErrorLocalized("race_not_exist").ConfigureAwait(false);
return;
}
await ar.JoinRace(Context.User as IGuildUser, amount);
}
//todo 85 needs to be completely isolated, shouldn't use any services in the constructor,
//then move the rest either to the module itself, or the service
public class AnimalRace
{
private ConcurrentQueue<string> animals { get; }
public bool Fail { get; set; }
private readonly List<Participant> _participants = new List<Participant>();
private readonly ulong _serverId;
private int _messagesSinceGameStarted;
private readonly string _prefix;
private readonly Logger _log;
private readonly ITextChannel _raceChannel;
private readonly IBotConfigProvider _bc;
private readonly CurrencyService _cs;
private readonly DiscordSocketClient _client;
private readonly ILocalization _localization;
private readonly NadekoStrings _strings;
public bool Started { get; private set; }
public AnimalRace(ulong serverId, ITextChannel channel, string prefix, IBotConfigProvider bc,
CurrencyService cs, DiscordSocketClient client, ILocalization localization,
NadekoStrings strings)
try
{
_prefix = prefix;
_bc = bc;
_cs = cs;
_log = LogManager.GetCurrentClassLogger();
_serverId = serverId;
_raceChannel = channel;
_client = client;
_localization = localization;
_strings = strings;
if (!AnimalRaces.TryAdd(serverId, this))
{
Fail = true;
return;
}
animals = new ConcurrentQueue<string>(_bc.BotConfig.RaceAnimals.Select(ra => ra.Icon).Shuffle());
var cancelSource = new CancellationTokenSource();
var token = cancelSource.Token;
var fullgame = CheckForFullGameAsync(token);
Task.Run(async () =>
{
try
{
try
{
await _raceChannel.SendConfirmAsync(GetText("animal_race"), GetText("animal_race_starting"),
footer: GetText("animal_race_join_instr", _prefix));
}
catch (Exception ex)
{
_log.Warn(ex);
}
var t = await Task.WhenAny(Task.Delay(20000, token), fullgame);
Started = true;
cancelSource.Cancel();
if (t == fullgame)
{
try { await _raceChannel.SendConfirmAsync(GetText("animal_race"), GetText("animal_race_full") ); } catch (Exception ex) { _log.Warn(ex); }
}
else if (_participants.Count > 1)
{
try { await _raceChannel.SendConfirmAsync(GetText("animal_race"), GetText("animal_race_starting_with_x", _participants.Count)); } catch (Exception ex) { _log.Warn(ex); }
}
else
{
try { await _raceChannel.SendErrorAsync(GetText("animal_race"), GetText("animal_race_failed")); } catch (Exception ex) { _log.Warn(ex); }
var p = _participants.FirstOrDefault();
if (p != null && p.AmountBet > 0)
await _cs.AddAsync(p.User, "BetRace", p.AmountBet, false).ConfigureAwait(false);
End();
return;
}
await Task.Run(StartRace);
End();
}
catch { try { End(); } catch { } }
});
}
private void End()
{
AnimalRaces.TryRemove(_serverId, out _);
}
private async Task StartRace()
{
var rng = new NadekoRandom();
Participant winner = null;
IUserMessage msg = null;
var place = 1;
try
{
_client.MessageReceived += Client_MessageReceived;
while (!_participants.All(p => p.Total >= 60))
{
//update the state
_participants.ForEach(p =>
{
p.Total += 1 + rng.Next(0, 10);
});
_participants
.OrderByDescending(p => p.Total)
.ForEach(p =>
{
if (p.Total > 60)
{
if (winner == null)
{
winner = p;
}
p.Total = 60;
if (p.Place == 0)
p.Place = place++;
}
});
//draw the state
var text = $@"|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|
{String.Join("\n", _participants.Select(p => $"{(int)(p.Total / 60f * 100),-2}%|{p.ToString()}"))}
|🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🏁🔚|";
if (msg == null || _messagesSinceGameStarted >= 10) // also resend the message if channel was spammed
{
if (msg != null)
try { await msg.DeleteAsync(); } catch { }
_messagesSinceGameStarted = 0;
try { msg = await _raceChannel.SendMessageAsync(text).ConfigureAwait(false); } catch (Exception ex) { _log.Warn(ex); }
}
else
{
try { await msg.ModifyAsync(m => m.Content = text).ConfigureAwait(false); } catch (Exception ex) { _log.Warn(ex); }
}
await Task.Delay(2500);
}
}
catch
{
// ignored
}
finally
{
_client.MessageReceived -= Client_MessageReceived;
}
if (winner != null)
{
if (winner.AmountBet > 0)
{
var wonAmount = winner.AmountBet * (_participants.Count - 1);
await _cs.AddAsync(winner.User, "Won a Race", wonAmount, true)
.ConfigureAwait(false);
await _raceChannel.SendConfirmAsync(GetText("animal_race"),
Format.Bold(GetText("animal_race_won_money", winner.User.Mention,
winner.Animal, wonAmount + _bc.BotConfig.CurrencySign)))
.ConfigureAwait(false);
}
else
{
await _raceChannel.SendConfirmAsync(GetText("animal_race"),
Format.Bold(GetText("animal_race_won", winner.User.Mention, winner.Animal))).ConfigureAwait(false);
}
}
}
private Task Client_MessageReceived(SocketMessage imsg)
{
var _ = Task.Run(() =>
{
var msg = imsg as SocketUserMessage;
if (msg == null)
return Task.CompletedTask;
if ((msg.Author.Id == _client.CurrentUser.Id) || !(imsg.Channel is ITextChannel) || imsg.Channel != _raceChannel)
return Task.CompletedTask;
Interlocked.Increment(ref _messagesSinceGameStarted);
return Task.CompletedTask;
});
return Task.CompletedTask;
}
private async Task CheckForFullGameAsync(CancellationToken cancelToken)
{
while (animals.Count > 0)
{
await Task.Delay(100, cancelToken);
}
}
public async Task JoinRace(IGuildUser u, int amount = 0)
{
string animal;
if (!animals.TryDequeue(out animal))
{
await _raceChannel.SendErrorAsync(GetText("animal_race_no_race")).ConfigureAwait(false);
return;
}
var p = new Participant(u, animal, amount);
if (_participants.Contains(p))
{
await _raceChannel.SendErrorAsync(GetText("animal_race_already_in")).ConfigureAwait(false);
return;
}
if (Started)
{
await _raceChannel.SendErrorAsync(GetText("animal_race_already_started")).ConfigureAwait(false);
return;
}
var user = await ar.JoinRace(Context.User.Id, Context.User.ToString(), amount)
.ConfigureAwait(false);
if (amount > 0)
if (!await _cs.RemoveAsync(u, "BetRace", amount, false).ConfigureAwait(false))
{
await _raceChannel.SendErrorAsync(GetText("not_enough", _bc.BotConfig.CurrencySign)).ConfigureAwait(false);
return;
}
_participants.Add(p);
string confStr;
if (amount > 0)
confStr = GetText("animal_race_join_bet", u.Mention, p.Animal, amount + _bc.BotConfig.CurrencySign);
await Context.Channel.SendConfirmAsync(GetText("animal_race_join_bet", Context.User.Mention, user.Animal.Icon, amount + _bc.BotConfig.CurrencySign)).ConfigureAwait(false);
else
confStr = GetText("animal_race_join", u.Mention, p.Animal);
await _raceChannel.SendConfirmAsync(GetText("animal_race"), Format.Bold(confStr)).ConfigureAwait(false);
await Context.Channel.SendConfirmAsync(GetText("animal_race_join", Context.User.Mention, user.Animal.Icon)).ConfigureAwait(false);
}
private string GetText(string text)
=> _strings.GetText(text,
_localization.GetCultureInfo(_raceChannel.Guild),
typeof(Gambling).Name.ToLowerInvariant());
private string GetText(string text, params object[] replacements)
=> _strings.GetText(text,
_localization.GetCultureInfo(_raceChannel.Guild),
typeof(Gambling).Name.ToLowerInvariant(),
replacements);
}
public class Participant
{
public IGuildUser User { get; }
public string Animal { get; }
public int AmountBet { get; }
public float Coeff { get; set; }
public int Total { get; set; }
public int Place { get; set; }
public Participant(IGuildUser u, string a, int amount)
catch (ArgumentOutOfRangeException)
{
User = u;
Animal = a;
AmountBet = amount;
//ignore if user inputed an invalid amount
}
public override int GetHashCode() => User.GetHashCode();
public override bool Equals(object obj)
catch (AlreadyJoinedException)
{
var p = obj as Participant;
return p != null && p.User == User;
// just ignore this
}
public override string ToString()
catch (AlreadyStartedException)
{
var str = new string('‣', Total) + Animal;
if (Place == 0)
return str;
str += $"`#{Place}`";
if (Place == 1)
str += "🏆";
return str;
//ignore
}
catch (AnimalRaceFullException)
{
await Context.Channel.SendConfirmAsync(GetText("animal_race"), GetText("animal_race_full"))
.ConfigureAwait(false);
}
catch (NotEnoughFundsException)
{
await Context.Channel.SendErrorAsync(GetText("not_enough", _bc.BotConfig.CurrencySign)).ConfigureAwait(false);
}
}
}

View File

@ -0,0 +1,161 @@
using NadekoBot.Common;
using NadekoBot.Extensions;
using NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions;
using NadekoBot.Services;
using NadekoBot.Services.Database.Models;
using System;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing
{
public class AnimalRace : IDisposable
{
public enum Phase
{
WaitingForPlayers,
Running,
Ended,
}
private const int _startingDelayMiliseconds = 20_000;
public Phase CurrentPhase = Phase.WaitingForPlayers;
public event Func<AnimalRace, Task> OnStarted = delegate { return Task.CompletedTask; };
public event Func<AnimalRace, Task> OnStartingFailed = delegate { return Task.CompletedTask; };
public event Func<AnimalRace, Task> OnStateUpdate = delegate { return Task.CompletedTask; };
public event Func<AnimalRace, Task> OnEnded = delegate { return Task.CompletedTask; };
public ImmutableArray<AnimalRacingUser> Users => _users.ToImmutableArray();
public List<AnimalRacingUser> FinishedUsers { get; } = new List<AnimalRacingUser>();
private readonly SemaphoreSlim _locker = new SemaphoreSlim(1, 1);
private readonly HashSet<AnimalRacingUser> _users = new HashSet<AnimalRacingUser>();
private readonly CurrencyService _currency;
private readonly Queue<RaceAnimal> _animalsQueue;
public int MaxUsers { get; }
public AnimalRace(CurrencyService currency, RaceAnimal[] availableAnimals)
{
this._currency = currency;
this._animalsQueue = new Queue<RaceAnimal>(availableAnimals);
this.MaxUsers = availableAnimals.Length;
if (this._animalsQueue.Count == 0)
CurrentPhase = Phase.Ended;
}
public void Initialize() //lame name
{
var _t = Task.Run(async () =>
{
await Task.Delay(_startingDelayMiliseconds).ConfigureAwait(false);
await _locker.WaitAsync().ConfigureAwait(false);
try
{
if (CurrentPhase != Phase.WaitingForPlayers)
return;
await Start().ConfigureAwait(false);
}
finally { _locker.Release(); }
});
}
public async Task<AnimalRacingUser> JoinRace(ulong userId, string userName, int bet = 0)
{
if (bet < 0)
throw new ArgumentOutOfRangeException(nameof(bet));
var user = new AnimalRacingUser(userName, userId, bet);
await _locker.WaitAsync().ConfigureAwait(false);
try
{
if (_users.Count == MaxUsers)
throw new AnimalRaceFullException();
if (CurrentPhase != Phase.WaitingForPlayers)
throw new AlreadyStartedException();
if (!await _currency.RemoveAsync(userId, "BetRace", bet).ConfigureAwait(false))
throw new NotEnoughFundsException();
if (_users.Contains(user))
throw new AlreadyJoinedException();
var animal = _animalsQueue.Dequeue();
user.Animal = animal;
_users.Add(user);
if (_animalsQueue.Count == 0) //start if no more spots left
await Start().ConfigureAwait(false);
return user;
}
finally { _locker.Release(); }
}
private async Task Start()
{
CurrentPhase = Phase.Running;
if (_users.Count <= 1)
{
foreach (var user in _users)
{
if(user.Bet > 0)
await _currency.AddAsync(user.UserId, "Race refund", user.Bet).ConfigureAwait(false);
}
var _sf = OnStartingFailed?.Invoke(this);
CurrentPhase = Phase.Ended;
return;
}
var _ = OnStarted?.Invoke(this);
var _t = Task.Run(async () =>
{
var rng = new NadekoRandom();
while (!_users.All(x => x.Progress >= 60))
{
foreach (var user in _users)
{
user.Progress += rng.Next(1, 11);
if (user.Progress >= 60)
user.Progress = 60;
}
var finished = _users.Where(x => x.Progress >= 60 && !FinishedUsers.Contains(x))
.Shuffle();
FinishedUsers.AddRange(finished);
var _ignore = OnStateUpdate?.Invoke(this);
await Task.Delay(2500).ConfigureAwait(false);
}
if (FinishedUsers[0].Bet > 0)
await _currency.AddAsync(FinishedUsers[0].UserId, "Won a Race", FinishedUsers[0].Bet * (_users.Count - 1))
.ConfigureAwait(false);
var _ended = OnEnded?.Invoke(this);
});
}
public void Dispose()
{
CurrentPhase = Phase.Ended;
OnStarted = null;
OnEnded = null;
OnStartingFailed = null;
OnStateUpdate = null;
_locker.Dispose();
_users.Clear();
}
}
}

View File

@ -0,0 +1,32 @@
using NadekoBot.Services.Database.Models;
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing
{
public class AnimalRacingUser
{
public int Bet { get; }
public string Username { get; }
public ulong UserId { get; }
public RaceAnimal Animal { get; set; }
public int Progress { get; set; }
public AnimalRacingUser(string username, ulong userId, int bet)
{
this.Bet = bet;
this.Username = username;
this.UserId = userId;
}
public override bool Equals(object obj)
{
return obj is AnimalRacingUser x
? x.UserId == this.UserId
: false;
}
public override int GetHashCode()
{
return this.UserId.GetHashCode();
}
}
}

View File

@ -0,0 +1,9 @@
using System;
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions
{
public class AlreadyJoinedException : Exception
{
}
}

View File

@ -0,0 +1,9 @@
using System;
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions
{
public class AlreadyStartedException : Exception
{
}
}

View File

@ -0,0 +1,8 @@
using System;
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions
{
public class AnimalRaceFullException : Exception
{
}
}

View File

@ -0,0 +1,9 @@
using System;
namespace NadekoBot.Modules.Gambling.Common.AnimalRacing.Exceptions
{
public class NotEnoughFundsException : Exception
{
}
}

View File

@ -12,12 +12,12 @@ namespace NadekoBot.Modules.Games.Common.Hangman
public class TermPool
{
const string termsPath = "data/hangman3.json";
public static IReadOnlyDictionary<string, HangmanObject[]> data { get; } = new Dictionary<string, HangmanObject[]>();
public static IReadOnlyDictionary<string, HangmanObject[]> Data { get; } = new Dictionary<string, HangmanObject[]>();
static TermPool()
{
try
{
data = JsonConvert.DeserializeObject<Dictionary<string, HangmanObject[]>>(File.ReadAllText(termsPath));
Data = JsonConvert.DeserializeObject<Dictionary<string, HangmanObject[]>>(File.ReadAllText(termsPath));
}
catch (Exception)
{
@ -35,11 +35,11 @@ namespace NadekoBot.Modules.Games.Common.Hangman
if (type == TermType.Random)
{
var keys = data.Keys.ToArray();
var keys = Data.Keys.ToArray();
type = _termTypes[rng.Next(0, _termTypes.Length - 1)]; // - 1 because last one is 'all'
}
if (!data.TryGetValue(type.ToString(), out var termTypes) || termTypes.Length == 0)
if (!Data.TryGetValue(type.ToString(), out var termTypes) || termTypes.Length == 0)
throw new TermNotFoundException();
var obj = termTypes[rng.Next(0, termTypes.Length)];

View File

@ -11,12 +11,11 @@ using NadekoBot.Services.Impl;
namespace NadekoBot.Modules.Games.Common
{
//todo 75 rewrite
public class Poll
{
private readonly IUserMessage _originalMessage;
private readonly IGuild _guild;
private string[] answers { get; }
private readonly string[] answers;
private readonly ConcurrentDictionary<ulong, int> _participants = new ConcurrentDictionary<ulong, int>();
private readonly string _question;
private readonly DiscordSocketClient _client;

View File

@ -29,7 +29,7 @@ namespace NadekoBot.Modules.Games
[RequireContext(ContextType.Guild)]
public async Task Hangmanlist()
{
await Context.Channel.SendConfirmAsync(Format.Code(GetText("hangman_types", Prefix)) + "\n" + string.Join(", ", TermPool.data.Keys));
await Context.Channel.SendConfirmAsync(Format.Code(GetText("hangman_types", Prefix)) + "\n" + string.Join(", ", TermPool.Data.Keys));
}
[NadekoCommand, Usage, Description, Aliases]

View File

@ -1,27 +1,4 @@
{
"clashofclans_base_already_claimed": "That base is already claimed or destroyed.",
"clashofclans_base_already_destroyed": "That base is already destroyed.",
"clashofclans_base_already_unclaimed": "That base is not claimed.",
"clashofclans_base_destroyed": "**DESTROYED** base #{0} in a war against {1}",
"clashofclans_base_unclaimed": "{0} has **UNCLAIMED** base #{1} in a war against {2}",
"clashofclans_claimed_base": "{0} claimed a base #{1} in a war against {2}",
"clashofclans_claimed_other": "@{0} You already claimed base #{1}. You can't claim a new one.",
"clashofclans_claim_expired": "Claim from @{0} in a war against {1} has expired.",
"clashofclans_enemy": "Enemy",
"clashofclans_info_about_war": "Info about war against {0}",
"clashofclans_invalid_base_number": "Invalid base number.",
"clashofclans_invalid_size": "Not a valid war size.",
"clashofclans_list_active_wars": "List of active wars",
"clashofclans_not_claimed": "not claimed",
"clashofclans_not_partic": "You are not participating in that war.",
"clashofclans_not_partic_or_destroyed": "@{0} You are either not participating in that war, or that base is already destroyed.",
"clashofclans_no_active_wars": "No active war.",
"clashofclans_size": "Size",
"clashofclans_war_already_started": "War against {0} has already started.",
"clashofclans_war_created": "War against {0} created.",
"clashofclans_war_ended": "War against {0} ended.",
"clashofclans_war_not_exist": "That war does not exist.",
"clashofclans_war_started": "War against {0} started!",
"customreactions_all_stats_cleared": "All custom reaction stats cleared.",
"customreactions_deleted": "Custom Reaction deleted",
"customreactions_insuff_perms": "Insufficient permissions. Requires Bot ownership for global custom reactions, and Administrator for server custom reactions.",