From: vitler Date: Sun, 19 Apr 2026 23:54:31 +0000 (+0100) Subject: first public version available X-Git-Url: https://vgcfreebox.myrthtech.pt/gitweb/alentejosemlei.git/commitdiff_plain/07207a6193ca05e59ee933ef9e187a132c7fb612?hp=d4f7635aedbc20d81aad03604ffda740175782ea first public version available --- diff --git a/asl-server-architect.drawio b/asl-server-architect.drawio new file mode 100644 index 0000000..54cf561 --- /dev/null +++ b/asl-server-architect.drawio @@ -0,0 +1,269 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/nwn-asl-ai/asl_npc_ai_receiver b/nwn-asl-ai/asl_npc_ai_receiver new file mode 100644 index 0000000..b09109a --- /dev/null +++ b/nwn-asl-ai/asl_npc_ai_receiver @@ -0,0 +1,369 @@ +// ============================================================================ +// File Name: asl_npc_ai_receiver +// Generative Agent "Act" Phase placed on Module OnHeartbeat event. +// ============================================================================ +#include "nwnx_redis" +#include "nwnx_redis_lib" +#include "nw_i0_generic" + +void ExecuteFallbackJump(object oNPC, object oHome) { + if (GetArea(oNPC) != GetArea(oHome)) { + ApplyEffectToObject(DURATION_TYPE_INSTANT, EffectVisualEffect(VFX_IMP_UNSUMMON), oNPC); + AssignCommand(oNPC, ActionJumpToObject(oHome)); + } +} + +void main() +{ + int nResultId = NWNX_Redis_LPOP("llm_to_nwn"); + string sReplyData = NWNX_Redis_GetResultAsString(nResultId); + int nSafety = 0; + + while (sReplyData != "" && sReplyData != "null" && sReplyData != "(nil)" && nSafety < 10) + { + nSafety++; + if (GetStringLength(sReplyData) < 10) break; + + json jResponse = JsonParse(sReplyData); + string sNpcTag = JsonGetString(JsonObjectGet(jResponse, "npc_tag")); + string sTargetPlayer = JsonGetString(JsonObjectGet(jResponse, "target_player")); + string sReplyText = JsonGetString(JsonObjectGet(jResponse, "reply")); + + object oNPC = GetObjectByTag(sNpcTag); + object oDebugPC = GetFirstPC(); + + if (!GetIsObjectValid(oNPC)) { + SendMessageToPC(oDebugPC, "[DEBUG ERROR] Could not find NPC with Tag: " + sNpcTag); + nResultId = NWNX_Redis_LPOP("llm_to_nwn"); + sReplyData = NWNX_Redis_GetResultAsString(nResultId); + continue; + } + + json jAgentBrain = JsonParse(sReplyText); + + string sSpeech = ""; + if (JsonGetType(JsonObjectGet(jAgentBrain, "speech")) == JSON_TYPE_STRING) sSpeech = JsonGetString(JsonObjectGet(jAgentBrain, "speech")); + + string sEmotion = "NEUTRAL"; + if (JsonGetType(JsonObjectGet(jAgentBrain, "emotion")) == JSON_TYPE_STRING) sEmotion = GetStringUpperCase(JsonGetString(JsonObjectGet(jAgentBrain, "emotion"))); + + string sAction = ""; + if (JsonGetType(JsonObjectGet(jAgentBrain, "action")) == JSON_TYPE_STRING) sAction = GetStringUpperCase(JsonGetString(JsonObjectGet(jAgentBrain, "action"))); + + string sTarget = ""; + if (JsonGetType(JsonObjectGet(jAgentBrain, "action_target")) == JSON_TYPE_STRING) sTarget = JsonGetString(JsonObjectGet(jAgentBrain, "action_target")); + + string sTargetSpeech = ""; + if (JsonGetType(JsonObjectGet(jAgentBrain, "target_speech")) == JSON_TYPE_STRING) sTargetSpeech = JsonGetString(JsonObjectGet(jAgentBrain, "target_speech")); + + if (GetObjectType(oNPC) == OBJECT_TYPE_CREATURE) { + if (GetIsInCombat(oNPC) || GetIsObjectValid(GetAttackTarget(oNPC))) { + if (sAction != "PEACE" && sAction != "ATTACK") { + if (sSpeech != "" && sSpeech != "*grunts quietly*") { + AssignCommand(oNPC, SpeakString(sSpeech)); + } + nResultId = NWNX_Redis_LPOP("llm_to_nwn"); + sReplyData = NWNX_Redis_GetResultAsString(nResultId); + continue; + } + } + } + + if (GetObjectType(oNPC) == OBJECT_TYPE_PLACEABLE) { + if (sSpeech != "") { + if (sSpeech == "*grunts quietly*") sSpeech = "*The shrine glows faintly but remains silent.*"; + AssignCommand(oNPC, SpeakString(sSpeech)); + ApplyEffectToObject(DURATION_TYPE_INSTANT, EffectVisualEffect(VFX_IMP_SUPER_HEROISM), oNPC); + } + nResultId = NWNX_Redis_LPOP("llm_to_nwn"); + sReplyData = NWNX_Redis_GetResultAsString(nResultId); + continue; + } + + SendMessageToPC(oDebugPC, "[DEBUG] " + GetName(oNPC) + " executing: [" + sAction + "] target: [" + sTarget + "]"); + AssignCommand(oNPC, ClearAllActions(TRUE)); + + if (sSpeech != "") { + object oTalkTarget = GetFirstPC(); + while (GetIsObjectValid(oTalkTarget)) { + if (GetName(oTalkTarget) == sTargetPlayer) { + AssignCommand(oNPC, SetFacingPoint(GetPosition(oTalkTarget))); + break; + } + oTalkTarget = GetNextPC(); + } + + DelayCommand(0.1, AssignCommand(oNPC, SpeakString(sSpeech))); + + if (sEmotion == "LAUGHING") DelayCommand(0.2, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_LOOPING_TALK_LAUGHING, 1.0, 4.0))); + else if (sEmotion == "ANGRY") DelayCommand(0.2, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_LOOPING_TALK_FORCEFUL, 1.0, 4.0))); + else if (sEmotion == "PLEADING") DelayCommand(0.2, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_LOOPING_TALK_PLEADING, 1.0, 4.0))); + else if (sEmotion == "BOW") DelayCommand(0.2, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_FIREFORGET_BOW))); + else if (sEmotion == "TAUNT") DelayCommand(0.2, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_FIREFORGET_TAUNT))); + else if (sEmotion == "CHEER") { + if (Random(2) == 0) DelayCommand(0.2, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_FIREFORGET_VICTORY1))); + else DelayCommand(0.2, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_FIREFORGET_GREETING))); + } + else DelayCommand(0.2, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_LOOPING_TALK_NORMAL, 1.0, 3.0))); + } + + if (sAction == "ATTACK") { + object oEnemy = OBJECT_INVALID; + if (sTarget != "") { + int i = 1; + object oSearch = GetNearestCreature(CREATURE_TYPE_IS_ALIVE, TRUE, oNPC, i); + while (GetIsObjectValid(oSearch) && GetDistanceBetween(oNPC, oSearch) < 20.0f) { + if (FindSubString(GetStringLowerCase(GetName(oSearch)), GetStringLowerCase(sTarget)) >= 0) { + oEnemy = oSearch; + break; + } + i++; + oSearch = GetNearestCreature(CREATURE_TYPE_IS_ALIVE, TRUE, oNPC, i); + } + } + if (!GetIsObjectValid(oEnemy)) oEnemy = GetNearestCreature(CREATURE_TYPE_REPUTATION, REPUTATION_TYPE_ENEMY, oNPC, 1, CREATURE_TYPE_IS_ALIVE, TRUE); + if (GetIsObjectValid(oEnemy)) { + if (GetIsPC(oEnemy)) SetIsTemporaryEnemy(oEnemy, oNPC, TRUE); + DelayCommand(0.3, AssignCommand(oNPC, ActionAttack(oEnemy))); + DelayCommand(0.4, AssignCommand(oNPC, DetermineCombatRound(oEnemy))); + } + } + + else if (sAction == "PEACE") { + object oMyArea = GetArea(oNPC); + object oForgive = GetFirstPC(); + while (GetIsObjectValid(oForgive)) { + if (GetArea(oForgive) == oMyArea) ClearPersonalReputation(oForgive, oNPC); + oForgive = GetNextPC(); + } + AssignCommand(oNPC, ClearAllActions(TRUE)); + AssignCommand(oNPC, SurrenderToEnemies()); + DelayCommand(0.2, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_FIREFORGET_PAUSE_SCRATCH_HEAD))); + } + + else if (sAction == "COMMAND") { + DelayCommand(0.2, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_LOOPING_TALK_FORCEFUL, 1.0, 3.0))); + object oVictim = OBJECT_INVALID; + if (sTarget != "RETREAT" && sTarget != "DEFEND_ME") { + int nScan = 1; + object oSearch = GetNearestCreature(CREATURE_TYPE_PLAYER_CHAR, PLAYER_CHAR_IS_PC, oNPC, nScan); + while (GetIsObjectValid(oSearch) && GetDistanceBetween(oNPC, oSearch) < 30.0f) { + if (FindSubString(GetStringLowerCase(GetName(oSearch)), GetStringLowerCase(sTarget)) >= 0) { + oVictim = oSearch; + break; + } + nScan++; + oSearch = GetNearestCreature(CREATURE_TYPE_PLAYER_CHAR, PLAYER_CHAR_IS_PC, oNPC, nScan); + } + } + int i = 1; + object oMinion = GetNearestCreature(CREATURE_TYPE_REPUTATION, REPUTATION_TYPE_FRIEND, oNPC, i); + while (GetIsObjectValid(oMinion) && GetDistanceBetween(oNPC, oMinion) < 30.0f) { + if (!GetIsPC(oMinion) && GetFactionEqual(oMinion, oNPC)) { + AssignCommand(oMinion, ClearAllActions(TRUE)); + if (GetIsObjectValid(oVictim)) AssignCommand(oMinion, ActionAttack(oVictim)); + else if (sTarget == "RETREAT") { + object oNearestPC = GetNearestCreature(CREATURE_TYPE_PLAYER_CHAR, PLAYER_CHAR_IS_PC, oMinion); + AssignCommand(oMinion, ActionMoveAwayFromObject(oNearestPC, TRUE, 40.0f)); + } + else if (sTarget == "DEFEND_ME") AssignCommand(oMinion, ActionForceFollowObject(oNPC, 1.5f)); + } + i++; + oMinion = GetNearestCreature(CREATURE_TYPE_REPUTATION, REPUTATION_TYPE_FRIEND, oNPC, i); + } + } + + else if (sAction == "WANDER") DelayCommand(0.2, AssignCommand(oNPC, ActionRandomWalk())); + else if (sAction == "PATROL") DelayCommand(0.2, AssignCommand(oNPC, WalkWayPoints())); + else if (sAction == "FOLLOW" || sAction == "FOLLOW_NEAREST") { + object oTargetPC = GetFirstPC(); + int bFound = FALSE; + while (GetIsObjectValid(oTargetPC)) { + if (FindSubString(GetStringLowerCase(GetName(oTargetPC)), GetStringLowerCase(sTarget)) >= 0 || GetName(oTargetPC) == sTargetPlayer) { + DelayCommand(0.2, AssignCommand(oNPC, ActionForceFollowObject(oTargetPC, 2.0f))); + bFound = TRUE; + break; + } + oTargetPC = GetNextPC(); + } + if (!bFound) { + object oNearest = GetNearestCreature(CREATURE_TYPE_PLAYER_CHAR, PLAYER_CHAR_IS_PC, oNPC); + if (GetIsObjectValid(oNearest)) DelayCommand(0.2, AssignCommand(oNPC, ActionForceFollowObject(oNearest, 2.0f))); + } + } + + else if (sAction == "REST") { + DelayCommand(0.2, ApplyEffectToObject(DURATION_TYPE_INSTANT, EffectVisualEffect(VFX_IMP_HEALING_M), oNPC)); + DelayCommand(0.3, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_LOOPING_SIT_CROSS, 1.0, 12.0))); + DelayCommand(0.5, ForceRest(oNPC)); + } + else if (sAction == "STEALTH") { + DelayCommand(0.2, AssignCommand(oNPC, SetActionMode(oNPC, ACTION_MODE_STEALTH, TRUE))); + DelayCommand(0.5, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_FIREFORGET_SPASM))); + } + else if (sAction == "SEARCH") { + DelayCommand(0.2, AssignCommand(oNPC, SetActionMode(oNPC, ACTION_MODE_DETECT, TRUE))); + } + else if (sAction == "UNSTEALTH") { + DelayCommand(0.2, AssignCommand(oNPC, SetActionMode(oNPC, ACTION_MODE_STEALTH, FALSE))); + DelayCommand(0.2, AssignCommand(oNPC, SetActionMode(oNPC, ACTION_MODE_DETECT, FALSE))); + } + else if (sAction == "GUARD") { + DelayCommand(0.2, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_FIREFORGET_HEAD_TURN_LEFT))); + DelayCommand(3.2, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_FIREFORGET_HEAD_TURN_RIGHT))); + } + else if (sAction == "GO_TO" && sTarget != "") { + object oDest = GetObjectByTag(sTarget); + if (GetIsObjectValid(oDest)) DelayCommand(0.2, AssignCommand(oNPC, ActionForceMoveToObject(oDest, FALSE, 1.0f))); + else DelayCommand(0.2, AssignCommand(oNPC, ActionRandomWalk())); + } + + else if (sAction == "INTERACT" || sAction == "USE_OBJECT") { + int bFoundTarget = FALSE; + string sTargetLower = GetStringLowerCase(sTarget); + + if (sTargetLower == "chair" || sTargetLower == "seat") sTargetLower = "stool"; + else if (sTargetLower == "drink" || sTargetLower == "mug" || sTargetLower == "ale") sTargetLower = "cup"; + else if (sTargetLower == "food" || sTargetLower == "meal") sTargetLower = "plate"; + else if (sTargetLower == "fire" || sTargetLower == "hearth") sTargetLower = "fireplace"; + + object oTargetObj = GetNearestObject(OBJECT_TYPE_PLACEABLE, oNPC, 1); + int nNth = 1; + + while (GetIsObjectValid(oTargetObj) && GetDistanceBetween(oNPC, oTargetObj) < 20.0) { + string sObjName = GetStringLowerCase(GetName(oTargetObj)); + + if ((sTargetLower != "" && FindSubString(sObjName, sTargetLower) >= 0) || + (sTargetLower == "" && GetLocalInt(oTargetObj, "NW_INTERACTIVE") == TRUE)) { + + bFoundTarget = TRUE; + AssignCommand(oNPC, ActionMoveToObject(oTargetObj, FALSE, 0.5f)); + AssignCommand(oNPC, ActionDoCommand(SetFacingPoint(GetPosition(oTargetObj)))); + + if (FindSubString(sObjName, "cup") >= 0 || FindSubString(sObjName, "wine") >= 0 || FindSubString(sObjName, "keg") >= 0) { + AssignCommand(oNPC, ActionDoCommand(ActionPlayAnimation(ANIMATION_FIREFORGET_DRINK))); + } else if (FindSubString(sObjName, "stool") >= 0 || FindSubString(sObjName, "bench") >= 0 || FindSubString(sObjName, "chair") >= 0) { + AssignCommand(oNPC, ActionDoCommand(ActionPlayAnimation(ANIMATION_LOOPING_SIT_CHAIR, 1.0, 9999.0))); + } else if (FindSubString(sObjName, "book") >= 0 || FindSubString(sObjName, "shelf") >= 0) { + AssignCommand(oNPC, ActionDoCommand(ActionPlayAnimation(ANIMATION_FIREFORGET_READ))); + } else if (FindSubString(sObjName, "cook") >= 0 || FindSubString(sObjName, "oven") >= 0 || FindSubString(sObjName, "pot") >= 0) { + AssignCommand(oNPC, ActionDoCommand(ActionPlayAnimation(ANIMATION_LOOPING_GET_MID, 1.0, 9999.0))); + } else { + AssignCommand(oNPC, ActionDoCommand(ActionPlayAnimation(ANIMATION_LOOPING_GET_MID, 1.0, 3.0))); + } + break; + } + nNth++; + oTargetObj = GetNearestObject(OBJECT_TYPE_PLACEABLE, oNPC, nNth); + } + + if (!bFoundTarget && sAction == "INTERACT" && sTargetLower != "") { + int i = 1; + object oCreature = GetNearestCreature(CREATURE_TYPE_IS_ALIVE, TRUE, oNPC, i); + while (GetIsObjectValid(oCreature) && GetDistanceBetween(oNPC, oCreature) < 20.0) { + if (FindSubString(GetStringLowerCase(GetName(oCreature)), sTargetLower) >= 0) { + AssignCommand(oNPC, ActionMoveToObject(oCreature, FALSE, 1.5f)); + AssignCommand(oNPC, ActionDoCommand(SetFacingPoint(GetPosition(oCreature)))); + AssignCommand(oNPC, ActionDoCommand(ActionPlayAnimation(ANIMATION_FIREFORGET_GREETING))); + bFoundTarget = TRUE; + break; + } + i++; + oCreature = GetNearestCreature(CREATURE_TYPE_IS_ALIVE, TRUE, oNPC, i); + } + } + if (!bFoundTarget) AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_FIREFORGET_PAUSE_SCRATCH_HEAD)); + } + + else if (sAction == "CONVERSE") { + int nScan = 1; + object oPuppet = GetNearestCreature(CREATURE_TYPE_IS_ALIVE, TRUE, oNPC, nScan); + object oFoundPuppet = OBJECT_INVALID; + + while (GetIsObjectValid(oPuppet) && GetDistanceBetween(oNPC, oPuppet) < 20.0f) { + if (!GetIsPC(oPuppet)) { + if (FindSubString(GetStringLowerCase(GetName(oPuppet)), GetStringLowerCase(sTarget)) >= 0) { + oFoundPuppet = oPuppet; + break; + } + } + nScan++; + oPuppet = GetNearestCreature(CREATURE_TYPE_IS_ALIVE, TRUE, oNPC, nScan); + } + + if (GetIsObjectValid(oFoundPuppet)) { + AssignCommand(oNPC, ActionDoCommand(SetFacingPoint(GetPosition(oFoundPuppet)))); + AssignCommand(oFoundPuppet, ClearAllActions(TRUE)); + AssignCommand(oFoundPuppet, SetFacingPoint(GetPosition(oNPC))); + + if (sTargetSpeech != "") { + DelayCommand(3.5f, AssignCommand(oFoundPuppet, ActionPlayAnimation(ANIMATION_FIREFORGET_GREETING))); + DelayCommand(4.0f, AssignCommand(oFoundPuppet, SpeakString(sTargetSpeech))); + } else { + DelayCommand(3.5f, AssignCommand(oFoundPuppet, ActionPlayAnimation(ANIMATION_FIREFORGET_PAUSE_SCRATCH_HEAD))); + } + } + } + + else if (sAction == "OPEN_STORE") { + string sShopTag = GetLocalString(oNPC, "LLM_SHOPTAG"); + if (sShopTag == "") sShopTag = "STORE_" + GetTag(oNPC); + + object oStore = GetNearestObjectByTag(sShopTag, oNPC); + object oBuyer = OBJECT_INVALID; + object oTestPC = GetFirstPC(); + while(GetIsObjectValid(oTestPC)) { + if(GetName(oTestPC) == sTargetPlayer) { + oBuyer = oTestPC; + break; + } + oTestPC = GetNextPC(); + } + + if (!GetIsObjectValid(oBuyer)) oBuyer = GetNearestCreature(CREATURE_TYPE_PLAYER_CHAR, PLAYER_CHAR_IS_PC, oNPC); + + if (GetIsObjectValid(oStore) && GetIsObjectValid(oBuyer)) { + DelayCommand(1.0f, AssignCommand(oNPC, ActionPlayAnimation(ANIMATION_FIREFORGET_GREETING))); + DelayCommand(1.5f, OpenStore(oStore, oBuyer)); + } else { + AssignCommand(oNPC, ActionSpeakString("I seem to have lost my wares...")); + } + } + + else if (sAction == "GIVE_QUEST") { + object oPlayer = OBJECT_INVALID; + object oTestPC = GetFirstPC(); + while(GetIsObjectValid(oTestPC)) { + if(GetName(oTestPC) == sTargetPlayer) { + oPlayer = oTestPC; + break; + } + oTestPC = GetNextPC(); + } + + if (!GetIsObjectValid(oPlayer)) oPlayer = GetNearestCreature(CREATURE_TYPE_PLAYER_CHAR, PLAYER_CHAR_IS_PC, oNPC); + + if (GetIsObjectValid(oPlayer)) { + DelayCommand(1.0f, ApplyEffectToObject(DURATION_TYPE_INSTANT, EffectVisualEffect(VFX_IMP_MAGIC_PROTECTION), oNPC)); + SendMessageToPC(oPlayer, "[SERVER] " + GetName(oNPC) + " has granted you a new quest objective!"); + } + } + + else if (sAction == "RETURN_TO_POST") { + object oHome = GetWaypointByTag("WP_" + GetTag(oNPC) + "_HOME"); + if (GetIsObjectValid(oHome)) { + DelayCommand(0.2, AssignCommand(oNPC, ActionForceMoveToObject(oHome, FALSE, 1.0f))); + object oBed = GetNearestObjectByTag("Bed", oNPC); + if (GetIsObjectValid(oBed) && GetDistanceBetween(oHome, oBed) < 5.0) { + AssignCommand(oNPC, ActionDoCommand(ActionPlayAnimation(ANIMATION_LOOPING_DEAD_BACK, 1.0, 9999.0))); + } else { + AssignCommand(oNPC, ActionDoCommand(SetFacing(GetFacing(oHome)))); + } + DelayCommand(20.0, ExecuteFallbackJump(oNPC, oHome)); + } + } + + nResultId = NWNX_Redis_LPOP("llm_to_nwn"); + sReplyData = NWNX_Redis_GetResultAsString(nResultId); + } +} \ No newline at end of file diff --git a/nwn-asl-ai/asl_npc_on_damaged b/nwn-asl-ai/asl_npc_on_damaged new file mode 100644 index 0000000..cca2c8a --- /dev/null +++ b/nwn-asl-ai/asl_npc_on_damaged @@ -0,0 +1,31 @@ +// ============================================================================ +// File Name: bpc_on_damaged +// Emergency LLM interruption for combat events +// ============================================================================ +#include "nwnx_redis" +void main() +{ + object oNPC = OBJECT_SELF; + object oAttacker = GetLastDamager(); + if (!GetIsObjectValid(oAttacker)) oAttacker = GetLastAttacker(); + + if (!GetIsObjectValid(oAttacker)) return; + + int nStrategy = GetLocalInt(oNPC, "LLM_STRATEGY"); + if (nStrategy == 3) return; // Maestros don't think in combat + + if (GetLocalInt(oNPC, "LLM_PAIN_THROTTLE") == TRUE) return; + SetLocalInt(oNPC, "LLM_PAIN_THROTTLE", TRUE); + DelayCommand(12.0f, DeleteLocalInt(oNPC, "LLM_PAIN_THROTTLE")); + + json jData = JsonObject(); + jData = JsonObjectSet(jData, "npc_tag", JsonString(GetTag(oNPC))); + jData = JsonObjectSet(jData, "llm_strategy", JsonInt(nStrategy)); + jData = JsonObjectSet(jData, "target_player", JsonString(GetName(oAttacker))); + + string sPainMsg = "[SYSTEM CRITICAL] You were just physically attacked and damaged by " + GetName(oAttacker) + "!! Make a tactical decision immediately!"; + jData = JsonObjectSet(jData, "message", JsonString(sPainMsg)); + jData = JsonObjectSet(jData, "persona", JsonString(GetLocalString(oNPC, "LLM_PERSONA"))); + + NWNX_Redis_RPush("nwn_to_llm", JsonDump(jData)); +} \ No newline at end of file diff --git a/nwn-asl-ai/asl_npc_on_heartbeat b/nwn-asl-ai/asl_npc_on_heartbeat new file mode 100644 index 0000000..3d7f38d --- /dev/null +++ b/nwn-asl-ai/asl_npc_on_heartbeat @@ -0,0 +1,93 @@ +// ============================================================================ +// File Name: bpc_heartbeat +// The "Sense" phase of the Generative AI Loop OnHeartbeat obecjt event +// ============================================================================ +#include "nwnx_redis" +#include "nwnx_redis_lib" + +void main() +{ + object oNPC = OBJECT_SELF; + + object oNearestPC = GetNearestCreature(CREATURE_TYPE_PLAYER_CHAR, PLAYER_CHAR_IS_PC, oNPC); + if (!GetIsObjectValid(oNearestPC) || GetDistanceBetween(oNPC, oNearestPC) > 25.0f) { + return; + } + + int nTick = GetLocalInt(oNPC, "LLM_RADAR_TICK") + 1; + if (nTick >= 10) nTick = 0; + SetLocalInt(oNPC, "LLM_RADAR_TICK", nTick); + + if (nTick != 0) return; + + int nMyMaxHP = GetMaxHitPoints(oNPC); + int nMyCurHP = GetCurrentHitPoints(oNPC); + string sMyHealth = "Healthy and uninjured."; + + if (nMyCurHP < nMyMaxHP) { + if (nMyCurHP <= (nMyMaxHP / 4)) sMyHealth = "Critically wounded, bleeding heavily, and near death! I need to REST immediately."; + else if (nMyCurHP <= (nMyMaxHP / 2)) sMyHealth = "Injured, bruised, and exhausted."; + else sMyHealth = "Slightly hurt, with a few cuts and scratches."; + } + + int nStrategy = GetLocalInt(oNPC, "LLM_STRATEGY"); + if (nStrategy == 0) nStrategy = 1; + + string sObservation = "You are observing your surroundings."; + if (nStrategy != 3) { + sObservation += " " + GetName(oNearestPC) + " is standing nearby."; + } + + string sNearbyNPCs = ""; + int nNPCScan = 1; + object oNearbyNPC = GetNearestCreature(CREATURE_TYPE_PLAYER_CHAR, PLAYER_CHAR_NOT_PC, oNPC, nNPCScan); + + while (GetIsObjectValid(oNearbyNPC) && GetDistanceBetween(oNPC, oNearbyNPC) <= 15.0f) { + if (oNearbyNPC != oNPC) sNearbyNPCs += GetName(oNearbyNPC) + ", "; + nNPCScan++; + oNearbyNPC = GetNearestCreature(CREATURE_TYPE_PLAYER_CHAR, PLAYER_CHAR_NOT_PC, oNPC, nNPCScan); + } + + int nPropScan = 1; + object oProp = GetNearestObject(OBJECT_TYPE_PLACEABLE, oNPC, nPropScan); + + while (GetIsObjectValid(oProp) && GetDistanceBetween(oNPC, oProp) < 15.0f && nPropScan <= 3) { + if (GetUseableFlag(oProp) || GetLocalInt(oProp, "NW_INTERACTIVE") == TRUE) { + sObservation += " You notice a " + GetName(oProp) + " nearby."; + break; + } + nPropScan++; + oProp = GetNearestObject(OBJECT_TYPE_PLACEABLE, oNPC, nPropScan); + } + + string sLocationLore = ""; + object oArea = GetArea(oNPC); + string sAreaLore = GetLocalString(oArea, "LLM_LOCATION_CONTEXT"); + if (sAreaLore != "") sLocationLore = sAreaLore; + + object oWaypoint = GetNearestObjectByTag("WP_LLM_LORE", oNPC); + if (GetIsObjectValid(oWaypoint) && GetDistanceBetween(oNPC, oWaypoint) <= 15.0f) { + string sWPLore = GetLocalString(oWaypoint, "LLM_LOCATION_CONTEXT"); + if (sWPLore != "") sLocationLore += " SPECIFIC SURROUNDINGS: " + sWPLore; + } + + json jData = JsonObject(); + jData = JsonObjectSet(jData, "npc_tag", JsonString(GetTag(oNPC))); + jData = JsonObjectSet(jData, "target_player", JsonString("Environment")); + jData = JsonObjectSet(jData, "message", JsonString(sObservation)); + jData = JsonObjectSet(jData, "persona", JsonString(GetLocalString(oNPC, "LLM_PERSONA"))); + jData = JsonObjectSet(jData, "profession", JsonString(GetLocalString(oNPC, "LLM_PROFESSION"))); + jData = JsonObjectSet(jData, "npc_health", JsonString(sMyHealth)); + jData = JsonObjectSet(jData, "llm_strategy", JsonInt(nStrategy)); + + if (sLocationLore != "") jData = JsonObjectSet(jData, "location_context", JsonString(sLocationLore)); + if (sNearbyNPCs != "") jData = JsonObjectSet(jData, "nearby_npcs", JsonString(sNearbyNPCs)); + + string sQuests = GetLocalString(oNPC, "LLM_QUESTS"); + if (sQuests != "") jData = JsonObjectSet(jData, "available_quests", JsonString(sQuests)); + + string sProps = GetLocalString(oNPC, "LLM_PROPS"); + if (sProps != "") jData = JsonObjectSet(jData, "available_props", JsonString(sProps)); + + NWNX_Redis_RPush("nwn_to_llm", JsonDump(jData)); +} \ No newline at end of file diff --git a/nwn-asl-ai/asl_npc_onnspawn b/nwn-asl-ai/asl_npc_onnspawn new file mode 100644 index 0000000..d4810e6 --- /dev/null +++ b/nwn-asl-ai/asl_npc_onnspawn @@ -0,0 +1,28 @@ +// ============================================================================ +// File Name: asl_npc_on_spawn +// Initialize Decoupled Agent Architecture, on Object OnSpawn event. +// ============================================================================ +#include "nw_i0_generic" + +void main() +{ + // 1. Initialize Default Bioware Combat/Perception AI + SetListeningPatterns(); + SetSpawnInCondition(NW_FLAG_AMBIENT_ANIMATIONS); + + // 2. Setup LLM Strategy Defaults + int nStrategy = GetLocalInt(OBJECT_SELF, "LLM_STRATEGY"); + + if (nStrategy == 0) { + SetLocalInt(OBJECT_SELF, "LLM_STRATEGY", 1); + nStrategy = 1; + } + + // 3. Strategy Specific Overrides + if (nStrategy == 3) { + // Maestros should wander randomly to look busy while they puppet + WalkWayPoints(); + } + + GenerateRandomDrop(); +} \ No newline at end of file diff --git a/nwn-asl-ai/on_player_chat b/nwn-asl-ai/on_player_chat new file mode 100644 index 0000000..38fd283 --- /dev/null +++ b/nwn-asl-ai/on_player_chat @@ -0,0 +1,200 @@ +// ============================================================================ +// File Name: on_player_chat +// Generative Agent "Sense" Phase - Placed on Module OnPlayerChat +// ============================================================================ +#include "nwnx_redis" +#include "nwnx_redis_lib" + +void main() +{ + object oSpeaker = GetPCChatSpeaker(); + string sMessage = GetPCChatMessage(); + int nVolume = GetPCChatVolume(); + + if (nVolume != TALKVOLUME_TALK) return; + + // ========================================================== + // 1. THE PHYSICAL EMOTE SYSTEM (/commands) + // ========================================================== + string sPhysicalEmote = ""; + string sLowerMsg = GetStringLowerCase(sMessage); + + if (GetStringLeft(sLowerMsg, 1) == "/") { + if (sLowerMsg == "/bow") { + AssignCommand(oSpeaker, ActionPlayAnimation(ANIMATION_FIREFORGET_BOW)); + sPhysicalEmote = "[The player physically bows respectfully to you.]"; + } + else if (sLowerMsg == "/laugh") { + AssignCommand(oSpeaker, ActionPlayAnimation(ANIMATION_LOOPING_TALK_LAUGHING, 1.0, 3.0)); + sPhysicalEmote = "[The player is physically laughing out loud.]"; + } + else if (sLowerMsg == "/taunt" || sLowerMsg == "/threaten") { + AssignCommand(oSpeaker, ActionPlayAnimation(ANIMATION_FIREFORGET_TAUNT)); + sPhysicalEmote = "[The player steps forward and physically taunts you.]"; + } + else if (sLowerMsg == "/wave" || sLowerMsg == "/greet") { + AssignCommand(oSpeaker, ActionPlayAnimation(ANIMATION_FIREFORGET_GREETING)); + sPhysicalEmote = "[The player physically waves hello to you.]"; + } + else if (sLowerMsg == "/cheer") { + AssignCommand(oSpeaker, ActionPlayAnimation(ANIMATION_FIREFORGET_VICTORY1)); + sPhysicalEmote = "[The player is cheering and celebrating.]"; + } + else if (sLowerMsg == "/drunk") { + AssignCommand(oSpeaker, ActionPlayAnimation(ANIMATION_LOOPING_PAUSE_DRUNK, 1.0, 6.0)); + sPhysicalEmote = "[The player is swaying physically, acting heavily intoxicated.]"; + } + + sMessage = ""; + } + + // ========================================================== + // 2. EXTRACT PLAYER BODY LANGUAGE & STATE + // ========================================================== + string sPlayerState = "Relaxed and unarmed."; + object oRightHand = GetItemInSlot(INVENTORY_SLOT_RIGHTHAND, oSpeaker); + object oLeftHand = GetItemInSlot(INVENTORY_SLOT_LEFTHAND, oSpeaker); + int bArmed = FALSE; + + if (GetIsObjectValid(oRightHand)) { + int nType = GetBaseItemType(oRightHand); + if (nType != BASE_ITEM_TORCH && nType != BASE_ITEM_BLANK_POTION && nType != BASE_ITEM_SPELLSCROLL) bArmed = TRUE; + } + if (GetIsObjectValid(oLeftHand) && GetBaseItemType(oLeftHand) != BASE_ITEM_TORCH) bArmed = TRUE; + + if (bArmed) sPlayerState += " They have a weapon drawn in their hands!"; + if (sPhysicalEmote != "") sPlayerState += " " + sPhysicalEmote; + + string sWorldState = GetLocalString(GetModule(), "LLM_WORLD_STATE"); + + // ========================================================== + // 3. THE RADIUS SCANNER (Group Conversations) + // ========================================================== + int nNth = 1; + object oTarget = GetNearestObject(OBJECT_TYPE_CREATURE | OBJECT_TYPE_PLACEABLE, oSpeaker, nNth); + + while (GetIsObjectValid(oTarget) && GetDistanceBetween(oSpeaker, oTarget) <= 5.0f) + { + string sPersona = GetLocalString(oTarget, "LLM_PERSONA"); + if (sPersona == "") sPersona = GetLocalString(oTarget, "LLM_PROMPT"); + + if (sPersona != "") + { + // --- STRATEGY OVERRIDE: Prevent Maestros from reacting to player chat --- + int nStrategy = GetLocalInt(oTarget, "LLM_STRATEGY"); + if (nStrategy == 0) nStrategy = 1; + + if (nStrategy == 3) { + nNth++; + oTarget = GetNearestObject(OBJECT_TYPE_CREATURE | OBJECT_TYPE_PLACEABLE, oSpeaker, nNth); + continue; + } + + // --- COOLDOWN CHECK --- + if (GetLocalInt(oTarget, "LLM_CHAT_COOLDOWN") == TRUE) { + nNth++; + oTarget = GetNearestObject(OBJECT_TYPE_CREATURE | OBJECT_TYPE_PLACEABLE, oSpeaker, nNth); + continue; + } + + SetLocalInt(oTarget, "LLM_CHAT_COOLDOWN", TRUE); + DelayCommand(6.0f, SetLocalInt(oTarget, "LLM_CHAT_COOLDOWN", FALSE)); + + ApplyEffectToObject(DURATION_TYPE_INSTANT, EffectVisualEffect(VFX_IMP_HEAD_MIND), oTarget); + SendMessageToPC(oSpeaker, "[" + GetName(oTarget) + " is listening...]"); + + string sLawChaos = "Neutral"; + int nLawChaos = GetAlignmentLawChaos(oTarget); + if (nLawChaos == ALIGNMENT_LAWFUL) sLawChaos = "Lawful"; + else if (nLawChaos == ALIGNMENT_CHAOTIC) sLawChaos = "Chaotic"; + + string sGoodEvil = "Neutral"; + int nGoodEvil = GetAlignmentGoodEvil(oTarget); + if (nGoodEvil == ALIGNMENT_GOOD) sGoodEvil = "Good"; + else if (nGoodEvil == ALIGNMENT_EVIL) sGoodEvil = "Evil"; + + string sAlignment = sLawChaos + " " + sGoodEvil; + if (sAlignment == "Neutral Neutral") sAlignment = "True Neutral"; + + string sGender = "Unknown"; + int nGender = GetGender(oTarget); + if (nGender == GENDER_MALE) sGender = "Male"; + else if (nGender == GENDER_FEMALE) sGender = "Female"; + + string sRace = "Creature"; + int nRace = GetRacialType(oTarget); + if (nRace == RACIAL_TYPE_HUMAN) sRace = "Human"; + else if (nRace == RACIAL_TYPE_ELF) sRace = "Elf"; + else if (nRace == RACIAL_TYPE_DWARF) sRace = "Dwarf"; + else if (nRace == RACIAL_TYPE_HALFLING) sRace = "Halfling"; + else if (nRace == RACIAL_TYPE_GNOME) sRace = "Gnome"; + else if (nRace == RACIAL_TYPE_HALFELF) sRace = "Half-Elf"; + else if (nRace == RACIAL_TYPE_HALFORC) sRace = "Half-Orc"; + + int nMyMaxHP = GetMaxHitPoints(oTarget); + int nMyCurHP = GetCurrentHitPoints(oTarget); + string sMyHealth = "Healthy and uninjured."; + if (nMyCurHP < nMyMaxHP) { + if (nMyCurHP <= (nMyMaxHP / 4)) sMyHealth = "Critically wounded, bleeding heavily, and near death! I need to REST immediately."; + else if (nMyCurHP <= (nMyMaxHP / 2)) sMyHealth = "Injured, bruised, and exhausted."; + else sMyHealth = "Slightly hurt, with a few cuts and scratches."; + } + + string sRelationship = "Neutral or Friendly."; + if (GetIsEnemy(oSpeaker, oTarget)) { + sRelationship = "CRITICAL: THIS PLAYER IS YOUR ENEMY! They have attacked you. You are FURIOUS, AGGRESSIVE, and HOSTILE. Do NOT be polite or friendly!"; + } + + string sLocationLore = ""; + object oArea = GetArea(oTarget); + string sAreaLore = GetLocalString(oArea, "LLM_LOCATION_CONTEXT"); + if (sAreaLore != "") sLocationLore = sAreaLore; + + object oWaypoint = GetNearestObjectByTag("WP_LLM_LORE", oTarget); + if (GetIsObjectValid(oWaypoint) && GetDistanceBetween(oTarget, oWaypoint) <= 15.0f) { + string sWPLore = GetLocalString(oWaypoint, "LLM_LOCATION_CONTEXT"); + if (sWPLore != "") sLocationLore += " SPECIFIC SURROUNDINGS: " + sWPLore; + } + + string sNearbyNPCs = ""; + int nNPCScan = 1; + object oRadarNPC = GetNearestCreature(CREATURE_TYPE_PLAYER_CHAR, PLAYER_CHAR_NOT_PC, oTarget, nNPCScan); + while (GetIsObjectValid(oRadarNPC) && GetDistanceBetween(oTarget, oRadarNPC) <= 15.0f) { + if (oRadarNPC != oTarget) sNearbyNPCs += GetName(oRadarNPC) + ", "; + nNPCScan++; + oRadarNPC = GetNearestCreature(CREATURE_TYPE_PLAYER_CHAR, PLAYER_CHAR_NOT_PC, oTarget, nNPCScan); + } + + json jData = JsonObject(); + jData = JsonObjectSet(jData, "npc_tag", JsonString(GetTag(oTarget))); + jData = JsonObjectSet(jData, "target_player", JsonString(GetName(oSpeaker))); + jData = JsonObjectSet(jData, "message", JsonString(sMessage)); + jData = JsonObjectSet(jData, "persona", JsonString(sPersona)); + jData = JsonObjectSet(jData, "profession", JsonString(GetLocalString(oTarget, "LLM_PROFESSION"))); + jData = JsonObjectSet(jData, "mood", JsonString(GetLocalString(oTarget, "LLM_MOOD"))); + jData = JsonObjectSet(jData, "secret", JsonString(GetLocalString(oTarget, "LLM_SECRET"))); + jData = JsonObjectSet(jData, "npc_routine", JsonString(GetLocalString(oTarget, "LLM_ROUTINE"))); + jData = JsonObjectSet(jData, "npc_alignment", JsonString(sAlignment)); + jData = JsonObjectSet(jData, "npc_gender", JsonString(sGender)); + jData = JsonObjectSet(jData, "npc_race", JsonString(sRace)); + jData = JsonObjectSet(jData, "player_state", JsonString(sPlayerState)); + jData = JsonObjectSet(jData, "npc_health", JsonString(sMyHealth)); + jData = JsonObjectSet(jData, "relationship", JsonString(sRelationship)); + jData = JsonObjectSet(jData, "llm_strategy", JsonInt(nStrategy)); + + string sQuests = GetLocalString(oTarget, "LLM_QUESTS"); + if (sQuests != "") jData = JsonObjectSet(jData, "available_quests", JsonString(sQuests)); + + string sProps = GetLocalString(oTarget, "LLM_PROPS"); + if (sProps != "") jData = JsonObjectSet(jData, "available_props", JsonString(sProps)); + + if (sLocationLore != "") jData = JsonObjectSet(jData, "location_context", JsonString(sLocationLore)); + if (sWorldState != "") jData = JsonObjectSet(jData, "world_state", JsonString(sWorldState)); + if (sNearbyNPCs != "") jData = JsonObjectSet(jData, "nearby_npcs", JsonString(sNearbyNPCs)); + + NWNX_Redis_RPush("nwn_to_llm", JsonDump(jData)); + } + nNth++; + oTarget = GetNearestObject(OBJECT_TYPE_CREATURE | OBJECT_TYPE_PLACEABLE, oSpeaker, nNth); + } +} \ No newline at end of file diff --git a/redis_bridge.py b/redis_bridge.py index 642d360..2cc86d2 100644 --- a/redis_bridge.py +++ b/redis_bridge.py @@ -59,11 +59,11 @@ async def memory_summarizer_worker(session): try: print(f"[MEMORY DB] Generating background memory for {player_name} and {npc_tag}...") async with session.post('http://localhost:11434/api/generate', json={ - "model": "llama3", # Changed back to llama3 from gemma4 based on previous setup + "model": "llama3", "prompt": prompt, "stream": False, "options": { - "temperature": 0.1 + "temperature": 0.2 } }) as response: result = await response.json() @@ -121,6 +121,10 @@ async def process_message(r, session, message_data): # Core Strategy Flag (1: Agent, 2: Villain, 3: Maestro, 4: Shrine) llm_strategy = int(data.get('llm_strategy', 1)) + + + available_quests = data.get('available_quests', '') + available_props = data.get('available_props', '') # --- Sub-Context Strings --- group_context = f"Be aware that these other players are listening nearby: {nearby_players}." if nearby_players else "" @@ -159,7 +163,21 @@ async def process_message(r, session, message_data): if llm_strategy == 1: # STRATEGY 1: The Autonomous Agent - strategy_rules = f"ROLE: You are an interactive, living NPC. You actively respond to players.\nGOALS: React to their words, use the environment, and establish your personality.\nSPECIAL CAPABILITIES: You can offer quests or open your merchant store if asked." + + # Anti-Hallucination Grounding for Quests + if available_quests: + quest_rules = f"SPECIAL CAPABILITIES: You can offer the following quests to the player: {available_quests}." + else: + quest_rules = "WARNING: You currently have NO quests to offer. Do NOT invent or offer any quests." + + # --- NEW: Anti-Hallucination Grounding for Props --- + if available_props: + prop_rules = f"ENVIRONMENT: You own and have access to these specific nearby objects: [{available_props}]. To roleplay working or relaxing, use the USE_OBJECT action with one of these exact items as your action_target." + else: + prop_rules = "" + + strategy_rules = f"ROLE: You are an interactive, living NPC. You actively respond to players.\nGOALS: React to their words, use the environment, and establish your personality.\n{quest_rules}\n{prop_rules}\nSPECIAL CAPABILITIES: You can open your merchant store if asked." + action_macros = "[WANDER, PATROL, FOLLOW, GUARD, GO_TO, INTERACT, USE_OBJECT, RETURN_TO_POST, OPEN_STORE, GIVE_QUEST, CONVERSE]" target_context = f"CURRENT TARGET: You are speaking to {player_name}, a {player_alignment} {player_race}.\nTheir physical state: {player_state}\nRelationship to you: {relationship}\n{group_context}" @@ -171,7 +189,7 @@ async def process_message(r, session, message_data): elif llm_strategy == 3: # STRATEGY 3: The Maestro (Puppeteer) - strategy_rules = "ROLE: You are an ambient Maestro NPC. You DO NOT interact with players. You only talk to other NPCs to make the world feel alive.\nGOALS: Observe the environment and initiate conversations with the generic NPCs listed in your context. Ignore players entirely." + strategy_rules = "ROLE: You are an ambient Maestro NPC. You DO NOT interact with players. You only talk to other NPCs to make the world feel alive.\nGOALS: Observe the environment and use the CONVERSE action to talk to the generic NPCs listed in your context. CRITICAL: You MUST invent and write their reply in the 'target_speech' field!" action_macros = "[WANDER, INTERACT, USE_OBJECT, CONVERSE]" target_context = "CURRENT TARGET: You are ignoring players and focusing on ambient life. Do not address players." @@ -218,7 +236,7 @@ async def process_message(r, session, message_data): Your "action" key MUST be exactly one of the following words: {action_macros} - - Use CONVERSE if you want to initiate a back-and-forth dialogue with a standard, unintelligent NPC. You will write their response for them in "target_speech". + - Use CONVERSE to initiate dialogue with a standard NPC. CRITICAL REQUIREMENT: When using CONVERSE, you absolutely MUST invent their response and put it in the "target_speech" field. Do not leave it blank! YOUR RESPONSE MUST BE A SINGLE, VALID JSON OBJECT. YOU MUST USE THIS EXACT TEMPLATE: {{ diff --git a/server_data/modules/Alentejo Sem Lei.mod b/server_data/modules/Alentejo Sem Lei.mod index 31ed2ae..85f1486 100644 Binary files a/server_data/modules/Alentejo Sem Lei.mod and b/server_data/modules/Alentejo Sem Lei.mod differ