-
Notifications
You must be signed in to change notification settings - Fork 591
Expand file tree
/
Copy pathBotGenerator.cs
More file actions
619 lines (539 loc) · 30.6 KB
/
Copy pathBotGenerator.cs
File metadata and controls
619 lines (539 loc) · 30.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
// <copyright file="BotGenerator.cs" company="MUnique">
// Licensed under the MIT License. See LICENSE file in the project root for full license information.
// </copyright>
namespace MUnique.OpenMU.GameLogic.Bots;
using System.Linq;
using System.Threading;
using Microsoft.Extensions.Logging;
using MUnique.OpenMU.AttributeSystem;
using MUnique.OpenMU.DataModel.Configuration;
using MUnique.OpenMU.DataModel.Configuration.Items;
using MUnique.OpenMU.DataModel.Entities;
using MUnique.OpenMU.GameLogic.Attributes;
using MUnique.OpenMU.GameLogic.Resets;
using MUnique.OpenMU.Persistence;
/// <summary>
/// Generates and maintains the persistent population of bot accounts and their characters.
/// </summary>
/// <remarks>
/// Accounts are flagged with <see cref="Account.IsBot"/> so they can be reliably reloaded on
/// startup (instead of being regenerated) and purged on request. The account login names follow
/// a deterministic, internal scheme (<see cref="GetLoginName"/>) which is never shown to other
/// players; the player-visible character names are realistic and unique (see <see cref="BotNameGenerator"/>).
/// Generation is idempotent: only the missing accounts are created, so it is safe to run on every start.
/// </remarks>
internal sealed class BotGenerator
{
private const string LoginPrefix = "bot";
/// <summary>
/// BCrypt work factor for a bot account's password. The password is a random <see cref="Guid"/>
/// which is discarded immediately and never used to log in - a bot is a connection-less
/// <c>OfflinePlayer</c>, so no client ever authenticates against it. A minimal factor is therefore
/// safe (a 128-bit random secret is infeasible to brute-force regardless of the factor) and keeps
/// generating a large population from becoming a multi-minute BCrypt bottleneck, while still storing
/// a valid BCrypt hash. The default factor is kept for real accounts.
/// </summary>
private const int BotPasswordWorkFactor = 4;
private const int StartMoney = 100000;
/// <summary>Number of inventory extensions (each 4 rows of 8 slots) a bot gets, so loot does not clog its backpack.</summary>
private const int BotInventoryExtensions = 4;
/// <summary>Highest item group that is a melee weapon (0 sword, 1 axe, 2 mace, 3 spear).</summary>
private const byte MaxMeleeGroup = 3;
/// <summary>Item group of bows (need ammunition).</summary>
private const byte BowGroup = 4;
/// <summary>Item group of staves/sticks (casters).</summary>
private const byte StaffGroup = 5;
/// <summary>Item group of body armor; its item number identifies the armor set.</summary>
private const byte ArmorGroup = 8;
/// <summary>
/// Armor set numbers tried in thematic order; the first the class is qualified for (by its chest piece)
/// is used: 5 Leather (warriors), 2 Pad (wizards), 10 Vine (elves), 39 Mistery (summoners), then fallbacks.
/// </summary>
private static readonly byte[] ArmorSetCandidates = { 5, 2, 10, 39, 6, 0, 4, 8 };
private readonly IGameContext _gameContext;
private readonly ILogger _logger;
private readonly BotNameGenerator _nameGenerator = new();
/// <summary>
/// Initializes a new instance of the <see cref="BotGenerator"/> class.
/// </summary>
/// <param name="gameContext">The game context.</param>
/// <param name="logger">The logger.</param>
public BotGenerator(IGameContext gameContext, ILogger logger)
{
this._gameContext = gameContext;
this._logger = logger;
}
/// <summary>
/// Gets the deterministic, internal login name of the bot account with the given one-based index.
/// </summary>
/// <param name="index">The one-based account index.</param>
/// <returns>The login name, e.g. <c>bot0001</c> (kept within the 10 character account name limit).</returns>
public static string GetLoginName(int index) => $"{LoginPrefix}{index:D4}";
/// <summary>
/// Ensures that the configured number of bot accounts (each with the configured number of
/// characters) exists. Only missing accounts are created.
/// </summary>
/// <param name="numberOfAccounts">The desired number of bot accounts.</param>
/// <param name="charactersPerAccount">The desired number of characters per account.</param>
/// <param name="profile">The startup profile of the generated characters (fresh or veteran).</param>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of accounts that were newly created.</returns>
public async ValueTask<int> EnsureBotsAsync(int numberOfAccounts, int charactersPerAccount, BotStartupProfile profile, CancellationToken cancellationToken = default)
{
var creatableClasses = this._gameContext.Configuration.CharacterClasses
.Where(c => c is { CanGetCreated: true, HomeMap: not null })
.ToList();
if (creatableClasses.Count == 0)
{
this._logger.LogWarning("No creatable character classes found - cannot generate bots.");
return 0;
}
var perAccount = Math.Clamp(
Math.Min(charactersPerAccount, this._gameContext.Configuration.MaximumCharactersPerAccount),
1,
BotConfiguration.MaxCharactersPerAccountLimit);
var experienceTable = this._gameContext.ExperienceTable;
var maxLevel = Math.Min(profile.MaxLevel, experienceTable.Length - 1);
var minLevel = Math.Clamp(profile.MinLevel, 1, maxLevel);
// On servers with the reset feature the existing population has resets, so freshly generated
// bots get a random reset history too - a visitor should meet believable veterans (even TOP,
// max-reset characters), not a population uniformly starting from zero. Only possible when the
// configuration bounds the resets; unlimited-reset servers keep unseeded bots.
var resetConfiguration = BotResetHandler.GetResetConfiguration(this._gameContext);
var maxSeededResets = resetConfiguration?.ResetLimit is > 0 ? resetConfiguration.ResetLimit.Value : 0;
using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration);
var reservedNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var created = 0;
// Build a balanced, shuffled queue of classes so the whole population is evenly split across
// all creatable classes. Independent random draws leave visible skew at this scale (e.g. 11
// Summoners vs 4 Elves for 50 bots); the quota queue guarantees ~even counts, drawn per character.
var classQueue = BuildBalancedClassQueue(creatableClasses, numberOfAccounts * perAccount);
for (var i = 1; i <= numberOfAccounts; i++)
{
cancellationToken.ThrowIfCancellationRequested();
var loginName = GetLoginName(i);
var existing = await context.GetAccountByLoginNameAsync(loginName, cancellationToken).ConfigureAwait(false);
if (existing is not null)
{
continue;
}
var account = context.CreateNew<Account>();
account.LoginName = loginName;
account.PasswordHash = BCrypt.Net.BCrypt.HashPassword(Guid.NewGuid().ToString(), BotPasswordWorkFactor);
account.IsBot = true;
account.Vault = context.CreateNew<ItemStorage>();
for (byte slot = 0; slot < perAccount; slot++)
{
var characterClass = classQueue.Count > 0 ? classQueue.Dequeue() : creatableClasses.SelectRandom()!;
var level = profile.GetStartLevel(minLevel, maxLevel);
var seededResets = profile.GetSeededResets(maxSeededResets);
var name = await this._nameGenerator.GenerateUniqueAsync(context, reservedNames, cancellationToken).ConfigureAwait(false);
this.CreateCharacter(context, account, name, characterClass, level, slot, experienceTable, seededResets, profile.StarterItemLevel, resetConfiguration);
}
// Save per account so a single failure does not roll back already generated accounts,
// and re-runs simply resume where they left off (idempotent).
if (await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false))
{
created++;
this._logger.LogInformation("Generated bot account '{LoginName}' with {Count} character(s).", loginName, perAccount);
}
}
return created;
}
/// <summary>
/// Deletes all bot accounts with their characters, item storages and items.
/// </summary>
/// <param name="cancellationToken">The cancellation token.</param>
/// <returns>The number of deleted bot accounts.</returns>
public async ValueTask<int> DeleteAllBotsAsync(CancellationToken cancellationToken = default)
{
using var context = this._gameContext.PersistenceContextProvider.CreateNewPlayerContext(this._gameContext.Configuration);
// Collect first, delete afterwards: the paging query orders by login name, so deleting while
// paging would shift the accounts which are not visited yet into the pages already passed.
var loginNames = new List<string>();
const int pageSize = 100;
var skip = 0;
while (true)
{
cancellationToken.ThrowIfCancellationRequested();
var page = (await context.GetAccountsOrderedByLoginNameAsync(skip, pageSize, cancellationToken).ConfigureAwait(false)).ToList();
if (page.Count == 0)
{
break;
}
loginNames.AddRange(page.Where(account => account.IsBot).Select(account => account.LoginName));
skip += page.Count;
}
var deleted = 0;
foreach (var loginName in loginNames)
{
cancellationToken.ThrowIfCancellationRequested();
// Load the account again, this time with its whole graph: the paging query returns the
// accounts untracked and without their characters, and deleting such a shallow account
// leaves its item storages behind. A character's inventory is referenced BY the character,
// so no delete cascade ever reaches it - those storages, and every item lying in them, would
// stay in the database forever as unreachable rows.
var account = await context.GetAccountByLoginNameAsync(loginName, cancellationToken).ConfigureAwait(false);
if (account is null)
{
continue;
}
foreach (var character in account.Characters)
{
if (character.Inventory is { } inventory)
{
await context.DeleteAsync(inventory).ConfigureAwait(false);
}
}
if (account.Vault is { } vault)
{
await context.DeleteAsync(vault).ConfigureAwait(false);
}
if (await context.DeleteAsync(account).ConfigureAwait(false))
{
deleted++;
}
else
{
// Not silent: a bot account which survives the purge is spawned again right after it.
this._logger.LogWarning("Bot account '{LoginName}' could not be deleted.", loginName);
}
// Save per account, so a single failure does not roll back the accounts already deleted.
await context.SaveChangesAsync(cancellationToken).ConfigureAwait(false);
}
return deleted;
}
private static byte[] CreateDefaultKeyConfiguration()
{
// Mirrors CreateCharacterAction: bind Q to the healing potion and W to the mana potion,
// leave E and R unbound. An all-zero blob would otherwise bind the apple (a heal) to all slots.
const byte healingPotion = 1;
const byte manaPotion = 4;
const byte unbound = 0xFF;
var keyConfiguration = new byte[30];
keyConfiguration[21] = healingPotion; // Q
keyConfiguration[22] = manaPotion; // W
keyConfiguration[23] = unbound; // E
keyConfiguration[25] = unbound; // R
return keyConfiguration;
}
/// <summary>
/// Builds a shuffled queue of character classes with even quotas across <paramref name="classes"/>,
/// so the generated population is balanced instead of relying on the variance of independent random
/// draws. The order is randomized so accounts do not get a predictable class pattern.
/// </summary>
private static Queue<CharacterClass> BuildBalancedClassQueue(IList<CharacterClass> classes, int total)
{
var pool = new List<CharacterClass>(total);
for (var n = 0; n < total; n++)
{
// Even quotas: class index cycles, so each class appears total/count times (+1 for the first remainder classes).
pool.Add(classes[n % classes.Count]);
}
// Fisher-Yates shuffle so the balanced pool is handed out in random order.
for (var n = pool.Count - 1; n > 0; n--)
{
var j = Rand.NextInt(0, n + 1);
(pool[n], pool[j]) = (pool[j], pool[n]);
}
return new Queue<CharacterClass>(pool);
}
/// <summary>
/// Spends the character's level-up points, so a high-level bot actually has high-level stats.
/// Without this a generated level-80 bot would fight with level-1 base stats (tiny health and
/// damage) and die instantly. The split follows the class build in <see cref="BotProgression"/>
/// - the same split the bot keeps using for points it earns at runtime - and respects each stat's
/// configured maximum (fun servers) as well as the bot's personal vitality target on reset-meta servers.
/// </summary>
private static void DistributeStatPoints(Character character, CharacterClass characterClass, bool resetMeta)
{
var points = character.LevelUpPoints;
if (points <= 0)
{
return;
}
var weights = BotProgression.GetStatWeights(characterClass, character.Name);
var vitalityTarget = resetMeta ? BotProgression.GetVitalityTarget(character.Name) : (int?)null;
long CapacityOf(AttributeDefinition stat)
{
var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat);
if (attribute is null)
{
return 0;
}
var classBase = characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == stat);
var capacity = long.MaxValue;
if (classBase?.Attribute?.MaximumValue is { } maximumValue)
{
capacity = (long)maximumValue - (long)attribute.Value;
}
if (vitalityTarget is { } target && stat == Stats.BaseVitality)
{
var invested = (long)attribute.Value - (long)(classBase?.BaseValue ?? 0f);
capacity = Math.Min(capacity, target - invested);
}
return capacity;
}
foreach (var (stat, amount) in BotProgression.SplitPoints(points, weights, CapacityOf))
{
var attribute = character.Attributes.FirstOrDefault(a => a.Definition == stat);
if (attribute is not null)
{
attribute.Value += amount;
character.LevelUpPoints -= amount;
}
}
}
/// <summary>
/// Calculates the level-up points a character with the given reset history would have available,
/// so a seeded bot invests the same total a player of that history would: the points granted by
/// the resets themselves (looped through <see cref="ResetProgressionCalculator"/>, so tiers,
/// multipliers and the replace/add mode of the server's configuration all apply) plus the points
/// earned by leveling. With <see cref="ResetConfiguration.ResetStats"/> the level points of the
/// finished cycles were invested and wiped again at each reset, so only the current cycle's count;
/// without it every cycle's investment survived and still counts.
/// </summary>
private static int CalculateLevelUpPoints(CharacterClass characterClass, int level, int seededResets, ResetConfiguration? resetConfiguration)
{
var pointsPerLevel = (int)characterClass.StatAttributes.First(a => a.Attribute == Stats.PointsPerLevelUp).BaseValue;
if (seededResets <= 0 || resetConfiguration is null)
{
return (level - 1) * pointsPerLevel;
}
var pointsPerResetOverride = (int)(characterClass.StatAttributes.FirstOrDefault(a => a.Attribute == Stats.PointsPerReset)?.BaseValue ?? 0f);
var resetPoints = 0;
for (var reset = 0; reset < seededResets; reset++)
{
var progression = ResetProgressionCalculator.Calculate(reset, pointsPerResetOverride, resetConfiguration);
resetPoints = resetConfiguration.ReplacePointsPerReset
? progression.TotalPointsAfterReset
: resetPoints + progression.PointsForReset;
}
var currentCyclePoints = Math.Max(0, level - resetConfiguration.LevelAfterReset) * pointsPerLevel;
if (resetConfiguration.ResetStats)
{
return resetPoints + currentCyclePoints;
}
var firstCyclePoints = Math.Max(0, resetConfiguration.RequiredLevel - 1) * pointsPerLevel;
var laterCyclesPoints = (seededResets - 1) * Math.Max(0, resetConfiguration.RequiredLevel - resetConfiguration.LevelAfterReset) * pointsPerLevel;
return resetPoints + firstCyclePoints + laterCyclesPoints + currentCyclePoints;
}
private void CreateCharacter(IPlayerContext context, Account account, string name, CharacterClass characterClass, int level, byte slot, long[] experienceTable, int seededResets, byte starterItemLevel, ResetConfiguration? resetConfiguration)
{
// A character generated beyond the class evolution level was created as its second-generation
// class right away - like a player who completed the class quest long ago. Everything downstream
// (stat weights, skills, gear) keys off the evolved class. A character with a seeded reset
// history evolved in its first cycle at the latest - provided the reset's required level lies
// beyond the evolution level (the check below), which makes it pass the evolution on the way
// to its first reset regardless of its current in-cycle level.
var passedEvolutionInEarlierCycle = seededResets > 0 && resetConfiguration?.RequiredLevel >= BotProgression.ClassEvolutionLevel;
if ((level >= BotProgression.ClassEvolutionLevel || passedEvolutionInEarlierCycle)
&& BotProgression.GetEvolutionTarget(characterClass) is { } evolvedClass)
{
characterClass = evolvedClass;
}
var character = context.CreateNew<Character>();
character.CharacterClass = characterClass;
character.Name = name;
character.CharacterSlot = slot;
character.CreateDate = DateTime.UtcNow;
character.KeyConfiguration = CreateDefaultKeyConfiguration();
// Distinct, because a character class may define the same stat attribute more than once (data
// which got duplicated by an update); a character must never hold an attribute twice.
foreach (var attribute in characterClass.StatAttributes
.DistinctBy(a => a.Attribute)
.Select(a => context.CreateNew<StatAttribute>(a.Attribute, a.BaseValue)))
{
character.Attributes.Add(attribute);
}
character.CurrentMap = characterClass.HomeMap;
var spawnGate = character.CurrentMap!.ExitGates.Where(g => g.IsSpawnGate).SelectRandom();
if (spawnGate is not null)
{
character.PositionX = (byte)Rand.NextInt(spawnGate.X1, spawnGate.X2);
character.PositionY = (byte)Rand.NextInt(spawnGate.Y1, spawnGate.Y2);
}
var levelAttribute = character.Attributes.First(a => a.Definition == Stats.Level);
levelAttribute.Value = level;
if (seededResets > 0
&& character.Attributes.FirstOrDefault(a => a.Definition == Stats.Resets) is { } resetsAttribute)
{
// Persisted exactly like a real player's resets (a per-character stat attribute), so the
// reset counter, the effective level and the reset limit all see the seeded history.
resetsAttribute.Value = seededResets;
}
character.Experience = experienceTable[Math.Min(level, experienceTable.Length - 1)];
character.LevelUpPoints = CalculateLevelUpPoints(characterClass, level, seededResets, resetConfiguration);
character.InventoryExtensions = BotInventoryExtensions;
DistributeStatPoints(character, characterClass, resetConfiguration is not null);
// Skills survive resets, so a seeded veteran knows everything the highest level of its past
// cycles unlocked - level-gated skills are checked against that level, not the current one.
var highestLevelReached = seededResets > 0 && resetConfiguration is not null
? Math.Max(level, resetConfiguration.RequiredLevel)
: level;
this.LearnClassSkills(context, character, characterClass, highestLevelReached);
character.Inventory = context.CreateNew<ItemStorage>();
character.Inventory.Money = StartMoney;
this.EquipStarterGear(context, character, starterItemLevel);
account.Characters.Add(character);
}
/// <summary>
/// Teaches the character the class skills appropriate to its level and stats - attack skills as well
/// as the class's own buffs and heals (e.g. elf Heal/Greater Defense/Greater Damage). Only skills the
/// class is qualified for are ever learned, gated by the skills' real learn requirements from the game
/// configuration (total energy, leadership, character level, ...) evaluated against the stats the bot
/// was just given - exactly the requirements a human player has to meet for the same skill.
/// </summary>
private void LearnClassSkills(IPlayerContext context, Character character, CharacterClass characterClass, int level)
{
float? GetValue(AttributeDefinition attribute)
{
if (BotProgression.TotalToBaseStat(attribute) is not { } baseStat)
{
return null;
}
return baseStat == Stats.Level
? level
: character.Attributes.FirstOrDefault(a => a.Definition == baseStat)?.Value;
}
var learnedNumbers = new HashSet<short>(character.LearnedSkills.Select(s => s.Skill!.Number));
var itemGrantedSkillNumbers = BotProgression.GetItemGrantedSkillNumbers(this._gameContext.Configuration);
foreach (var skill in this._gameContext.Configuration.Skills)
{
if (!BotProgression.IsBotLearnableSkill(skill, itemGrantedSkillNumbers)
|| !skill.QualifiedCharacters.Contains(characterClass)
|| !BotProgression.MeetsRequirements(skill, GetValue)
|| !learnedNumbers.Add(skill.Number))
{
continue;
}
var entry = context.CreateNew<SkillEntry>();
entry.Skill = skill;
entry.Level = 0;
character.LearnedSkills.Add(entry);
}
}
/// <summary>
/// Equips the bot with a basic, class-appropriate weapon and armor set (mirrors the low-level test
/// account gear), so it is not naked and punching with its fists. The item level scales modestly
/// with the bot level for a bit more defense/damage without raising the equip requirements too high.
/// </summary>
/// <param name="context">The persistence context.</param>
/// <param name="character">The character to equip.</param>
/// <param name="starterItemLevel">The upgrade level of the starter items (level 0 for fresh characters).</param>
private void EquipStarterGear(IPlayerContext context, Character character, byte starterItemLevel)
{
var inventory = character.Inventory!;
var characterClass = character.CharacterClass!;
// Data-driven so every class gets gear it is actually QUALIFIED to wear (a Dark Lord must never
// end up in a Pad/wizard set). We pick the most basic options (lowest DropLevel) the class can use:
// - a weapon from the weapon groups (0 sword, 1 axe, 2 mace, 3 spear, 4 bow, 5 staff),
// - the armor set whose chest piece (group 8) has the lowest DropLevel; its NUMBER identifies the set,
// and the equipment type is the GROUP (7 helm, 8 armor, 9 pants, 10 gloves, 11 boots).
// The weapon type follows the bot's BUILD (BotProgression.IsPreferredWeaponGroup - the same rule the
// later upgrades use), so an energy-specced Magic Gladiator starts with a staff instead of a blade.
// The Small Axe is qualified for almost every class, so without this filter casters and archers would
// all end up with one.
bool IsPreferredWeapon(ItemDefinition definition)
=> BotProgression.IsPreferredWeaponGroup(characterClass, character.Name, (byte)definition.Group);
// Ammunition shares the bow group (Bolt/Arrows have DropLevel 0), so without this filter every
// archer would get a bolt stack as its "weapon" and end up punching with its fists.
var weapon = this._gameContext.Configuration.Items
.Where(d => IsPreferredWeapon(d) && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass))
.MinBy(d => d.DropLevel)
?? this._gameContext.Configuration.Items
.Where(d => d.Group <= StaffGroup && !d.IsAmmunition && d.QualifiedCharacters.Contains(characterClass))
.MinBy(d => d.DropLevel);
if (weapon is not null)
{
if (weapon.Group == BowGroup)
{
// Bows need ammunition; the arrows go into the left hand.
this.AddEquippedItem(context, inventory, characterClass, InventoryConstants.RightHandSlot, weapon, starterItemLevel);
this.AddAmmunition(context, inventory);
}
else
{
this.AddEquippedItem(context, inventory, characterClass, InventoryConstants.LeftHandSlot, weapon, starterItemLevel);
}
}
// Choose a thematically appropriate armor set the class can wear, tried in order (warriors -> Leather,
// wizards -> Pad, elves -> Vine, summoners -> Mistery, then fallbacks). Each piece is added only if the
// class is qualified for it, so e.g. the Magic Gladiator keeps the set but skips the helm it can't wear.
foreach (var set in ArmorSetCandidates)
{
if (this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == ArmorGroup && d.Number == set) is not { } chest
|| !chest.QualifiedCharacters.Contains(characterClass))
{
continue;
}
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.HelmSlot, 7, set, starterItemLevel);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.ArmorSlot, 8, set, starterItemLevel);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.PantsSlot, 9, set, starterItemLevel);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.GlovesSlot, 10, set, starterItemLevel);
this.EquipArmorPiece(context, inventory, characterClass, InventoryConstants.BootsSlot, 11, set, starterItemLevel);
break;
}
this.AddPotions(context, inventory);
}
private void AddPotions(IPlayerContext context, ItemStorage inventory)
{
// A stack of Large Healing Potions so the offline HealingHandler has something to drink, and a
// stack of Large Mana Potions so casters can keep casting instead of degrading to weak melee once
// their mana runs dry. The BotNavigator tops both up at runtime, so the bot never runs out.
// Durability holds the stack count.
this.AddPotionStack(context, inventory, 3, InventoryConstants.EquippableSlotsCount); // Large Healing Potion, first backpack slot
this.AddPotionStack(context, inventory, 6, (byte)(InventoryConstants.EquippableSlotsCount + 1)); // Large Mana Potion, second backpack slot
}
private void AddPotionStack(IPlayerContext context, ItemStorage inventory, byte potionNumber, byte slot)
{
var potion = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == 14 && d.Number == potionNumber);
if (potion is null)
{
return;
}
var item = context.CreateNew<Item>();
item.Definition = potion;
// Only a handful of charges to start with: fresh bots head to the merchant right away and buy
// their supplies with their starting Zen, kicking off the shopping economy from minute one
// (kept just above the emergency top-up threshold, so the economy path - not the fallback - runs).
item.Durability = Rand.NextInt(10, 16);
item.ItemSlot = slot;
inventory.Items.Add(item);
}
private void EquipArmorPiece(IPlayerContext context, ItemStorage inventory, CharacterClass characterClass, byte slot, int group, int number, byte starterItemLevel)
{
var definition = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == group && d.Number == number);
if (definition is null || !definition.QualifiedCharacters.Contains(characterClass))
{
return;
}
this.AddEquippedItem(context, inventory, characterClass, slot, definition, starterItemLevel);
}
private void AddEquippedItem(IPlayerContext context, ItemStorage inventory, CharacterClass characterClass, byte slot, ItemDefinition definition, byte starterItemLevel)
{
if (!definition.QualifiedCharacters.Contains(characterClass))
{
return;
}
var item = context.CreateNew<Item>();
item.Definition = definition;
item.Level = starterItemLevel;
item.Durability = definition.Durability;
item.ItemSlot = slot;
inventory.Items.Add(item);
}
private void AddAmmunition(IPlayerContext context, ItemStorage inventory)
{
var arrows = this._gameContext.Configuration.Items.FirstOrDefault(d => d.Group == 4 && d.Number == 15);
if (arrows is null)
{
return;
}
var item = context.CreateNew<Item>();
item.Definition = arrows;
item.Durability = 255;
item.ItemSlot = InventoryConstants.LeftHandSlot;
inventory.Items.Add(item);
}
}