diff --git a/CppAPI/.buildenv/.gitignore b/CppAPI/.buildenv/.gitignore new file mode 100644 index 000000000..567609b12 --- /dev/null +++ b/CppAPI/.buildenv/.gitignore @@ -0,0 +1 @@ +build/ diff --git a/CppAPI/.buildenv/CMakeLists.txt b/CppAPI/.buildenv/CMakeLists.txt new file mode 100644 index 000000000..580280ff9 --- /dev/null +++ b/CppAPI/.buildenv/CMakeLists.txt @@ -0,0 +1,30 @@ +cmake_minimum_required(VERSION 3.21) + +project(SkyrimNetPlugins VERSION 1.0.0 LANGUAGES CXX) + +set(OUTPUT_FOLDER "${CMAKE_CURRENT_SOURCE_DIR}/../../") + +find_package(CommonLibSSE CONFIG REQUIRED) + +function(add_skse_plugin PLUGIN_NAME) + add_commonlibsse_plugin(${PLUGIN_NAME} VERSION 1.0.0 SOURCES ${ARGN}) + target_compile_features(${PLUGIN_NAME} PRIVATE cxx_std_23) + target_include_directories(${PLUGIN_NAME} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/..) + target_precompile_headers(${PLUGIN_NAME} PRIVATE ../PCH.h) + + if(DEFINED OUTPUT_FOLDER) + set(DLL_FOLDER "${OUTPUT_FOLDER}/SKSE/Plugins") + message(STATUS "${PLUGIN_NAME} output: ${DLL_FOLDER}") + add_custom_command( + TARGET "${PLUGIN_NAME}" + POST_BUILD + COMMAND "${CMAKE_COMMAND}" -E make_directory "${DLL_FOLDER}" + COMMAND "${CMAKE_COMMAND}" -E copy_if_different "$" "${DLL_FOLDER}/$" + VERBATIM + ) + endif() +endfunction() + +add_skse_plugin("SkyrimNetMountDecorator" "../src/main.mount.cpp") +add_skse_plugin("SkyrimNetTemperDecorator" "../src/main.temper.cpp") +add_skse_plugin("SkyrimNetQuestJournalDecorator" "../src/main.quest_journal.cpp") diff --git a/CppAPI/.buildenv/CMakePresets.json b/CppAPI/.buildenv/CMakePresets.json new file mode 100644 index 000000000..462c56e24 --- /dev/null +++ b/CppAPI/.buildenv/CMakePresets.json @@ -0,0 +1,39 @@ +{ + "version": 3, + "configurePresets": [ + { + "name": "base", + "hidden": true, + "generator": "Ninja", + "binaryDir": "${sourceDir}/build/${presetName}", + "installDir": "${sourceDir}/install/${presetName}", + "architecture": { "value": "x64", "strategy": "external" }, + "cacheVariables": { + "CMAKE_CXX_COMPILER": "cl.exe", + "CMAKE_CXX_FLAGS": "/permissive- /Zc:preprocessor /EHsc /MP /W4 -DWIN32_LEAN_AND_MEAN -DNOMINMAX -DUNICODE -D_UNICODE", + "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake", + "VCPKG_TARGET_TRIPLET": "x64-windows-static-md", + "CMAKE_MSVC_RUNTIME_LIBRARY": "MultiThreaded$<$:Debug>DLL", + "CMAKE_EXPORT_COMPILE_COMMANDS": "ON" + } + }, + { + "name": "debug", + "inherits": ["base"], + "displayName": "Debug", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } + }, + { + "name": "release", + "inherits": ["base"], + "displayName": "Release", + "cacheVariables": { "CMAKE_BUILD_TYPE": "Release" } + }, + { + "name": "releasewithdeb", + "inherits": ["base"], + "displayName": "Release With Debug Info", + "cacheVariables": { "CMAKE_BUILD_TYPE": "RelWithDebInfo" } + } + ] +} diff --git a/CppAPI/.buildenv/vcpkg-configuration.json b/CppAPI/.buildenv/vcpkg-configuration.json new file mode 100644 index 000000000..612aa7c5f --- /dev/null +++ b/CppAPI/.buildenv/vcpkg-configuration.json @@ -0,0 +1,18 @@ +{ + "default-registry": { + "kind": "git", + "repository": "https://github.com/microsoft/vcpkg.git", + "baseline": "c756f54b9c1431080a54084f24cbf464579f675f" + }, + "registries": [ + { + "kind": "git", + "repository": "https://gitlab.com/colorglass/vcpkg-colorglass", + "baseline": "6fb127f7d425ae3cf3fab0f79005d907c885c0d8", + "packages": [ + "commonlibsse-ng" + ] + } + ], + "overlay-ports": ["./overlayports"] +} diff --git a/CppAPI/.buildenv/vcpkg.json b/CppAPI/.buildenv/vcpkg.json new file mode 100644 index 000000000..7c136305c --- /dev/null +++ b/CppAPI/.buildenv/vcpkg.json @@ -0,0 +1,7 @@ +{ + "$schema": "https://raw.githubusercontent.com/microsoft/vcpkg-tool/main/docs/vcpkg.schema.json", + "dependencies": [ + "commonlibsse-ng", + "spdlog" + ] +} diff --git a/CppAPI/PCH.h b/CppAPI/PCH.h new file mode 100644 index 000000000..a8b43eaef --- /dev/null +++ b/CppAPI/PCH.h @@ -0,0 +1,11 @@ +#pragma once + +#undef ENABLE_SKYRIM_VR + +#include "RE/Skyrim.h" +#include "SKSE/SKSE.h" + +#include + +using namespace std::literals; +namespace logger = SKSE::log; diff --git a/CppAPI/PublicAPI.h b/CppAPI/PublicAPI.h new file mode 100644 index 000000000..6cfb35169 --- /dev/null +++ b/CppAPI/PublicAPI.h @@ -0,0 +1,842 @@ +#pragma once +#include +#include +#include + +/** + * SkyrimNet Public API — loaded at runtime via LoadLibraryA + GetProcAddress. + * + * Drop this header into your SKSE plugin project. Call FindFunctions() once + * during initialization (e.g., SKSE kDataLoaded message). If it returns true, + * the function pointers below are ready to use. + * + * ## Initialization timing + * + * Call FindFunctions() during kDataLoaded. After it returns true: + * - Action registration works immediately. + * - Data query functions are safe to call but return empty results until + * the database initializes — which happens when a save is loaded (or a + * new game starts). Use PublicIsMemorySystemReady() to check. + * + * Quick start: + * @code + * // In your SKSE kDataLoaded handler — resolve function pointers. + * void OnDataLoaded() { + * if (!FindFunctions()) { + * logger::warn("SkyrimNet not found"); + * return; + * } + * logger::info("SkyrimNet API v{}", PublicGetVersion()); + * + * // Register actions here — they're available before a save loads. + * PublicRegisterCPPAction("MyMod_Wave", "Wave at someone", + * [](RE::Actor* a) { return a != nullptr; }, + * [](RE::Actor* a, std::string params) { return true; }, + * "dialogue", "Social", 50, "{}", "", "", ""); + * } + * + * // Query data later, after a save is loaded. + * void SomeGameplayFunction(uint32_t formId) { + * if (!PublicIsMemorySystemReady || !PublicIsMemorySystemReady()) return; + * std::string memories = PublicGetMemoriesForActor(formId, 10, ""); + * std::string events = PublicGetRecentEvents(formId, 20, "dialogue"); + * } + * @endcode + * + * ABI requirement: Both DLLs must use the same MSVC version and CRT linkage + * (dynamic /MD). All CommonLibSSE-NG SKSE plugins satisfy this. + * + * Thread safety: All data query functions (v3+) are thread-safe and return + * empty results gracefully if called before the database is ready. Action + * registration should be done during plugin initialization only. + */ +extern "C" { + +// ============================================================================= +// Core (v2+) +// ============================================================================= + +/** + * Returns the runtime API version (currently 9). + * Version history: 2 = action registration, 3 = data queries + UUID + config, + * 4 = diary queries, + * 5 = decorator registration + event callbacks + memory creation, + * 6 = actor busy state, + * 7 = save unique ID + world knowledge CRUD, + * 8 = send custom prompt to LLM, + * 9 = per-actor world knowledge for prompt enrichment. + */ +int (*PublicGetVersion)() = nullptr; + +/** + * Register a custom action that NPCs can perform via the LLM action system. + * + * @param name Unique action identifier (e.g., "MyMod_DoThing"). + * @param description Human-readable description for LLM context. + * @param eligibleCallback Called to test if an NPC can perform this action. + * Receives the NPC's Actor*. Must be thread-safe. + * @param executeCallback Called when the NPC executes this action. + * Receives Actor* and a JSON params string. + * Return true on success. + * @param triggeringEventTypesCSV Comma-separated event types that trigger + * eligibility checks (e.g., "combat_hit,dialogue"). + * @param categoryStr Built-in category: "Combat", "Social", etc. + * Use customCategory for mod-defined categories. + * @param priority Execution priority (higher = checked first). 50 + * is a reasonable default. + * @param parameterSchemaJSON JSON Schema describing parameters your action + * accepts. The LLM uses this to generate params. + * Pass "{}" if no parameters. + * @param customCategory Custom category name (empty to use categoryStr). + * @param customParentCategory Parent category for nesting (empty = top-level). + * @param tagsCSV Comma-separated tags for filtering. + * @return true if registration succeeded. + */ +bool (*PublicRegisterCPPAction)(const std::string name, const std::string description, std::function eligibleCallback, + std::function executeCallback, + const std::string triggeringEventTypesCSV, std::string categoryStr, int priority, + std::string parameterSchemaJSON, std::string customCategory, std::string customParentCategory, + std::string tagsCSV) = nullptr; + +/** + * Register an action subcategory for organizational grouping. + * + * Subcategories appear as folders in the action tree. They don't execute + * on their own but provide structure for related actions. + * + * @param name Unique subcategory identifier. + * @param description Human-readable description. + * @param eligibleCallback Controls visibility — shown only when this + * returns true for the current NPC. + * @param triggeringEventTypesCSV Comma-separated triggering event types. + * @param priority Display priority within the parent category. + * @param parameterSchemaJSON Reserved, pass "{}". + * @param customCategory Category name for this subcategory. + * @param customParentCategory Parent category (empty = top-level). + * @param tagsCSV Comma-separated tags. + * @return true if registration succeeded. + */ +bool (*PublicRegisterCPPSubCategory)(const std::string name, const std::string description, + std::function eligibleCallback, const std::string triggeringEventTypesCSV, + int priority, std::string parameterSchemaJSON, std::string customCategory, + std::string customParentCategory, std::string tagsCSV) = nullptr; + +// ============================================================================= +// UUID Resolution (v3+) +// ============================================================================= + +/** + * Convert a Skyrim FormID to SkyrimNet's internal UUID. + * @param formId Actor FormID (e.g., 0x00000014 for the player). + * @return UUID, or 0 if the actor is unknown. + */ +uint64_t (*PublicFormIDToUUID)(uint32_t formId) = nullptr; + +/** + * Convert a SkyrimNet UUID back to a Skyrim FormID. + * @return FormID, or 0 if unknown. + */ +uint32_t (*PublicUUIDToFormID)(uint64_t uuid) = nullptr; + +/** + * Get an actor's display name from their UUID. + * @return Actor name, or "" if unknown. + */ +std::string (*PublicGetActorNameByUUID)(uint64_t uuid) = nullptr; + +// ============================================================================= +// Bio Template (v3+) +// ============================================================================= + +/** + * Get the bio template name assigned to an actor. + * @param formId Actor FormID. + * @return Template name, or "" if none assigned. + */ +std::string (*PublicGetBioTemplateName)(uint32_t formId) = nullptr; + +// ============================================================================= +// Data Queries (v3+) +// +// All functions in this section are thread-safe. +// Functions returning arrays return "[]" on error. +// Functions returning objects return a default JSON object on error. +// ============================================================================= + +/** + * Retrieve memories stored for an actor. + * + * @param formId Actor FormID. + * @param maxCount Maximum memories to return (<=0 defaults to 50). + * @param contextQuery If non-empty, performs semantic (vector) search ranked + * by relevance. If empty, returns most recent first. + * + * @return JSON array of memory objects: + * @code + * [ + * { + * "id": 42, + * "text": "I saw the Dragonborn defeat a dragon at Whiterun", + * "importance": 0.85, + * "timestamp": 1234.5, + * "type": "observation" + * } + * ] + * @endcode + */ +std::string (*PublicGetMemoriesForActor)(uint32_t formId, int maxCount, const char* contextQuery) = nullptr; + +/** + * Retrieve recent world events, optionally filtered. + * + * @param formId Actor FormID (0 = all events, non-zero = events + * involving this actor). + * @param maxCount Maximum events to return (<=0 defaults to 50). + * @param eventTypeFilter Comma-separated event types to include + * (e.g., "dialogue,direct_narration"). Empty = all. + * + * @return JSON array of event objects: + * @code + * [ + * { + * "type": "dialogue", + * "text": "Hello, traveler!", + * "gameTime": 1234.5, + * "originatingActorName": "Lydia", + * "targetActorName": "Player" + * } + * ] + * @endcode + */ +std::string (*PublicGetRecentEvents)(uint32_t formId, int maxCount, const char* eventTypeFilter) = nullptr; + +/** + * Retrieve recent dialogue between the player and an NPC. + * + * @param formId NPC's FormID. + * @param maxExchanges Maximum exchanges to return (<=0 defaults to 10). + * + * @return JSON array in chronological order (oldest first): + * @code + * [ + * { + * "speaker": "Player", + * "text": "What do you think about the war?", + * "gameTime": 1234.5 + * }, + * { + * "speaker": "Lydia", + * "text": "I follow you, my Thane.", + * "gameTime": 1234.6 + * } + * ] + * @endcode + */ +std::string (*PublicGetRecentDialogue)(uint32_t formId, int maxExchanges) = nullptr; + +/** + * Get info about the most recent NPC who spoke to the player. + * + * @return JSON object: + * @code + * { + * "npcFormId": 655544, + * "gameTime": 1234.5, + * "npcName": "Lydia" + * } + * @endcode + */ +std::string (*PublicGetLatestDialogueInfo)() = nullptr; + +/** + * Check if the memory/database system is initialized and ready for queries. + * + * Returns false until a save is loaded or a new game is started. All data + * query functions are safe to call regardless — they return empty results + * when the database isn't ready — but this lets you avoid unnecessary calls. + * + * @return true if the database is ready. + */ +bool (*PublicIsMemorySystemReady)() = nullptr; + +/** + * Get per-actor engagement statistics for scoring and prioritization. + * + * @param maxCount Max actors to return (0 = all with any activity). + * @param excludePlayer Omit the player (FormID 0x14) from results. + * @param playerEventsOnly Only count events involving the player. NPC-to-NPC + * events still tracked as npcToNpcEventCount. + * @param shortWindowSeconds Short recency window in game-seconds + * (e.g., 86400 = 1 game-day). + * @param mediumWindowSeconds Medium recency window in game-seconds + * (e.g., 604800 = 7 game-days). + * + * @return JSON array: + * @code + * [ + * { + * "formId": 655544, + * "name": "Lydia", + * "memoryCount": 12, + * "totalMemoryImportance": 8.5, + * "recentMemoryImportanceShort": 2.1, + * "recentMemoryImportanceMedium": 5.3, + * "eventCount": 30, + * "recentEventCountShort": 5, + * "recentEventCountMedium": 18, + * "lastEventTime": 1234.5, + * "npcToNpcEventCount": 4 + * } + * ] + * @endcode + */ +std::string (*PublicGetActorEngagement)(int maxCount, bool excludePlayer, bool playerEventsOnly, double shortWindowSeconds, double mediumWindowSeconds) = nullptr; + +/** + * Get actors related to a given actor via shared event history. + * + * @param formId Anchor actor's FormID. + * @param maxCount Max related actors to return (0 = all). + * @param shortWindowSeconds Short recency window in game-seconds. + * @param mediumWindowSeconds Medium recency window in game-seconds. + * + * @return JSON array: + * @code + * [ + * { + * "formId": 655545, + * "name": "Ulfric Stormcloak", + * "sharedEventCount": 15, + * "recentSharedEventsShort": 3, + * "recentSharedEventsMedium": 10, + * "lastSharedEventTime": 1234.5 + * } + * ] + * @endcode + */ +std::string (*PublicGetRelatedActors)(uint32_t formId, int maxCount, double shortWindowSeconds, double mediumWindowSeconds) = nullptr; + +/** + * Get comprehensive player context: current time, recent interactions, + * and relationship data. + * + * @param withinGameHours Time window in game-hours for recent interactions. + * 0 = all time. + * + * @return JSON object: + * @code + * { + * "currentTime": 1234.5, + * "recentInteractionNames": ["Lydia", "Farengar"], + * "relationships": [ + * { + * "name": "Lydia", + * "formId": 655544, + * "interactionCount": 12 + * } + * ] + * } + * @endcode + */ +std::string (*PublicGetPlayerContext)(float withinGameHours) = nullptr; + +/** + * Get NPC-to-NPC event pair counts within a candidate pool. + * + * @param formIdListCSV Comma-separated FormIDs defining the pool + * (e.g., "655544,655545,655546"). + * @param minSharedEvents Minimum shared events to include a pair (0 = all). + * + * @return JSON array: + * @code + * [ + * { + * "formId1": 655544, + * "formId2": 655545, + * "sharedEvents": 8 + * } + * ] + * @endcode + */ +std::string (*PublicGetEventPairCounts)(const char* formIdListCSV, int minSharedEvents) = nullptr; + +// ============================================================================= +// Diary Queries (v4+) +// ============================================================================= + +/** + * Retrieve diary entries for an actor, optionally filtered by time range. + * + * @param formId Actor FormID (0 = all actors). + * @param maxCount Maximum entries to return (<=0 defaults to 50). + * @param startTime Only entries at or after this time (seconds since epoch). + * 0.0 = no lower bound. + * @param endTime Only entries at or before this time (seconds since epoch). + * 0.0 = no upper bound. + * + * @return JSON array of diary entry objects. Each entry includes all fields + * from DiaryEntry::ToJson() plus an `actor_name` field. + */ +std::string (*PublicGetDiaryEntries)(uint32_t formId, int maxCount, double startTime, double endTime) = nullptr; + +// ============================================================================= +// Memory Creation (v5+) +// ============================================================================= + +/** + * Create and store a memory for an actor. + * + * Creates a first-class memory with vector embedding for semantic search. + * The memory integrates with get_relevant_memories(), importance decay, and + * all existing retrieval mechanisms. + * + * @param formId Actor FormID (the memory owner). + * @param contentText Memory content text (embedded for semantic search). + * @param importance Importance score, clamped to 0.0-1.0. + * @param memoryType One of: "EXPERIENCE", "RELATIONSHIP", "KNOWLEDGE", + * "LOCATION", "SKILL", "TRAUMA", "JOY". + * @param emotion Optional emotion tag (e.g., "happy", "anxious"). + * @param location Optional location name (e.g., "Whiterun"). + * @param tagsJSON Optional JSON array of tags: ["combat", "dragon"]. + * @param relatedActorsJSON Optional JSON array of related actor FormIDs. + * @return Memory ID (>0) on success, 0 on error. + */ +int (*PublicAddMemory)(uint32_t formId, const char* contentText, float importance, + const char* memoryType, const char* emotion, const char* location, + const char* tagsJSON, const char* relatedActorsJSON) = nullptr; + +// ============================================================================= +// Plugin Configuration (v3+) +// ============================================================================= + +/** + * Get the full JSON configuration for a registered plugin. + * + * Plugins register YAML config files under "Plugin_" in ConfigManager. + * + * @param pluginName Plugin name (without "Plugin_" prefix). + * @return JSON object of the config, or "{}" if not found. + */ +std::string (*PublicGetPluginConfig)(const char* pluginName) = nullptr; + +/** + * Get a single config value by dot-path from a plugin's settings. + * + * @param pluginName Plugin name (without "Plugin_" prefix). + * @param path Dot-separated path (e.g., "feature.enabled"). + * @param defaultValue Returned if the path doesn't exist. + * @return The config value as a string, or defaultValue if not found. + */ +std::string (*PublicGetPluginConfigValue)(const char* pluginName, const char* path, const char* defaultValue) = nullptr; + +// ============================================================================= +// Decorator Registration (v5+) +// ============================================================================= + +/** + * Register a custom decorator for use in Inja templates and action eligibility rules. + * + * Decorators are invoked with the current NPC's Actor* and return a string value. + * In templates: {{ my_decorator(actor_uuid) }} renders the returned string. + * In eligibility rules: the returned string is compared via operators (==, !=, >, <, etc.). + * + * @param name Unique decorator identifier (e.g., "intel_standing"). + * Must not conflict with built-in decorators. + * @param description Human-readable description (for documentation generation). + * @param callback Function receiving RE::Actor* and returning a string value. + * Must be thread-safe. Return "" for invalid/unknown actors. + * + * @return true if registration succeeded, false if name conflicts or callback is null. + * + * @code + * // Register during plugin initialization (kDataLoaded): + * PublicRegisterDecorator("intel_standing", "Player's standing with this NPC's faction", + * [](RE::Actor* actor) -> std::string { + * auto* fp = FactionPolitics::GetSingleton(); + * auto factionId = fp->GetNPCFactionId(actor); + * if (factionId.empty()) return ""; + * return std::to_string(fp->GetPlayerStanding(factionId)); + * }); + * + * // Use in Inja templates: + * // {{ intel_standing(actor_uuid) }} + * // + * // Use in action YAML eligibility rules: + * // eligibilityRules: + * // - conditions: + * // - decorator: intel_standing + * // arguments: ["currentactor"] + * // operator: ">=" + * // expected: "20" + * @endcode + */ +bool (*PublicRegisterDecorator)(const char* name, const char* description, + std::function callback) = nullptr; + +/** + * Check if a decorator with the given name exists (built-in or external). + * + * @param name Decorator name to check. + * @return true if registered, false otherwise. + */ +bool (*PublicHasDecorator)(const char* name) = nullptr; + +// ============================================================================= +// Event Callbacks (v5+) +// ============================================================================= + +/** + * Register a callback for a specific event type (e.g., "dialogue", "combat", "death"). + * + * The callback fires on SkyrimNet's ThreadPool whenever an event of the specified type + * is registered. The callback receives a JSON string with event data: + * { + * "type": "dialogue", + * "data": "{\"text\":\"...\",\"speaker\":\"Lydia\"}", + * "originatingActorUUID": 12345, + * "targetActorUUID": 67890, + * "originatingActorFormId": 1234, + * "targetActorFormId": 5678, + * "id": 42 + * } + * + * Common event types: "dialogue", "dialogue_player_text", "dialogue_player_stt", + * "combat", "death", "hit", "activation", "spell_cast", "equip" + * + * @param eventType Event type to listen for. Must not be empty. + * @param callback Function receiving JSON string. Must be thread-safe. + * Called on ThreadPool — do NOT call RE:: functions directly. + * The const char* is only valid for the duration of the call — copy if needed. + * @return Callback ID (> 0) for unregistration, or 0 on failure. + * + * @code + * auto id = PublicRegisterEventCallback("dialogue", [](const char* json) { + * auto j = nlohmann::json::parse(json); + * uint32_t formId = j["originatingActorFormId"].get(); + * // increment counter, check threshold, etc. + * }); + * @endcode + */ +uint64_t (*PublicRegisterEventCallback)(const char* eventType, + std::function callback) = nullptr; + +/** + * Unregister a previously registered event callback. + * + * @param callbackId The ID returned by PublicRegisterEventCallback. + * @return true if the callback was found and removed. + */ +bool (*PublicUnregisterEventCallback)(uint64_t callbackId) = nullptr; + +// ============================================================================= +// Actor Busy State (v6+) +// ============================================================================= + +/** + * Mark an actor as busy with a multi-step action. + * + * While busy, the is_busy() decorator returns true and busy_reason() returns + * the reason string, allowing action YAMLs to exclude busy actors via + * eligibility rules — either all busy states or selectively by reason. + * The plugin is responsible for calling PublicClearActorBusy() when done. + * + * @param formId The actor's FormID. + * @param reason Short reason string (e.g., "arrest", "travel", "crafting"). + * Queryable via busy_reason() decorator for selective exclusions. + * @return true on success, false if the FormID couldn't be resolved. + */ +bool (*PublicSetActorBusy)(uint32_t formId, const char* reason) = nullptr; + +/** + * Clear an actor's busy state. + * + * Call this when the multi-step action completes (or fails/is interrupted). + * + * @param formId The actor's FormID. + * @return true on success, false if the FormID couldn't be resolved. + */ +bool (*PublicClearActorBusy)(uint32_t formId) = nullptr; + +/** + * Check if an actor is currently busy. + * + * @param formId The actor's FormID. + * @return true if the actor is busy, false otherwise. + */ +bool (*PublicIsActorBusy)(uint32_t formId) = nullptr; + +// ============================================================================= +// Current save UUID (v7+) +// ============================================================================= + +/** + * Get the unique save ID for the current save. + * @return the unique save ID, empty string if a save is not loaded. +*/ +std::string (*PublicGetSaveUniqueID)() = nullptr; + +// ============================================================================= +// World Knowledge (v7+) +// ============================================================================= + +/** + * Create a world knowledge entry. + * + * World knowledge entries are shared facts that apply to NPCs based on + * condition expressions. Unlike PublicAddMemory (per-actor), these are + * retrieved for any NPC whose condition evaluates to true. + * + * @param content Knowledge text (required, non-empty). Embedded for semantic search. + * @param conditionExpr Inja condition expression controlling which NPCs receive this. + * Examples: + * - "" (empty = applies to all NPCs) + * - "is_in_faction(actorUUID, \"CompanionsFaction\")" + * - "decnpc(actorUUID).race == \"Nord\"" + * - "is_in_npc_group(actorUUID, \"MyGroup\")" + * - "get_quest_stage(\"MQ101\") >= 50" + * @param alwaysInject If true, always included when condition passes (deterministic). + * If false, only surfaces via semantic search (probabilistic). + * @param importance Importance score 0.0-1.0 (clamped). Affects semantic ranking. + * @param displayName Optional human-readable label. Pass "" to omit. + * @return Memory ID (>0) on success, 0 on error. + * + * @code + * // All Companions learn about an event: + * int id = PublicAddWorldKnowledge( + * "Silver Hand attacked Jorrvaskr last night.", + * "is_in_faction(actorUUID, \"CompanionsFaction\")", + * true, 0.8, "Silver Hand Attack"); + * + * // Everyone learns about the dragon: + * PublicAddWorldKnowledge( + * "A dragon was slain at the Western Watchtower.", + * "", true, 0.9, "Dragon Attack"); + * @endcode + */ +int (*PublicAddWorldKnowledge)(const char* content, const char* conditionExpr, + bool alwaysInject, float importance, + const char* displayName) = nullptr; + +/** + * Remove a world knowledge entry by ID. + * Refuses to delete per-actor memories (actor_uuid != 0). + * + * @param memoryId The ID returned by PublicAddWorldKnowledge. + * @return true on success, false if not found or not a world knowledge entry. + */ +bool (*PublicRemoveWorldKnowledge)(int memoryId) = nullptr; + +/** + * Get all world knowledge entries as a JSON array. + * + * @param maxCount Maximum entries to return (<=0 defaults to 100). + * @return JSON array of knowledge entry objects. Each includes id, content, + * condition_expr, always_inject, importance, display_name, is_active. + */ +std::string (*PublicGetWorldKnowledge)(int maxCount) = nullptr; + +/** + * Get world knowledge entries applicable to a specific actor as a JSON array. + * + * Mirrors the get_world_knowledge() Inja decorator used in dialogue prompts — + * but exposed at C++ level so plugins can attach knowledge to their own + * pre-built prompt context (faction leaders, candidate pools, etc.). + * + * Two retrieval modes based on `searchQuery`: + * - "" (empty) — cheap path: deterministic always-inject entries only. + * Cache scan + condition evaluation, no HNSW. Suitable for + * hot per-NPC enrichment loops. + * - non-empty — combined: always-inject + semantic HNSW search. Use only + * when you have a concrete query (e.g., "faction war"). + * + * Resolves formId → UUID internally via UUIDResolver. + * + * Thread-safe (KnowledgeManager uses shared_mutex). Returns "[]" on error, + * unknown formId, or empty result. + * + * @param formId Actor FormID. 0 returns "[]". + * @param maxResults Maximum entries to return (<=0 defaults to 5). + * @param searchQuery Inja semantic search query, or "" for always-inject only. + * @return JSON array: [{"content":"...","always_inject":true,"importance":0.8, + * "display_name":"..."}, ...]. "[]" if none. + */ +std::string (*PublicGetWorldKnowledgeForActor)(uint32_t formId, int maxResults, + const char* searchQuery) = nullptr; + +// ============================================================================= +// Custom LLM Prompts (v8+) +// ============================================================================= + +/** + * Send a custom prompt to the LLM and receive the response asynchronously. + * + * Renders the named prompt template (with optional context variables), submits + * it to the configured LLM, and calls the callback on a ThreadPool worker when + * done. The callback must be thread-safe — do NOT call RE:: functions from it. + * + * @param promptName Template name registered with ContextEngine (required). + * @param variant OpenRouter variant to use. Pass "" for the default variant. + * @param contextJson JSON object of context variables injected before rendering. + * Pass "" or "{}" if no extra variables are needed. + * Example: "{\"actorName\":\"Lydia\",\"topic\":\"dragons\"}" + * @param callback Called on completion with (response, success). + * success == 1 on success, 0 on failure. + * response is the LLM text, or an error string on failure. + * The const char* is only valid for the call — copy if needed. + * @return true if the task was successfully queued, false on immediate error. + * + * @code + * PublicSendCustomPromptToLLM("my_prompt", "", "{\"name\":\"Lydia\"}", + * [](const char* response, int success) { + * if (success) logger::info("LLM: {}", response); + * }); + * @endcode + */ +bool (*PublicSendCustomPromptToLLM)(const char* promptName, const char* variant, + const char* contextJson, + std::function callback) = nullptr; + +// ============================================================================= +// Initialization +// ============================================================================= + +/** + * Load SkyrimNet and resolve all exported function pointers. + * + * Call once during plugin initialization (e.g., SKSE DataLoaded message). + * After this returns true, check individual function pointers before use — + * functions from newer API versions may be nullptr if the installed + * SkyrimNet is older. + * + * @return true if SkyrimNet.dll was loaded and at least PublicGetVersion resolved. + */ +inline bool FindFunctions() { + auto hDLL = LoadLibraryA("SkyrimNet"); + if (hDLL != nullptr) { + PublicGetVersion = (int (*)()) GetProcAddress(hDLL, "PublicGetVersion"); + if (PublicGetVersion != nullptr) { + int version = PublicGetVersion(); + + // v2+ functions + if (version >= 2) { + PublicRegisterCPPAction = + (bool (*)(const std::string name, const std::string description, std::function eligibleCallback, + std::function executeCallback, const std::string triggeringEventTypesCSV, + std::string categoryStr, int priority, std::string parameterSchemaJSON, std::string customCategory, + std::string customParentCategory, std::string tagsCSV)) GetProcAddress(hDLL, "PublicRegisterCPPAction"); + PublicRegisterCPPSubCategory = (bool (*)( + const std::string name, const std::string description, std::function eligibleCallback, + const std::string triggeringEventTypesCSV, int priority, std::string parameterSchemaJSON, std::string customCategory, + std::string customParentCategory, std::string tagsCSV)) GetProcAddress(hDLL, "PublicRegisterCPPSubCategory"); + } + + // v3+ functions + if (version >= 3) { + // UUID resolution + PublicFormIDToUUID = reinterpret_cast( + GetProcAddress(hDLL, "PublicFormIDToUUID")); + PublicUUIDToFormID = reinterpret_cast( + GetProcAddress(hDLL, "PublicUUIDToFormID")); + PublicGetActorNameByUUID = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetActorNameByUUID")); + + // Bio template + PublicGetBioTemplateName = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetBioTemplateName")); + + // Data API + PublicGetMemoriesForActor = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetMemoriesForActor")); + PublicGetRecentEvents = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetRecentEvents")); + PublicGetRecentDialogue = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetRecentDialogue")); + PublicGetLatestDialogueInfo = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetLatestDialogueInfo")); + PublicIsMemorySystemReady = reinterpret_cast( + GetProcAddress(hDLL, "PublicIsMemorySystemReady")); + PublicGetActorEngagement = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetActorEngagement")); + PublicGetRelatedActors = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetRelatedActors")); + PublicGetPlayerContext = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetPlayerContext")); + PublicGetEventPairCounts = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetEventPairCounts")); + + // Plugin config API + PublicGetPluginConfig = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetPluginConfig")); + PublicGetPluginConfigValue = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetPluginConfigValue")); + } + + // v4+ functions + if (version >= 4) { + PublicGetDiaryEntries = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetDiaryEntries")); + } + + // v5+ functions + if (version >= 5) { + // Decorator registration + PublicRegisterDecorator = reinterpret_cast)>( + GetProcAddress(hDLL, "PublicRegisterDecorator")); + PublicHasDecorator = reinterpret_cast( + GetProcAddress(hDLL, "PublicHasDecorator")); + + // Event callbacks + PublicRegisterEventCallback = reinterpret_cast)>( + GetProcAddress(hDLL, "PublicRegisterEventCallback")); + PublicUnregisterEventCallback = reinterpret_cast( + GetProcAddress(hDLL, "PublicUnregisterEventCallback")); + + // Memory creation + PublicAddMemory = reinterpret_cast( + GetProcAddress(hDLL, "PublicAddMemory")); + } + + // v6+ functions: Actor busy state + if (version >= 6) { + PublicSetActorBusy = reinterpret_cast( + GetProcAddress(hDLL, "PublicSetActorBusy")); + PublicClearActorBusy = reinterpret_cast( + GetProcAddress(hDLL, "PublicClearActorBusy")); + PublicIsActorBusy = reinterpret_cast( + GetProcAddress(hDLL, "PublicIsActorBusy")); + } + + // v7+ functions + if (version >= 7) { + PublicGetSaveUniqueID = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetSaveUniqueID")); + PublicAddWorldKnowledge = reinterpret_cast( + GetProcAddress(hDLL, "PublicAddWorldKnowledge")); + PublicRemoveWorldKnowledge = reinterpret_cast( + GetProcAddress(hDLL, "PublicRemoveWorldKnowledge")); + PublicGetWorldKnowledge = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetWorldKnowledge")); + } + + // v8+ functions + if (version >= 8) { + PublicSendCustomPromptToLLM = reinterpret_cast)>( + GetProcAddress(hDLL, "PublicSendCustomPromptToLLM")); + } + + // v9+ functions + if (version >= 9) { + PublicGetWorldKnowledgeForActor = reinterpret_cast( + GetProcAddress(hDLL, "PublicGetWorldKnowledgeForActor")); + } + } + return true; + } + return false; +} +} diff --git a/CppAPI/src/main.quest_journal.cpp b/CppAPI/src/main.quest_journal.cpp new file mode 100644 index 000000000..9b522b0cc --- /dev/null +++ b/CppAPI/src/main.quest_journal.cpp @@ -0,0 +1,335 @@ +#include "PublicAPI.h" +#include +#include +#include +#include +#include +#include + +// Set to 0 to disable all logging (default for production/integration) +#define QJ_LOG_ENABLED 1 + +#if QJ_LOG_ENABLED +static std::shared_ptr g_logger; + +static void QJLogInit() +{ + if (g_logger) return; + auto logDir = SKSE::log::log_directory(); + if (!logDir) return; + auto path = logDir->string() + "\\SkyrimNetQuestJournalDecorator.log"; + g_logger = spdlog::basic_logger_mt("QuestJournal", path); + g_logger->set_level(spdlog::level::debug); + g_logger->flush_on(spdlog::level::debug); +} + +static void QJLogInfo(const std::string& msg) { if (g_logger) g_logger->info(msg); } +static void QJLogDebug(const std::string& msg) { if (g_logger) g_logger->debug(msg); } +#else +static void QJLogInit() {} +static void QJLogInfo([[maybe_unused]] const std::string&) {} +static void QJLogDebug([[maybe_unused]] const std::string&) {} +#endif + +static void ResolveAliasTokens(RE::BSString& text, const RE::TESQuest* quest, std::uint32_t instanceID) +{ + using func_t = void(*)(RE::BSString*, const RE::TESQuest*, std::uint32_t); + REL::Relocation func{ RELOCATION_ID(23429, 23897) }; + func(&text, quest, instanceID); +} + +static const char* ResolveJournalEntryText(RE::TESQuestStageItem* stageItem, const RE::TESQuest* quest) +{ + using func_t = const char*(*)(RE::TESQuestStageItem*, const RE::TESQuest*); + REL::Relocation func{ RELOCATION_ID(24778, 25259) }; + return func(stageItem, quest); +} + +static std::string EscapeJson(const std::string& s) +{ + std::string out; + out.reserve(s.size() + 8); + for (char c : s) { + switch (c) { + case '"': out += "\\\""; break; + case '\\': out += "\\\\"; break; + case '\n': out += "\\n"; break; + case '\r': out += "\\r"; break; + case '\t': out += "\\t"; break; + default: + if (static_cast(c) < 0x20) { + char buf[8]; + snprintf(buf, sizeof(buf), "\\u%04x", static_cast(c)); + out += buf; + } else { + out += c; + } + break; + } + } + return out; +} + +static bool HasAliasToken(const std::string& s) +{ + auto pos = s.find('<'); + if (pos == std::string::npos) return false; + return (s.compare(pos, 6, "currentInstanceID); + if (resolved.c_str() && resolved.c_str()[0] != '\0') + result = resolved.c_str(); + } + + auto gpos = result.find("', gpos); + if (endPos == std::string::npos) break; + auto globalName = result.substr(gpos + 8, endPos - gpos - 8); + std::string replacement; + auto& globals = RE::TESDataHandler::GetSingleton()->GetFormArray(); + for (auto* g : globals) { + if (g) { + auto* eid = g->GetFormEditorID(); + if (eid && globalName == eid) { + replacement = std::to_string(static_cast(g->value)); + break; + } + } + } + if (!replacement.empty()) { + result.replace(gpos, endPos - gpos + 1, replacement); + gpos = result.find(" 2 && isdigit(editorID[2])) return true; + return false; +} + +static std::string BuildQuestJournalJson([[maybe_unused]] RE::Actor* actor) +{ + auto* player = RE::PlayerCharacter::GetSingleton(); + if (!player) return "[]"; + + auto* dataHandler = RE::TESDataHandler::GetSingleton(); + if (!dataHandler) return "[]"; + + auto& questLog = player->GetPlayerRuntimeData().questLog; + auto& questTargets = player->GetPlayerRuntimeData().questTargets; + auto& quests = dataHandler->GetFormArray(); + + std::unordered_set knownQuests; + for (auto* stageItem : questLog) { + if (stageItem && stageItem->owner) knownQuests.insert(stageItem->owner); + } + { + RE::BSSpinLockGuard lock(player->GetPlayerRuntimeData().questTargetsLock); + for (auto& [quest, targets] : questTargets) { + if (quest) knownQuests.insert(quest); + } + } + + int totalQuests = 0, filteredQuests = 0, miscCount = 0; + + std::string result = "["; + std::string questListLog; + bool firstQuest = true; + + for (auto& quest : quests) { + if (!quest) continue; + totalQuests++; + + auto* editorID = quest->GetFormEditorID(); + if (!editorID || editorID[0] == '\0') continue; + std::string editorIDStr(editorID); + + if (!quest->IsRunning()) continue; + if (quest->IsCompleted()) continue; + + if (ShouldSkipQuest(editorIDStr)) continue; + + bool hasDisplayedObjective = false; + { + RE::BSReadLockGuard lock(quest->aliasAccessLock); + for (auto* obj : quest->objectives) { + if (obj) { + auto s = obj->state.underlying(); + if (s == static_cast(RE::QUEST_OBJECTIVE_STATE::kDisplayed) || + s == static_cast(RE::QUEST_OBJECTIVE_STATE::kCompletedDisplayed) || + s == static_cast(RE::QUEST_OBJECTIVE_STATE::kFailedDisplayed)) { + hasDisplayedObjective = true; + break; + } + } + } + } + bool inKnown = knownQuests.count(quest) > 0; + if (!hasDisplayedObjective && !inKnown) continue; + + filteredQuests++; + auto typeNum = static_cast(quest->GetType()); + if (typeNum == 6) miscCount++; + + if (!questListLog.empty()) questListLog += ", "; + questListLog += editorIDStr; + + auto* questName = quest->GetName(); + std::string questNameStr = questName ? questName : ""; + questNameStr = ResolveText(questNameStr, quest); + if (questNameStr.empty()) { + RE::BSReadLockGuard lock(quest->aliasAccessLock); + for (auto* obj : quest->objectives) { + if (obj) { + auto* dt = obj->displayText.c_str(); + if (dt && dt[0] != '\0') { + questNameStr = ResolveText(dt, quest); + if (!questNameStr.empty()) break; + } + } + } + } + if (questNameStr.empty()) questNameStr = editorIDStr; + + bool inQuestLog = inKnown; + + auto stageNum = quest->GetCurrentStageID(); + + if (!firstQuest) result += ","; + firstQuest = false; + + result += "{\"questName\":\""; + result += EscapeJson(questNameStr); + result += "\",\"editorID\":\""; + result += EscapeJson(editorIDStr); + result += "\",\"stage\":"; + result += std::to_string(stageNum); + result += ",\"questType\":"; + result += std::to_string(typeNum); + result += ",\"completed\":false"; + result += ",\"isMisc\":"; + result += (typeNum == 6) ? "true" : "false"; + result += ",\"inQuestLog\":"; + result += inQuestLog ? "true" : "false"; + + { + RE::BSReadLockGuard lock(quest->aliasAccessLock); + auto& objectives = quest->objectives; + if (!objectives.empty()) { + result += ",\"objectives\":["; + bool firstObj = true; + for (auto* obj : objectives) { + if (!obj) continue; + auto* dt = obj->displayText.c_str(); + if (!dt || dt[0] == '\0') continue; + + std::string textStr = ResolveText(dt, quest); + + auto objState = obj->state.underlying(); + bool objCompleted = (objState == static_cast(RE::QUEST_OBJECTIVE_STATE::kCompleted) || + objState == static_cast(RE::QUEST_OBJECTIVE_STATE::kCompletedDisplayed)); + bool objFailed = (objState == static_cast(RE::QUEST_OBJECTIVE_STATE::kFailed) || + objState == static_cast(RE::QUEST_OBJECTIVE_STATE::kFailedDisplayed)); + bool objDisplayed = (objState == static_cast(RE::QUEST_OBJECTIVE_STATE::kDisplayed) || + objState == static_cast(RE::QUEST_OBJECTIVE_STATE::kCompletedDisplayed) || + objState == static_cast(RE::QUEST_OBJECTIVE_STATE::kFailedDisplayed)); + + if (!firstObj) result += ","; + firstObj = false; + + result += "{\"text\":\""; + result += EscapeJson(textStr); + result += "\",\"completed\":"; + result += objCompleted ? "true" : "false"; + result += ",\"failed\":"; + result += objFailed ? "true" : "false"; + result += ",\"displayed\":"; + result += objDisplayed ? "true" : "false"; + result += "}"; + } + result += "]"; + } + } + + std::string resolvedJournalText; + int bestStageIndex = -1; + for (auto* stageItem : questLog) { + if (!stageItem) continue; + if (stageItem->owner != quest) continue; + if (!stageItem->hasLogEntry) continue; + if (!stageItem->owningStage) continue; + auto entryStage = stageItem->owningStage->data.index; + if (entryStage > stageNum) continue; + if (entryStage <= bestStageIndex) continue; + + const char* logText = ResolveJournalEntryText(stageItem, quest); + if (logText && logText[0] != '\0') { + resolvedJournalText = ResolveText(logText, quest); + bestStageIndex = entryStage; + } + } + + if (!resolvedJournalText.empty()) { + result += ",\"journalText\":\""; + result += EscapeJson(resolvedJournalText); + result += "\""; + } + + result += "}"; + } + + result += "]"; + QJLogDebug("get_quest_journal: " + std::to_string(filteredQuests) + " quests (" + std::to_string(miscCount) + " misc) from " + std::to_string(totalQuests) + " total"); + QJLogDebug("quest list: " + questListLog); + return result; +} + +bool SKSEPluginLoad(const SKSE::LoadInterface* skse) { + SKSE::Init(skse); + QJLogInit(); + QJLogInfo("SkyrimNetQuestJournalDecorator loaded"); + + SKSE::GetMessagingInterface()->RegisterListener( + [](SKSE::MessagingInterface::Message* msg) { + if (!msg || msg->type != SKSE::MessagingInterface::kPostPostLoad) return; + + if (!FindFunctions()) return; + + if (!PublicRegisterDecorator) return; + + PublicRegisterDecorator( + "get_quest_journal", + "Returns JSON array of active quests with resolved objective text and journal entries. Each entry: questName, editorID, stage, questType, isMisc, inQuestLog, objectives[{text,completed,failed}], journalText.", + [](RE::Actor* actor) -> std::string { + return BuildQuestJournalJson(actor); + }); + QJLogInfo("Registered decorator 'get_quest_journal'"); + }); + return true; +} diff --git a/SKSE/Plugins/SkyrimNet/prompts/submodules/character_bio/0610_party_quests.prompt b/SKSE/Plugins/SkyrimNet/prompts/submodules/character_bio/0610_party_quests.prompt new file mode 100644 index 000000000..cb1c5f3b8 --- /dev/null +++ b/SKSE/Plugins/SkyrimNet/prompts/submodules/character_bio/0610_party_quests.prompt @@ -0,0 +1,42 @@ +{# Modified from prompt originally from jykej: #} +{# https://discord.com/channels/1287232260617015336/1372339819459514368/1442640958671032350 #} +{% if render_mode == "full" or render_mode == "thoughts" or render_mode == "transform" %} +{% if is_follower(actorUUID) or actorUUID == player.UUID %} +{% set tracked_quests = get_selected_quests() %} +{% set journal = get_quest_journal(player.UUID) %} +{% set show_misc_tasks = show_misc_tasks | default(true) %} +{% set show_inactive_quests = show_inactive_quests | default(false) %} +{% if length(tracked_quests) > 0 %} +{% if is_follower(actorUUID) %}## {{ player.name }}'s Party's Active Quests{% else %}## Active Quests{% endif %} +(Quests remain available indefinitely unless explicitly stated otherwise. Do NOT create urgency, invent deadlines, or repeatedly remind about objectives.) +{% for quest in tracked_quests %} +{% for jq in journal %}{% if jq.editorID == quest.editorID %} +{% if jq.journalText and not jq.isMisc %} +**{{ jq.questName }}** +{{ jq.journalText }} +{% for obj in jq.objectives %}{% if obj.displayed %}{% if obj.completed %} +- {{ obj.text }} [DONE]{% elif not obj.failed %} +- {{ obj.text }}{% endif %}{% endif %}{% endfor %} + +{% endif %} +{% endif %}{% endfor %} +{% endfor %} +{% if show_inactive_quests %} +{% for jq in journal %}{% if jq.inQuestLog %}{% if jq.journalText %} +**{{ jq.questName }}** (inactive) +{{ jq.journalText }} +{% endif %}{% endif %}{% endfor %} +{% endif %} +{% if show_misc_tasks %} +### Misc. Tasks +{% for quest in tracked_quests %} +{% for jq in journal %}{% if jq.editorID == quest.editorID %}{% if jq.isMisc %}{% for obj in jq.objectives %}{% if obj.displayed and not obj.completed and not obj.failed %} +- {{ obj.text }}{% endif %}{% endfor %}{% endif %}{% endif %}{% endfor %} +{% endfor %} +{% endif %} +{% else %} +## {{ player.name }}'s Party's Active Quests +No active quests currently tracked. +{% endif %} +{% endif %} +{% endif %} \ No newline at end of file diff --git a/SKSE/Plugins/SkyrimNetQuestJournalDecorator.dll b/SKSE/Plugins/SkyrimNetQuestJournalDecorator.dll new file mode 100644 index 000000000..00d847f84 Binary files /dev/null and b/SKSE/Plugins/SkyrimNetQuestJournalDecorator.dll differ