Server/project/src/generators/BotGenerator.ts
2024-01-19 19:21:51 +00:00

564 lines
20 KiB
TypeScript

import { inject, injectable } from "tsyringe";
import { BotInventoryGenerator } from "@spt-aki/generators/BotInventoryGenerator";
import { BotLevelGenerator } from "@spt-aki/generators/BotLevelGenerator";
import { BotDifficultyHelper } from "@spt-aki/helpers/BotDifficultyHelper";
import { BotHelper } from "@spt-aki/helpers/BotHelper";
import { ProfileHelper } from "@spt-aki/helpers/ProfileHelper";
import { WeightedRandomHelper } from "@spt-aki/helpers/WeightedRandomHelper";
import {
Common,
Health as PmcHealth,
IBaseJsonSkills,
IBaseSkill,
IBotBase,
Info,
Skills as botSkills,
} from "@spt-aki/models/eft/common/tables/IBotBase";
import { Appearance, Health, IBotType } from "@spt-aki/models/eft/common/tables/IBotType";
import { Item, Upd } from "@spt-aki/models/eft/common/tables/IItem";
import { BaseClasses } from "@spt-aki/models/enums/BaseClasses";
import { ConfigTypes } from "@spt-aki/models/enums/ConfigTypes";
import { MemberCategory } from "@spt-aki/models/enums/MemberCategory";
import { BotGenerationDetails } from "@spt-aki/models/spt/bots/BotGenerationDetails";
import { IBotConfig } from "@spt-aki/models/spt/config/IBotConfig";
import { IPmcConfig } from "@spt-aki/models/spt/config/IPmcConfig";
import { ILogger } from "@spt-aki/models/spt/utils/ILogger";
import { ConfigServer } from "@spt-aki/servers/ConfigServer";
import { DatabaseServer } from "@spt-aki/servers/DatabaseServer";
import { BotEquipmentFilterService } from "@spt-aki/services/BotEquipmentFilterService";
import { LocalisationService } from "@spt-aki/services/LocalisationService";
import { SeasonalEventService } from "@spt-aki/services/SeasonalEventService";
import { HashUtil } from "@spt-aki/utils/HashUtil";
import { JsonUtil } from "@spt-aki/utils/JsonUtil";
import { RandomUtil } from "@spt-aki/utils/RandomUtil";
import { TimeUtil } from "@spt-aki/utils/TimeUtil";
@injectable()
export class BotGenerator
{
protected botConfig: IBotConfig;
protected pmcConfig: IPmcConfig;
constructor(
@inject("WinstonLogger") protected logger: ILogger,
@inject("HashUtil") protected hashUtil: HashUtil,
@inject("RandomUtil") protected randomUtil: RandomUtil,
@inject("TimeUtil") protected timeUtil: TimeUtil,
@inject("JsonUtil") protected jsonUtil: JsonUtil,
@inject("ProfileHelper") protected profileHelper: ProfileHelper,
@inject("DatabaseServer") protected databaseServer: DatabaseServer,
@inject("BotInventoryGenerator") protected botInventoryGenerator: BotInventoryGenerator,
@inject("BotLevelGenerator") protected botLevelGenerator: BotLevelGenerator,
@inject("BotEquipmentFilterService") protected botEquipmentFilterService: BotEquipmentFilterService,
@inject("WeightedRandomHelper") protected weightedRandomHelper: WeightedRandomHelper,
@inject("BotHelper") protected botHelper: BotHelper,
@inject("BotDifficultyHelper") protected botDifficultyHelper: BotDifficultyHelper,
@inject("SeasonalEventService") protected seasonalEventService: SeasonalEventService,
@inject("LocalisationService") protected localisationService: LocalisationService,
@inject("ConfigServer") protected configServer: ConfigServer,
)
{
this.botConfig = this.configServer.getConfig(ConfigTypes.BOT);
this.pmcConfig = this.configServer.getConfig(ConfigTypes.PMC);
}
/**
* Generate a player scav bot object
* @param role e.g. assault / pmcbot
* @param difficulty easy/normal/hard/impossible
* @param botTemplate base bot template to use (e.g. assault/pmcbot)
* @returns
*/
public generatePlayerScav(sessionId: string, role: string, difficulty: string, botTemplate: IBotType): IBotBase
{
let bot = this.getCloneOfBotBase();
bot.Info.Settings.BotDifficulty = difficulty;
bot.Info.Settings.Role = role;
bot.Info.Side = "Savage";
const botGenDetails: BotGenerationDetails = {
isPmc: false,
side: "Savage",
role: role,
playerLevel: 0,
botRelativeLevelDeltaMax: 0,
botRelativeLevelDeltaMin: 0,
botCountToGenerate: 1,
botDifficulty: difficulty,
isPlayerScav: true,
};
bot = this.generateBot(sessionId, bot, botTemplate, botGenDetails);
return bot;
}
/**
* Create 1 bots of the type/side/difficulty defined in botGenerationDetails
* @param sessionId Session id
* @param botGenerationDetails details on how to generate bots
* @returns constructed bot
*/
public prepareAndGenerateBot(sessionId: string, botGenerationDetails: BotGenerationDetails): IBotBase
{
let bot = this.getCloneOfBotBase();
bot.Info.Settings.Role = botGenerationDetails.eventRole
? botGenerationDetails.eventRole
: botGenerationDetails.role;
bot.Info.Side = botGenerationDetails.side;
bot.Info.Settings.BotDifficulty = botGenerationDetails.botDifficulty;
// Get raw json data for bot (Cloned)
const botJsonTemplate = this.jsonUtil.clone(
this.botHelper.getBotTemplate((botGenerationDetails.isPmc)
? bot.Info.Side
: botGenerationDetails.role),
);
bot = this.generateBot(sessionId, bot, botJsonTemplate, botGenerationDetails);
return bot;
}
/**
* Get a clone of the database\bots\base.json file
* @returns IBotBase object
*/
protected getCloneOfBotBase(): IBotBase
{
return this.jsonUtil.clone(this.databaseServer.getTables().bots.base);
}
/**
* Create a IBotBase object with equipment/loot/exp etc
* @param sessionId Session id
* @param bot bots base file
* @param botJsonTemplate Bot template from db/bots/x.json
* @param botGenerationDetails details on how to generate the bot
* @returns IBotBase object
*/
protected generateBot(
sessionId: string,
bot: IBotBase,
botJsonTemplate: IBotType,
botGenerationDetails: BotGenerationDetails,
): IBotBase
{
const botRole = botGenerationDetails.role.toLowerCase();
const botLevel = this.botLevelGenerator.generateBotLevel(
botJsonTemplate.experience.level,
botGenerationDetails,
bot,
);
if (!botGenerationDetails.isPlayerScav)
{
this.botEquipmentFilterService.filterBotEquipment(
sessionId,
botJsonTemplate,
botLevel.level,
botGenerationDetails,
);
}
bot.Info.Nickname = this.generateBotNickname(
botJsonTemplate,
botGenerationDetails.isPlayerScav,
botRole,
sessionId,
);
if (!this.seasonalEventService.christmasEventEnabled())
{
this.seasonalEventService.removeChristmasItemsFromBotInventory(
botJsonTemplate.inventory,
botGenerationDetails.role,
);
}
// Remove hideout data if bot is not a PMC or pscav
if (!(botGenerationDetails.isPmc || botGenerationDetails.isPlayerScav))
{
bot.Hideout = null;
}
bot.Info.Experience = botLevel.exp;
bot.Info.Level = botLevel.level;
bot.Info.Settings.Experience = this.randomUtil.getInt(
botJsonTemplate.experience.reward.min,
botJsonTemplate.experience.reward.max,
);
bot.Info.Settings.StandingForKill = botJsonTemplate.experience.standingForKill;
bot.Info.Voice = this.randomUtil.getArrayValue(botJsonTemplate.appearance.voice);
bot.Health = this.generateHealth(botJsonTemplate.health, bot.Info.Side === "Savage");
bot.Skills = this.generateSkills(<any>botJsonTemplate.skills); // TODO: fix bad type, bot jsons store skills in dict, output needs to be array
this.setBotAppearance(bot, botJsonTemplate.appearance, botGenerationDetails);
bot.Inventory = this.botInventoryGenerator.generateInventory(
sessionId,
botJsonTemplate,
botRole,
botGenerationDetails.isPmc,
botLevel.level,
);
if (this.botHelper.isBotPmc(botRole))
{
this.getRandomisedGameVersionAndCategory(bot.Info);
bot.Info.IsStreamerModeAvailable = true; // Set to true so client patches can pick it up later - client sometimes alters botrole to assaultGroup
}
if (this.botConfig.botRolesWithDogTags.includes(botRole))
{
this.addDogtagToBot(bot);
}
// generate new bot ID
bot = this.generateId(bot);
// generate new inventory ID
bot = this.generateInventoryID(bot);
// Set role back to originally requested now its been generated
if (botGenerationDetails.eventRole)
{
bot.Info.Settings.Role = botGenerationDetails.eventRole;
}
return bot;
}
/**
* Choose various appearance settings for a bot using weights
* @param bot Bot to adjust
* @param appearance Appearance settings to choose from
* @param botGenerationDetails Generation details
*/
protected setBotAppearance(bot: IBotBase, appearance: Appearance, botGenerationDetails: BotGenerationDetails): void
{
bot.Customization.Head = this.randomUtil.getArrayValue(appearance.head);
bot.Customization.Body = this.weightedRandomHelper.getWeightedValue<string>(appearance.body);
bot.Customization.Feet = this.weightedRandomHelper.getWeightedValue<string>(appearance.feet);
bot.Customization.Hands = this.randomUtil.getArrayValue(appearance.hands);
}
/**
* Create a bot nickname
* @param botJsonTemplate x.json from database
* @param isPlayerScav Will bot be player scav
* @param botRole role of bot e.g. assault
* @returns Nickname for bot
*/
protected generateBotNickname(
botJsonTemplate: IBotType,
isPlayerScav: boolean,
botRole: string,
sessionId: string,
): string
{
let name = `${this.randomUtil.getArrayValue(botJsonTemplate.firstName)} ${
this.randomUtil.getArrayValue(botJsonTemplate.lastName) || ""
}`;
name = name.trim();
const playerProfile = this.profileHelper.getPmcProfile(sessionId);
// Simulate bot looking like a Player scav with the pmc name in brackets
if (botRole === "assault" && this.randomUtil.getChance100(this.botConfig.chanceAssaultScavHasPlayerScavName))
{
if (isPlayerScav)
{
return name;
}
const pmcNames = [
...this.databaseServer.getTables().bots.types.usec.firstName,
...this.databaseServer.getTables().bots.types.bear.firstName,
];
return `${name} (${this.randomUtil.getArrayValue(pmcNames)})`;
}
if (this.botConfig.showTypeInNickname && !isPlayerScav)
{
name += ` ${botRole}`;
}
// If bot name matches current players name, chance to add localised prefix to name
if (name.toLowerCase() === playerProfile.Info.Nickname.toLowerCase())
{
if (this.randomUtil.getChance100(this.pmcConfig.addPrefixToSameNamePMCAsPlayerChance))
{
const prefix = this.localisationService.getRandomTextThatMatchesPartialKey("pmc-name_prefix_");
name = `${prefix} ${name}`;
}
}
return name;
}
/**
* Log the number of PMCs generated to the debug console
* @param output Generated bot array, ready to send to client
*/
protected logPmcGeneratedCount(output: IBotBase[]): void
{
const pmcCount = output.reduce(
(acc, cur) => cur.Info.Side === "Bear" || cur.Info.Side === "Usec" ? ++acc : acc,
0,
);
this.logger.debug(`Generated ${output.length} total bots. Replaced ${pmcCount} with PMCs`);
}
/**
* Converts health object to the required format
* @param healthObj health object from bot json
* @param playerScav Is a pscav bot being generated
* @returns PmcHealth object
*/
protected generateHealth(healthObj: Health, playerScav = false): PmcHealth
{
const bodyParts = playerScav ? healthObj.BodyParts[0] : this.randomUtil.getArrayValue(healthObj.BodyParts);
const newHealth: PmcHealth = {
Hydration: {
Current: this.randomUtil.getInt(healthObj.Hydration.min, healthObj.Hydration.max),
Maximum: healthObj.Hydration.max,
},
Energy: {
Current: this.randomUtil.getInt(healthObj.Energy.min, healthObj.Energy.max),
Maximum: healthObj.Energy.max,
},
Temperature: {
Current: this.randomUtil.getInt(healthObj.Temperature.min, healthObj.Temperature.max),
Maximum: healthObj.Temperature.max,
},
BodyParts: {
Head: {
Health: {
Current: this.randomUtil.getInt(bodyParts.Head.min, bodyParts.Head.max),
Maximum: Math.round(bodyParts.Head.max),
},
},
Chest: {
Health: {
Current: this.randomUtil.getInt(bodyParts.Chest.min, bodyParts.Chest.max),
Maximum: Math.round(bodyParts.Chest.max),
},
},
Stomach: {
Health: {
Current: this.randomUtil.getInt(bodyParts.Stomach.min, bodyParts.Stomach.max),
Maximum: Math.round(bodyParts.Stomach.max),
},
},
LeftArm: {
Health: {
Current: this.randomUtil.getInt(bodyParts.LeftArm.min, bodyParts.LeftArm.max),
Maximum: Math.round(bodyParts.LeftArm.max),
},
},
RightArm: {
Health: {
Current: this.randomUtil.getInt(bodyParts.RightArm.min, bodyParts.RightArm.max),
Maximum: Math.round(bodyParts.RightArm.max),
},
},
LeftLeg: {
Health: {
Current: this.randomUtil.getInt(bodyParts.LeftLeg.min, bodyParts.LeftLeg.max),
Maximum: Math.round(bodyParts.LeftLeg.max),
},
},
RightLeg: {
Health: {
Current: this.randomUtil.getInt(bodyParts.RightLeg.min, bodyParts.RightLeg.max),
Maximum: Math.round(bodyParts.RightLeg.max),
},
},
},
UpdateTime: this.timeUtil.getTimestamp(),
};
return newHealth;
}
/**
* Get a bots skills with randomsied progress value between the min and max values
* @param botSkills Skills that should have their progress value randomised
* @returns
*/
protected generateSkills(botSkills: IBaseJsonSkills): botSkills
{
const skillsToReturn: botSkills = {
Common: this.getSkillsWithRandomisedProgressValue(botSkills.Common, true),
Mastering: this.getSkillsWithRandomisedProgressValue(botSkills.Mastering, false),
Points: 0,
};
return skillsToReturn;
}
/**
* Randomise the progress value of passed in skills based on the min/max value
* @param skills Skills to randomise
* @param isCommonSkills Are the skills 'common' skills
* @returns Skills with randomised progress values as an array
*/
protected getSkillsWithRandomisedProgressValue(
skills: Record<string, IBaseSkill>,
isCommonSkills: boolean,
): IBaseSkill[]
{
if (Object.keys(skills ?? []).length === 0)
{
return [];
}
return Object.keys(skills).map((skillKey): IBaseSkill =>
{
// Get skill from dict, skip if not found
const skill = skills[skillKey];
if (!skill)
{
return null;
}
// All skills have id and progress props
const skillToAdd: IBaseSkill = { Id: skillKey, Progress: this.randomUtil.getInt(skill.min, skill.max) };
// Common skills have additional props
if (isCommonSkills)
{
(skillToAdd as Common).PointsEarnedDuringSession = 0;
(skillToAdd as Common).LastAccess = 0;
}
return skillToAdd;
}).filter((x) => x !== null);
}
/**
* Generate a random Id for a bot and apply to bots _id and aid value
* @param bot bot to update
* @returns updated IBotBase object
*/
protected generateId(bot: IBotBase): IBotBase
{
const botId = this.hashUtil.generate();
bot._id = botId;
bot.aid = this.hashUtil.generateAccountId();
return bot;
}
protected generateInventoryID(profile: IBotBase): IBotBase
{
const defaultInventory = "55d7217a4bdc2d86028b456d";
const itemsByParentHash: Record<string, Item[]> = {};
const inventoryItemHash: Record<string, Item> = {};
// Generate inventoryItem list
let inventoryId = "";
for (const item of profile.Inventory.items)
{
inventoryItemHash[item._id] = item;
if (item._tpl === defaultInventory)
{
inventoryId = item._id;
continue;
}
if (!("parentId" in item))
{
continue;
}
if (!(item.parentId in itemsByParentHash))
{
itemsByParentHash[item.parentId] = [];
}
itemsByParentHash[item.parentId].push(item);
}
// update inventoryId
const newInventoryId = this.hashUtil.generate();
inventoryItemHash[inventoryId]._id = newInventoryId;
profile.Inventory.equipment = newInventoryId;
// update inventoryItem id
if (inventoryId in itemsByParentHash)
{
for (const item of itemsByParentHash[inventoryId])
{
item.parentId = newInventoryId;
}
}
return profile;
}
/**
* Randomise a bots game version and account category
* Chooses from all the game versions (standard, eod etc)
* Chooses account type (default, Sherpa, etc)
* @param botInfo bot info object to update
*/
protected getRandomisedGameVersionAndCategory(botInfo: Info): void
{
if (botInfo.Nickname.toLowerCase() === "nikita")
{
botInfo.GameVersion = "edge_of_darkness";
botInfo.MemberCategory = MemberCategory.DEVELOPER;
return;
}
// more color = more op
botInfo.GameVersion = this.weightedRandomHelper.getWeightedValue(this.pmcConfig.gameVersionWeight);
botInfo.MemberCategory = Number.parseInt(
this.weightedRandomHelper.getWeightedValue(this.pmcConfig.accountTypeWeight),
);
}
/**
* Add a side-specific (usec/bear) dogtag item to a bots inventory
* @param bot bot to add dogtag to
* @returns Bot with dogtag added
*/
protected addDogtagToBot(bot: IBotBase): void
{
const dogtagUpd: Upd = {
SpawnedInSession: true,
Dogtag: {
AccountId: bot.sessionId,
ProfileId: bot._id,
Nickname: bot.Info.Nickname,
Side: bot.Info.Side,
Level: bot.Info.Level,
Time: (new Date().toISOString()),
Status: "Killed by ",
KillerAccountId: "Unknown",
KillerProfileId: "Unknown",
KillerName: "Unknown",
WeaponName: "Unknown",
},
};
const inventoryItem: Item = {
_id: this.hashUtil.generate(),
_tpl: ((bot.Info.Side === "Usec") ? BaseClasses.DOG_TAG_USEC : BaseClasses.DOG_TAG_BEAR),
parentId: bot.Inventory.equipment,
slotId: "Dogtag",
location: undefined,
upd: dogtagUpd,
};
bot.Inventory.items.push(inventoryItem);
}
}