Adding Mods w/Symlink

This commit is contained in:
2025-01-02 20:23:50 -05:00
parent 88fc2c3c93
commit 404890ecbb
10218 changed files with 3191380 additions and 0 deletions
+28
View File
@@ -0,0 +1,28 @@
[General]
gameName=spt
modid=0
version=d2025.1.2.0
newestVersion=
category="1,"
nexusFileStatus=1
installationFile=inory-dynamicgoons-1.1.0.rar
repository=Nexus
ignoredVersion=
comments=
notes=
nexusDescription=
url=
hasCustomURL=true
lastNexusQuery=
lastNexusUpdate=
nexusLastModified=2024-12-16T05:56:04Z
nexusCategory=0
converted=false
validated=false
color=@Variant(\0\0\0\x43\0\xff\xff\0\0\0\0\0\0\0\0)
tracked=0
[installedFiles]
1\modid=0
1\fileid=0
size=1
@@ -0,0 +1,6 @@
{
"debugLogs": false,
"preventSameMapRotation": true,
"goonsSpawnChance": 30,
"rotationInterval": 180
}
@@ -0,0 +1,15 @@
{
"enabledMaps": {
"bigmap": true,
"shoreline": true,
"lighthouse": true,
"woods": true,
"rezervbase": false,
"laboratory": false,
"tarkovstreets": false,
"factory4_day": false,
"factory4_night": false,
"sandbox_high": false,
"interchange": false
}
}
@@ -0,0 +1,30 @@
{
"name": "Dynamic Goons",
"version": "1.1.0",
"sptVersion": "~3.10",
"loadBefore": [],
"loadAfter": [],
"incompatibilities": [],
"isBundleMod": false,
"main": "src/mod.js",
"scripts": {
"setup": "npm i",
"build": "node ./build.mjs",
"buildinfo": "node ./build.mjs --verbose"
},
"devDependencies": {
"@types/node": "20.11",
"@typescript-eslint/eslint-plugin": "7.2",
"@typescript-eslint/parser": "7.2",
"archiver": "^6.0",
"eslint": "8.57",
"fs-extra": "11.2",
"ignore": "^5.2",
"tsyringe": "4.8.0",
"typescript": "5.4",
"winston": "3.12"
},
"author": "inory",
"contributors": [],
"license": "MIT"
}
@@ -0,0 +1,40 @@
import { inject, injectAll, injectable } from "tsyringe";
import { AbstractDialogueChatBot } from "@spt/helpers/Dialogue/AbstractDialogueChatBot";
import { IChatCommand } from "@spt/helpers/Dialogue/Commando/IChatCommand";
import { IUserDialogInfo } from "@spt/models/eft/profile/ISptProfile";
import { MemberCategory } from "@spt/models/enums/MemberCategory";
import { ILogger } from "@spt/models/spt/utils/ILogger";
import { MailSendService } from "@spt/services/MailSendService";
@injectable()
export class GoonsTracker extends AbstractDialogueChatBot {
constructor(
@inject("WinstonLogger") logger: ILogger,
@inject("MailSendService") mailSendService: MailSendService,
@injectAll("TrackerCommands") chatCommands: IChatCommand[]
) {
super(logger, mailSendService, chatCommands);
}
public getChatBot(): IUserDialogInfo {
return {
_id: "674d96b02225f02fff47b3be",
aid: 777,
Info: {
Level: 1,
MemberCategory: MemberCategory.SHERPA,
SelectedMemberCategory: MemberCategory.SHERPA,
Nickname: "Goons Tracker",
Side: "Usec",
},
};
}
protected getUnrecognizedCommandMessage(): string {
return (
"Unrecognized command, please type goons track to receive details of The Goon's location. " +
"Or type goons rotation if you need an explanation for their rotation mechanic."
);
}
}
@@ -0,0 +1,76 @@
import { inject, injectable } from "tsyringe";
import { IChatCommand } from "@spt/helpers/Dialogue/Commando/IChatCommand";
import { ISendMessageRequest } from "@spt/models/eft/dialog/ISendMessageRequest";
import { IUserDialogInfo } from "@spt/models/eft/profile/ISptProfile";
import { MailSendService } from "@spt/services/MailSendService";
import { ChatLocationService } from "../services/ChatLocationService";
@injectable()
export class TrackerCommands implements IChatCommand {
constructor(
@inject("MailSendService") protected mailSendService: MailSendService,
@inject("ChatLocationService") private locationService: ChatLocationService
) {}
public getCommandPrefix(): string {
return "goons";
}
public getCommandHelp(command: string): string {
if (command === "track") {
return "Usage: goons track - Get current rotation information.";
} else if (command === "rotation") {
return "Usage: goons rotation - Learn about the rotation mechanics.";
}
}
public getCommands(): Set<string> {
return new Set<string>(["track", "rotation"]);
}
public handle(
command: string,
commandHandler: IUserDialogInfo,
sessionId: string,
request: ISendMessageRequest
): string {
if (command === "track") {
try {
const locationData = this.locationService.getLocationData();
const responseMessage =
`Location: ${locationData.location}\n` +
`Last Seen: ${locationData.timeSinceLastSeen} minutes ago\n` +
`Rotation: ${locationData.rotationChance.toFixed(2)}%\n` +
`${locationData.dateLastSeen}`;
this.mailSendService.sendUserMessageToPlayer(
sessionId,
commandHandler,
responseMessage
);
} catch (error) {
console.error("Error in handle:", error.message);
this.mailSendService.sendUserMessageToPlayer(
sessionId,
commandHandler,
"Error retrieving location data. Please try again later."
);
}
} else if (command === "rotation") {
const rotationExplanation =
"The Goons stay on a map for a variable amount of time. " +
"As time passes, the chance of them switching to a new " +
"map increases. They will rotate to a new map at the end of a raid based on this chance. " +
"Use goons track to see where they are currently and their rotation chance.";
this.mailSendService.sendUserMessageToPlayer(
sessionId,
commandHandler,
rotationExplanation
);
}
return request.dialogId;
}
}
@@ -0,0 +1,9 @@
{
"factory4_day": ["BotZone"],
"factory4_night": ["BotZone"],
"tarkovstreets": ["ZoneCinema", "ZoneConcordiaParking"],
"laboratory": ["BotZoneFloor1", "BotZoneFloor2"],
"rezervbase": ["ZoneRailStrorage", "ZoneBarrack"],
"sandbox_high": ["ZoneSandbox"],
"interchange": ["ZonePowerStation", "ZoneCenterBot"]
}
@@ -0,0 +1,6 @@
{
"nextUpdateTime": 0,
"selectedMap": "0",
"lastRotationInterval": 180,
"lastUpdateTime": 0
}
@@ -0,0 +1,147 @@
import * as path from "path";
import type { DependencyContainer } from "tsyringe";
import type { ILogger } from "@spt/models/spt/utils/ILogger";
import type { IPostDBLoadMod } from "@spt/models/external/IPostDBLoadMod";
import type { IPreSptLoadMod } from "@spt/models/external/IPreSptLoadMod";
import type { DatabaseServer } from "@spt/servers/DatabaseServer";
import type { IDatabaseTables } from "@spt/models/spt/server/IDatabaseTables";
import type { StaticRouterModService } from "@spt/services/mod/staticRouter/StaticRouterModService";
import type { LocationCallbacks } from "@spt/callbacks/LocationCallback";
import { ILocations } from "@spt/models/spt/server/ILocations";
import { DialogueController } from "@spt/controllers/DialogueController";
import { TrackerCommands } from "./chatbot/TrackerCommands";
import { GoonsTracker } from "./chatbot/GoonsTracker";
import { ChatLocationService } from "./services/ChatLocationService";
import { RotationService } from "./services/RotationService";
import { AddBossToMaps } from "./services/AddGoonsToMaps";
class Mod implements IPostDBLoadMod, IPreSptLoadMod {
private logger: ILogger;
private databaseServer: DatabaseServer;
private tables: IDatabaseTables;
private maps: ILocations;
private locationCallbacks: LocationCallbacks;
private rotationService: RotationService;
private addBossToAllMaps: AddBossToMaps;
private rotationData = path.resolve(__dirname, "db/rotationData.json");
private modConfig = require("../config/config.json");
private mapConfig = path.resolve(__dirname, "../config/mapConfig.json");
private zonesConfigPath = path.resolve(__dirname, "../src/db/mapZones.json");
private async postDBLoad(container: DependencyContainer): Promise<void> {
this.databaseServer = container.resolve<DatabaseServer>("DatabaseServer");
this.tables = this.databaseServer.getTables();
this.maps = this.tables.locations;
this.locationCallbacks =
container.resolve<LocationCallbacks>("LocationCallbacks");
container.register("ChatLocationService", {
useClass: ChatLocationService,
});
container.register<TrackerCommands>("TrackerCommands", TrackerCommands);
container.register<GoonsTracker>("GoonsTracker", GoonsTracker);
container
.resolve<DialogueController>("DialogueController")
.registerChatBot(container.resolve<GoonsTracker>("GoonsTracker"));
this.addBossToAllMaps = new AddBossToMaps(
this.logger,
this.zonesConfigPath,
this.modConfig
);
this.addBossToAllMaps.addBossToMaps(this.maps);
this.rotationService = new RotationService(
this.logger,
this.modConfig,
this.rotationData,
this.mapConfig
);
const rotationData = await this.rotationService.readRotationData();
const currentTime = Date.now();
const rotationInterval = this.modConfig.rotationInterval;
await this.rotationService.handleRotationChance(
rotationData,
currentTime,
rotationInterval
);
}
public async preSptLoad(container: DependencyContainer): Promise<void> {
this.logger = container.resolve<ILogger>("WinstonLogger");
const staticRouterModService = container.resolve<StaticRouterModService>(
"StaticRouterModService"
);
staticRouterModService.registerStaticRouter(
"RotationUpdate",
[
{
url: "/client/locations",
action: async (
url: string,
info: any,
sessionId: string,
output: string
) => {
await this.updateBossSpawnChances();
return this.locationCallbacks.getLocationData(url, info, sessionId);
},
},
{
url: "/client/match/local/end",
action: async (url, info, sessionId, output) => {
const rotationData = await this.rotationService.readRotationData();
const currentTime = Date.now();
const rotationInterval = this.modConfig.rotationInterval || 180;
await this.rotationService.handleRotationChance(
rotationData,
currentTime,
rotationInterval
);
return output;
},
},
],
"spt"
);
}
private async updateBossSpawnChances(): Promise<void> {
const { selectedMap } =
await this.rotationService.getNextUpdateTimeAndMapData();
const bossName = "bossKnight";
const spawnChance = this.modConfig.goonsSpawnChance;
for (const mapName in this.maps) {
const mapBosses = this.maps[mapName]?.base?.BossLocationSpawn || [];
for (const mapBoss of mapBosses) {
if (mapBoss.BossName !== bossName) continue;
if (this.modConfig.debugLogs) {
this.logger.info(
`[Dynamic Goons] ${mapName}: Before Chance: ${mapBoss.BossChance}`
);
}
mapBoss.BossChance = mapName === selectedMap ? spawnChance : 0;
if (this.modConfig.debugLogs) {
this.logger.info(
`[Dynamic Goons] ${mapName}: After Chance: ${mapBoss.BossChance}`
);
}
}
}
}
}
export const mod = new Mod();
@@ -0,0 +1,98 @@
import * as fs from "fs";
import { inject, injectable } from "tsyringe";
import { ILogger } from "@spt/models/spt/utils/ILogger";
import { ILocation } from "@spt/models/eft/common/ILocation";
import { IBossLocationSpawn } from "@spt/models/eft/common/ILocationBase";
@injectable()
export class AddBossToMaps {
private zonesConfig: Record<string, string[]>;
private bossData: IBossLocationSpawn;
private modConfig: any;
constructor(
@inject("Logger") private logger: ILogger,
private zonesConfigPath: string,
modConfig: any
) {
this.zonesConfig = this.loadZonesConfig();
this.bossData = this.defineBossData();
this.modConfig = modConfig;
}
public addBossToMaps(locationList: Record<string, ILocation>): void {
const bossName = this.bossData.BossName;
for (const mapName in this.zonesConfig) {
const location = locationList[mapName];
if (!location) {
this.logger.warning(
`[Dynamic Goons] Skipping map '${mapName}' as it is not present in the locationList.`
);
continue;
}
const zonesForMap = this.zonesConfig[mapName];
const combinedZones = zonesForMap.join(",");
const mapBosses = location.base?.BossLocationSpawn || [];
mapBosses.push({
...this.bossData,
BossZone: combinedZones, // Combine all zones into a single entry
});
if (this.modConfig.debugLogs) {
this.logger.info(
`[Dynamic Goons] Added boss '${bossName}' to map '${mapName}' with zones '${combinedZones}'.`
);
}
}
}
private loadZonesConfig(): Record<string, string[]> {
try {
const data = fs.readFileSync(this.zonesConfigPath, "utf8");
return JSON.parse(data);
} catch (error) {
this.logger.error(
`[Dynamic Goons] Error loading zones config: ${error.message}`
);
return {};
}
}
private defineBossData(): IBossLocationSpawn {
return {
BossChance: 30,
BossDifficult: "normal",
BossEscortAmount: "0",
BossEscortDifficult: "normal",
BossEscortType: "exUsec",
BossName: "bossKnight",
BossPlayer: false,
BossZone: "",
Delay: 0,
DependKarma: false,
DependKarmaPVE: false,
ForceSpawn: false,
IgnoreMaxBots: true,
RandomTimeSpawn: true,
SpawnMode: ["pve", "regular"],
Supports: [
{
BossEscortAmount: "1",
BossEscortDifficult: ["normal"],
BossEscortType: "followerBigPipe",
},
{
BossEscortAmount: "1",
BossEscortDifficult: ["normal"],
BossEscortType: "followerBirdEye",
},
],
Time: -1,
TriggerId: "",
TriggerName: "",
};
}
}
@@ -0,0 +1,87 @@
import { injectable } from "tsyringe";
import * as fs from "fs";
import * as path from "path";
import { rotationChanceCalculator } from "./RotationChanceCalculator";
@injectable()
export class ChatLocationService {
private dataFilePath = path.resolve(__dirname, "../db/rotationData.json");
private modConfig = require("../../config/config.json");
public getLocationData(): {
location: string;
timeSinceLastSeen: number;
rotationChance: number;
dateLastSeen: string;
} {
try {
const data = fs.readFileSync(this.dataFilePath, "utf-8");
const parsedData = JSON.parse(data);
if (
parsedData &&
parsedData.selectedMap &&
typeof parsedData.lastUpdateTime === "number"
) {
const locationMap: { [key: string]: string } = {
bigmap: "Customs",
woods: "Woods",
shoreline: "Shoreline",
lighthouse: "Lighthouse",
tarkovstreets: "Streets of Tarkov",
interchange: "Interchange",
sandbox_high: "Ground Zero",
factory4_day: "Factory Day",
factory4_night: "Factory Night",
laboratory: "The Lab",
rezervbase: "Reserve",
};
const location = locationMap[parsedData.selectedMap.toLowerCase()];
const currentTime = Date.now();
const timeSinceLastSeen = Math.max(
0,
Math.floor((currentTime - parsedData.lastUpdateTime) / 1000 / 60)
);
const remainingTime = parsedData.nextUpdateTime - currentTime;
const rotationChance = this.calculateRotationChance(remainingTime);
const dateLastSeen = new Date(parsedData.lastUpdateTime).toLocaleString(
"en-US",
{
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: true,
}
);
return {
location,
timeSinceLastSeen,
rotationChance,
dateLastSeen,
};
} else {
throw new Error("Invalid data format in the JSON file.");
}
} catch (error) {
console.error("Error reading or parsing the JSON file:", error.message);
throw new Error(
"Error retrieving location data. Please try again later."
);
}
}
public calculateRotationChance(remainingTime: number): number {
return rotationChanceCalculator(
remainingTime,
this.modConfig.rotationInterval
);
}
}
@@ -0,0 +1,29 @@
import { ILogger } from "@spt/models/spt/utils/ILogger";
export function rotationChanceCalculator(
remainingTime: number,
rotationInterval: number,
logger: ILogger | null = null,
debugLogs: boolean = false
): number {
const maxTime = rotationInterval * 60 * 1000;
const maxChance = 100;
if (remainingTime <= 0) {
return maxChance;
}
const factor = remainingTime / maxTime;
const steepFactor = Math.pow(factor, 0.3); // Adjust the rotation chance curve, smaller exponent, slower the initial rise
const chance = maxChance * (1 - Math.pow(steepFactor, 1));
if (debugLogs && logger) {
logger.info(
`[Dynamic Goons] Remaining time: ${remainingTime}ms, Rotation chance: ${chance}%`
);
}
return chance;
}
@@ -0,0 +1,228 @@
import * as fs from "fs";
import { ILogger } from "@spt/models/spt/utils/ILogger";
import { inject, injectable } from "tsyringe";
import { rotationChanceCalculator } from "../services/RotationChanceCalculator";
@injectable()
export class RotationService {
private modConfig: any;
private rotationData: string;
private mapConfig: any;
constructor(
@inject("Logger") private logger: ILogger,
modConfig: any,
rotationDataFilePath: string,
mapConfig: any
) {
this.modConfig = modConfig;
this.rotationData = rotationDataFilePath;
this.mapConfig = mapConfig;
}
public async getNextUpdateTimeAndMapData(): Promise<{
nextUpdateTime: number;
selectedMap: string;
}> {
try {
const rotationData = await this.readRotationData();
return {
nextUpdateTime: rotationData.nextUpdateTime,
selectedMap: rotationData.selectedMap,
};
} catch (error) {
this.logger.error(
`[Dynamic Goons] Error reading rotation data: ${error.message}`
);
return { nextUpdateTime: 0, selectedMap: "bigmap" };
}
}
public async handleRotationChance(
rotationData: any,
currentTime: number,
rotationInterval: number
): Promise<void> {
const remainingTime = rotationData.nextUpdateTime - currentTime;
const rotationChance = rotationChanceCalculator(
remainingTime,
rotationInterval,
this.logger,
this.modConfig.debugLogs
);
if (this.modConfig.debugLogs) {
this.logger.info(
`[Dynamic Goons] Remaining time: ${remainingTime}ms, Rotation chance: ${rotationChance}%`
);
}
const randomRoll = Math.random() * 100;
if (rotationData.lastRotationInterval !== this.modConfig.rotationInterval) {
if (this.modConfig.debugLogs) {
this.logger.info(
`[Dynamic Goons] Rotation interval changed. Rotating now.`
);
}
await this.selectRandomMapAndSave(rotationInterval);
}
if (randomRoll <= rotationChance) {
if (this.modConfig.debugLogs) {
this.logger.info(`[Dynamic Goons] Rotation triggered. Rotating now.`);
}
await this.selectRandomMapAndSave(rotationInterval);
}
}
public calculateRotationChance(remainingTime: number): number {
const maxTime = this.modConfig.rotationInterval * 60 * 1000;
const maxChance = 100;
if (remainingTime <= 0) return maxChance;
const factor = Math.min(Math.max(remainingTime / maxTime, 0), 1);
const chance = maxChance * (1 - Math.pow(factor, 2));
if (this.modConfig.debugLogs) {
this.logger.info(
`[Dynamic Goons] Remaining time: ${remainingTime}ms, Rotation chance: ${chance}%`
);
}
return chance;
}
private async selectRandomMapAndSave(
rotationInterval: number
): Promise<void> {
const rotationData = await this.readRotationData();
const chosenMap = await this.getRandomMap(rotationData.selectedMap);
const nextUpdateTime = Date.now() + rotationInterval * 60 * 1000;
const lastUpdateTime = Date.now();
if (this.modConfig.debugLogs) {
const updateTimeString = new Date(nextUpdateTime).toLocaleTimeString(
"en-GB",
{
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
}
);
const remainingTimeFormatted = this.formatTime(
nextUpdateTime - Date.now()
);
this.logger.info(
`[Dynamic Goons] Selected Map: ${chosenMap}, Update Scheduled in: ${updateTimeString}, Remaining Time: ${remainingTimeFormatted}, Last Rotation: ${new Date(
lastUpdateTime
).toLocaleString()}`
);
}
await this.saveNextUpdateTimeAndMapData(
nextUpdateTime,
chosenMap,
rotationInterval,
lastUpdateTime
);
}
private async saveNextUpdateTimeAndMapData(
nextUpdateTime: number,
selectedMap: string,
lastRotationInterval: number,
lastUpdateTime: number
): Promise<void> {
try {
const data = {
nextUpdateTime,
selectedMap,
lastRotationInterval,
lastUpdateTime,
};
await fs.promises.writeFile(
this.rotationData,
JSON.stringify(data, null, 4),
"utf8"
);
if (this.modConfig.debugLogs) {
this.logger.info(`[Dynamic Goons] Rotation data saved successfully.`);
}
} catch (error) {
this.logger.error(
`[Dynamic Goons] Error saving rotation data: ${error.message}`
);
}
}
public async readRotationData(): Promise<any> {
try {
const data = await fs.promises.readFile(this.rotationData, "utf8");
return JSON.parse(data);
} catch (error) {
this.logger.error(
`[Dynamic Goons] Error reading rotation data: ${error.message}`
);
return {
lastRotationInterval: 180,
nextUpdateTime: 0,
selectedMap: "bigmap",
lastUpdateTime: 0,
};
}
}
private async getEnabledMaps(): Promise<string[]> {
try {
const data = await fs.promises.readFile(this.mapConfig, "utf8");
const json = JSON.parse(data);
if (!json || !json.enabledMaps || typeof json.enabledMaps !== "object") {
throw new Error("Invalid JSON structure for enabled maps.");
}
// Filter only the maps that are set to true
return Object.entries(json.enabledMaps)
.filter(([_, enabled]) => enabled)
.map(([mapName, _]) => mapName);
} catch (error) {
this.logger.error(
`[Dynamic Goons] Error reading enabled maps file: ${error.message}`
);
// Fallback to default maps if reading fails
return ["bigmap", "shoreline", "lighthouse", "woods"];
}
}
private async getRandomMap(
excludeMap: string | null = null
): Promise<string> {
const enabledMaps = await this.getEnabledMaps();
// Filter out the excluded map
const availableMaps = excludeMap
? enabledMaps.filter((map) => map !== excludeMap)
: enabledMaps;
// Fallback to default maps if no available maps
const defaultFallbackMaps = ["bigmap", "shoreline", "lighthouse", "woods"];
const finalMaps =
availableMaps.length > 0 ? availableMaps : defaultFallbackMaps;
// Randomly select a map
const selectedMap = finalMaps[Math.floor(Math.random() * finalMaps.length)];
return selectedMap;
}
// This is just for making debugging logs easier to read for me :v
private formatTime(ms: number): string {
const hours = Math.floor(ms / (1000 * 60 * 60));
const minutes = Math.floor((ms % (1000 * 60 * 60)) / (1000 * 60));
const seconds = Math.floor((ms % (1000 * 60)) / 1000);
return `${hours}h ${minutes}m ${seconds}s`;
}
}