diff --git a/conf/conf.sample.php b/conf/conf.sample.php index ae7ae00d8..0a3b0802c 100755 --- a/conf/conf.sample.php +++ b/conf/conf.sample.php @@ -150,8 +150,6 @@ $MAGIC_EVENT_BLACKLIST=""; //Comma-separated list of magic events to exclude from logging. $LOCATION_BLACKLIST="Dark Brotherhood Sanctuary, Twilight Sepulcher"; //Comma-separated list of location names to exclude from Points of Interest context. $ITEM_BLACKLIST=""; //Comma-separated list of item/armor names to exclude from dynamic context. -$CARRIAGE_DRIVERS="Bjorlam, Alfarinn, Kibell, Sigaar, Thaer, Engar, Gunjar, Markus"; //Comma-separated NPC names that can offer carriage fast travel. -$FERRY_DRIVERS="Gort, Harlaug, Jolf"; //Comma-separated NPC names that can offer ferry fast travel. $EVENT_TYPE_FILTER=""; //Comma-separated list of event types to exclude from context generation. $GROUND_ITEMS_DESCRIPTIONS_ONLY=false; //Only show nearby ground items that have descriptions in the database. $INVENTORY_ITEMS_DESCRIPTIONS_ONLY=false; //Only show inventory items that have descriptions in the database. diff --git a/conf/conf_loader.php b/conf/conf_loader.php index 78c0e2c22..0cbc57a40 100755 --- a/conf/conf_loader.php +++ b/conf/conf_loader.php @@ -19,20 +19,20 @@ function conf_loader_load() { foreach ($confSchema as $name=>$definition) { if (isset($definition["type"])) { - $definition["currentValue"] = isset($GLOBALS[$name]) ? $GLOBALS[$name] : ''; + $definition["currentValue"] = isset($GLOBALS[$name]) ? $GLOBALS[$name] : ($definition["default"] ?? ''); $confMap[$name]=$definition; } else { if (is_array($definition)) { foreach ($definition as $name2=>$definition2) { if (isset($definition2["type"])) { - $definition2["currentValue"] = isset($GLOBALS[$name][$name2]) ? $GLOBALS[$name][$name2] : ''; + $definition2["currentValue"] = isset($GLOBALS[$name][$name2]) ? $GLOBALS[$name][$name2] : ($definition2["default"] ?? ''); $confMap["$name $name2"]=$definition2; } else if (is_array($definition2)) { foreach ($definition2 as $name3=>$definition3) { if (isset($definition3["type"])) { - $definition3["currentValue"] = isset($GLOBALS[$name][$name2][$name3]) ? $GLOBALS[$name][$name2][$name3] : ''; + $definition3["currentValue"] = isset($GLOBALS[$name][$name2][$name3]) ? $GLOBALS[$name][$name2][$name3] : ($definition3["default"] ?? ''); $confMap["$name $name2 $name3"]=$definition3; } diff --git a/conf/conf_schema.json b/conf/conf_schema.json index 95cc78a83..11b842f67 100644 --- a/conf/conf_schema.json +++ b/conf/conf_schema.json @@ -58,8 +58,6 @@ "MAGIC_EVENT_BLACKLIST": {"userlvl":"basic","type":"longstring","scope":"global","description":"Comma-separated list of magic event names to exclude from logging (e.g. 'Administer Mixture, [BFCO-AttackSwingFX] 0.5/1.5, Healing')."}, "LOCATION_BLACKLIST": {"userlvl":"basic","type":"longstring","scope":"global","description":"Comma-separated list of location names to exclude from Points of Interest context (e.g. 'Dark Brotherhood Sanctuary, Twilight Sepulcher')."}, "ITEM_BLACKLIST": {"userlvl":"basic","type":"longstring","scope":"global","description":"Comma-separated list of item/armor names to exclude from dynamic context (e.g. 'Iron Sword, Leather Armor, Health Potion')."}, - "CARRIAGE_DRIVERS": {"userlvl":"basic","type":"longstring","scope":"global","description":"Comma-separated NPC names that can offer carriage fast travel (e.g. 'Bjorlam, Alfarinn, Kibell')."}, - "FERRY_DRIVERS": {"userlvl":"basic","type":"longstring","scope":"global","description":"Comma-separated NPC names that can offer ferry fast travel (e.g. 'Gort, Harlaug, Jolf')."}, "EVENT_TYPE_FILTER": {"userlvl":"basic","type":"longstring","scope":"global","description":"Comma-separated list of event types to exclude from context generation (e.g. 'death, itemfound, spellcast')."}, "GROUND_ITEMS_DESCRIPTIONS_ONLY": {"userlvl":"basic","type":"boolean","scope":"global","description":"Only show nearby ground items in context that have descriptions in the description manager."}, "INVENTORY_ITEMS_DESCRIPTIONS_ONLY": {"userlvl":"basic","type":"boolean","scope":"global","description":"Only show inventory items in context that have descriptions in the description manager."}, diff --git a/connector/google_openaijson.php b/connector/google_openaijson.php index 7272b555f..9ea7e58c9 100755 --- a/connector/google_openaijson.php +++ b/connector/google_openaijson.php @@ -152,9 +152,7 @@ public function open($contextData, $customParms) $lastActionName=$element["tool_calls"][0]["function"]["name"]; $localFuncCodeName=getFunctionCodeName($element["tool_calls"][0]["function"]["name"]); $localArguments=json_decode($element["tool_calls"][0]["function"]["arguments"],true); - $lastAction=strtr($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName],[ - "#TARGET#"=>current($localArguments), - ]); + $lastAction=herikaFormatReturnMessageTemplate($localFuncCodeName, current($localArguments)); $contextDataCopy[]=[ "role"=>"assistant", @@ -414,6 +412,10 @@ public function process() $finalData["message"]=implode(",",$finalData["message"]); } + if (isset($finalData["action"]) && chimActionShouldSuppressImmediateMessage($finalData["action"] ?? '')) { + $finalData["message"] = ""; + } + if (isset($finalData["message"])) { if (is_array($finalData)&&isset($finalData["message"])) { $mangledBuffer = str_replace($this->_extractedbuffer, "", $finalData["message"]); @@ -466,15 +468,17 @@ public function processActions() $parameterArr = json_decode($this->_parameterBuff, true); if (is_array($parameterArr)) { $parameter = current($parameterArr); // Only support for one parameter + $functionCodeName = getFunctionCodeName($this->_functionName); + $parameter = buildFunctionExecutionParameter($functionCodeName, $parameter); + $commandStr = "{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@$parameter\r\n"; - if (!isset($alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n")])) { - $functionCodeName=getFunctionCodeName($this->_functionName); - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@$parameter\r\n"; + if (!isset($alreadysent[md5($commandStr)])) { + $this->_commandBuffer[] = $commandStr; //echo "Herika|command|$functionCodeName@$parameter\r\n"; } - $alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n")] = "{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n"; + $alreadysent[md5($commandStr)] = $commandStr; if (ob_get_level()) @ob_flush(); } else return null; @@ -483,33 +487,8 @@ public function processActions() $parsedResponse=__jpd_decode_lazy($this->_buffer); // USE JPD_LAZY? if (is_array($parsedResponse)) { if (!empty($parsedResponse["action"])) { - if (!isset($alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$parsedResponse["action"]}@{$parsedResponse["target"]}\r\n")])) { - - $functionDef=findFunctionByName($parsedResponse["action"]); - if ($functionDef) { - $functionCodeName=getFunctionCodeName($parsedResponse["action"]); - if (strlen($functionDef["parameters"]["required"][0] ?? '')>0) { - if (!empty($parsedResponse["target"])) { - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@{$parsedResponse["target"]}\r\n"; - } - else { - Logger::warn("Missing required parameter"); - } - - } else { - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@{$parsedResponse["target"]}\r\n"; - } - } elseif ($parsedResponse["action"] != "Talk") { - Logger::warn("Function not found for {$parsedResponse["action"]}"); - } - - //$functionCodeName=getFunctionCodeName($parsedResponse["action"]); - //$this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|{$parsedResponse["action"]}@{$parsedResponse["target"]}\r\n"; - //echo "Herika|command|$functionCodeName@$parameter\r\n"; - $alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$parsedResponse["action"]}@{$parsedResponse["target"]}\r\n")]=end($this->_commandBuffer); - - } - + $executionContext = buildFunctionExecutionContextFromResponse($parsedResponse); + queueFunctionExecutionCommand($this->_commandBuffer, $alreadysent, $executionContext, "google_openaijson"); } if (ob_get_level()) @ob_flush(); diff --git a/connector/groqjson.php b/connector/groqjson.php index 41c469b29..f34585ada 100644 --- a/connector/groqjson.php +++ b/connector/groqjson.php @@ -209,9 +209,7 @@ public function open($contextData, $customParms) $localFuncCodeName=getFunctionCodeName($element["tool_calls"][0]["function"]["name"]); $localArguments=json_decode($element["tool_calls"][0]["function"]["arguments"],true); if (isset($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName])) { - $lastAction=strtr($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName],[ - "#TARGET#"=>current($localArguments), - ]); + $lastAction=herikaFormatReturnMessageTemplate($localFuncCodeName, current($localArguments)); } $contextDataCopy[]=[ "role"=>"assistant", @@ -481,6 +479,10 @@ public function process() $finalData["message"]=implode(",",$finalData["message"]); } + if (isset($finalData["action"]) && chimActionShouldSuppressImmediateMessage($finalData["action"] ?? '')) { + $finalData["message"] = ""; + } + if (isset($finalData["message"])) { if (is_array($finalData)&&isset($finalData["message"])) { $mangledBuffer = str_replace($this->_extractedbuffer, "", $finalData["message"]); @@ -567,13 +569,15 @@ public function processActions() $parameterArr = json_decode($this->_parameterBuff, true); if (is_array($parameterArr)) { $parameter = current($parameterArr); + $functionCodeName = getFunctionCodeName($this->_functionName); + $parameter = buildFunctionExecutionParameter($functionCodeName, $parameter); + $commandStr = "{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@$parameter\r\n"; - if (!isset($alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n")])) { - $functionCodeName=getFunctionCodeName($this->_functionName); - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@$parameter\r\n"; + if (!isset($alreadysent[md5($commandStr)])) { + $this->_commandBuffer[] = $commandStr; } - $alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n")] = "{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n"; + $alreadysent[md5($commandStr)] = $commandStr; if (ob_get_level()) @ob_flush(); } else return null; @@ -584,67 +588,9 @@ public function processActions() if (!empty($parsedResponse["action"])) { if (!isset($parsedResponse["target"])) $parsedResponse["target"] = ""; - - $functionDef=findFunctionByName($parsedResponse["action"]); - $paramString = ""; - $functionCodeName = ""; - if (isset($functionDef)) { - $functionCodeName=getFunctionCodeName($parsedResponse["action"]); - $paramCount = count($functionDef["parameters"]["properties"] ?? []); - - if ($paramCount > 1) { - $params = []; - foreach (array_keys($functionDef["parameters"]["properties"] ?? []) as $paramName) { - if (isset($parsedResponse[$paramName])) { - $paramValue = $parsedResponse[$paramName]; - $paramType = $functionDef["parameters"]["properties"][$paramName]["type"] ?? "string"; - if ($paramType === "integer" && is_numeric($paramValue)) { - $paramValue = intval($paramValue); - } - $params[$paramName] = $paramValue; - } - } - - $requiredParams = $functionDef["parameters"]["required"] ?? []; - foreach ($requiredParams as $reqParam) { - if (!isset($parsedResponse[$reqParam]) || $parsedResponse[$reqParam] === "") { - Logger::warn("groqjson: Missing required parameter '{$reqParam}' for function {$parsedResponse["action"]}"); - } - } - - $paramString = json_encode($params); - } else { - $paramString = $parsedResponse["target"] ?? ""; - } - } else { - $paramString = $parsedResponse["target"] ?? ""; - $functionCodeName = $parsedResponse["action"] ?? ""; - } - - $commandStr = "{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@{$paramString}\r\n"; - if (!isset($alreadysent[md5($commandStr)])) { - - if (isset($functionDef)) { - if (strlen($functionDef["parameters"]["required"][0] ?? '')>0) { - if (!empty($paramString)) { - $this->_commandBuffer[]=$commandStr; - } - else { - Logger::warn("groqjson: Missing required parameters"); - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@\r\n"; - } - - } else { - $this->_commandBuffer[]=$commandStr; - } - } elseif ($parsedResponse["action"] != "Talk") { - Logger::warn("groqjson: Function not found for {$parsedResponse["action"]}"); - } - - $alreadysent[md5($commandStr)]=end($this->_commandBuffer); - - } - + + $executionContext = buildFunctionExecutionContextFromResponse($parsedResponse); + queueFunctionExecutionCommand($this->_commandBuffer, $alreadysent, $executionContext, "groqjson"); } if (ob_get_level()) @ob_flush(); diff --git a/connector/koboldcpp.php b/connector/koboldcpp.php index 0b8218372..5de75317e 100755 --- a/connector/koboldcpp.php +++ b/connector/koboldcpp.php @@ -471,7 +471,7 @@ public function processActions() else if ($intent=="WriteIntoQuestJournal"||$intent=="UpdateQuestJournal") { // bypass reponse. if (isset($jsonData["topic"])) { - $this->_functionRawName="SetCurrentTask@{$jsonData["topic"]}"; + $this->_functionRawName=""; $GLOBALS["db"]->insert( 'currentmission', array( @@ -524,7 +524,7 @@ public function processActions() else if ($kobParsed[0]=="SetCurrentPlan") { // bypass reponse. - $this->_functionRawName="SetCurrentTask@{$kobParsed[1]}"; + $this->_functionRawName=""; $GLOBALS["db"]->insert( 'currentmission', array( @@ -535,7 +535,6 @@ public function processActions() 'localts' => time() ) ); - $alreadysent[md5("Herika|command|{$this->_functionRawName}\r\n")] = "Herika|command|{$this->_functionRawName}\r\n"; } else if ($kobParsed[0]=="ExchangeItems") { // bypass reponse. diff --git a/connector/koboldcppjson.php b/connector/koboldcppjson.php index 7c263139c..2ff0b7805 100755 --- a/connector/koboldcppjson.php +++ b/connector/koboldcppjson.php @@ -334,6 +334,9 @@ public function process() if (($partialResult[0]["action"]=="Inspect")) { return ""; } + if (chimActionShouldSuppressImmediateMessage($partialResult[0]["action"] ?? '')) { + return ""; + } } // workaround for some LLMs that return an array of strings for the dialogue in the JSON response. if (is_array($partialResult[0]["message"])) { @@ -419,35 +422,8 @@ public function processActions() $parsedResponse=$jsonData; if (!empty($parsedResponse["action"])) { - if (!isset($alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$parsedResponse["action"]}@{$parsedResponse["target"]}\r\n")])) { - - $functionDef=findFunctionByName(trim($parsedResponse["action"])); - if ($functionDef) { - $functionCodeName=getFunctionCodeName($parsedResponse["action"]); - if (strlen($functionDef["parameters"]["required"][0] ?? '')>0) { - if (!empty($parsedResponse["target"])) { - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@{$parsedResponse["target"]}\r\n"; - } - else { - Logger::warn("Missing required parameter"); - } - - } else { - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@{$parsedResponse["target"]}\r\n"; - } - } elseif ($parsedResponse["action"] != "Talk") { - Logger::warn("Function not found for {$parsedResponse["action"]}"); - } - - //$functionCodeName=getFunctionCodeName($parsedResponse["action"]); - //$this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|{$parsedResponse["action"]}@{$parsedResponse["target"]}\r\n"; - //echo "Herika|command|$functionCodeName@$parameter\r\n"; - $alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$parsedResponse["action"]}@{$parsedResponse["target"]}\r\n")]=end($this->_commandBuffer); - - } else { - Logger::warn("Function not found for {$parsedResponse["action"]} already sent"); - } - + $executionContext = buildFunctionExecutionContextFromResponse($parsedResponse); + queueFunctionExecutionCommand($this->_commandBuffer, $alreadysent, $executionContext, "koboldcppjson"); } } diff --git a/connector/llamacpp.php b/connector/llamacpp.php index eafc1d1a3..50c680564 100755 --- a/connector/llamacpp.php +++ b/connector/llamacpp.php @@ -660,7 +660,7 @@ public function processActions() else if ($kobParsed[0]=="SetCurrentPlan") { // bypass reponse. - $this->_functionRawName="SetCurrentTask@{$kobParsed[1]}"; + $this->_functionRawName=""; $GLOBALS["db"]->insert( 'currentmission', array( diff --git a/connector/openai.php b/connector/openai.php index 854ce031d..79d4749bf 100755 --- a/connector/openai.php +++ b/connector/openai.php @@ -236,9 +236,7 @@ public function open($contextData, $customParms) $localFuncCodeName=getFunctionCodeName($element["tool_calls"][0]["function"]["name"]); $localArguments=json_decode($element["tool_calls"][0]["function"]["arguments"],true); - $lastAction=strtr($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName],[ - "#TARGET#"=>current($localArguments), - ]); + $lastAction=herikaFormatReturnMessageTemplate($localFuncCodeName, current($localArguments)); unset($contextData[$n]); } else @@ -546,15 +544,18 @@ public function process() if (is_array($parameterArr)) { $parameter = current($parameterArr); // Only support for one parameter - if (!isset($alreadysent[md5("Herika|command|{$this->_functionName}@$parameter\r\n")])) { - $functionCodeName=getFunctionCodeName($this->_functionName); - $this->_commandBuffer[]="Herika|command|$functionCodeName@$parameter\r\n"; + $functionCodeName = getFunctionCodeName($this->_functionName); + $parameter = buildFunctionExecutionParameter($functionCodeName, $parameter); + $commandStr = "Herika|command|$functionCodeName@$parameter\r\n"; + + if (!isset($alreadysent[md5($commandStr)])) { + $this->_commandBuffer[] = $commandStr; file_put_contents(__DIR__.DIRECTORY_SEPARATOR."..".DIRECTORY_SEPARATOR."data".DIRECTORY_SEPARATOR.".last_tool_call_openai.id.txt",$this->_fid); //echo "Herika|command|$functionCodeName@$parameter\r\n"; } - $alreadysent[md5("Herika|command|{$this->_functionName}@$parameter\r\n")] = "Herika|command|{$this->_functionName}@$parameter\r\n"; + $alreadysent[md5($commandStr)] = $commandStr; if (ob_get_level()) @ob_flush(); } @@ -604,14 +605,17 @@ public function processActions() if (is_array($parameterArr)) { $parameter = current($parameterArr); // Only support for one parameter - if (!isset($alreadysent[md5("Herika|command|{$this->_functionName}@$parameter\r\n")])) { - $functionCodeName=getFunctionCodeName($this->_functionName); - $this->_commandBuffer[]="Herika|command|$functionCodeName@$parameter\r\n"; + $functionCodeName = getFunctionCodeName($this->_functionName); + $parameter = buildFunctionExecutionParameter($functionCodeName, $parameter); + $commandStr = "Herika|command|$functionCodeName@$parameter\r\n"; + + if (!isset($alreadysent[md5($commandStr)])) { + $this->_commandBuffer[] = $commandStr; //echo "Herika|command|$functionCodeName@$parameter\r\n"; } - $alreadysent[md5("Herika|command|{$this->_functionName}@$parameter\r\n")] = "Herika|command|{$this->_functionName}@$parameter\r\n"; + $alreadysent[md5($commandStr)] = $commandStr; if (ob_get_level()) @ob_flush(); } else return null; diff --git a/connector/openaijson.php b/connector/openaijson.php index 441348745..d1d6a492d 100755 --- a/connector/openaijson.php +++ b/connector/openaijson.php @@ -583,9 +583,7 @@ public function open($contextData, $customParms) $localFuncCodeName=getFunctionCodeName($element["tool_calls"][0]["function"]["name"]); $localArguments=json_decode($element["tool_calls"][0]["function"]["arguments"],true); if (isset($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName])) { - $lastAction=strtr($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName],[ - "#TARGET#"=>current($localArguments), - ]); + $lastAction=herikaFormatReturnMessageTemplate($localFuncCodeName, current($localArguments)); } $contextDataCopy[]=[ "role"=>"assistant", @@ -1122,6 +1120,10 @@ public function process() $finalData["message"]=implode(",",$finalData["message"]); } + if (isset($finalData["action"]) && chimActionShouldSuppressImmediateMessage($finalData["action"] ?? '')) { + $finalData["message"] = ""; + } + if (isset($finalData["message"])) { if (is_array($finalData)&&isset($finalData["message"])) { $mangledBuffer = str_replace($this->_extractedbuffer, "", $finalData["message"]); @@ -1227,15 +1229,17 @@ public function processActions() $parameterArr = json_decode($this->_parameterBuff, true); if (is_array($parameterArr)) { $parameter = current($parameterArr); // Only support for one parameter + $functionCodeName = getFunctionCodeName($this->_functionName); + $parameter = buildFunctionExecutionParameter($functionCodeName, $parameter); + $commandStr = "{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@$parameter\r\n"; - if (!isset($alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n")])) { - $functionCodeName=getFunctionCodeName($this->_functionName); - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@$parameter\r\n"; + if (!isset($alreadysent[md5($commandStr)])) { + $this->_commandBuffer[] = $commandStr; //echo "Herika|command|$functionCodeName@$parameter\r\n"; } - $alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n")] = "{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n"; + $alreadysent[md5($commandStr)] = $commandStr; if (ob_get_level()) @ob_flush(); } else return null; @@ -1246,74 +1250,9 @@ public function processActions() if (!empty($parsedResponse["action"])) { if (!isset($parsedResponse["target"])) $parsedResponse["target"] = ""; - - // Build parameter string - use JSON for functions with multiple parameters - $functionDef=findFunctionByName($parsedResponse["action"]); - $paramString = ""; - $functionCodeName = ""; - if (isset($functionDef)) { - $functionCodeName=getFunctionCodeName($parsedResponse["action"]); - $paramCount = count($functionDef["parameters"]["properties"] ?? []); - - // For functions with multiple parameters, send as JSON - if ($paramCount > 1) { - $params = []; - foreach (array_keys($functionDef["parameters"]["properties"] ?? []) as $paramName) { - if (isset($parsedResponse[$paramName])) { - $paramValue = $parsedResponse[$paramName]; - // Convert to appropriate type based on function definition - $paramType = $functionDef["parameters"]["properties"][$paramName]["type"] ?? "string"; - if ($paramType === "integer" && is_numeric($paramValue)) { - $paramValue = intval($paramValue); - } - $params[$paramName] = $paramValue; - } - } - - // Check if required parameters are missing (validate against original $parsedResponse) - $requiredParams = $functionDef["parameters"]["required"] ?? []; - foreach ($requiredParams as $reqParam) { - // Check $parsedResponse for original params, not $params (which may be converted) - if (!isset($parsedResponse[$reqParam]) || $parsedResponse[$reqParam] === "") { - Logger::warn("openaijson: Missing required parameter '{$reqParam}' for function {$parsedResponse["action"]}"); - } - } - - $paramString = json_encode($params); - } else { - // Legacy: single parameter as plain string - $paramString = $parsedResponse["target"] ?? ""; - } - } else { - $paramString = $parsedResponse["target"] ?? ""; - $functionCodeName = $parsedResponse["action"] ?? ""; - } - - $commandStr = "{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@{$paramString}\r\n"; - if (!isset($alreadysent[md5($commandStr)])) { - - if (isset($functionDef)) { - if (strlen($functionDef["parameters"]["required"][0] ?? '')>0) { - if (!empty($paramString)) { - $this->_commandBuffer[]=$commandStr; - } - else { - Logger::warn("openaijson: Missing required parameters"); - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@\r\n"; - // Change. we allow this. Post filter maybe can fix. - - } - - } else { - $this->_commandBuffer[]=$commandStr; - } - } elseif ($parsedResponse["action"] != "Talk") { - Logger::warn("openaijson: Function not found for {$parsedResponse["action"]}"); - } - - $alreadysent[md5($commandStr)]=end($this->_commandBuffer); - - } + + $executionContext = buildFunctionExecutionContextFromResponse($parsedResponse); + queueFunctionExecutionCommand($this->_commandBuffer, $alreadysent, $executionContext, "openaijson"); } diff --git a/connector/openrouter.php b/connector/openrouter.php index b85b17c9f..e8d8522f8 100755 --- a/connector/openrouter.php +++ b/connector/openrouter.php @@ -505,15 +505,17 @@ public function process() $parameterArr = json_decode($this->_parameterBuff, true); $parameter = current($parameterArr); // Only support for one parameter + $functionCodeName = getFunctionCodeName($this->_functionName); + $parameter = buildFunctionExecutionParameter($functionCodeName, $parameter); + $commandStr = "Herika|command|$functionCodeName@$parameter\r\n"; - if (!isset($alreadysent[md5("Herika|command|{$this->_functionName}@$parameter\r\n")])) { - $functionCodeName=getFunctionCodeName($this->_functionName); - $this->_commandBuffer[]="Herika|command|$functionCodeName@$parameter\r\n"; + if (!isset($alreadysent[md5($commandStr)])) { + $this->_commandBuffer[] = $commandStr; //echo "Herika|command|$functionCodeName@$parameter\r\n"; } - $alreadysent[md5("Herika|command|{$this->_functionName}@$parameter\r\n")] = "Herika|command|{$this->_functionName}@$parameter\r\n"; + $alreadysent[md5($commandStr)] = $commandStr; if (ob_get_level()) @ob_flush(); } @@ -560,15 +562,17 @@ public function processActions() if ($this->_functionName) { $parameterArr = json_decode($this->_parameterBuff, true); $parameter = current($parameterArr); // Only support for one parameter + $functionCodeName = getFunctionCodeName($this->_functionName); + $parameter = buildFunctionExecutionParameter($functionCodeName, $parameter); + $commandStr = "Herika|command|$functionCodeName@$parameter\r\n"; - if (!isset($alreadysent[md5("Herika|command|{$this->_functionName}@$parameter\r\n")])) { - $functionCodeName=getFunctionCodeName($this->_functionName); - $this->_commandBuffer[]="Herika|command|$functionCodeName@$parameter\r\n"; + if (!isset($alreadysent[md5($commandStr)])) { + $this->_commandBuffer[] = $commandStr; //echo "Herika|command|$functionCodeName@$parameter\r\n"; } - $alreadysent[md5("Herika|command|{$this->_functionName}@$parameter\r\n")] = "Herika|command|{$this->_functionName}@$parameter\r\n"; + $alreadysent[md5($commandStr)] = $commandStr; if (ob_get_level()) @ob_flush(); } diff --git a/connector/openrouterjson.php b/connector/openrouterjson.php index ceaef453c..49df1c34b 100755 --- a/connector/openrouterjson.php +++ b/connector/openrouterjson.php @@ -426,9 +426,7 @@ public function open($contextData, $customParms) $localFuncCodeName=getFunctionCodeName($element["tool_calls"][0]["function"]["name"]); $localArguments=json_decode($element["tool_calls"][0]["function"]["arguments"],true); if (isset($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName])) { - $lastAction=strtr($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName],[ - "#TARGET#"=>current($localArguments), - ]); + $lastAction=herikaFormatReturnMessageTemplate($localFuncCodeName, current($localArguments)); } $contextDataCopy[]=[ "role"=>"assistant", @@ -991,6 +989,10 @@ public function process() if (isset($finalData[0])&& is_array($finalData[0])) $finalData=$finalData[0]; + if (isset($finalData["action"]) && chimActionShouldSuppressImmediateMessage($finalData["action"] ?? '')) { + $finalData["message"] = ""; + } + if (isset($finalData["message"])) { // Check first if action was issued if (is_array($finalData)&&isset($finalData["action"])) { @@ -1097,15 +1099,17 @@ public function processActions() $parameterArr = json_decode($this->_parameterBuff, true); if (is_array($parameterArr)) { $parameter = current($parameterArr); // Only support for one parameter + $functionCodeName = getFunctionCodeName($this->_functionName); + $parameter = buildFunctionExecutionParameter($functionCodeName, $parameter); + $commandStr = "{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@$parameter\r\n"; - if (!isset($alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n")])) { - $functionCodeName=getFunctionCodeName($this->_functionName); - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@$parameter\r\n"; + if (!isset($alreadysent[md5($commandStr)])) { + $this->_commandBuffer[] = $commandStr; //echo "Herika|command|$functionCodeName@$parameter\r\n"; } - $alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n")] = "{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n"; + $alreadysent[md5($commandStr)] = $commandStr; if (ob_get_level()) @ob_flush(); } else return null; @@ -1123,88 +1127,10 @@ public function processActions() if (!isset($parsedResponse["target"])) $parsedResponse["target"] = ""; - - // Build parameter string - use JSON for functions with multiple parameters - $functionDef=findFunctionByName(trim($parsedResponse["action"])); - $paramString = ""; - $functionCodeName = ""; - if (isset($functionDef)) { - $functionCodeName=getFunctionCodeName($parsedResponse["action"]); - $paramCount = count($functionDef["parameters"]["properties"] ?? []); - - // For functions with multiple parameters, send as JSON - if ($paramCount > 1) { - $params = []; - foreach (array_keys($functionDef["parameters"]["properties"] ?? []) as $paramName) { - if (isset($parsedResponse[$paramName])) { - $paramValue = $parsedResponse[$paramName]; - // Convert to appropriate type based on function definition - $paramType = $functionDef["parameters"]["properties"][$paramName]["type"] ?? "string"; - if ($paramType === "integer" && is_numeric($paramValue)) { - $paramValue = intval($paramValue); - } - $params[$paramName] = $paramValue; - } - } - - // Check if required parameters are missing (validate against original $parsedResponse) - $requiredParams = $functionDef["parameters"]["required"] ?? []; - $missingParams = []; - foreach ($requiredParams as $reqParam) { - // Check $parsedResponse for original params, not $params (which may be converted) - if (!isset($parsedResponse[$reqParam]) || $parsedResponse[$reqParam] === "") { - $missingParams[] = $reqParam; - } - } - - if (!empty($missingParams)) { - Logger::warn("openrouterjson: Missing required parameters for {$functionCodeName}: " . implode(", ", $missingParams) . ". Skipping command."); - // Skip this command by setting action to empty - $parsedResponse["action"] = ""; - $functionCodeName = ""; - } else { - $paramString = json_encode($params); - Logger::info("openrouterjson: Multi-param function {$functionCodeName}, params: {$paramString}"); - } - } else { - // Legacy: single parameter as plain string - $paramString = $parsedResponse["target"] ?? ""; - } - } else { - $paramString = $parsedResponse["target"] ?? ""; - $functionCodeName = $parsedResponse["action"] ?? ""; - } - - $commandStr = "{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@{$paramString}\r\n"; - Logger::info("openrouterjson: Sending command: {$commandStr}"); - if (!empty($parsedResponse["action"])) { - if (!isset($alreadysent[md5($commandStr)])) { - - if (isset($functionDef)) { - if (strlen($functionDef["parameters"]["required"][0] ?? '')>0) { - if (!empty($paramString)) { - $this->_commandBuffer[]=$commandStr; - } - else { - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@\r\n"; - Logger::warn("openrouterjson: Missing required parameter: target"); - // Change. we allow this. Post filter maybe can fix. - } - - } else { - $this->_commandBuffer[]=$commandStr; - } - } elseif ($parsedResponse["action"] != "Talk") { - Logger::warn("openrouterjson: Function not found for {$parsedResponse["action"]}"); - } - - $alreadysent[md5($commandStr)]=end($this->_commandBuffer); - - } else { - Logger::warn("openrouterjson: Function not found for {$parsedResponse["action"]} already sent"); - } - - } + + $executionContext = buildFunctionExecutionContextFromResponse($parsedResponse); + Logger::info("openrouterjson: Prepared command payload for " . strval($executionContext["function_code_name"] ?? "")); + queueFunctionExecutionCommand($this->_commandBuffer, $alreadysent, $executionContext, "openrouterjson"); if (ob_get_level()) @ob_flush(); } else { diff --git a/connector/player2json.php b/connector/player2json.php index a9abb910b..318141768 100644 --- a/connector/player2json.php +++ b/connector/player2json.php @@ -351,9 +351,7 @@ public function open($contextData, $customParms) $localFuncCodeName=getFunctionCodeName($element["tool_calls"][0]["function"]["name"]); $localArguments=json_decode($element["tool_calls"][0]["function"]["arguments"],true); if (isset($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName])) { - $lastAction=strtr($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName],[ - "#TARGET#"=>current($localArguments), - ]); + $lastAction=herikaFormatReturnMessageTemplate($localFuncCodeName, current($localArguments)); } $contextDataCopy[]=[ "role"=>"assistant", @@ -610,6 +608,10 @@ public function process() $finalData["message"]=implode(",",$finalData["message"]); } + if (isset($finalData["action"]) && chimActionShouldSuppressImmediateMessage($finalData["action"] ?? '')) { + $finalData["message"] = ""; + } + if (isset($finalData["message"])) { if (is_array($finalData)&&isset($finalData["message"])) { $mangledBuffer = str_replace($this->_extractedbuffer, "", $finalData["message"]); @@ -679,15 +681,17 @@ public function processActions() $parameterArr = json_decode($this->_parameterBuff, true); if (is_array($parameterArr)) { $parameter = current($parameterArr); // Only support for one parameter + $functionCodeName = getFunctionCodeName($this->_functionName); + $parameter = buildFunctionExecutionParameter($functionCodeName, $parameter); + $commandStr = "{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@$parameter\r\n"; - if (!isset($alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n")])) { - $functionCodeName=getFunctionCodeName($this->_functionName); - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@$parameter\r\n"; + if (!isset($alreadysent[md5($commandStr)])) { + $this->_commandBuffer[] = $commandStr; //echo "Herika|command|$functionCodeName@$parameter\r\n"; } - $alreadysent[md5("{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n")] = "{$GLOBALS["HERIKA_NAME"]}|command|{$this->_functionName}@$parameter\r\n"; + $alreadysent[md5($commandStr)] = $commandStr; if (ob_get_level()) @ob_flush(); } else return null; @@ -698,74 +702,9 @@ public function processActions() if (!empty($parsedResponse["action"])) { if (!isset($parsedResponse["target"])) $parsedResponse["target"] = ""; - - // Build parameter string - use JSON for functions with multiple parameters - $functionDef=findFunctionByName($parsedResponse["action"]); - $paramString = ""; - $functionCodeName = ""; - if (isset($functionDef)) { - $functionCodeName=getFunctionCodeName($parsedResponse["action"]); - $paramCount = count($functionDef["parameters"]["properties"] ?? []); - - // For functions with multiple parameters, send as JSON - if ($paramCount > 1) { - $params = []; - foreach (array_keys($functionDef["parameters"]["properties"] ?? []) as $paramName) { - if (isset($parsedResponse[$paramName])) { - $paramValue = $parsedResponse[$paramName]; - // Convert to appropriate type based on function definition - $paramType = $functionDef["parameters"]["properties"][$paramName]["type"] ?? "string"; - if ($paramType === "integer" && is_numeric($paramValue)) { - $paramValue = intval($paramValue); - } - $params[$paramName] = $paramValue; - } - } - - // Check if required parameters are missing (validate against original $parsedResponse) - $requiredParams = $functionDef["parameters"]["required"] ?? []; - foreach ($requiredParams as $reqParam) { - // Check $parsedResponse for original params, not $params (which may be converted) - if (!isset($parsedResponse[$reqParam]) || $parsedResponse[$reqParam] === "") { - Logger::warn("player2json: Missing required parameter '{$reqParam}' for function {$parsedResponse["action"]}"); - } - } - - $paramString = json_encode($params); - } else { - // Legacy: single parameter as plain string - $paramString = $parsedResponse["target"] ?? ""; - } - } else { - $paramString = $parsedResponse["target"] ?? ""; - $functionCodeName = $parsedResponse["action"] ?? ""; - } - - $commandStr = "{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@{$paramString}\r\n"; - if (!isset($alreadysent[md5($commandStr)])) { - - if (isset($functionDef)) { - if (strlen($functionDef["parameters"]["required"][0] ?? '')>0) { - if (!empty($paramString)) { - $this->_commandBuffer[]=$commandStr; - } - else { - Logger::warn("player2json: Missing required parameter: target"); - $this->_commandBuffer[]="{$GLOBALS["HERIKA_NAME"]}|command|$functionCodeName@\r\n"; - // Change. we allow this. Post filter maybe can fix. - - } - - } else { - $this->_commandBuffer[]=$commandStr; - } - } elseif ($parsedResponse["action"] != "Talk") { - Logger::warn("player2json: Function not found for {$parsedResponse["action"]}"); - } - - $alreadysent[md5($commandStr)]=end($this->_commandBuffer); - - } + + $executionContext = buildFunctionExecutionContextFromResponse($parsedResponse); + queueFunctionExecutionCommand($this->_commandBuffer, $alreadysent, $executionContext, "player2json"); } diff --git a/connector/templates/alpaca.php b/connector/templates/alpaca.php index 314b6bd17..886e24aa9 100755 --- a/connector/templates/alpaca.php +++ b/connector/templates/alpaca.php @@ -67,9 +67,7 @@ $localFuncCodeName=getFunctionCodeName($s_msg["tool_calls"][0]["function"]["name"]); $localArguments=json_decode($s_msg["tool_calls"][0]["function"]["arguments"],true); - $lastAction=strtr($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName],[ - "#TARGET#"=>current($localArguments), - ]); + $lastAction=herikaFormatReturnMessageTemplate($localFuncCodeName, current($localArguments)); } else { diff --git a/connector/templates/alpacajson.php b/connector/templates/alpacajson.php index aada034d3..e8f402109 100755 --- a/connector/templates/alpacajson.php +++ b/connector/templates/alpacajson.php @@ -73,9 +73,7 @@ $localFuncCodeName=getFunctionCodeName($s_msg["tool_calls"][0]["function"]["name"]); $localArguments=json_decode($s_msg["tool_calls"][0]["function"]["arguments"],true); - $lastAction=strtr($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName],[ - "#TARGET#"=>current($localArguments), - ]); + $lastAction=herikaFormatReturnMessageTemplate($localFuncCodeName, current($localArguments)); } else { diff --git a/connector/templates/chatml.php b/connector/templates/chatml.php index 4783e0f1f..422d7593b 100755 --- a/connector/templates/chatml.php +++ b/connector/templates/chatml.php @@ -32,9 +32,7 @@ $localFuncCodeName=getFunctionCodeName($s_msg["tool_calls"][0]["function"]["name"]); $localArguments=json_decode($s_msg["tool_calls"][0]["function"]["arguments"],true); - $lastAction=strtr($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName],[ - "#TARGET#"=>current($localArguments), - ]); + $lastAction=herikaFormatReturnMessageTemplate($localFuncCodeName, current($localArguments)); } else { diff --git a/connector/templates/gemma2.php b/connector/templates/gemma2.php index 5b899a10f..fdc498073 100755 --- a/connector/templates/gemma2.php +++ b/connector/templates/gemma2.php @@ -33,9 +33,7 @@ $localFuncCodeName=getFunctionCodeName($s_msg["tool_calls"][0]["function"]["name"]); $localArguments=json_decode($s_msg["tool_calls"][0]["function"]["arguments"],true); - $lastAction=strtr($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName],[ - "#TARGET#"=>current($localArguments), - ]); + $lastAction=herikaFormatReturnMessageTemplate($localFuncCodeName, current($localArguments)); } else { diff --git a/connector/templates/gromenauer.php b/connector/templates/gromenauer.php index 0947661bc..8dd56c0b0 100755 --- a/connector/templates/gromenauer.php +++ b/connector/templates/gromenauer.php @@ -34,9 +34,7 @@ $localFuncCodeName=getFunctionCodeName($s_msg["tool_calls"][0]["function"]["name"]); $localArguments=json_decode($s_msg["tool_calls"][0]["function"]["arguments"],true); - $lastAction=strtr($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName],[ - "#TARGET#"=>current($localArguments), - ]); + $lastAction=herikaFormatReturnMessageTemplate($localFuncCodeName, current($localArguments)); } else { diff --git a/connector/templates/phi.php b/connector/templates/phi.php index c09a96862..1ebdaaba7 100755 --- a/connector/templates/phi.php +++ b/connector/templates/phi.php @@ -59,9 +59,7 @@ $localFuncCodeName=getFunctionCodeName($s_msg["tool_calls"][0]["function"]["name"]); $localArguments=json_decode($s_msg["tool_calls"][0]["function"]["arguments"],true); - $lastAction=strtr($GLOBALS["F_RETURNMESSAGES"][$localFuncCodeName],[ - "#TARGET#"=>current($localArguments), - ]); + $lastAction=herikaFormatReturnMessageTemplate($localFuncCodeName, current($localArguments)); } else { diff --git a/csv_import.php b/csv_import.php index 2f40a710c..c450afe77 100644 --- a/csv_import.php +++ b/csv_import.php @@ -31,7 +31,7 @@ $game_timestamp = $_GET['gamets'] ?? 0; $filename = $_GET['filename'] ?? ''; -if (!in_array($import_type, ['biography_import', 'oghma_import', 'dynamic_oghma_import', 'description_import'])) { +if (!in_array($import_type, ['biography_import', 'oghma_import', 'dynamic_oghma_import', 'description_import', 'custom_action_import'])) { http_response_code(400); echo json_encode(['success' => false, 'error' => 'Invalid import type']); exit; @@ -129,4 +129,4 @@ ]); } -?> \ No newline at end of file +?> diff --git a/data/add_game_plugins.sql b/data/add_game_plugins.sql new file mode 100644 index 000000000..35b90fde4 --- /dev/null +++ b/data/add_game_plugins.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS public.game_plugins ( + plugin_name text PRIMARY KEY, + is_light boolean NOT NULL DEFAULT false, + compile_index integer NOT NULL DEFAULT 0, + small_file_compile_index integer NOT NULL DEFAULT 0, + partial_index integer NOT NULL DEFAULT 0, + formid_prefix text NOT NULL DEFAULT '', + updated_at timestamp without time zone NOT NULL DEFAULT CURRENT_TIMESTAMP +); + + +ALTER TABLE public.game_plugins OWNER TO dwemer; + +COMMENT ON TABLE public.game_plugins IS 'Loaded Skyrim plugins sent from the game runtime for plugin-aware form resolution'; diff --git a/data/database_default.sql b/data/database_default.sql index cc6a6b9db..a6cad7f5a 100755 --- a/data/database_default.sql +++ b/data/database_default.sql @@ -717,6 +717,172 @@ UNION ALL ALTER TABLE public.combined_npc_templates OWNER TO dwemer; +-- +-- Name: core_action; Type: TABLE; Schema: public; Owner: dwemer +-- + +CREATE TABLE public.core_action ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + code_name character varying(128) NOT NULL UNIQUE, + action_name character varying(255) NOT NULL, + description text DEFAULT ''::text NOT NULL, + return_message text DEFAULT ''::text NOT NULL, + available_to_npc boolean DEFAULT false NOT NULL, + available_to_followers boolean DEFAULT false NOT NULL, + is_activated boolean DEFAULT true NOT NULL, + parameters_json jsonb DEFAULT '{}'::jsonb NOT NULL, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + game_function boolean DEFAULT true NOT NULL, + import_version bigint DEFAULT 0 NOT NULL, + script_proxy_program jsonb, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() +); + + +ALTER TABLE public.core_action OWNER TO dwemer; + +-- +-- Name: core_action_custom; Type: TABLE; Schema: public; Owner: dwemer +-- + +CREATE TABLE public.core_action_custom ( + id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + code_name character varying(128) NOT NULL UNIQUE, + action_name character varying(255) NOT NULL, + description text DEFAULT ''::text NOT NULL, + return_message text DEFAULT ''::text NOT NULL, + available_to_npc boolean DEFAULT false NOT NULL, + available_to_followers boolean DEFAULT false NOT NULL, + is_activated boolean DEFAULT true NOT NULL, + parameters_json jsonb DEFAULT '{}'::jsonb NOT NULL, + metadata jsonb DEFAULT '{}'::jsonb NOT NULL, + game_function boolean DEFAULT true NOT NULL, + import_version bigint DEFAULT 0 NOT NULL, + script_proxy_program jsonb, + created_at timestamp without time zone DEFAULT now(), + updated_at timestamp without time zone DEFAULT now() +); + + +ALTER TABLE public.core_action_custom OWNER TO dwemer; + +-- +-- Name: combined_core_action; Type: VIEW; Schema: public; Owner: dwemer +-- + +CREATE VIEW public.combined_core_action AS + SELECT c.id, + c.code_name, + c.action_name, + c.description, + c.return_message, + c.available_to_npc, + c.available_to_followers, + c.is_activated, + c.parameters_json, + c.metadata, + c.game_function, + c.import_version, + c.script_proxy_program, + c.created_at, + c.updated_at + FROM public.core_action_custom c +UNION ALL +SELECT b.id, + b.code_name, + b.action_name, + b.description, + b.return_message, + b.available_to_npc, + b.available_to_followers, + b.is_activated, + b.parameters_json, + b.metadata, + b.game_function, + b.import_version, + b.script_proxy_program, + b.created_at, + b.updated_at + FROM (public.core_action b + LEFT JOIN public.core_action_custom c ON (lower((b.code_name)::text) = lower((c.code_name)::text))) + WHERE (c.code_name IS NULL); + + +ALTER TABLE public.combined_core_action OWNER TO dwemer; + +-- +-- Name: idx_core_action_code_name_lower; Type: INDEX; Schema: public; Owner: dwemer +-- + +CREATE INDEX idx_core_action_code_name_lower ON public.core_action USING btree (lower((code_name)::text)); + +-- +-- Name: idx_core_action_action_name_lower; Type: INDEX; Schema: public; Owner: dwemer +-- + +CREATE INDEX idx_core_action_action_name_lower ON public.core_action USING btree (lower((action_name)::text)); + +-- +-- Name: idx_core_action_is_activated; Type: INDEX; Schema: public; Owner: dwemer +-- + +CREATE INDEX idx_core_action_is_activated ON public.core_action USING btree (is_activated); + +-- +-- Name: idx_core_action_available_to_npc; Type: INDEX; Schema: public; Owner: dwemer +-- + +CREATE INDEX idx_core_action_available_to_npc ON public.core_action USING btree (available_to_npc); + +-- +-- Name: idx_core_action_available_to_followers; Type: INDEX; Schema: public; Owner: dwemer +-- + +CREATE INDEX idx_core_action_available_to_followers ON public.core_action USING btree (available_to_followers); + +-- +-- Name: idx_core_action_game_function; Type: INDEX; Schema: public; Owner: dwemer +-- + +CREATE INDEX idx_core_action_game_function ON public.core_action USING btree (game_function); + +-- +-- Name: idx_core_action_custom_code_name_lower; Type: INDEX; Schema: public; Owner: dwemer +-- + +CREATE INDEX idx_core_action_custom_code_name_lower ON public.core_action_custom USING btree (lower((code_name)::text)); + +-- +-- Name: idx_core_action_custom_action_name_lower; Type: INDEX; Schema: public; Owner: dwemer +-- + +CREATE INDEX idx_core_action_custom_action_name_lower ON public.core_action_custom USING btree (lower((action_name)::text)); + +-- +-- Name: idx_core_action_custom_is_activated; Type: INDEX; Schema: public; Owner: dwemer +-- + +CREATE INDEX idx_core_action_custom_is_activated ON public.core_action_custom USING btree (is_activated); + +-- +-- Name: idx_core_action_custom_available_to_npc; Type: INDEX; Schema: public; Owner: dwemer +-- + +CREATE INDEX idx_core_action_custom_available_to_npc ON public.core_action_custom USING btree (available_to_npc); + +-- +-- Name: idx_core_action_custom_available_to_followers; Type: INDEX; Schema: public; Owner: dwemer +-- + +CREATE INDEX idx_core_action_custom_available_to_followers ON public.core_action_custom USING btree (available_to_followers); + +-- +-- Name: idx_core_action_custom_game_function; Type: INDEX; Schema: public; Owner: dwemer +-- + +CREATE INDEX idx_core_action_custom_game_function ON public.core_action_custom USING btree (game_function); + -- -- Name: conf_opts; Type: TABLE; Schema: public; Owner: dwemer -- diff --git a/debug/db_updates.php b/debug/db_updates.php index ee4825ecc..608e8af2e 100644 --- a/debug/db_updates.php +++ b/debug/db_updates.php @@ -104,10 +104,14 @@ $db->execQuery(file_get_contents(__DIR__."/../lib/core/database_schema/core_llm_connector.sql")); $db->execQuery("SET search_path TO public"); } -if ($checkTableExists("core_profiles") == -1) { + if ($checkTableExists("core_profiles") == -1) { $db->execQuery(file_get_contents(__DIR__."/../lib/core/database_schema/core_profiles.sql")); $db->execQuery("SET search_path TO public"); } + if ($checkTableExists("core_action") == -1) { + $db->execQuery(file_get_contents(__DIR__."/../lib/core/database_schema/core_action.sql")); + $db->execQuery("SET search_path TO public"); + } if ($checkTableExists("core_npc_master") == -1) { $db->execQuery(file_get_contents(__DIR__."/../lib/core/database_schema/core_npc_master.sql")); $db->execQuery("SET search_path TO public"); @@ -124,6 +128,140 @@ Logger::warn("Bootstrap core tables: " . $e->getMessage()); } +if ($checkVersion("core_action") < 20260426001) { + Logger::debug("Applying core_action 20260426001 - add parameters/metadata/game function fields"); + + $db->execQuery("ALTER TABLE public.core_action ADD COLUMN IF NOT EXISTS parameters_json JSONB NOT NULL DEFAULT '{}'::jsonb"); + $db->execQuery("ALTER TABLE public.core_action ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb"); + $db->execQuery("ALTER TABLE public.core_action ADD COLUMN IF NOT EXISTS game_function BOOLEAN NOT NULL DEFAULT TRUE"); + $db->execQuery("ALTER TABLE public.core_action ADD COLUMN IF NOT EXISTS script_proxy_program JSONB"); + + $db->execQuery("ALTER TABLE public.core_action_custom ADD COLUMN IF NOT EXISTS parameters_json JSONB NOT NULL DEFAULT '{}'::jsonb"); + $db->execQuery("ALTER TABLE public.core_action_custom ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb"); + $db->execQuery("ALTER TABLE public.core_action_custom ADD COLUMN IF NOT EXISTS game_function BOOLEAN NOT NULL DEFAULT TRUE"); + $db->execQuery("ALTER TABLE public.core_action_custom ADD COLUMN IF NOT EXISTS script_proxy_program JSONB"); + + $db->execQuery("CREATE INDEX IF NOT EXISTS idx_core_action_game_function ON public.core_action (game_function)"); + $db->execQuery("CREATE INDEX IF NOT EXISTS idx_core_action_custom_game_function ON public.core_action_custom (game_function)"); + + $db->execQuery("DROP VIEW IF EXISTS public.combined_core_action"); + $db->execQuery(" + CREATE VIEW public.combined_core_action AS + SELECT + c.id, + c.code_name, + c.action_name, + c.description, + c.return_message, + c.available_to_npc, + c.available_to_followers, + c.is_activated, + c.parameters_json, + c.metadata, + c.game_function, + c.script_proxy_program, + c.created_at, + c.updated_at + FROM public.core_action_custom c + UNION ALL + SELECT + b.id, + b.code_name, + b.action_name, + b.description, + b.return_message, + b.available_to_npc, + b.available_to_followers, + b.is_activated, + b.parameters_json, + b.metadata, + b.game_function, + b.script_proxy_program, + b.created_at, + b.updated_at + FROM public.core_action b + LEFT JOIN public.core_action_custom c ON LOWER(b.code_name) = LOWER(c.code_name) + WHERE c.code_name IS NULL + "); + + $db->execQuery(" + DELETE FROM public.core_action_custom + WHERE code_name IN ('AttackHunt', 'ReadQuestJournal', 'GetDateTime', 'SearchDiary', 'SetCurrentTask', 'ReadDiaryPage', 'SearchMemory') + "); + $db->execQuery(" + DELETE FROM public.core_action + WHERE code_name IN ('AttackHunt', 'ReadQuestJournal', 'GetDateTime', 'SearchDiary', 'SetCurrentTask', 'ReadDiaryPage', 'SearchMemory') + "); + + $updateVersion("core_action", 20260426001); + Logger::info("Applied patch core_action 20260426001"); +} + +if ($checkVersion("core_action") < 20260427001) { + Logger::debug("Applying core_action 20260427001 - add import_version field"); + + $db->execQuery("ALTER TABLE public.core_action ADD COLUMN IF NOT EXISTS import_version BIGINT NOT NULL DEFAULT 0"); + $db->execQuery("ALTER TABLE public.core_action_custom ADD COLUMN IF NOT EXISTS import_version BIGINT NOT NULL DEFAULT 0"); + + $db->execQuery("UPDATE public.core_action SET import_version = 0 WHERE import_version IS NULL"); + $db->execQuery("UPDATE public.core_action_custom SET import_version = 0 WHERE import_version IS NULL"); + + $db->execQuery("DROP VIEW IF EXISTS public.combined_core_action"); + $db->execQuery(" + CREATE VIEW public.combined_core_action AS + SELECT + c.id, + c.code_name, + c.action_name, + c.description, + c.return_message, + c.available_to_npc, + c.available_to_followers, + c.is_activated, + c.parameters_json, + c.metadata, + c.game_function, + c.import_version, + c.script_proxy_program, + c.created_at, + c.updated_at + FROM public.core_action_custom c + UNION ALL + SELECT + b.id, + b.code_name, + b.action_name, + b.description, + b.return_message, + b.available_to_npc, + b.available_to_followers, + b.is_activated, + b.parameters_json, + b.metadata, + b.game_function, + b.import_version, + b.script_proxy_program, + b.created_at, + b.updated_at + FROM public.core_action b + LEFT JOIN public.core_action_custom c ON LOWER(b.code_name) = LOWER(c.code_name) + WHERE c.code_name IS NULL + "); + + $updateVersion("core_action", 20260427001); + Logger::info("Applied patch core_action 20260427001"); +} + +if ($checkVersion("game_plugins") < 20260427001) { + Logger::debug("Applying game_plugins 20260427001 - create loaded plugin manifest table"); + + $db->execQuery(file_get_contents(__DIR__ . "/../data/add_game_plugins.sql")); + $db->execQuery("SET search_path TO public"); + + $updateVersion("game_plugins", 20260427001); + Logger::info("Applied patch game_plugins 20260427001"); +} + // Narrator is now managed via core_narrator table, not core_npc_master // Seeding of narrator data happens in the core_narrator migration blocks @@ -1839,8 +1977,21 @@ $db->execQuery("UPDATE public.core_api_badge SET label = 'Nano-GPT' WHERE LOWER(label) = 'nano-gpt'"); $db->execQuery("UPDATE public.core_api_badge SET label = 'DeepL' WHERE LOWER(label) = 'deepl'"); - // Add unique constraint - $db->execQuery("ALTER TABLE public.core_api_badge ADD CONSTRAINT core_api_badge_label_unique UNIQUE (label)"); + // Add unique constraint once; reinstall/update paths can revisit this patch. + $db->execQuery(" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'core_api_badge_label_unique' + AND conrelid = 'public.core_api_badge'::regclass + ) THEN + ALTER TABLE public.core_api_badge + ADD CONSTRAINT core_api_badge_label_unique UNIQUE (label); + END IF; + END $$; + "); // Add case-insensitive index for faster lookups $db->execQuery("CREATE INDEX IF NOT EXISTS idx_core_api_badge_label_lower ON public.core_api_badge (LOWER(label))"); @@ -2466,7 +2617,7 @@ '-'::text AS speaker, '-'::text AS listener, memory.ts - FROM memory + FROM public.memory WHERE memory.message !~~ 'Dear Diary%'::text AND memory.message <> ''::text and event<>'backgroundlife_diary'::text UNION SELECT (((('(Context Location:'::text || speech.location) || ') '::text) || speech.speaker) || ': '::text) || speech.speech, @@ -2475,7 +2626,7 @@ speech.speaker, speech.listener, speech.ts - FROM speech + FROM public.speech WHERE speech.speech <> ''::text UNION SELECT eventlog.data, @@ -2484,7 +2635,7 @@ '-'::text AS text, '-'::text AS listener, eventlog.ts - FROM eventlog + FROM public.eventlog WHERE eventlog.type::text = ANY (ARRAY['death'::character varying::text, 'location'::character varying::text])) subquery ORDER BY gamets, ts"); $updateVersion("memory_v",20251122001); @@ -2545,6 +2696,10 @@ +// Some imported dump-style SQL files clear search_path; restore it before +// running unqualified late-stage migrations. +$db->execQuery("SET search_path TO public"); + $db->execQuery("ALTER TABLE public.locations ADD COLUMN IF NOT EXISTS region text"); $db->execQuery("ALTER TABLE public.locations ADD COLUMN IF NOT EXISTS hold text"); $db->execQuery("ALTER TABLE public.locations ADD COLUMN IF NOT EXISTS tags text"); @@ -2556,9 +2711,9 @@ $db->execQuery("ALTER TABLE public.locations ADD COLUMN IF NOT EXISTS cleared boolean"); $db->execQuery("ALTER TABLE public.locations ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP"); $db->execQuery(" -CREATE OR REPLACE VIEW locations_v +CREATE OR REPLACE VIEW public.locations_v as -select * FROM locations +select * FROM public.locations where case when formid=102771 and cleared=FALSE then FALSE -- Dustman's Cairn is closed until The Companions quest, 'Proving Honor' has been activated @@ -2599,30 +2754,30 @@ $db->execQuery("CREATE INDEX IF NOT EXISTS event_log_type ON public.eventlog USING btree (type)"); $db->execQuery("CREATE INDEX IF NOT EXISTS idx_eventlog_people_trgm -ON eventlog +ON public.eventlog USING gin (people gin_trgm_ops)"); $db->execQuery("CREATE INDEX IF NOT EXISTS idx_eventlog_people_trgm2 -ON eventlog +ON public.eventlog USING gin (data gin_trgm_ops)"); $db->execQuery("CREATE INDEX IF NOT EXISTS idx_speech_speaker_trgm -ON speech +ON public.speech USING gin (speaker gin_trgm_ops)"); $db->execQuery("CREATE INDEX IF NOT EXISTS idx_speech_listener_trgm -ON speech +ON public.speech USING gin (listener gin_trgm_ops)"); $db->execQuery("CREATE INDEX IF NOT EXISTS idx_eventlog_gamets_pos -ON eventlog (gamets) +ON public.eventlog (gamets) WHERE gamets > 0"); $db->execQuery("CREATE INDEX IF NOT EXISTS idx_eventlog_gamets_ts_pos -ON eventlog (gamets DESC, ts DESC)"); +ON public.eventlog (gamets DESC, ts DESC)"); $db->execQuery("CREATE INDEX IF NOT EXISTS idx_speech_gamets_pos -ON speech (gamets) +ON public.speech (gamets) WHERE gamets > 0"); @@ -3887,28 +4042,28 @@ //---------------------------------------------------- // Relationship Evaluation and Initialization Queues -$db->execQuery("CREATE TABLE IF NOT EXISTS relationship_eval_queue ( +$db->execQuery("CREATE TABLE IF NOT EXISTS public.relationship_eval_queue ( id SERIAL PRIMARY KEY, npc_id INTEGER NOT NULL UNIQUE, eval_data JSONB NOT NULL, created_at TIMESTAMP DEFAULT NOW() )"); -$db->execQuery("CREATE TABLE IF NOT EXISTS relationship_init_queue ( +$db->execQuery("CREATE TABLE IF NOT EXISTS public.relationship_init_queue ( id SERIAL PRIMARY KEY, npc_id INTEGER NOT NULL UNIQUE, init_data JSONB NOT NULL, created_at TIMESTAMP DEFAULT NOW() )"); $db->execQuery(" - ALTER TABLE relationship_init_queue + ALTER TABLE public.relationship_init_queue ADD COLUMN IF NOT EXISTS retry_count INTEGER DEFAULT 0 "); $db->execQuery(" - ALTER TABLE relationship_init_queue + ALTER TABLE public.relationship_init_queue ADD COLUMN IF NOT EXISTS last_error TEXT "); $db->execQuery(" - ALTER TABLE relationship_eval_queue + ALTER TABLE public.relationship_eval_queue ADD COLUMN IF NOT EXISTS retry_count INTEGER DEFAULT 0 "); Logger::info(__FILE__." update file processed"); diff --git a/functions/functions.php b/functions/functions.php index e17742f38..b618ea8c9 100755 --- a/functions/functions.php +++ b/functions/functions.php @@ -3,29 +3,20 @@ // Functions to be provided to OpenAI $ENABLED_FUNCTIONS_LOCAL = [ - 'Inspect', - 'LookAt', - 'InspectSurroundings', 'MoveTo', 'OpenInventory', 'OpenInventory2', 'Attack', - 'AttackHunt', 'Follow', 'CheckInventory', 'SheatheWeapon', 'Relax', 'LeadTheWayTo', 'TakeASeat', - 'ReadQuestJournal', 'IncreaseWalkSpeed', 'DecreaseWalkSpeed', - 'GetDateTime', - 'SearchDiary', - 'SetCurrentTask', 'StopWalk', 'TravelTo', - 'SearchMemory', 'GiveItemToPlayer', 'FollowPlayer', 'ComeCloser', @@ -40,10 +31,10 @@ 'MakeFollower', 'Toast', 'Drink', + 'Consume', 'StartRitualCeremony', 'EndRitualCeremony', 'Training', - 'Surrender', 'RentRoom', 'HireCarriage', 'HireFerry', @@ -85,38 +76,153 @@ $GLOBALS["PLAYER_NAME"] = $safePlayerName; } +require_once __DIR__ . DIRECTORY_SEPARATOR . ".." . DIRECTORY_SEPARATOR . "lib" . DIRECTORY_SEPARATOR . "core" . DIRECTORY_SEPARATOR . "action_catalog.php"; + +function getConfiguredPositiveActionGoldCost($codeName, $configKey, $defaultCost) +{ + $override = null; + if (function_exists('herikaActionCatalogGetCustomConfigValue')) { + $override = herikaActionCatalogGetCustomConfigValue($codeName, $configKey, null); + } + + $overrideCost = intval($override); + if ($override !== null && $overrideCost > 0) { + return $overrideCost; + } + + return intval($defaultCost); +} + +function formatConfiguredActionGoldCost($cost) +{ + $cost = intval($cost); + return ($cost === 1) ? '1 gold' : ("{$cost} gold"); +} + +function getConfiguredRentRoomCost() +{ + return getConfiguredPositiveActionGoldCost("RentRoom", "rent_room_cost", 10); +} + +function getConfiguredHireCarriageCost() +{ + return getConfiguredPositiveActionGoldCost("HireCarriage", "hire_carriage_cost", 20); +} + +function getConfiguredHireFerryCost() +{ + return getConfiguredPositiveActionGoldCost("HireFerry", "hire_ferry_cost", 50); +} + +function decodeFunctionExecutionParameterPayload($parameter) +{ + if (is_array($parameter)) { + return $parameter; + } + + $text = trim(strval($parameter)); + if ($text === '' || $text[0] !== '{') { + return null; + } + + $decoded = json_decode($text, true); + return is_array($decoded) ? $decoded : null; +} + +function buildTravelExecutionParameter($parameter, $amount) +{ + $payload = decodeFunctionExecutionParameterPayload($parameter); + if (!is_array($payload)) { + $payload = []; + } + + if (!isset($payload["target"]) || trim(strval($payload["target"])) === "") { + $payload["target"] = is_array($parameter) ? "" : trim(strval($parameter)); + } + + $payload["amount"] = intval($amount); + + return json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +} + +function buildConfiguredActionParameterFromMetadata($functionCodeName, $parameter) +{ + if (!function_exists('herikaGetActionCatalogRow') || !function_exists('herikaActionCatalogResolveTemplateValue')) { + return null; + } + + $row = herikaGetActionCatalogRow($functionCodeName); + if (!is_array($row)) { + return null; + } + + $metadata = herikaActionCatalogDecodeJson($row['metadata'] ?? [], []); + $parameterTemplate = $metadata['parameter_template'] ?? null; + if ($parameterTemplate === null || $parameterTemplate === '') { + return null; + } + + $parameterData = decodeFunctionExecutionParameterPayload($parameter); + if (!is_array($parameterData)) { + $parameterData = []; + } + + $parameterTarget = strval($parameterData['target'] ?? (is_array($parameter) ? '' : trim(strval($parameter)))); + $context = [ + 'action_name' => $functionCodeName, + 'parameter_raw' => is_array($parameter) + ? json_encode($parameter, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) + : strval($parameter), + 'parameter_target' => $parameterTarget, + 'parameters' => $parameterData, + 'config' => function_exists('herikaActionCatalogGetResolvedCustomConfig') + ? herikaActionCatalogGetResolvedCustomConfig($functionCodeName, $row) + : [], + ]; + + $resolved = herikaActionCatalogResolveTemplateValue($parameterTemplate, $context); + if (is_array($resolved)) { + return json_encode($resolved, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + if ($resolved === null) { + return ''; + } + + return is_string($resolved) + ? $resolved + : json_encode($resolved, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); +} + +$rentRoomCost = getConfiguredRentRoomCost(); +$hireCarriageCost = getConfiguredHireCarriageCost(); +$hireFerryCost = getConfiguredHireFerryCost(); +$rentRoomCostText = formatConfiguredActionGoldCost($rentRoomCost); +$hireCarriageCostText = formatConfiguredActionGoldCost($hireCarriageCost); +$hireFerryCostText = formatConfiguredActionGoldCost($hireFerryCost); + // We must use internal keys here. -$F_TRANSLATIONS_LOCAL["Inspect"] = "Inspects ONLY an ACTOR/NPC. Wait for result to give a dialogue message."; -$F_TRANSLATIONS_LOCAL["LookAt"] = "Inspects ONLY an ACTOR/NPC. Wait for result to give a dialogue message."; -$F_TRANSLATIONS_LOCAL["InspectSurroundings"] = "Looks for actors around.Wait for result to give a dialogue message"; $F_TRANSLATIONS_LOCAL["MoveTo"] = "Move to a visible building or visible actor, also used to guide {$GLOBALS["PLAYER_NAME"]} to a actor or building."; $F_TRANSLATIONS_LOCAL["OpenInventory"] = "Initiates trading or exchange ITEMS with {$GLOBALS["PLAYER_NAME"]}."; $F_TRANSLATIONS_LOCAL["OpenInventory2"] = "Initiates trading, {$GLOBALS["PLAYER_NAME"]} must give ITEMS to {$GLOBALS["HERIKA_NAME"]}"; $F_TRANSLATIONS_LOCAL["Attack"] = "Attack with intention to kill an Actor, NPC or entity."; -$F_TRANSLATIONS_LOCAL["AttackHunt"] = "Hunt with intention to kill an Actor, NPC or entity."; $F_TRANSLATIONS_LOCAL["Follow"] = "Move to and follow the specified target actor"; $F_TRANSLATIONS_LOCAL["CheckInventory"] = "Search in {$GLOBALS["HERIKA_NAME"]}'s inventory, backpack or pocket. List their inventory contents"; $F_TRANSLATIONS_LOCAL["SheatheWeapon"] = "Sheathes/put away current weapon"; $F_TRANSLATIONS_LOCAL["Relax"] = "Stop whatever you are doing and relax at the current location.Used to Unwind,Loosen Up,Enjoy Moment,Chill"; $F_TRANSLATIONS_LOCAL["TravelTo"] = "Use it to move to major locations and landmarks and POIs."; $F_TRANSLATIONS_LOCAL["TakeASeat"] = "{$GLOBALS["HERIKA_NAME"]} take a seat at seating location nearby."; -$F_TRANSLATIONS_LOCAL["ReadQuestJournal"] = "Only use if {$GLOBALS["PLAYER_NAME"]} explicitly ask for a quest. Get info about current quests"; $F_TRANSLATIONS_LOCAL["IncreaseWalkSpeed"] = "Increase {$GLOBALS["HERIKA_NAME"]} speed when moving or travelling"; $F_TRANSLATIONS_LOCAL["DecreaseWalkSpeed"] = "Decrease {$GLOBALS["HERIKA_NAME"]} speed when moving or travelling"; -$F_TRANSLATIONS_LOCAL["GetDateTime"] = "Get Current Date and Time"; -$F_TRANSLATIONS_LOCAL["SearchDiary"] = "Read {$GLOBALS["HERIKA_NAME"]}'s diary to make her remember something. Search in diary index"; -$F_TRANSLATIONS_LOCAL["SetCurrentTask"] = "Set the current plan of action or task or quest"; -$F_TRANSLATIONS_LOCAL["ReadDiaryPage"] = "Read {$GLOBALS["HERIKA_NAME"]}'s diary to access a specific topic"; $F_TRANSLATIONS_LOCAL["StopWalk"] = "Stop all {$GLOBALS["HERIKA_NAME"]}'s actions inmediately"; $F_TRANSLATIONS_LOCAL["TravelTo"] = "Only use if {$GLOBALS["PLAYER_NAME"]} explicitly suggest it. Guide {$GLOBALS["PLAYER_NAME"]} to a Town o City. Also known as lead the way"; -$F_TRANSLATIONS_LOCAL["SearchMemory"] = "{$GLOBALS["HERIKA_NAME"]} tries to remember information. REPLY with hashtags"; $F_TRANSLATIONS_LOCAL["WaitHere"] = "{$GLOBALS["HERIKA_NAME"]} waits and loiters at the current location"; $F_TRANSLATIONS_LOCAL["GiveItemToPlayer"] = "{$GLOBALS["HERIKA_NAME"]} gives item (property target) to {$GLOBALS["PLAYER_NAME"]} (property listener)"; $F_TRANSLATIONS_LOCAL["TakeGoldFromPlayer"] = "{$GLOBALS["HERIKA_NAME"]} takes amount (property target) of gold from {$GLOBALS["PLAYER_NAME"]}, once {$GLOBALS["PLAYER_NAME"]} is agree. infer amount from context."; -$F_TRANSLATIONS_LOCAL["RentRoom"] = "{$GLOBALS["HERIKA_NAME"]} rents a room to {$GLOBALS["PLAYER_NAME"]} for 10 gold coins. Only innkeepers can use this action and it only applies to {$GLOBALS["PLAYER_NAME"]}."; -$F_TRANSLATIONS_LOCAL["HireCarriage"] = "{$GLOBALS["HERIKA_NAME"]} accepts carriage fare and transports {$GLOBALS["PLAYER_NAME"]} to the specified destination. Reply with one short acceptance line, do not ask follow-up questions, then end the conversation."; -$F_TRANSLATIONS_LOCAL["HireFerry"] = "{$GLOBALS["HERIKA_NAME"]} accepts ferry fare and transports {$GLOBALS["PLAYER_NAME"]} to the specified destination. Reply with one short acceptance line, do not ask follow-up questions, then end the conversation."; +$F_TRANSLATIONS_LOCAL["RentRoom"] = "{$GLOBALS["HERIKA_NAME"]} rents a room to {$GLOBALS["PLAYER_NAME"]} for {$rentRoomCostText}. Only innkeepers can use this action and it only applies to {$GLOBALS["PLAYER_NAME"]}."; +$F_TRANSLATIONS_LOCAL["HireCarriage"] = "{$GLOBALS["HERIKA_NAME"]} accepts {$hireCarriageCostText} for carriage travel and transports {$GLOBALS["PLAYER_NAME"]} to the specified destination. Reply with one short acceptance line, do not ask follow-up questions, then end the conversation."; +$F_TRANSLATIONS_LOCAL["HireFerry"] = "{$GLOBALS["HERIKA_NAME"]} accepts {$hireFerryCostText} for ferry travel and transports {$GLOBALS["PLAYER_NAME"]} to the specified destination. Reply with one short acceptance line, do not ask follow-up questions, then end the conversation."; $F_TRANSLATIONS_LOCAL["AddBounty"] = "{$GLOBALS["HERIKA_NAME"]} adds a crime bounty to {$GLOBALS["PLAYER_NAME"]} for a witnessed or reported crime. Guard-only action."; $F_TRANSLATIONS_LOCAL["PayBounty"] = "{$GLOBALS["PLAYER_NAME"]} pays off their bounty to {$GLOBALS["HERIKA_NAME"]}. Stolen items are confiscated and the matter is resolved immediately. Guard-only action."; $F_TRANSLATIONS_LOCAL["ArrestPlayer"] = "{$GLOBALS["HERIKA_NAME"]} attempts to arrest {$GLOBALS["PLAYER_NAME"]}. The player can submit or resist. Guard-only action for serious crimes or refusal to pay."; @@ -135,43 +241,33 @@ $F_TRANSLATIONS_LOCAL["Toast"] = "Raises a glass in celebration or honor."; $F_TRANSLATIONS_LOCAL["Drink"] = "Drinks a beverage to quench thirst or enjoy flavor."; +$F_TRANSLATIONS_LOCAL["Consume"] = "{$GLOBALS["HERIKA_NAME"]} consumes a food, drink, or potion from inventory. Use the exact inventory item name in the target field."; $F_TRANSLATIONS_LOCAL["StartRitualCeremony"] = "Participates in a ritual or ceremony, following its customs and practices."; $F_TRANSLATIONS_LOCAL["EndRitualCeremony"] = "Concludes a ritual or ceremony, marking its completion."; $F_TRANSLATIONS_LOCAL["Training"] = "Opens training menu to improve skills with a trainer."; -$F_TRANSLATIONS_LOCAL["Surrender"] = "{$GLOBALS["HERIKA_NAME"]} surrenders to avoid conflict or harm."; $F_TRANSLATIONS_LOCAL["EndConversation"] = "{$GLOBALS["HERIKA_NAME"]} ends the conversation and becomes unavailable to talk for a short time."; -$F_RETURNMESSAGES_LOCAL["Inspect"] = "{$GLOBALS["HERIKA_NAME"]} inspects #TARGET# and see this: #RESULT#"; -$F_RETURNMESSAGES_LOCAL["LookAt"] = "LOOK at or Inspects NPC, Actor, or being OUTFIT and GEAR"; -$F_RETURNMESSAGES_LOCAL["InspectSurroundings"] = "{$GLOBALS["HERIKA_NAME"]} takes a look around and see this: #RESULT#"; $F_RETURNMESSAGES_LOCAL["MoveTo"] = "Walk to a visible building or visible actor, also used to guide {$GLOBALS["PLAYER_NAME"]} to a actor or building."; $F_RETURNMESSAGES_LOCAL["OpenInventory"] = "Initiates trading or exchange items with {$GLOBALS["PLAYER_NAME"]}."; $F_RETURNMESSAGES_LOCAL["OpenInventory2"] = "{$GLOBALS["PLAYER_NAME"]} give items to {$GLOBALS["HERIKA_NAME"]}. Accept gift."; $F_RETURNMESSAGES_LOCAL["Attack"] = "{$GLOBALS["HERIKA_NAME"]} Attacks #TARGET# "; -$F_RETURNMESSAGES_LOCAL["AttackHunt"] = "{$GLOBALS["HERIKA_NAME"]} Attacks #TARGET# "; $F_RETURNMESSAGES_LOCAL["Follow"] = "{$GLOBALS["HERIKA_NAME"]} follows #TARGET# "; $F_RETURNMESSAGES_LOCAL["CheckInventory"] = "{$GLOBALS["HERIKA_NAME"]}'s INVENTORY:#RESULT#"; $F_RETURNMESSAGES_LOCAL["SheatheWeapon"] = "Sheathes/put away current weapon"; $F_RETURNMESSAGES_LOCAL["Relax"] = "{$GLOBALS["HERIKA_NAME"]} is relaxed. Time to enjoy life."; $F_RETURNMESSAGES_LOCAL["LeadTheWayTo"] = "Only use if {$GLOBALS["PLAYER_NAME"]} explicitly orders it. Guide {$GLOBALS["PLAYER_NAME"]} to a Town o City. "; $F_RETURNMESSAGES_LOCAL["TakeASeat"] = "{$GLOBALS["HERIKA_NAME"]} seats in nearby chair or furniture "; -$F_RETURNMESSAGES_LOCAL["ReadQuestJournal"] = ""; $F_RETURNMESSAGES_LOCAL["IncreaseWalkSpeed"] = "Increases {$GLOBALS["HERIKA_NAME"]} speed/pace when moving or travelling"; $F_RETURNMESSAGES_LOCAL["DecreaseWalkSpeed"] = "Decreases {$GLOBALS["HERIKA_NAME"]} speed/pace when moving or travelling"; -$F_RETURNMESSAGES_LOCAL["GetDateTime"] = "Get Current Date and Time"; -$F_RETURNMESSAGES_LOCAL["SearchDiary"] = "Read {$GLOBALS["HERIKA_NAME"]}'s diary to make her remember something. Search in diary index"; -$F_RETURNMESSAGES_LOCAL["SetCurrentTask"] = "Set the current plan of action or task or quest"; -$F_RETURNMESSAGES_LOCAL["ReadDiaryPage"] = "Read {$GLOBALS["HERIKA_NAME"]}'s diary to access a specific topic"; $F_RETURNMESSAGES_LOCAL["StopWalk"] = "Stop all {$GLOBALS["HERIKA_NAME"]}'s actions inmediately"; $F_RETURNMESSAGES_LOCAL["TravelTo"] = "{$GLOBALS["HERIKA_NAME"]} begins travelling to #TARGET#"; -$F_RETURNMESSAGES_LOCAL["SearchMemory"] = "{$GLOBALS["HERIKA_NAME"]} tries to remember information. JUST REPLY something like 'Let me think' and wait"; $F_RETURNMESSAGES_LOCAL["WaitHere"] = "{$GLOBALS["HERIKA_NAME"]} waits and stands at the place"; $F_RETURNMESSAGES_LOCAL["GiveItemToPlayer"] = "{$GLOBALS["HERIKA_NAME"]} gave #TARGET# to {$GLOBALS["PLAYER_NAME"]}.If this a transaction, maybe TakeGoldFromPlayer is needed."; $F_RETURNMESSAGES_LOCAL["TakeGoldFromPlayer"] = "{$GLOBALS["PLAYER_NAME"]} gave #TARGET# coins to {$GLOBALS["HERIKA_NAME"]}. If this a transaction, maybe GiveItemToPlayer is needed."; -$F_RETURNMESSAGES_LOCAL["RentRoom"] = "{$GLOBALS["HERIKA_NAME"]} rented a room to {$GLOBALS["PLAYER_NAME"]} for 10 gold."; -$F_RETURNMESSAGES_LOCAL["HireCarriage"] = "{$GLOBALS["HERIKA_NAME"]} accepted the fare to #TARGET# and ended the conversation."; -$F_RETURNMESSAGES_LOCAL["HireFerry"] = "{$GLOBALS["HERIKA_NAME"]} accepted the ferry fare to #TARGET# and ended the conversation."; +$F_RETURNMESSAGES_LOCAL["RentRoom"] = "{$GLOBALS["HERIKA_NAME"]} rented a room to {$GLOBALS["PLAYER_NAME"]} for {$rentRoomCostText}."; +$F_RETURNMESSAGES_LOCAL["HireCarriage"] = "{$GLOBALS["HERIKA_NAME"]} accepted the {$hireCarriageCostText} carriage fare to #TARGET# and ended the conversation."; +$F_RETURNMESSAGES_LOCAL["HireFerry"] = "{$GLOBALS["HERIKA_NAME"]} accepted the {$hireFerryCostText} ferry fare to #TARGET# and ended the conversation."; $F_RETURNMESSAGES_LOCAL["AddBounty"] = "{$GLOBALS["HERIKA_NAME"]} added a bounty for #TARGET# to {$GLOBALS["PLAYER_NAME"]}."; $F_RETURNMESSAGES_LOCAL["PayBounty"] = "{$GLOBALS["PLAYER_NAME"]} paid off their bounty to {$GLOBALS["HERIKA_NAME"]}, and stolen items were removed from inventory."; $F_RETURNMESSAGES_LOCAL["ArrestPlayer"] = "{$GLOBALS["HERIKA_NAME"]} attempted to arrest {$GLOBALS["PLAYER_NAME"]}."; @@ -189,41 +285,31 @@ $F_RETURNMESSAGES_LOCAL["Toast"] = "{$GLOBALS["HERIKA_NAME"]} raises a glass in celebration or honor."; $F_RETURNMESSAGES_LOCAL["Drink"] = "{$GLOBALS["HERIKA_NAME"]} drinks a beverage to quench thirst or enjoy flavor."; +$F_RETURNMESSAGES_LOCAL["Consume"] = "{$GLOBALS["HERIKA_NAME"]} consumes an item from inventory."; $F_RETURNMESSAGES_LOCAL["StartRitualCeremony"] = "{$GLOBALS["HERIKA_NAME"]} begins a ritual or ceremony, following its customs and practices."; $F_RETURNMESSAGES_LOCAL["EndRitualCeremony"] = "{$GLOBALS["HERIKA_NAME"]} concludes a ritual or ceremony, marking its completion."; $F_RETURNMESSAGES_LOCAL["Training"] = "{$GLOBALS["HERIKA_NAME"]} opens the training menu."; -$F_RETURNMESSAGES_LOCAL["Surrender"] = "{$GLOBALS["HERIKA_NAME"]} surrenders to avoid conflict or harm."; // What is this?. We can translate functions or give them a custom name. // This array will handle translations. Plugin must receive the codename always. -$F_NAMES_LOCAL["Inspect"] = "Inspect"; -$F_NAMES_LOCAL["LookAt"] = "LookAt"; -$F_NAMES_LOCAL["InspectSurroundings"] = "InspectSurroundings"; $F_NAMES_LOCAL["MoveTo"] = "MoveTo"; -$F_NAMES_LOCAL["OpenInventory"] = "ExchangeItems"; +$F_NAMES_LOCAL["OpenInventory"] = "TradeItems"; $F_NAMES_LOCAL["OpenInventory2"] = "AcceptGift"; $F_NAMES_LOCAL["Attack"] = "Attack"; -$F_NAMES_LOCAL["AttackHunt"] = "Hunt"; $F_NAMES_LOCAL["Follow"] = "Follow"; -$F_NAMES_LOCAL["CheckInventory"] = "ListInventory"; +$F_NAMES_LOCAL["CheckInventory"] = "CheckInventory"; $F_NAMES_LOCAL["SheatheWeapon"] = "SheatheWeapon"; -$F_NAMES_LOCAL["Relax"] = "LetsRelax"; +$F_NAMES_LOCAL["Relax"] = "Relax"; //$F_NAMES_LOCAL["LeadTheWayTo"]="LeadTheWayTo"; $F_NAMES_LOCAL["TakeASeat"] = "TakeASeat"; -$F_NAMES_LOCAL["ReadQuestJournal"] = "ReadQuestJournal"; $F_NAMES_LOCAL["IncreaseWalkSpeed"] = "IncreaseWalkSpeed"; $F_NAMES_LOCAL["DecreaseWalkSpeed"] = "DecreaseWalkSpeed"; -$F_NAMES_LOCAL["GetDateTime"] = "GetDateTime"; -$F_NAMES_LOCAL["SearchDiary"] = "SearchDiary"; -$F_NAMES_LOCAL["SetCurrentTask"] = "SetCurrentTask"; -$F_NAMES_LOCAL["ReadDiaryPage"] = "ReadDiaryPage"; $F_NAMES_LOCAL["StopWalk"] = "StopWalk"; $F_NAMES_LOCAL["TravelTo"] = "TravelTo"; -$F_NAMES_LOCAL["SearchMemory"] = "TryToRemember"; $F_NAMES_LOCAL["WaitHere"] = "WaitHere"; $F_NAMES_LOCAL["GiveItemToPlayer"] = "GiveItemToPlayer"; -$F_NAMES_LOCAL["TakeGoldFromPlayer"] = "TakeMoneyFrom{$GLOBALS["PLAYER_NAME"]}"; // Mmm +$F_NAMES_LOCAL["TakeGoldFromPlayer"] = "TakeGoldFrom{$GLOBALS["PLAYER_NAME"]}"; $F_NAMES_LOCAL["RentRoom"] = "RentRoom"; $F_NAMES_LOCAL["HireCarriage"] = "HireCarriage"; $F_NAMES_LOCAL["HireFerry"] = "HireFerry"; @@ -233,34 +319,62 @@ $F_NAMES_LOCAL["ForgiveCrime"] = "ForgiveCrime"; $F_NAMES_LOCAL["FollowPlayer"] = "FollowPlayer"; $F_NAMES_LOCAL["ComeCloser"] = "ComeCloser"; -$F_NAMES_LOCAL["Brawl"] = "Fight"; -$F_NAMES_LOCAL["ReturnBackHome"] = "ReturnBackHome"; +$F_NAMES_LOCAL["Brawl"] = "Brawl"; +$F_NAMES_LOCAL["ReturnBackHome"] = "ReturnHome"; $F_NAMES_LOCAL["GiveGoldTo"] = "GiveGoldTo"; $F_NAMES_LOCAL["GiveItemTo"] = "GiveItemTo"; $F_NAMES_LOCAL["PickupItem"] = "PickupItem"; $F_NAMES_LOCAL["GoToSleep"] = "GoToSleep"; $F_NAMES_LOCAL["UseSoulGaze"] = "UseSoulGaze"; $F_NAMES_LOCAL["CastSpell"] = "CastSpell"; -$F_NAMES_LOCAL["MakeFollower"] = "JoinTo{$GLOBALS["PLAYER_NAME"]}Squad"; +$F_NAMES_LOCAL["MakeFollower"] = "Join{$GLOBALS["PLAYER_NAME"]}Party"; -$F_NAMES_LOCAL["Toast"] = "MakeAToast"; +$F_NAMES_LOCAL["Toast"] = "Toast"; $F_NAMES_LOCAL["Drink"] = "Drink"; +$F_NAMES_LOCAL["Consume"] = "Consume"; $F_NAMES_LOCAL["StartRitualCeremony"] = "StartRitualCeremony"; $F_NAMES_LOCAL["EndRitualCeremony"] = "EndRitualCeremony"; $F_NAMES_LOCAL["Training"] = "Training"; -$F_NAMES_LOCAL["Surrender"] = "Surrender"; $F_NAMES_LOCAL["EndConversation"] = "EndConversation"; +if (function_exists('herikaNormalizeActionCatalogDisplayActionName')) { + foreach ($F_NAMES_LOCAL as $functionCode => $functionName) { + $F_NAMES_LOCAL[$functionCode] = herikaNormalizeActionCatalogDisplayActionName($functionName); + } +} + if (isset($GLOBALS["CORE_LANG"])) { if (file_exists(__DIR__ . DIRECTORY_SEPARATOR . ".." . DIRECTORY_SEPARATOR . "lang" . DIRECTORY_SEPARATOR . $GLOBALS["CORE_LANG"] . DIRECTORY_SEPARATOR . "functions.php")) { require_once __DIR__ . DIRECTORY_SEPARATOR . ".." . DIRECTORY_SEPARATOR . "lang" . DIRECTORY_SEPARATOR . $GLOBALS["CORE_LANG"] . DIRECTORY_SEPARATOR . "functions.php"; } } +$herikaRetiredActionCodes = [ + 'AttackHunt', + 'Inspect', + 'InspectSurroundings', + 'LookAt', + 'Surrender', + 'ReadQuestJournal', + 'GetDateTime', + 'SearchDiary', + 'SetCurrentTask', + 'ReadDiaryPage', + 'SearchMemory', +]; +$herikaRetiredActionNames = []; +foreach ($herikaRetiredActionCodes as $herikaRetiredActionCode) { + if (isset($F_NAMES_LOCAL[$herikaRetiredActionCode])) { + $herikaRetiredActionNames[] = $F_NAMES_LOCAL[$herikaRetiredActionCode]; + } +} + $GLOBALS["F_TRANSLATIONS"] = $F_TRANSLATIONS_LOCAL; $GLOBALS["F_RETURNMESSAGES"] = $F_RETURNMESSAGES_LOCAL; $GLOBALS["F_NAMES"] = $F_NAMES_LOCAL; +$GLOBALS["F_TRANSLATIONS_BASE"] = $F_TRANSLATIONS_LOCAL; +$GLOBALS["F_RETURNMESSAGES_BASE"] = $F_RETURNMESSAGES_LOCAL; $hireCarriageDestinations = [ "Whiterun", @@ -296,52 +410,6 @@ $crimeTypes = ["Assault", "Murder", "Theft", "Pickpocketing", "Trespassing", "Jailbreak", "Custom"]; $GLOBALS["FUNCTIONS"] = [ - [ - "name" => $F_NAMES_LOCAL["Inspect"], - "description" => $F_TRANSLATIONS_LOCAL["Inspect"], - "parameters" => [ - "type" => "object", - "properties" => [ - "target" => [ - "type" => "string", - "description" => "Target NPC, Actor, or being", - "enum" => isset($GLOBALS['FUNCTION_PARM_INSPECT']) ? $GLOBALS['FUNCTION_PARM_INSPECT'] : [], - - ], - ], - "required" => ["target"], - ], - ], - [ - "name" => $F_NAMES_LOCAL["InspectSurroundings"], - "description" => $F_TRANSLATIONS_LOCAL["InspectSurroundings"], - "parameters" => [ - "type" => "object", - "properties" => [ - "target" => [ - "type" => "string", - "description" => "Keep it blank", - ], - ], - "required" => [], - ], - ], - [ - "name" => $F_NAMES_LOCAL["LookAt"], - "description" => $F_TRANSLATIONS_LOCAL["Inspect"], - "parameters" => [ - "type" => "object", - "properties" => [ - "target" => [ - "type" => "string", - "description" => "Target NPC, Actor, or being", - "enum" => isset($GLOBALS['FUNCTION_PARM_INSPECT']) ? $GLOBALS['FUNCTION_PARM_INSPECT'] : [], - - ], - ], - "required" => ["target"], - ], - ], [ "name" => $F_NAMES_LOCAL["MoveTo"], "description" => $F_TRANSLATIONS_LOCAL["MoveTo"], @@ -399,20 +467,6 @@ "required" => ["target"], ], ], - [ - "name" => $F_NAMES_LOCAL["AttackHunt"], - "description" => $F_TRANSLATIONS_LOCAL["AttackHunt"], - "parameters" => [ - "type" => "object", - "properties" => [ - "target" => [ - "type" => "string", - "description" => "Target animal", - ], - ], - "required" => ["target"], - ], - ], [ "name" => $F_NAMES_LOCAL["Follow"], "description" => $F_TRANSLATIONS_LOCAL["Follow"], @@ -513,20 +567,6 @@ "required" => [""], ], ], - [ - "name" => $F_NAMES_LOCAL["ReadQuestJournal"], - "description" => $F_TRANSLATIONS_LOCAL["ReadQuestJournal"], - "parameters" => [ - "type" => "object", - "properties" => [ - "id_quest" => [ - "type" => "string", - "description" => "Specific quest to get info for, or blank to get all", - ], - ], - "required" => [""], - ], - ], [ "name" => $F_NAMES_LOCAL["IncreaseWalkSpeed"], "description" => $F_TRANSLATIONS_LOCAL["IncreaseWalkSpeed"], @@ -559,49 +599,6 @@ "required" => [], ], ], - [ - "name" => $F_NAMES_LOCAL["GetDateTime"], - "description" => $F_TRANSLATIONS_LOCAL["GetDateTime"], - "parameters" => [ - "type" => "object", - "properties" => [ - "datestring" => [ - "type" => "string", - "description" => "Formatted date and time", - ], - - ], - "required" => [], - ], - ], - [ - "name" => $F_NAMES_LOCAL["SearchDiary"], - "description" => $F_TRANSLATIONS_LOCAL["SearchDiary"], - "parameters" => [ - "type" => "object", - "properties" => [ - "keyword" => [ - "type" => "string", - "description" => "keyword to search in full-text query syntax", - ], - ], - "required" => [""], - ], - ], - [ - "name" => $F_NAMES_LOCAL["SetCurrentTask"], - "description" => $F_TRANSLATIONS_LOCAL["SetCurrentTask"], - "parameters" => [ - "type" => "object", - "properties" => [ - "description" => [ - "type" => "string", - "description" => "Short description of current task talked by the party", - ], - ], - "required" => ["description"], - ], - ], [ "name" => $F_NAMES_LOCAL["StopWalk"], "description" => $F_TRANSLATIONS_LOCAL["StopWalk"], @@ -616,20 +613,6 @@ "required" => [""], ], ], - [ - "name" => $F_NAMES_LOCAL["SearchMemory"], - "description" => $F_TRANSLATIONS_LOCAL["SearchMemory"], - "parameters" => [ - "type" => "object", - "properties" => [ - "target" => [ - "type" => "string", - "description" => "", - ], - ], - "required" => [""], - ], - ], [ "name" => $F_NAMES_LOCAL["WaitHere"], "description" => $F_TRANSLATIONS_LOCAL["WaitHere"], @@ -976,6 +959,24 @@ "required" => [""], ], ], + [ + "name" => $F_NAMES_LOCAL["Consume"], + "description" => $F_TRANSLATIONS_LOCAL["Consume"], + "parameters" => [ + "type" => "object", + "properties" => [ + "target" => [ + "type" => "string", + "description" => "REQUIRED: Exact name of the food, drink, or potion from to consume.", + ], + "item" => [ + "type" => "string", + "description" => "Optional fallback copy of the same inventory item name if target is empty.", + ], + ], + "required" => ["target"], + ], + ], [ "name" => $F_NAMES_LOCAL["Training"], "description" => $F_TRANSLATIONS_LOCAL["Training"], @@ -1018,20 +1019,6 @@ ], "required" => [""], ], - ], - [ - "name" => $F_NAMES_LOCAL["Surrender"], - "description" => $F_TRANSLATIONS_LOCAL["Surrender"], - "parameters" => [ - "type" => "object", - "properties" => [ - "target" => [ - "type" => "string", - "description" => "Keep it blank", - ], - ], - "required" => [""], - ], ], [ "name" => $F_NAMES_LOCAL["EndConversation"], @@ -1049,29 +1036,49 @@ ], ]; +foreach ($herikaRetiredActionCodes as $herikaRetiredActionCode) { + unset($F_TRANSLATIONS_LOCAL[$herikaRetiredActionCode], $F_RETURNMESSAGES_LOCAL[$herikaRetiredActionCode], $F_NAMES_LOCAL[$herikaRetiredActionCode]); +} + +$GLOBALS["F_TRANSLATIONS"] = $F_TRANSLATIONS_LOCAL; +$GLOBALS["F_RETURNMESSAGES"] = $F_RETURNMESSAGES_LOCAL; +$GLOBALS["F_NAMES"] = $F_NAMES_LOCAL; +$GLOBALS["FUNCTIONS"] = array_values(array_filter($GLOBALS["FUNCTIONS"], function ($functionEntry) use ($herikaRetiredActionNames) { + return !in_array($functionEntry["name"] ?? "", $herikaRetiredActionNames, true); +})); + // Mantain a copy of all functions defined here foreach ($GLOBALS["FUNCTIONS"] as $n => $functionEntry) { $GLOBALS["BASE_FUNCTIONS"][getFunctionCodeName($functionEntry["name"])] = $GLOBALS["FUNCTIONS"][$n]; } +$HERIKA_BASE_FUNCTIONS_LOCAL = $GLOBALS["BASE_FUNCTIONS"]; -// This function only is offered when SearchDiary -$FUNCTIONS_GHOSTED_LOCAL = [ - "name" => $F_NAMES_LOCAL["ReadDiaryPage"], - "description" => $F_TRANSLATIONS_LOCAL["ReadDiaryPage"], - "parameters" => [ - "type" => "object", - "properties" => [ - "page" => [ - "type" => "string", - "description" => "topic to search in full-text query syntax", - ], - ], - "required" => ["topic"], - ], -] -; +function getFunctionNameAliases() +{ + $playerName = strval($GLOBALS["PLAYER_NAME"] ?? "Player"); + + $aliases = [ + 'ExchangeItems' => 'OpenInventory', + 'ListInventory' => 'CheckInventory', + 'LetsRelax' => 'Relax', + "TakeMoneyFrom{$playerName}" => 'TakeGoldFromPlayer', + 'Fight' => 'Brawl', + 'ReturnBackHome' => 'ReturnBackHome', + "JoinTo{$playerName}Squad" => 'MakeFollower', + 'MakeAToast' => 'Toast', + ]; + + if (function_exists('herikaNormalizeActionCatalogDisplayActionName')) { + foreach ($aliases as $legacyActionName => $codeName) { + $normalizedLegacyActionName = herikaNormalizeActionCatalogDisplayActionName($legacyActionName); + if ($normalizedLegacyActionName !== '' && !isset($aliases[$normalizedLegacyActionName])) { + $aliases[$normalizedLegacyActionName] = $codeName; + } + } + } -$GLOBALS["FUNCTIONS_GHOSTED"] = $FUNCTIONS_GHOSTED_LOCAL; + return $aliases; +} function getFunctionCodeName($key) { @@ -1079,10 +1086,132 @@ function getFunctionCodeName($key) return false; } - $functionCode = array_search($key, $GLOBALS["F_NAMES"]); + $key = strval($key); + if (isset($GLOBALS["F_NAMES"][$key])) { + return $key; + } + + if (isset($GLOBALS["HERIKA_ACTION_NAME_PREFERRED_CODE"]) && is_array($GLOBALS["HERIKA_ACTION_NAME_PREFERRED_CODE"])) { + $preferredCode = $GLOBALS["HERIKA_ACTION_NAME_PREFERRED_CODE"][$key] ?? false; + if ($preferredCode !== false) { + return $preferredCode; + } + } + + $keysToTry = [$key]; + if (function_exists('herikaNormalizeActionCatalogDisplayActionName')) { + $normalizedKey = herikaNormalizeActionCatalogDisplayActionName($key); + if ($normalizedKey !== '' && !in_array($normalizedKey, $keysToTry, true)) { + $keysToTry[] = $normalizedKey; + } + } + + foreach ($keysToTry as $candidateKey) { + if (isset($GLOBALS["HERIKA_ACTION_NAME_PREFERRED_CODE"]) && is_array($GLOBALS["HERIKA_ACTION_NAME_PREFERRED_CODE"])) { + $preferredCode = $GLOBALS["HERIKA_ACTION_NAME_PREFERRED_CODE"][$candidateKey] ?? false; + if ($preferredCode !== false) { + return $preferredCode; + } + } + + $matchingCodes = []; + foreach ($GLOBALS["F_NAMES"] as $functionCode => $functionName) { + if ($functionName === $candidateKey) { + $matchingCodes[] = $functionCode; + } + } + + if (count($matchingCodes) === 1) { + return $matchingCodes[0]; + } + + if (count($matchingCodes) > 1) { + foreach ($matchingCodes as $matchingCode) { + if (function_exists('herikaGetActionCatalogRow')) { + $row = herikaGetActionCatalogRow($matchingCode); + if (is_array($row) && herikaActionCatalogRowIsAvailableInCurrentMode($row) && !empty(($row['metadata'] ?? [])['builtin']) === false) { + return $matchingCode; + } + } + } + + return $matchingCodes[0]; + } + } + + $aliases = getFunctionNameAliases(); + return $aliases[$key] ?? false; +} + +function herikaFormatReturnMessageTemplate($codeName, $primaryArgument = '', array $extraReplacements = []) +{ + $codeName = trim(strval($codeName)); + if ($codeName === '' || !isset($GLOBALS["F_RETURNMESSAGES"][$codeName])) { + return ''; + } + + $template = strval($GLOBALS["F_RETURNMESSAGES"][$codeName] ?? ''); + if ($template === '') { + return ''; + } - // If not found, return false (function will be filtered out) - return $functionCode !== false ? $functionCode : false; + if (is_scalar($primaryArgument) || $primaryArgument === null) { + $primaryArgument = strval($primaryArgument ?? ''); + } else { + $primaryArgument = ''; + } + + $replacements = [ + '#TARGET#' => $primaryArgument, + '#HERIKA_NAME#' => strval($GLOBALS["HERIKA_NAME"] ?? 'NPC'), + '#PLAYER_NAME#' => strval($GLOBALS["PLAYER_NAME"] ?? 'Player'), + ]; + + foreach ($extraReplacements as $key => $value) { + $replacements[strval($key)] = is_scalar($value) || $value === null ? strval($value ?? '') : ''; + } + + return strtr($template, $replacements); +} + +function herikaFormatActionPromptTemplate($template, array $extraReplacements = []) +{ + $template = strval($template); + if ($template === '') { + return ''; + } + + $replacements = [ + '#HERIKA_NAME#' => strval($GLOBALS["HERIKA_NAME"] ?? 'NPC'), + '#PLAYER_NAME#' => strval($GLOBALS["PLAYER_NAME"] ?? 'Player'), + '{$GLOBALS["HERIKA_NAME"]}' => strval($GLOBALS["HERIKA_NAME"] ?? 'NPC'), + '{$GLOBALS["PLAYER_NAME"]}' => strval($GLOBALS["PLAYER_NAME"] ?? 'Player'), + ]; + + foreach ($extraReplacements as $key => $value) { + $replacements[strval($key)] = is_scalar($value) || $value === null ? strval($value ?? '') : ''; + } + + $rendered = strtr($template, $replacements); + + // Some catalog/imported strings can still carry SQL-style doubled apostrophes. + return str_replace("''", "'", $rendered); +} + +function herikaGetPromptActionDescription($codeName, $fallbackDescription = '') +{ + $codeName = trim(strval($codeName)); + $description = ''; + + if ($codeName !== '' && isset($GLOBALS["F_TRANSLATIONS"][$codeName])) { + $description = strval($GLOBALS["F_TRANSLATIONS"][$codeName] ?? ''); + } + + if ($description === '') { + $description = strval($fallbackDescription); + } + + return herikaFormatActionPromptTemplate($description); } function getFunctionTrlName($key) @@ -1095,6 +1224,206 @@ function getFunctionTrlName($key) } +function getSingleFunctionParameterValue($functionDef, $parsedResponse) +{ + if (!is_array($parsedResponse)) { + return ""; + } + + $properties = $functionDef["parameters"]["properties"] ?? []; + if (is_array($properties) && count($properties) === 0) { + return ""; + } + + if (is_array($properties) && count($properties) === 1) { + $parameterName = array_key_first($properties); + if (is_string($parameterName) && array_key_exists($parameterName, $parsedResponse)) { + return $parsedResponse[$parameterName]; + } + } + + return $parsedResponse["target"] ?? ""; +} + +function normalizeFunctionParameterValueFromSchema($parameterSchema, $value) +{ + if (!is_array($parameterSchema)) { + return $value; + } + + $parameterType = strtolower(trim(strval($parameterSchema["type"] ?? ""))); + if ($parameterType === "integer" && is_numeric($value)) { + return intval(round(floatval($value))); + } + + if ($parameterType === "number" && is_numeric($value)) { + return floatval($value); + } + + if ($parameterType === "boolean") { + if (is_bool($value)) { + return $value; + } + + $text = strtolower(trim(strval($value))); + if (in_array($text, ["1", "true", "yes", "on", "t"], true)) { + return true; + } + if (in_array($text, ["0", "false", "no", "off", "f"], true)) { + return false; + } + } + + return $value; +} + +function functionDefinitionHasRequiredParameters($functionDef) +{ + return is_array($functionDef) && count($functionDef["parameters"]["required"] ?? []) > 0; +} + +function functionExecutionParameterValueIsEmpty($parameterValue) +{ + if (is_array($parameterValue)) { + return count($parameterValue) === 0; + } + + return trim(strval($parameterValue)) === ""; +} + +function buildFunctionParameterValueFromResponse($functionDef, $parsedResponse) +{ + $properties = $functionDef["parameters"]["properties"] ?? []; + $requiredParameters = []; + foreach (($functionDef["parameters"]["required"] ?? []) as $requiredParameter) { + $requiredParameter = trim(strval($requiredParameter)); + if ($requiredParameter !== "") { + $requiredParameters[] = $requiredParameter; + } + } + + $missingRequiredParameters = []; + foreach ($requiredParameters as $requiredParameter) { + if (!array_key_exists($requiredParameter, $parsedResponse) || $parsedResponse[$requiredParameter] === "" || $parsedResponse[$requiredParameter] === null) { + $missingRequiredParameters[] = $requiredParameter; + } + } + + if (count($properties) > 1) { + $parameters = []; + foreach ($properties as $parameterName => $parameterSchema) { + if (array_key_exists($parameterName, $parsedResponse)) { + $parameters[$parameterName] = normalizeFunctionParameterValueFromSchema($parameterSchema, $parsedResponse[$parameterName]); + } + } + + return [ + "parameter_value" => $parameters, + "missing_required" => $missingRequiredParameters, + ]; + } + + return [ + "parameter_value" => getSingleFunctionParameterValue($functionDef, $parsedResponse), + "missing_required" => $missingRequiredParameters, + ]; +} + +function buildFunctionExecutionContextFromResponse($parsedResponse) +{ + $actionName = trim(strval($parsedResponse["action"] ?? "")); + $functionDef = $actionName !== "" ? findFunctionByName($actionName) : null; + $functionCodeName = $actionName; + $parameterValue = $parsedResponse["target"] ?? ""; + $missingRequired = []; + + if (is_array($functionDef)) { + $resolvedCodeName = getFunctionCodeName($actionName); + if (is_string($resolvedCodeName) && $resolvedCodeName !== "") { + $functionCodeName = $resolvedCodeName; + } + + $parameterData = buildFunctionParameterValueFromResponse($functionDef, is_array($parsedResponse) ? $parsedResponse : []); + $parameterValue = $parameterData["parameter_value"]; + $missingRequired = $parameterData["missing_required"]; + } + + return [ + "action_name" => $actionName, + "function_def" => $functionDef, + "function_found" => is_array($functionDef), + "function_code_name" => $functionCodeName, + "parameter_value" => $parameterValue, + "parameter_string" => buildFunctionExecutionParameter($functionCodeName, $parameterValue), + "missing_required" => $missingRequired, + "has_required_parameters" => functionDefinitionHasRequiredParameters($functionDef), + "parameter_is_empty" => functionExecutionParameterValueIsEmpty($parameterValue), + ]; +} + +function queueFunctionExecutionCommand(&$commandBuffer, &$alreadySent, $executionContext, $connectorName, $actorName = null) +{ + $actionName = trim(strval($executionContext["action_name"] ?? "")); + if ($actionName === "") { + return false; + } + + if (empty($executionContext["function_found"])) { + if ($actionName !== "Talk") { + Logger::warn("{$connectorName}: Function not found for {$actionName}"); + } + return false; + } + + $missingRequired = $executionContext["missing_required"] ?? []; + if (count($missingRequired) > 0) { + Logger::warn("{$connectorName}: Missing required parameter(s) for " . strval($executionContext["function_code_name"] ?? $actionName) . ": " . implode(", ", $missingRequired)); + } + + if (!empty($executionContext["has_required_parameters"]) && !empty($executionContext["parameter_is_empty"])) { + return false; + } + + $actorName = ($actorName !== null && trim(strval($actorName)) !== "") ? strval($actorName) : strval($GLOBALS["HERIKA_NAME"] ?? "Herika"); + $commandStr = $actorName . "|command|" . strval($executionContext["function_code_name"] ?? "") . "@" . strval($executionContext["parameter_string"] ?? "") . "\r\n"; + $commandHash = md5($commandStr); + + if (isset($alreadySent[$commandHash])) { + return false; + } + + $commandBuffer[] = $commandStr; + $alreadySent[$commandHash] = $commandStr; + return true; +} + +function chimActionShouldSuppressImmediateMessage($actionName) +{ + $actionName = trim(strval($actionName)); + if ($actionName === '') { + return false; + } + + return getFunctionCodeName($actionName) === 'Consume'; +} + + +function buildFunctionExecutionParameter($functionCodeName, $parameter) +{ + $functionCodeName = trim(strval($functionCodeName)); + + $configuredPayload = buildConfiguredActionParameterFromMetadata($functionCodeName, $parameter); + if ($configuredPayload !== null) { + return $configuredPayload; + } + + if (is_array($parameter)) { + return json_encode($parameter, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + return strval($parameter); +} + function findFunctionByName($name) { foreach ($GLOBALS["FUNCTIONS"] as $function) { @@ -1151,222 +1480,39 @@ function unsetFunction($functionCodename) } -if (isset($GLOBALS["IS_NPC"]) && $GLOBALS["IS_NPC"]) { - $GLOBALS["ENABLED_FUNCTIONS"] = [ - 'Inspect', - //'LookAt', - 'InspectSurroundings', - 'MoveTo', - 'OpenInventory', - 'OpenInventory2', - 'Attack', - 'AttackHunt', - 'TravelTo', - 'Follow', - 'CheckInventory', // Commented out - redundant since tag already shows NPC's items. Could be repurposed to check OTHER NPCs' inventories later. - //'SheatheWeapon', - 'Relax', - //'LeadTheWayTo', - 'TakeASeat', - 'IncreaseWalkSpeed', - 'DecreaseWalkSpeed', - //'GetDateTime', - //'SearchDiary', - //'SetCurrentTask', - //'SearchMemory', - //'StopWalk' - 'WaitHere', - 'ComeCloser', - //'GiveItemToPlayer', - 'TakeGoldFromPlayer', - 'RentRoom', - 'HireCarriage', - 'HireFerry', - 'AddBounty', - 'PayBounty', - 'ArrestPlayer', - 'ForgiveCrime', - 'FollowPlayer', - 'Brawl', - 'GiveGoldTo', - 'GiveItemTo', - 'PickupItem', - 'GoToSleep', - 'UseSoulGaze', - 'CastSpell', - 'MakeFollower', - 'Drink', - 'Toast', - 'StartRitualCeremony', - 'EndRitualCeremony', - 'Training', - 'EndConversation' - - ]; - error_log("[DEBUG functions.php] IS_NPC=true, CastSpell in ENABLED: " . (in_array('CastSpell', $GLOBALS["ENABLED_FUNCTIONS"]) ? "YES" : "NO")); -} else { - $GLOBALS["ENABLED_FUNCTIONS"] = [ - 'Inspect', - //'LookAt', - 'InspectSurroundings', - //'MoveTo', - 'OpenInventory', - 'OpenInventory2', - 'Attack', - 'AttackHunt', - 'TravelTo', - 'Follow', - 'CheckInventory', // Commented out - redundant since tag already shows NPC's items. Could be repurposed to check OTHER NPCs' inventories later. - 'SheatheWeapon', - 'Relax', - //'LeadTheWayTo', - 'TakeASeat', - 'ReadQuestJournal', - 'IncreaseWalkSpeed', - 'DecreaseWalkSpeed', - 'WaitHere', - //'SetCurrentTask', - 'ComeCloser', - //'GiveItemToPlayer', - 'TakeGoldFromPlayer', - 'RentRoom', - 'HireCarriage', - 'HireFerry', - 'AddBounty', - 'PayBounty', - 'ArrestPlayer', - 'ForgiveCrime', - 'Brawl', - 'GiveGoldTo', - 'GiveItemTo', - 'PickupItem', - 'GoToSleep', - 'UseSoulGaze', - 'CastSpell', - 'Drink', - 'Toast', - 'Training', - 'StartRitualCeremony', - 'EndRitualCeremony', - //'GetDateTime', - //'SearchDiary', - //'SearchMemory', - //'StopWalk' - ]; -} - -if ($GLOBALS["ENABLED_FUNCTIONS"]) { - if ($GLOBALS["HERIKA_NAME"] && $GLOBALS["HERIKA_NAME"]!="(actor)") { - $cnName=$GLOBALS["db"]->escape($GLOBALS["HERIKA_NAME"]); - $playerCnName=$GLOBALS["db"]->escape($GLOBALS["PLAYER_NAME"]); - $isCombat=$GLOBALS["db"]->fetchOne("SELECT EXISTS ( - SELECT 1 as combat_active - FROM public.eventlog start_evt - WHERE start_evt.data LIKE '%$cnName engages fair combat with $playerCnName%' and type='funcret' - AND NOT EXISTS ( - SELECT 1 - FROM public.eventlog defeat_evt - WHERE defeat_evt.gamets > start_evt.gamets - AND defeat_evt.data LIKE '%$playerCnName%defeat%$cnName%' and type='death' - ) ) -"); - if (isset($isCombat["exists"])) { - error_log("[DEBUG functions.php] Combat active detected for {$GLOBALS["HERIKA_NAME"]}, adding Surrender function"); - $GLOBALS["ENABLED_FUNCTIONS"][]="Surrender"; - } else { - error_log("[DEBUG functions.php] No active combat detected for {$GLOBALS["HERIKA_NAME"]}"); - } - - if (in_array("RentRoom", $GLOBALS["ENABLED_FUNCTIONS"])) { - $npcMaster = new NpcMaster(); - $npcData = $npcMaster->getByName($GLOBALS["HERIKA_NAME"]); - $isInnkeeper = false; - if (!empty($npcData)) { - $isInnkeeper = $npcMaster->isNpcInFaction($npcData, "0005091B"); - } - if (!$isInnkeeper) { - error_log("[DEBUG functions.php] {$GLOBALS["HERIKA_NAME"]} is not innkeeper, removing RentRoom function"); - unsetFunction('RentRoom'); - } - } - if (in_array("HireCarriage", $GLOBALS["ENABLED_FUNCTIONS"])) { - $allowedDriversRaw = isset($GLOBALS["CARRIAGE_DRIVERS"]) ? (string) $GLOBALS["CARRIAGE_DRIVERS"] : ""; - if (trim($allowedDriversRaw) === "") { - // Backward-compatible fallback for older conf.php files that don't have CARRIAGE_DRIVERS yet. - $allowedDriversRaw = "Bjorlam, Alfarinn, Kibell, Sigaar, Thaer, Engar, Gunjar, Markus"; - } - $allowedDrivers = array_filter(array_map("trim", explode(",", $allowedDriversRaw))); - $isAllowedDriver = false; - - foreach ($allowedDrivers as $driverName) { - if (strcasecmp($driverName, $GLOBALS["HERIKA_NAME"]) === 0) { - $isAllowedDriver = true; - break; - } - } - - if (!$isAllowedDriver) { - error_log("[DEBUG functions.php] {$GLOBALS["HERIKA_NAME"]} is not in CARRIAGE_DRIVERS, removing HireCarriage function"); - unsetFunction("HireCarriage"); - } - } - if (in_array("HireFerry", $GLOBALS["ENABLED_FUNCTIONS"])) { - $allowedFerryDriversRaw = isset($GLOBALS["FERRY_DRIVERS"]) ? (string) $GLOBALS["FERRY_DRIVERS"] : ""; - if (trim($allowedFerryDriversRaw) === "") { - // Backward-compatible fallback for older conf.php files that don't have FERRY_DRIVERS yet. - $allowedFerryDriversRaw = "Gort, Harlaug, Jolf"; - } - $allowedFerryDrivers = array_filter(array_map("trim", explode(",", $allowedFerryDriversRaw))); - $isAllowedFerryDriver = false; - - foreach ($allowedFerryDrivers as $driverName) { - if (strcasecmp($driverName, $GLOBALS["HERIKA_NAME"]) === 0) { - $isAllowedFerryDriver = true; - break; - } - } - - if (!$isAllowedFerryDriver) { - error_log("[DEBUG functions.php] {$GLOBALS["HERIKA_NAME"]} is not in FERRY_DRIVERS, removing HireFerry function"); - unsetFunction("HireFerry"); - } - } - } +$seedActionRows = herikaBuildActionCatalogSeedRows( + $F_NAMES_LOCAL ?? [], + $F_TRANSLATIONS_LOCAL ?? [], + $F_RETURNMESSAGES_LOCAL ?? [], + [], + $ENABLED_FUNCTIONS_LOCAL, + herikaBuildActionCatalogFunctionDefinitionsByCode($HERIKA_BASE_FUNCTIONS_LOCAL ?? []) +); +if (herikaActionCatalogDbReady()) { + herikaSyncActionCatalogBaseRows($seedActionRows); + herikaImportLegacyActionPreferences($seedActionRows); } -$guardActions = ["AddBounty", "PayBounty", "ArrestPlayer", "ForgiveCrime"]; -$hasGuardAction = false; -foreach ($guardActions as $ga) { - if (in_array($ga, $GLOBALS["ENABLED_FUNCTIONS"])) { - $hasGuardAction = true; - break; - } -} -if ($hasGuardAction) { - $npcMasterGuard = new NpcMaster(); - $npcDataGuard = $npcMasterGuard->getByName($GLOBALS["HERIKA_NAME"]); - $isGuard = false; - // Skyrim's IsGuardFaction - $guardFactionIds = ["00086EEE"]; - if (!empty($npcDataGuard)) { - foreach ($guardFactionIds as $guardFactionId) { - if ($npcMasterGuard->isNpcInFaction($npcDataGuard, $guardFactionId)) { - $isGuard = true; - break; - } - } - } - if (!$isGuard) { - error_log("[DEBUG functions.php] {$GLOBALS["HERIKA_NAME"]} is not in GuardFaction, removing guard crime actions"); - foreach ($guardActions as $ga) { - unsetFunction($ga); - } - } -} +$isNpcMode = isset($GLOBALS["IS_NPC"]) && $GLOBALS["IS_NPC"]; +$defaultEnabledFunctions = $isNpcMode ? herikaGetNpcDefaultActionCodes() : herikaGetFollowerDefaultActionCodes(); +$dbEnabledFunctions = herikaLoadEnabledActionCodesForMode($isNpcMode, true); +$GLOBALS["ENABLED_FUNCTIONS"] = herikaActionCatalogDbReady() + ? $dbEnabledFunctions + : $defaultEnabledFunctions; $folderPath = __DIR__ . DIRECTORY_SEPARATOR . "../ext/"; requireFunctionFilesRecursively($folderPath); +if (herikaActionCatalogDbReady()) { + // Do not re-seed core_action from the live runtime list here. + // Runtime functions may already include DB-backed custom actions that + // intentionally share an action_name with shipped actions (for example + // CHIM-Custom NFF wrappers like WaitHere / FollowMe / BehindMe). If we + // write back from the runtime list, those custom rows can be mistaken for + // built-in functions and get rewritten as source=function.php rows. + herikaActionCatalogApplyRowsToRuntimeFunctions(); +} + // Why is this here? if (file_exists(__DIR__ . DIRECTORY_SEPARATOR . "lang" . DIRECTORY_SEPARATOR . $GLOBALS["CORE_LANG"] . DIRECTORY_SEPARATOR . "prompts.php")) { require __DIR__ . DIRECTORY_SEPARATOR . "lang" . DIRECTORY_SEPARATOR . $GLOBALS["CORE_LANG"] . DIRECTORY_SEPARATOR . "prompts.php"; @@ -1420,6 +1566,11 @@ function unsetFunction($functionCodename) $actionParts2=explode("@",$actionParts[2]); if (isset($actionParts2[0])) { + if (herikaActionCatalogExecuteScriptProxyAction($action)) { + unset($actionsCopy[$n]); + continue; + } + // Parameter part if ($actionParts2[0]=="Drink") { @@ -1601,6 +1752,22 @@ function unsetFunction($functionCodename) ) ); + chimApplyNpcMetadataUpdatesByName($actionParts[0], [ + 'ritual_state' => [ + 'active' => true, + 'type' => strval($actionParts2[1] ?? ''), + 'started_at' => time(), + 'gamets' => $gameRequest[2], + ], + 'activity_status' => [ + 'current_action' => 'ritual', + 'current_use' => strval($actionParts2[1] ?? ''), + 'use_type' => 'ritual', + 'timestamp' => (int) round(microtime(true) * 1000), + 'gamets' => $gameRequest[2], + ], + ]); + error_log("[ACTION POSTFILTER StartRitualCeremony] Executed server-side"); } else if ($actionParts2[0]=="EndRitualCeremony") { @@ -1636,39 +1803,20 @@ function unsetFunction($functionCodename) ) ); - error_log("[ACTION POSTFILTER Toast] Executed server-side"); - - } else if ($actionParts2[0]=="Surrender") { - - $npcMaster = new Npcmaster(); - $npcData = $npcMaster->getByName($actionParts[0]); - - $skyrimCmd = new SkyrimCommandBuilder(); - $json = $skyrimCmd->Actor->SetRelationshipRank("0x{$npcData["refid"]}", "0x00000014", 0);// Set to Ally - $skyrimCmd->send(cmd: $json); - - $json = $skyrimCmd->Actor->StopCombat("0x{$npcData["refid"]}"); - $skyrimCmd->send(cmd: $json); - - $GLOBALS["db"]->insert( - 'eventlog', - [ - 'ts' => $gameRequest[1], - 'gamets' => $gameRequest[2], - 'type' => "combatend", - 'data' => "{$GLOBALS["PLAYER_NAME"]} has defeated {$actionParts[0]}, {$actionParts[0]} surrenders.", - 'sess' => 'pending', - 'localts' => time(), - 'people'=> $GLOBALS["CACHE_PEOPLE_LIMITED"], - 'location'=>$GLOBALS["CACHE_LOCATION"], - 'party'=>$GLOBALS["CACHE_PARTY"] - ] - ); - unset($actionsCopy[$n]);// Remove action from list, so client does not execute it + chimApplyNpcMetadataUpdatesByName($actionParts[0], [ + 'ritual_state' => null, + 'activity_status' => [ + 'current_action' => 'idle', + 'current_use' => '', + 'use_type' => '', + 'furniture_name' => '', + 'timestamp' => (int) round(microtime(true) * 1000), + 'gamets' => $gameRequest[2], + ], + ]); - + error_log("[ACTION POSTFILTER Toast] Executed server-side"); - error_log("[ACTION POSTFILTER Surrender] Executed server-side"); } } } diff --git a/functions/functions_instruction.php b/functions/functions_instruction.php index 9361220fe..f93fa4ac6 100644 --- a/functions/functions_instruction.php +++ b/functions/functions_instruction.php @@ -5,7 +5,9 @@ // We must use internal named keys here. $GLOBALS["F_TRANSLATIONS_NEW"]["TravelTo"]="Long distance travel command. Use it to move to major locations and landmarks, or nearby buildings."; -$GLOBALS["F_NAMES_NEW"]["TravelTo"]="TravelTo"; +$GLOBALS["F_NAMES_NEW"]["TravelTo"] = function_exists('herikaNormalizeActionCatalogDisplayActionName') + ? herikaNormalizeActionCatalogDisplayActionName("TravelTo") + : "TravelTo"; foreach ($GLOBALS["FUNCTIONS"] as $n=>$f) { $internalCode=getFunctionByTrlName($f["name"]); diff --git a/functions/json_response.php b/functions/json_response.php index a6e9d810c..8905760be 100755 --- a/functions/json_response.php +++ b/functions/json_response.php @@ -67,33 +67,39 @@ function chimIsDirectNarratorDialogue() { continue; } + $actionDescription = function_exists('herikaGetPromptActionDescription') + ? herikaGetPromptActionDescription($fname, $function["description"] ?? '') + : strval($function["description"] ?? ''); + $GLOBALS["FUNC_LIST"][]=$function["name"]; - if ($function["name"]==$GLOBALS["F_NAMES"]["Attack"] || $function["name"]==$GLOBALS["F_NAMES"]["Brawl"] || $function["name"]==$GLOBALS["F_NAMES"]["AttackHunt"]) { - $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$function["description"]})"; + if ($function["name"]==$GLOBALS["F_NAMES"]["Attack"] || $function["name"]==$GLOBALS["F_NAMES"]["Brawl"]) { + $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$actionDescription})"; $GLOBALS["PROMPT_ACTIONS_LIST"].="(available targets: ".implode(",",$GLOBALS["FUNCTION_PARM_INSPECT"]).")"; - } else if ($function["name"]==$GLOBALS["F_NAMES"]["SearchMemory"]) { - $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]}(keywords to search ({$function["description"]})"; } else if ($fname == "GiveGoldTo") { require_once(__DIR__ . DIRECTORY_SEPARATOR . ".." . DIRECTORY_SEPARATOR . "lib" . DIRECTORY_SEPARATOR . "data_functions.php"); $goldAmount = getGoldFromMetadata(); - $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$function["description"]}). You currently have {$goldAmount} gold. Put the amount in the 'item' field."; + $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$actionDescription}). You currently have {$goldAmount} gold. Put the amount in the 'item' field."; } else if ($fname == "HireCarriage") { $majorDestinations = "Whiterun, Solitude, Markarth, Riften, Windhelm"; $minorDestinations = "Morthal, Dawnstar, Falkreath, Winterhold, Darkwater Crossing, Dragon Bridge, Ivarstead, Karthwasten, Kynesgrove, Old Hroldan, Riverwood, Rorikstead, Shor's Stone, Stonehills"; - $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$function["description"]}). Vanilla carriage costs: 20 gold for major destinations ({$majorDestinations}) and 50 gold for minor destinations ({$minorDestinations}). Put the destination in the 'target' field. Keep the spoken line short, accept payment, and do not ask questions."; + $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$actionDescription}). Vanilla carriage costs: 20 gold for major destinations ({$majorDestinations}) and 50 gold for minor destinations ({$minorDestinations}). Put the destination in the 'target' field. Keep the spoken line short, accept payment, and do not ask questions."; } else if ($fname == "HireFerry") { $fiftyGoldDestinations = "Windhelm, Dawnstar, Solitude, Giant's Tooth"; - $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$function["description"]}). Vanilla ferry costs: 50 gold for {$fiftyGoldDestinations}, 500 gold for Icewater Jetty, and free travel to Castle Volkihar. Put the destination in the 'target' field. Keep the spoken line short, accept payment when needed, and do not ask questions."; + $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$actionDescription}). Vanilla ferry costs: 50 gold for {$fiftyGoldDestinations}, 500 gold for Icewater Jetty, and free travel to Castle Volkihar. Put the destination in the 'target' field. Keep the spoken line short, accept payment when needed, and do not ask questions."; } else if ($fname == "AddBounty") { - $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$function["description"]}). Crime types and vanilla bounty amounts: Assault=40 (violent), Murder=1000 (violent), Theft=100, Pickpocketing=25, Trespassing=5, Jailbreak=100, Custom (specify amount in 'item' field). Put the crime type in 'target'."; + $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$actionDescription}). Crime types and vanilla bounty amounts: Assault=40 (violent), Murder=1000 (violent), Theft=100, Pickpocketing=25, Trespassing=5, Jailbreak=100, Custom (specify amount in 'item' field). Put the crime type in 'target'."; } else if ($fname == "PayBounty") { - $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$function["description"]}). Use when the player agrees to pay now. Bounty payment and stolen-item confiscation happen immediately in one step. After using it, reply with a short confirmation and end the conversation."; + $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$actionDescription}). Use when {$GLOBALS["PLAYER_NAME"]} agrees to pay now. Bounty payment and stolen-item confiscation happen immediately in one step. After using it, reply with a short confirmation and end the conversation."; } else if ($fname == "ArrestPlayer") { - $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$function["description"]}). Use for serious crimes or if the player refuses to pay their bounty. The player gets a submit/resist popup. Submit sends them to jail with inventory confiscated. Resist makes guards attack."; + $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$actionDescription}). Use for serious crimes or if {$GLOBALS["PLAYER_NAME"]} refuses to pay their bounty. {$GLOBALS["PLAYER_NAME"]} gets a submit/resist popup. Submit sends them to jail with inventory confiscated. Resist makes guards attack."; } else if ($fname == "ForgiveCrime") { - $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$function["description"]}). Use when the player successfully persuades, bribes, or invokes thane status to clear their bounty."; + $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$actionDescription}). Use when {$GLOBALS["PLAYER_NAME"]} successfully persuades, bribes, or invokes thane status to clear their bounty."; + } else if ($fname == "TeachRightHandSpell") { + $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$actionDescription}). Do not put anything in the 'target' or 'item' field. This action automatically teaches whatever spell {$GLOBALS["PLAYER_NAME"]} currently has equipped in the right hand."; + } else if ($fname == "Consume") { + $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$actionDescription}). Put the exact item name from in the 'target' field. Only use this for food, drinks, or potions already in inventory. Leave 'item' blank unless you need it as a fallback copy of the same item name. The spoken reply for this action happens after the item is consumed, so use it only when {$GLOBALS["HERIKA_NAME"]} is actually going to eat or drink the item."; } else { - $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$function["description"]})"; + $GLOBALS["PROMPT_ACTIONS_LIST"].="\nAVAILABLE ACTION: {$function["name"]} ({$actionDescription})"; } } @@ -155,8 +161,8 @@ function chimIsDirectNarratorDialogue() { "message"=>$messageDescription, "mood"=>$moodDescription, "action"=>implode("|",$GLOBALS["FUNC_LIST"]), - "target"=>"action target actor|action destination location name", - "item"=>"item name (REQUIRED when action is GiveItemTo or PickupItem or CastSpell - use exact item name from inventory or spell name from spells) OR amount of gold (REQUIRED when action is GiveGoldTo - number as string, e.g. '50')", + "target"=>"action target actor|action destination location name. Leave blank when the chosen action does not need a target.", + "item"=>"item name (REQUIRED when action is GiveItemTo or PickupItem or CastSpell - use exact item name from inventory or spell name from spells) OR amount of gold (REQUIRED when action is GiveGoldTo - number as string, e.g. '50'). Leave blank when the chosen action does not need an item.", "lang"=>isset($GLOBALS["LLM_LANG"])?$GLOBALS["LLM_LANG"]:"en|es|fr|de|it|pt|ru|zh-cn|ja|ko|ar|pl|tr|cs|nl|hu|hi", ]; } else { @@ -166,8 +172,8 @@ function chimIsDirectNarratorDialogue() { "message"=>$messageDescription, "mood"=>$moodDescription, "action"=>implode("|",$GLOBALS["FUNC_LIST"]), - "target"=>"action target actor|action destination location name", - "item"=>"item name (REQUIRED when action is GiveItemTo or PickupItem or CastSpell - use exact item name from inventory or spell name from spells) OR amount of gold (REQUIRED when action is GiveGoldTo - number as string, e.g. '50')" + "target"=>"action target actor|action destination location name. Leave blank when the chosen action does not need a target.", + "item"=>"item name (REQUIRED when action is GiveItemTo or PickupItem or CastSpell - use exact item name from inventory or spell name from spells) OR amount of gold (REQUIRED when action is GiveGoldTo - number as string, e.g. '50'). Leave blank when the chosen action does not need an item." ]; } } else { @@ -177,8 +183,8 @@ function chimIsDirectNarratorDialogue() { "listener"=>$listenerDesc, "mood"=>$moodDescription, "action"=>implode("|",$GLOBALS["FUNC_LIST"]), - "target"=>"action target actor|action destination location name", - "item"=>"item name (REQUIRED when action is GiveItemTo or PickupItem or CastSpell - use exact item name from inventory or spell name from spells) OR amount of gold (REQUIRED when action is GiveGoldTo - number as string, e.g. '50')", + "target"=>"action target actor|action destination location name. Leave blank when the chosen action does not need a target.", + "item"=>"item name (REQUIRED when action is GiveItemTo or PickupItem or CastSpell - use exact item name from inventory or spell name from spells) OR amount of gold (REQUIRED when action is GiveGoldTo - number as string, e.g. '50'). Leave blank when the chosen action does not need an item.", "lang"=>isset($GLOBALS["LLM_LANG"])?$GLOBALS["LLM_LANG"]:"en|es|fr|de|it|pt|ru|zh-cn|ja|ko|ar|pl|tr|cs|nl|hu|hi", "message"=>$messageDescription ]; @@ -188,8 +194,8 @@ function chimIsDirectNarratorDialogue() { "listener"=>$listenerDesc, "mood"=>$moodDescription, "action"=>implode("|",$GLOBALS["FUNC_LIST"]), - "target"=>"action target actor|action destination location name", - "item"=>"item name (REQUIRED when action is GiveItemTo or PickupItem or CastSpell - use exact item name from inventory or spell name from spells)", + "target"=>"action target actor|action destination location name. Leave blank when the chosen action does not need a target.", + "item"=>"item name (REQUIRED when action is GiveItemTo or PickupItem or CastSpell - use exact item name from inventory or spell name from spells). Leave blank when the chosen action does not need an item.", "message"=>$messageDescription ]; } @@ -284,11 +290,11 @@ function chimIsDirectNarratorDialogue() { ), "target" => array( "type" => "string", - "description" => "action target actor| action destination location name" + "description" => "action target actor| action destination location name| exact inventory item name when action is Consume. Leave blank when the chosen action does not need a target." ), "item" => array( "type" => "string", - "description" => "item name (REQUIRED when action is GiveItemTo or PickupItem or CastSpell - use exact name from inventory, nearby_items, or spell name from spells) OR amount of gold (REQUIRED when action is GiveGoldTo - number as string, e.g. '50')" + "description" => "item name (REQUIRED when action is GiveItemTo or PickupItem or CastSpell - use exact name from inventory, nearby_items, or spell name from spells) OR amount of gold (REQUIRED when action is GiveGoldTo - number as string, e.g. '50'). For Consume, leave item blank unless target is empty and you need item as the same exact inventory item name fallback." ) ), "required" => [ diff --git a/gamedata.php b/gamedata.php index f2710aed0..73491c535 100644 --- a/gamedata.php +++ b/gamedata.php @@ -15,6 +15,7 @@ $GLOBALS["db"] = new sql(); require_once(__DIR__ . "/lib/core/npc_master.class.php"); require_once(__DIR__ . "/lib/core/activity_status.php"); +require_once(__DIR__ . "/lib/core/game_plugins.php"); require_once(__DIR__ . "/lib/logger.php"); // Only accept POST requests @@ -36,7 +37,7 @@ } // Types that operate on global data and do not require an actor -$actorlessTypes = ['market_stock', 'activity_status_bulk']; +$actorlessTypes = ['market_stock', 'activity_status_bulk', 'loaded_plugins']; // Validate required fields (skipped for actorless types) if (!in_array($data['type'], $actorlessTypes)) { @@ -85,6 +86,9 @@ case 'market_stock': handleMarketStockUpdate($data); break; + case 'loaded_plugins': + handleLoadedPluginsUpdate($data); + break; default: http_response_code(400); echo "Bad Request: Unknown type"; @@ -118,14 +122,8 @@ function handleEquipmentUpdate(array $data, NpcMaster $npcMaster): void { try { require_once(__DIR__ . "/lib/core/player.class.php"); $player = new Player(); - - // Format equipment data for storage - $equipmentData = []; - foreach ($equipment as $slot => $item) { - $equipmentData[$slot] = isset($item['name']) ? $item['name'] : ''; - $equipmentData[$slot . '_baseid'] = isset($item['baseid']) ? $item['baseid'] : ''; - } - + + $equipmentData = buildEquipmentMetadataValue($equipment); $player->setJson('equipment', $equipmentData); Logger::debug("[gamedata.php] Saved player equipment to core_player table"); } catch (Exception $e) { @@ -135,22 +133,9 @@ function handleEquipmentUpdate(array $data, NpcMaster $npcMaster): void { // For backward compatibility, also try to update NPC record if it exists $currentData = $npcMaster->getByName($actorName); if ($currentData) { - $meta = []; - if (!empty($currentData['metadata'])) { - $meta = json_decode($currentData['metadata'], true); - if (!is_array($meta)) { - $meta = []; - } - } - - $meta['equipment'] = []; - foreach ($equipment as $slot => $item) { - $meta['equipment'][$slot] = isset($item['name']) ? $item['name'] : ''; - $meta['equipment'][$slot . '_baseid'] = isset($item['baseid']) ? $item['baseid'] : ''; - } - - $currentData = $npcMaster->setMetadata($currentData, $meta); - $npcMaster->updateByArray($currentData); + $npcMaster->updateMetadataKeysByName($actorName, [ + 'equipment' => buildEquipmentMetadataValue($equipment), + ]); } return; // Done with player, exit early @@ -164,25 +149,9 @@ function handleEquipmentUpdate(array $data, NpcMaster $npcMaster): void { return; } - // Get existing metadata - $meta = []; - if (!empty($currentData['metadata'])) { - $meta = json_decode($currentData['metadata'], true); - if (!is_array($meta)) { - $meta = []; - } - } - - // Update equipment section - $meta['equipment'] = []; - foreach ($equipment as $slot => $item) { - $meta['equipment'][$slot] = isset($item['name']) ? $item['name'] : ''; - $meta['equipment'][$slot . '_baseid'] = isset($item['baseid']) ? $item['baseid'] : ''; - } - - // Save back to database - $currentData = $npcMaster->setMetadata($currentData, $meta); - $npcMaster->updateByArray($currentData); + $npcMaster->updateMetadataKeysByName($actorName, [ + 'equipment' => buildEquipmentMetadataValue($equipment), + ]); Logger::debug("[gamedata.php] Updated equipment for {$actorType}: {$actorName}"); } @@ -194,15 +163,13 @@ function handleFurnitureUpdate(array $data, NpcMaster $npcMaster): void return; } - $meta = $npcMaster->getMetadata($currentData); - $meta = chimUpsertActivityStatusMetadata($meta, [ + chimApplyNpcMetadataUpdatesByName($data['actor_name'], [ + 'activity_status' => [ 'furniture_name' => $data['furniture'] ?? '', 'timestamp' => $data['timestamp'] ?? chimActivityStatusNowMs(), 'gamets' => $data['gamets'] ?? 0, + ], ]); - - $currentData = $npcMaster->setMetadata($currentData, $meta); - $npcMaster->updateByArray($currentData); } function handleActivityStatusUpdate(array $data, NpcMaster $npcMaster): void @@ -212,11 +179,9 @@ function handleActivityStatusUpdate(array $data, NpcMaster $npcMaster): void return; } - $meta = $npcMaster->getMetadata($currentData); - $meta = chimUpsertActivityStatusMetadata($meta, $data); - - $currentData = $npcMaster->setMetadata($currentData, $meta); - $npcMaster->updateByArray($currentData); + chimApplyNpcMetadataUpdatesByName($data['actor_name'], [ + 'activity_status' => $data, + ]); } function handleActivityStatusBulkUpdate(array $data, NpcMaster $npcMaster): void @@ -236,12 +201,89 @@ function handleActivityStatusBulkUpdate(array $data, NpcMaster $npcMaster): void continue; } - $meta = $npcMaster->getMetadata($currentData); - $meta = chimUpsertActivityStatusMetadata($meta, $statusRow); + chimApplyNpcMetadataUpdatesByName($statusRow['actor_name'], [ + 'activity_status' => $statusRow, + ]); + } +} + +function handleLoadedPluginsUpdate(array $data): void +{ + if (empty($data['plugins']) || !is_array($data['plugins'])) { + Logger::warn("[gamedata.php] loaded_plugins missing plugins payload"); + return; + } + + $pluginCount = chimReplaceLoadedGamePlugins($data['plugins']); + Logger::debug("[gamedata.php] Updated loaded plugin manifest ({$pluginCount} plugins)"); +} + +function buildEquipmentMetadataValue(array $equipment): array +{ + $equipmentData = []; + foreach ($equipment as $slot => $item) { + $equipmentData[$slot] = isset($item['name']) ? $item['name'] : ''; + $equipmentData[$slot . '_baseid'] = isset($item['baseid']) ? $item['baseid'] : ''; + } + + return $equipmentData; +} + +function buildInventoryMetadataValue(array $items): array +{ + $inventoryData = []; + foreach ($items as $item) { + if (isset($item['name']) && isset($item['baseid']) && isset($item['count'])) { + $inventoryData[] = [ + 'name' => $item['name'], + 'baseid' => $item['baseid'], + 'count' => intval($item['count']), + ]; + } + } + + return $inventoryData; +} - $currentData = $npcMaster->setMetadata($currentData, $meta); - $npcMaster->updateByArray($currentData); +function buildSkillsMetadataValue(array $skills): array +{ + $skillsData = []; + foreach ($skills as $skillName => $skillValue) { + $skillsData[$skillName] = floatval($skillValue); } + + return $skillsData; +} + +function buildStatsMetadataValue(array $stats): array +{ + return [ + 'level' => isset($stats['level']) ? intval($stats['level']) : 1, + 'health' => isset($stats['health']) ? floatval($stats['health']) : 0, + 'health_max' => isset($stats['health_max']) ? floatval($stats['health_max']) : 0, + 'magicka' => isset($stats['magicka']) ? floatval($stats['magicka']) : 0, + 'magicka_max' => isset($stats['magicka_max']) ? floatval($stats['magicka_max']) : 0, + 'stamina' => isset($stats['stamina']) ? floatval($stats['stamina']) : 0, + 'stamina_max' => isset($stats['stamina_max']) ? floatval($stats['stamina_max']) : 0, + 'scale' => isset($stats['scale']) ? floatval($stats['scale']) : 1.0, + ]; +} + +function buildSpellsMetadataValue(array $spells): array +{ + $spellData = []; + foreach ($spells as $spell) { + if (isset($spell['name']) && isset($spell['baseid'])) { + $spellData[] = [ + 'name' => $spell['name'], + 'baseid' => $spell['baseid'], + 'casting_type' => isset($spell['casting_type']) ? intval($spell['casting_type']) : 0, + 'delivery' => isset($spell['delivery']) ? intval($spell['delivery']) : 0, + ]; + } + } + + return $spellData; } /** @@ -263,19 +305,8 @@ function handleInventoryUpdate(array $data, NpcMaster $npcMaster): void { try { require_once(__DIR__ . "/lib/core/player.class.php"); $player = new Player(); - - // Format inventory data for storage - $inventoryData = []; - foreach ($items as $item) { - if (isset($item['name']) && isset($item['baseid']) && isset($item['count'])) { - $inventoryData[] = [ - 'name' => $item['name'], - 'baseid' => $item['baseid'], - 'count' => intval($item['count']) - ]; - } - } - + + $inventoryData = buildInventoryMetadataValue($items); $player->setJson('inventory', $inventoryData); Logger::debug("[gamedata.php] Saved player inventory to core_player table"); } catch (Exception $e) { @@ -285,27 +316,9 @@ function handleInventoryUpdate(array $data, NpcMaster $npcMaster): void { // For backward compatibility, also try to update NPC record if it exists $currentData = $npcMaster->getByName($actorName); if ($currentData) { - $meta = []; - if (!empty($currentData['metadata'])) { - $meta = json_decode($currentData['metadata'], true); - if (!is_array($meta)) { - $meta = []; - } - } - - $meta['inventory'] = []; - foreach ($items as $item) { - if (isset($item['name']) && isset($item['baseid']) && isset($item['count'])) { - $meta['inventory'][] = [ - 'name' => $item['name'], - 'baseid' => $item['baseid'], - 'count' => intval($item['count']) - ]; - } - } - - $currentData = $npcMaster->setMetadata($currentData, $meta); - $npcMaster->updateByArray($currentData); + $npcMaster->updateMetadataKeysByName($actorName, [ + 'inventory' => buildInventoryMetadataValue($items), + ]); } $itemCount = count($items); @@ -321,30 +334,9 @@ function handleInventoryUpdate(array $data, NpcMaster $npcMaster): void { return; } - // Get existing metadata - $meta = []; - if (!empty($currentData['metadata'])) { - $meta = json_decode($currentData['metadata'], true); - if (!is_array($meta)) { - $meta = []; - } - } - - // Update inventory section - store as array for easier processing - $meta['inventory'] = []; - foreach ($items as $item) { - if (isset($item['name']) && isset($item['baseid']) && isset($item['count'])) { - $meta['inventory'][] = [ - 'name' => $item['name'], - 'baseid' => $item['baseid'], - 'count' => intval($item['count']) - ]; - } - } - - // Save back to database - $currentData = $npcMaster->setMetadata($currentData, $meta); - $npcMaster->updateByArray($currentData); + $npcMaster->updateMetadataKeysByName($actorName, [ + 'inventory' => buildInventoryMetadataValue($items), + ]); $itemCount = count($items); Logger::debug("[gamedata.php] Updated inventory for {$actorType}: {$actorName} ({$itemCount} items)"); @@ -369,13 +361,8 @@ function handleSkillsUpdate(array $data, NpcMaster $npcMaster): void { try { require_once(__DIR__ . "/lib/core/player.class.php"); $player = new Player(); - - // Format skills data for storage - $skillsData = []; - foreach ($skills as $skillName => $skillValue) { - $skillsData[$skillName] = floatval($skillValue); - } - + + $skillsData = buildSkillsMetadataValue($skills); $player->setJson('skills', $skillsData); Logger::debug("[gamedata.php] Saved player skills to core_player table"); } catch (Exception $e) { @@ -385,21 +372,9 @@ function handleSkillsUpdate(array $data, NpcMaster $npcMaster): void { // For backward compatibility, also try to update NPC record if it exists $currentData = $npcMaster->getByName($actorName); if ($currentData) { - $meta = []; - if (!empty($currentData['metadata'])) { - $meta = json_decode($currentData['metadata'], true); - if (!is_array($meta)) { - $meta = []; - } - } - - $meta['skills'] = []; - foreach ($skills as $skillName => $skillValue) { - $meta['skills'][$skillName] = floatval($skillValue); - } - - $currentData = $npcMaster->setMetadata($currentData, $meta); - $npcMaster->updateByArray($currentData); + $npcMaster->updateMetadataKeysByName($actorName, [ + 'skills' => buildSkillsMetadataValue($skills), + ]); } Logger::debug("[gamedata.php] Updated skills for player: {$actorName}"); @@ -414,24 +389,9 @@ function handleSkillsUpdate(array $data, NpcMaster $npcMaster): void { return; } - // Get existing metadata - $meta = []; - if (!empty($currentData['metadata'])) { - $meta = json_decode($currentData['metadata'], true); - if (!is_array($meta)) { - $meta = []; - } - } - - // Update skills section - $meta['skills'] = []; - foreach ($skills as $skillName => $skillValue) { - $meta['skills'][$skillName] = floatval($skillValue); - } - - // Save back to database - $currentData = $npcMaster->setMetadata($currentData, $meta); - $npcMaster->updateByArray($currentData); + $npcMaster->updateMetadataKeysByName($actorName, [ + 'skills' => buildSkillsMetadataValue($skills), + ]); Logger::debug("[gamedata.php] Updated skills for {$actorType}: {$actorName}"); } @@ -455,19 +415,8 @@ function handleStatsUpdate(array $data, NpcMaster $npcMaster): void { try { require_once(__DIR__ . "/lib/core/player.class.php"); $player = new Player(); - - // Format stats data for storage - $statsData = [ - 'level' => isset($stats['level']) ? intval($stats['level']) : 1, - 'health' => isset($stats['health']) ? floatval($stats['health']) : 0, - 'health_max' => isset($stats['health_max']) ? floatval($stats['health_max']) : 0, - 'magicka' => isset($stats['magicka']) ? floatval($stats['magicka']) : 0, - 'magicka_max' => isset($stats['magicka_max']) ? floatval($stats['magicka_max']) : 0, - 'stamina' => isset($stats['stamina']) ? floatval($stats['stamina']) : 0, - 'stamina_max' => isset($stats['stamina_max']) ? floatval($stats['stamina_max']) : 0, - 'scale' => isset($stats['scale']) ? floatval($stats['scale']) : 1.0 - ]; - + + $statsData = buildStatsMetadataValue($stats); $player->setJson('stats', $statsData); Logger::debug("[gamedata.php] Saved player stats to core_player table"); } catch (Exception $e) { @@ -477,27 +426,9 @@ function handleStatsUpdate(array $data, NpcMaster $npcMaster): void { // For backward compatibility, also try to update NPC record if it exists $currentData = $npcMaster->getByName($actorName); if ($currentData) { - $meta = []; - if (!empty($currentData['metadata'])) { - $meta = json_decode($currentData['metadata'], true); - if (!is_array($meta)) { - $meta = []; - } - } - - $meta['stats'] = [ - 'level' => isset($stats['level']) ? intval($stats['level']) : 1, - 'health' => isset($stats['health']) ? floatval($stats['health']) : 0, - 'health_max' => isset($stats['health_max']) ? floatval($stats['health_max']) : 0, - 'magicka' => isset($stats['magicka']) ? floatval($stats['magicka']) : 0, - 'magicka_max' => isset($stats['magicka_max']) ? floatval($stats['magicka_max']) : 0, - 'stamina' => isset($stats['stamina']) ? floatval($stats['stamina']) : 0, - 'stamina_max' => isset($stats['stamina_max']) ? floatval($stats['stamina_max']) : 0, - 'scale' => isset($stats['scale']) ? floatval($stats['scale']) : 1.0 - ]; - - $currentData = $npcMaster->setMetadata($currentData, $meta); - $npcMaster->updateByArray($currentData); + $npcMaster->updateMetadataKeysByName($actorName, [ + 'stats' => buildStatsMetadataValue($stats), + ]); } Logger::debug("[gamedata.php] Updated stats for player: {$actorName}"); @@ -512,30 +443,9 @@ function handleStatsUpdate(array $data, NpcMaster $npcMaster): void { return; } - // Get existing metadata - $meta = []; - if (!empty($currentData['metadata'])) { - $meta = json_decode($currentData['metadata'], true); - if (!is_array($meta)) { - $meta = []; - } - } - - // Update stats section - $meta['stats'] = [ - 'level' => isset($stats['level']) ? intval($stats['level']) : 1, - 'health' => isset($stats['health']) ? floatval($stats['health']) : 0, - 'health_max' => isset($stats['health_max']) ? floatval($stats['health_max']) : 0, - 'magicka' => isset($stats['magicka']) ? floatval($stats['magicka']) : 0, - 'magicka_max' => isset($stats['magicka_max']) ? floatval($stats['magicka_max']) : 0, - 'stamina' => isset($stats['stamina']) ? floatval($stats['stamina']) : 0, - 'stamina_max' => isset($stats['stamina_max']) ? floatval($stats['stamina_max']) : 0, - 'scale' => isset($stats['scale']) ? floatval($stats['scale']) : 1.0 - ]; - - // Save back to database - $currentData = $npcMaster->setMetadata($currentData, $meta); - $npcMaster->updateByArray($currentData); + $npcMaster->updateMetadataKeysByName($actorName, [ + 'stats' => buildStatsMetadataValue($stats), + ]); Logger::debug("[gamedata.php] Updated stats for {$actorType}: {$actorName}"); } @@ -568,32 +478,10 @@ function handleSpellsUpdate(array $data, NpcMaster $npcMaster): void { return; } - // Get existing metadata - $meta = []; - if (!empty($currentData['metadata'])) { - $meta = json_decode($currentData['metadata'], true); - if (!is_array($meta)) { - $meta = []; - } - } - - // Update spells section - store as array - $meta['spells'] = []; - foreach ($spells as $spell) { - if (isset($spell['name']) && isset($spell['baseid'])) { - $meta['spells'][] = [ - 'name' => $spell['name'], - 'baseid' => $spell['baseid'], - 'casting_type' => isset($spell['casting_type']) ? intval($spell['casting_type']) : 0, - 'delivery' => isset($spell['delivery']) ? intval($spell['delivery']) : 0 - ]; - } - } - $meta['spells_updated'] = time(); - - // Save back to database - $currentData = $npcMaster->setMetadata($currentData, $meta); - $npcMaster->updateByArray($currentData); + $npcMaster->updateMetadataKeysByName($actorName, [ + 'spells' => buildSpellsMetadataValue($spells), + 'spells_updated' => time(), + ]); Logger::debug("[gamedata.php] Updated spells for NPC: {$actorName}"); } diff --git a/lib/core/action_catalog.php b/lib/core/action_catalog.php new file mode 100644 index 000000000..cf680b863 --- /dev/null +++ b/lib/core/action_catalog.php @@ -0,0 +1,2818 @@ + herikaActionCatalogNormalizeImportVersion($existingVersion); +} + +function herikaActionCatalogSqlText($value) +{ + $text = strval($value); + if ($text === '') { + return "''"; + } + + return $GLOBALS["db"]->escapeLiteral($text); +} + +function herikaActionCatalogSqlJson($value, $allowNull = false) +{ + if ($value === null) { + return $allowNull ? 'NULL' : "'{}'::jsonb"; + } + + if (is_string($value)) { + $json = trim($value); + if ($json === '') { + return $allowNull ? 'NULL' : "'{}'::jsonb"; + } + } else { + $json = herikaActionCatalogJsonEncode($value); + if ($json === '') { + return $allowNull ? 'NULL' : "'{}'::jsonb"; + } + } + + return $GLOBALS["db"]->escapeLiteral($json) . '::jsonb'; +} + +function herikaActionCatalogJsonEncode($value) +{ + $json = json_encode($value, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return is_string($json) ? $json : ''; +} + +function herikaActionCatalogDecodeJson($value, $default = []) +{ + if (is_array($value)) { + return $value; + } + + $text = trim(strval($value)); + if ($text === '') { + return $default; + } + + $decoded = json_decode($text, true); + return is_array($decoded) ? $decoded : $default; +} + +function herikaActionCatalogMergePreservedCustomMetadata($baseMetadata, $existingMetadata) +{ + $baseMetadata = herikaActionCatalogDecodeJson($baseMetadata, []); + $existingMetadata = herikaActionCatalogDecodeJson($existingMetadata, []); + + if (isset($existingMetadata['custom_config']) && is_array($existingMetadata['custom_config']) && count($existingMetadata['custom_config']) > 0) { + $baseMetadata['custom_config'] = $existingMetadata['custom_config']; + } + + return $baseMetadata; +} + +function herikaActionCatalogNormalizeEditorFieldOptions($options) +{ + if (!is_array($options)) { + return []; + } + + $normalized = []; + foreach ($options as $key => $option) { + if (is_array($option)) { + $value = strval($option['value'] ?? ''); + if ($value === '') { + $value = is_string($key) ? $key : ''; + } + if ($value === '') { + continue; + } + + $normalized[] = [ + 'value' => $value, + 'label' => strval($option['label'] ?? $value), + ]; + continue; + } + + if (is_string($key) && $key !== '') { + $normalized[] = [ + 'value' => $key, + 'label' => strval($option), + ]; + continue; + } + + $value = strval($option); + if ($value === '') { + continue; + } + + $normalized[] = [ + 'value' => $value, + 'label' => $value, + ]; + } + + return $normalized; +} + +function herikaActionCatalogNormalizeEditorField($field) +{ + if (!is_array($field)) { + return null; + } + + $key = trim(strval($field['key'] ?? '')); + if ($key === '') { + return null; + } + + $type = strtolower(trim(strval($field['type'] ?? 'text'))); + if (!in_array($type, ['text', 'textarea', 'integer', 'number', 'boolean', 'select'], true)) { + $type = 'text'; + } + + $normalized = [ + 'key' => $key, + 'label' => trim(strval($field['label'] ?? $key)), + 'type' => $type, + 'default' => $field['default'] ?? null, + 'global_default_key' => trim(strval($field['global_default_key'] ?? '')), + 'minimum' => array_key_exists('minimum', $field) ? $field['minimum'] : null, + 'maximum' => array_key_exists('maximum', $field) ? $field['maximum'] : null, + 'step' => array_key_exists('step', $field) ? $field['step'] : null, + 'format' => trim(strval($field['format'] ?? '')), + 'placeholder' => strval($field['placeholder'] ?? ''), + 'help' => strval($field['help'] ?? ''), + 'options' => herikaActionCatalogNormalizeEditorFieldOptions($field['options'] ?? []), + ]; + + if ($normalized['label'] === '') { + $normalized['label'] = $key; + } + + return $normalized; +} + +function herikaActionCatalogGetEditorFields($rowOrCode = null) +{ + $row = null; + if (is_array($rowOrCode)) { + $row = $rowOrCode; + } elseif ($rowOrCode !== null) { + $row = herikaGetActionCatalogRow($rowOrCode); + } + + if (!is_array($row)) { + return []; + } + + $metadata = herikaActionCatalogDecodeJson($row['metadata'] ?? [], []); + $fields = $metadata['editor_fields'] ?? []; + if (!is_array($fields)) { + return []; + } + + $normalized = []; + foreach ($fields as $field) { + $normalizedField = herikaActionCatalogNormalizeEditorField($field); + if ($normalizedField === null) { + continue; + } + + $normalized[$normalizedField['key']] = $normalizedField; + } + + return array_values($normalized); +} + +function herikaActionCatalogCastEditorFieldValue($field, $value) +{ + $field = herikaActionCatalogNormalizeEditorField($field); + if ($field === null) { + return $value; + } + + $type = $field['type']; + if ($type === 'boolean') { + return herikaActionCatalogToBool($value); + } + + if ($type === 'integer') { + if (is_bool($value) || $value === null || trim(strval($value)) === '' || !is_numeric($value)) { + $value = $field['default'] ?? 0; + } + + $normalizedValue = intval(round(floatval($value))); + if (is_numeric($field['minimum'])) { + $normalizedValue = max($normalizedValue, intval($field['minimum'])); + } + if (is_numeric($field['maximum'])) { + $normalizedValue = min($normalizedValue, intval($field['maximum'])); + } + return $normalizedValue; + } + + if ($type === 'number') { + if (is_bool($value) || $value === null || trim(strval($value)) === '' || !is_numeric($value)) { + $value = $field['default'] ?? 0; + } + + $normalizedValue = floatval($value); + if (is_numeric($field['minimum'])) { + $normalizedValue = max($normalizedValue, floatval($field['minimum'])); + } + if (is_numeric($field['maximum'])) { + $normalizedValue = min($normalizedValue, floatval($field['maximum'])); + } + return $normalizedValue; + } + + if ($type === 'select') { + $textValue = trim(strval($value)); + foreach ($field['options'] as $option) { + if ($textValue === strval($option['value'] ?? '')) { + return $textValue; + } + } + + if (count($field['options']) > 0) { + return strval($field['options'][0]['value'] ?? ''); + } + + return ''; + } + + return strval($value ?? ''); +} + +function herikaActionCatalogGetEditorFieldDefaultValue($field) +{ + $field = herikaActionCatalogNormalizeEditorField($field); + if ($field === null) { + return null; + } + + $defaultValue = $field['default'] ?? null; + $globalDefaultKey = trim(strval($field['global_default_key'] ?? '')); + if ($globalDefaultKey !== '' && array_key_exists($globalDefaultKey, $GLOBALS)) { + $defaultValue = $GLOBALS[$globalDefaultKey]; + } + + return herikaActionCatalogCastEditorFieldValue($field, $defaultValue); +} + +function herikaActionCatalogGetResolvedCustomConfig($codeName, $row = null) +{ + $codeName = trim(strval($codeName)); + if ($codeName === '') { + return []; + } + + if (!is_array($row)) { + $row = herikaGetActionCatalogRow($codeName); + } + if (!is_array($row)) { + return []; + } + + $metadata = herikaActionCatalogDecodeJson($row['metadata'] ?? [], []); + $customConfig = is_array($metadata['custom_config'] ?? null) ? $metadata['custom_config'] : []; + $resolvedConfig = []; + + foreach (herikaActionCatalogGetEditorFields($row) as $field) { + $fieldKey = $field['key']; + if (array_key_exists($fieldKey, $customConfig)) { + $resolvedConfig[$fieldKey] = herikaActionCatalogCastEditorFieldValue($field, $customConfig[$fieldKey]); + } else { + $resolvedConfig[$fieldKey] = herikaActionCatalogGetEditorFieldDefaultValue($field); + } + } + + foreach ($customConfig as $fieldKey => $fieldValue) { + if (!array_key_exists($fieldKey, $resolvedConfig)) { + $resolvedConfig[$fieldKey] = $fieldValue; + } + } + + return $resolvedConfig; +} + +function herikaActionCatalogToBool($value) +{ + if (is_bool($value)) { + return $value; + } + + $text = strtolower(trim(strval($value))); + return in_array($text, ['1', 'true', 't', 'yes', 'on'], true); +} + +function herikaNormalizeActionCatalogDisplayToken($text, $token, $replacement) +{ + $token = trim(strval($token)); + if ($token === '') { + return $text; + } + + $quotedToken = preg_quote($token, '/'); + $text = preg_replace('/\b[Tt]he\s+' . $quotedToken . '\b/u', $replacement, $text); + return str_replace($token, $replacement, $text); +} + +function herikaNormalizeActionCatalogDisplayText($text) +{ + $text = strval($text); + if ($text === '') { + return ''; + } + + $text = herikaNormalizeActionCatalogDisplayToken($text, $GLOBALS["HERIKA_NAME"] ?? '', 'NPC'); + $text = herikaNormalizeActionCatalogDisplayToken($text, $GLOBALS["PLAYER_NAME"] ?? '', 'PLAYER'); + $text = herikaNormalizeActionCatalogDisplayToken($text, 'The Narrator', 'NPC'); + $text = herikaNormalizeActionCatalogDisplayToken($text, 'Narrator', 'NPC'); + + $text = preg_replace('/\b[Tt]he\s+NPC\b/u', 'NPC', $text); + $text = preg_replace('/\b[Tt]he\s+PLAYER\b/u', 'PLAYER', $text); + + return $text; +} + +function herikaNormalizeActionCatalogDisplayActionName($text) +{ + $text = strval($text); + if ($text === '') { + return ''; + } + + $text = herikaNormalizeActionCatalogDisplayToken($text, $GLOBALS["HERIKA_NAME"] ?? '', 'Npc'); + $text = herikaNormalizeActionCatalogDisplayToken($text, $GLOBALS["PLAYER_NAME"] ?? '', 'Player'); + $text = herikaNormalizeActionCatalogDisplayToken($text, 'The Narrator', 'Npc'); + $text = herikaNormalizeActionCatalogDisplayToken($text, 'Narrator', 'Npc'); + + $text = preg_replace('/\b[Tt]he\s+Npc\b/u', 'Npc', $text); + $text = preg_replace('/\b[Tt]he\s+Player\b/u', 'Player', $text); + + $text = preg_replace('/[\s\-]+/u', '_', $text); + $text = preg_replace('/(?<=[a-z0-9])(?=[A-Z])/u', '_', $text); + $text = preg_replace('/(?<=[A-Z])(?=[A-Z][a-z])/u', '_', $text); + $text = preg_replace('/(?<=[A-Za-z])(?=\d)/u', '_', $text); + $text = preg_replace('/(?<=\d)(?=[A-Za-z])/u', '_', $text); + $text = preg_replace('/_+/u', '_', $text); + $text = trim($text, '_'); + + return $text; +} + +function herikaActionCatalogNormalizeParameterSchema($parameters) +{ + if (!is_array($parameters)) { + return [ + 'type' => 'object', + 'properties' => [], + 'required' => [], + ]; + } + + if (($parameters['type'] ?? '') !== 'object') { + $parameters['type'] = 'object'; + } + + if (!isset($parameters['properties']) || !is_array($parameters['properties'])) { + $parameters['properties'] = []; + } + + if (!isset($parameters['required']) || !is_array($parameters['required'])) { + $parameters['required'] = []; + } + + return $parameters; +} + +function herikaActionCatalogGetBaseScriptProxyPrograms() +{ + static $programs = null; + if ($programs !== null) { + return $programs; + } + + $programs = [ + 'Drink' => [ + 'switch_on' => 'actor_furniture', + 'cases' => [ + 'Chair' => [ + 'commands' => [ + [ + 'cmd_id' => 34, + 'args' => [ + 'targetObjectFormId' => '{{actor_refid}}', + 'akIdle' => '0x00065d07', + ], + ], + ], + ], + '__default' => [ + 'commands' => [ + [ + 'cmd_id' => 34, + 'args' => [ + 'targetObjectFormId' => '{{actor_refid}}', + 'akIdle' => '0x00103656', + ], + ], + ], + ], + ], + 'db_inserts' => [ + [ + 'table' => 'actions_issued', + 'data' => [ + 'action' => 'Drink', + 'fullcall' => '{{full_call}}', + 'actorname' => '{{actor_name}}', + 'ts' => '{{request_ts}}', + 'gamets' => '{{game_ts}}', + 'localts' => '{{local_ts}}', + 'original' => '', + ], + ], + ], + ], + 'Toast' => [ + 'commands' => [ + [ + 'cmd_id' => 34, + 'args' => [ + 'targetObjectFormId' => '{{actor_refid}}', + 'akIdle' => '0x0010528a', + ], + ], + [ + 'cmd_id' => 34, + 'args' => [ + 'targetObjectFormId' => '{{actor_refid}}', + 'akIdle' => '0x00103656', + ], + 'delay_seconds' => '{{toast_delay_seconds}}', + ], + ], + 'db_inserts' => [ + [ + 'table' => 'actions_issued', + 'data' => [ + 'action' => 'Toast', + 'fullcall' => '{{full_call}}', + 'actorname' => '{{actor_name}}', + 'ts' => '{{request_ts}}', + 'gamets' => '{{game_ts}}', + 'localts' => '{{local_ts}}', + 'original' => '', + ], + ], + ], + ], + 'StartRitualCeremony' => [ + 'switch_on' => 'parameter_target', + 'cases' => [ + 'Magical' => [ + 'commands' => [ + [ + 'cmd_id' => 34, + 'args' => [ + 'targetObjectFormId' => '{{actor_refid}}', + 'akIdle' => '0x000f11e2', + ], + ], + [ + 'cmd_id' => 300, + 'args' => [ + 'targetObjectFormId' => '0x0005fb82', + 'akObject' => '{{actor_refid}}', + 'afDuration' => 20, + ], + ], + ], + ], + 'Blood' => [ + 'commands' => [ + [ + 'cmd_id' => 34, + 'args' => [ + 'targetObjectFormId' => '{{actor_refid}}', + 'akIdle' => '0x000af886', + ], + ], + [ + 'cmd_id' => 300, + 'args' => [ + 'targetObjectFormId' => '0x0010f505', + 'akObject' => '{{actor_refid}}', + 'afDuration' => 20, + ], + ], + [ + 'cmd_id' => 34, + 'args' => [ + 'targetObjectFormId' => '{{actor_refid}}', + 'akIdle' => '0x0006f300', + ], + 'delay_seconds' => 10, + ], + ], + ], + 'Religious' => [ + 'commands' => [], + ], + 'Cultural' => [ + 'commands' => [], + ], + 'Personal' => [ + 'commands' => [], + ], + '__default' => [ + 'commands' => [ + [ + 'cmd_id' => 34, + 'args' => [ + 'targetObjectFormId' => '{{actor_refid}}', + 'akIdle' => '0x000f11e1', + ], + ], + [ + 'cmd_id' => 300, + 'args' => [ + 'targetObjectFormId' => '0x00050f02', + 'akObject' => '{{actor_refid}}', + 'afDuration' => 20, + ], + ], + ], + ], + ], + 'npc_metadata_updates' => [ + 'ritual_state' => [ + 'active' => true, + 'type' => '{{parameter_target}}', + 'started_at' => '{{local_ts}}', + 'gamets' => '{{game_ts}}', + ], + 'activity_status' => [ + 'current_action' => 'ritual', + 'current_use' => '{{parameter_target}}', + 'use_type' => 'ritual', + 'timestamp' => '{{local_ts_ms}}', + 'gamets' => '{{game_ts}}', + ], + ], + 'db_inserts' => [ + [ + 'table' => 'rolemaster', + 'data' => [ + 'localts' => '{{local_ts}}', + 'ttl' => 60, + 'type' => 'scenenote', + 'data' => '{{actor_name}} is celebrating a ritual', + ], + ], + [ + 'table' => 'actions_issued', + 'data' => [ + 'action' => 'StartRitualCeremony', + 'fullcall' => '{{full_call}}', + 'actorname' => '{{actor_name}}', + 'ts' => '{{request_ts}}', + 'gamets' => '{{game_ts}}', + 'localts' => '{{local_ts}}', + 'original' => '', + ], + ], + ], + ], + 'EndRitualCeremony' => [ + 'commands' => [ + [ + 'cmd_id' => 34, + 'args' => [ + 'targetObjectFormId' => '{{actor_refid}}', + 'akIdle' => '0x000f11e3', + ], + ], + ], + 'npc_metadata_updates' => [ + 'ritual_state' => null, + 'activity_status' => [ + 'current_action' => 'idle', + 'current_use' => '', + 'use_type' => '', + 'furniture_name' => '', + 'timestamp' => '{{local_ts_ms}}', + 'gamets' => '{{game_ts}}', + ], + ], + 'db_inserts' => [ + [ + 'table' => 'rolemaster', + 'data' => [ + 'localts' => '{{local_ts}}', + 'ttl' => 30, + 'type' => 'scenenote', + 'data' => '{{actor_name}} just ended the ritual celebration', + ], + ], + [ + 'table' => 'actions_issued', + 'data' => [ + 'action' => 'EndRitualCeremony', + 'fullcall' => '{{full_call}}', + 'actorname' => '{{actor_name}}', + 'ts' => '{{request_ts}}', + 'gamets' => '{{game_ts}}', + 'localts' => '{{local_ts}}', + 'original' => '', + ], + ], + ], + ], + ]; + + return $programs; +} + +function herikaActionCatalogGetBuiltinEditorFields($codeName) +{ + $fields = [ + 'RentRoom' => [ + [ + 'key' => 'rent_room_cost', + 'label' => 'Room Cost', + 'type' => 'integer', + 'default' => 10, + 'minimum' => 1, + 'format' => 'gold', + 'help' => 'How much gold the player pays to rent a room from this NPC.', + ], + ], + 'HireCarriage' => [ + [ + 'key' => 'hire_carriage_cost', + 'label' => 'Carriage Fare', + 'type' => 'integer', + 'default' => 20, + 'minimum' => 1, + 'format' => 'gold', + 'help' => 'How much gold the player pays for carriage travel.', + ], + [ + 'key' => 'allowed_npc_names', + 'label' => 'Allowed NPCs', + 'type' => 'textarea', + 'default' => "Bjorlam\nAlfarinn\nKibell\nSigaar\nThaer\nEngar\nGunjar\nMarkus", + 'format' => 'name_list', + 'placeholder' => "One NPC name per line", + 'help' => 'Only these NPC names will offer carriage travel.', + ], + ], + 'HireFerry' => [ + [ + 'key' => 'hire_ferry_cost', + 'label' => 'Ferry Fare', + 'type' => 'integer', + 'default' => 50, + 'minimum' => 1, + 'format' => 'gold', + 'help' => 'How much gold the player pays for ferry travel.', + ], + [ + 'key' => 'allowed_npc_names', + 'label' => 'Allowed NPCs', + 'type' => 'textarea', + 'default' => "Gort\nHarlaug\nJolf", + 'format' => 'name_list', + 'placeholder' => "One NPC name per line", + 'help' => 'Only these NPC names will offer ferry travel.', + ], + ], + ]; + + return $fields[$codeName] ?? []; +} + +function herikaActionCatalogGetBuiltinParameterTemplate($codeName) +{ + $templates = [ + 'RentRoom' => [ + 'amount' => '{{config.rent_room_cost}}', + ], + 'HireCarriage' => [ + 'target' => '{{parameter_target}}', + 'amount' => '{{config.hire_carriage_cost}}', + ], + 'HireFerry' => [ + 'target' => '{{parameter_target}}', + 'amount' => '{{config.hire_ferry_cost}}', + ], + ]; + + return $templates[$codeName] ?? null; +} + +function herikaActionCatalogGetBuiltinCooldownSeconds($codeName) +{ + $cooldowns = [ + 'ComeCloser' => 120, + 'WaitHere' => 300, + 'UseSoulGaze' => 300, + 'Relax' => 180, + 'MakeAToast' => 60, + 'Toast' => 60, + 'StartRitualCeremony' => 60, + 'Follow' => 60, + 'FollowPlayer' => 60, + ]; + + return $cooldowns[$codeName] ?? null; +} + +function herikaActionCatalogGetBuiltinRequirements($codeName) +{ + $requirements = [ + 'RentRoom' => [ + 'npc_factions_any' => ['0005091B'], + 'activity' => [ + 'current_action_not_in' => ['dead', 'unconscious', 'sleeping'], + ], + ], + 'HireCarriage' => [ + 'npc_name_in_action_config_list' => [ + 'config_key' => 'allowed_npc_names', + ], + 'activity' => [ + 'current_action_not_in' => ['dead', 'unconscious', 'sleeping', 'combat', 'attacking'], + ], + ], + 'HireFerry' => [ + 'npc_name_in_action_config_list' => [ + 'config_key' => 'allowed_npc_names', + ], + 'activity' => [ + 'current_action_not_in' => ['dead', 'unconscious', 'sleeping', 'combat', 'attacking'], + ], + ], + 'AddBounty' => [ + 'npc_factions_any' => ['00086EEE'], + ], + 'PayBounty' => [ + 'npc_factions_any' => ['00086EEE'], + ], + 'ArrestPlayer' => [ + 'npc_factions_any' => ['00086EEE'], + ], + 'ForgiveCrime' => [ + 'npc_factions_any' => ['00086EEE'], + ], + 'ReturnBackHome' => [ + 'requires_rolemaster' => true, + ], + 'Training' => [ + 'requires_training_service' => true, + ], + 'SheatheWeapon' => [ + 'activity' => [ + 'require_fresh' => true, + 'is_weapon_drawn' => true, + 'current_action_not_in' => ['dead', 'unconscious', 'sleeping'], + ], + ], + 'TakeASeat' => [ + 'activity' => [ + 'current_action_not_in' => ['dead', 'unconscious', 'sleeping', 'sitting', 'using', 'leaning'], + ], + ], + 'GoToSleep' => [ + 'activity' => [ + 'current_action_not_in' => ['dead', 'unconscious', 'sleeping', 'combat', 'attacking'], + ], + ], + 'Relax' => [ + 'activity' => [ + 'current_action_not_in' => ['dead', 'unconscious', 'sleeping', 'combat', 'attacking'], + ], + ], + 'StartRitualCeremony' => [ + 'activity' => [ + 'current_action_not_in' => ['dead', 'unconscious', 'sleeping', 'combat', 'attacking', 'ritual'], + ], + ], + 'EndRitualCeremony' => [ + 'activity' => [ + 'current_action_in' => ['ritual'], + ], + ], + ]; + + return $requirements[$codeName] ?? []; +} + +function herikaActionCatalogBuildBaseMetadata($codeName, $scriptProxyProgram = null) +{ + $dispatch = 'plugin_command'; + if ($scriptProxyProgram !== null) { + $dispatch = 'script_proxy'; + } elseif ($codeName === 'Training') { + $dispatch = 'rolecommand'; + } + + $metadata = [ + 'dispatch' => $dispatch, + 'builtin' => true, + 'status' => 'active', + 'source' => 'functions.php', + ]; + + $editorFields = herikaActionCatalogGetBuiltinEditorFields($codeName); + if (count($editorFields) > 0) { + $metadata['editor_fields'] = $editorFields; + } + + $parameterTemplate = herikaActionCatalogGetBuiltinParameterTemplate($codeName); + if ($parameterTemplate !== null) { + $metadata['parameter_template'] = $parameterTemplate; + } + + $requirements = herikaActionCatalogGetBuiltinRequirements($codeName); + if (count($requirements) > 0) { + $metadata['requirements'] = $requirements; + } + + $cooldownSeconds = herikaActionCatalogGetBuiltinCooldownSeconds($codeName); + if ($cooldownSeconds !== null) { + $metadata['cooldown_seconds'] = intval($cooldownSeconds); + } + + return $metadata; +} + +function herikaActionCatalogIsGameFunction($metadata) +{ + $dispatch = strtolower(trim(strval($metadata['dispatch'] ?? 'plugin_command'))); + return !in_array($dispatch, ['server_action', 'server_query'], true); +} + +function herikaActionCatalogNormalizeRequirementStringList($values) +{ + if (is_string($values)) { + $values = explode(',', $values); + } + + if (!is_array($values)) { + return []; + } + + $normalized = []; + foreach ($values as $value) { + $text = strtolower(trim(strval($value))); + if ($text === '') { + continue; + } + + $normalized[] = $text; + } + + return array_values(array_unique($normalized)); +} + +function herikaActionCatalogRequirementListContains($needle, $values) +{ + $needle = strtolower(trim(strval($needle))); + if ($needle === '') { + return false; + } + + return in_array($needle, herikaActionCatalogNormalizeRequirementStringList($values), true); +} + +function herikaActionCatalogGetCurrentNpcLookup() +{ + static $cachedKey = null; + static $cachedLookup = null; + + $herikaName = trim(strval($GLOBALS["HERIKA_NAME"] ?? '')); + if ($cachedKey === $herikaName && is_array($cachedLookup)) { + return $cachedLookup; + } + + $cachedKey = $herikaName; + $cachedLookup = [ + 'npc_master' => null, + 'npc_data' => [], + 'metadata' => [], + 'extended' => [], + ]; + + if ($herikaName === '' || $herikaName === '(actor)' || !class_exists('NpcMaster')) { + return $cachedLookup; + } + + $npcMaster = new NpcMaster(); + $npcData = $npcMaster->getByName($herikaName); + if (!is_array($npcData) || count($npcData) === 0) { + return $cachedLookup; + } + + $cachedLookup['npc_master'] = $npcMaster; + $cachedLookup['npc_data'] = $npcData; + $cachedLookup['metadata'] = $npcMaster->getMetadata($npcData); + $cachedLookup['extended'] = $npcMaster->getExtendedData($npcData); + + return $cachedLookup; +} + +function herikaActionCatalogGetRuntimeRequirementContext() +{ + static $cachedKey = null; + static $cachedContext = null; + + $requestType = strtolower(trim(strval($GLOBALS["gameRequest"][0] ?? ''))); + $cacheKey = implode('|', [ + trim(strval($GLOBALS["HERIKA_NAME"] ?? '')), + trim(strval($GLOBALS["PLAYER_NAME"] ?? '')), + !empty($GLOBALS["IS_NPC"]) ? '1' : '0', + $requestType, + strval($GLOBALS["gameRequest"][2] ?? ''), + !empty($GLOBALS["is_rolemastered"]) ? '1' : '0', + ]); + + if ($cachedKey === $cacheKey && is_array($cachedContext)) { + return $cachedContext; + } + + require_once __DIR__ . DIRECTORY_SEPARATOR . 'activity_status.php'; + + $lookup = herikaActionCatalogGetCurrentNpcLookup(); + $metadata = is_array($lookup['metadata']) ? $lookup['metadata'] : []; + $extended = is_array($lookup['extended']) ? $lookup['extended'] : []; + $activityStatus = chimNormalizeActivityStatus($metadata); + + $metadataRolemaster = !empty($metadata['is_rolemastered']) || !empty($extended['is_rolemastered']); + + $cachedKey = $cacheKey; + $cachedContext = [ + 'npc_name' => trim(strval($GLOBALS["HERIKA_NAME"] ?? '')), + 'player_name' => trim(strval($GLOBALS["PLAYER_NAME"] ?? '')), + 'request_type' => $requestType, + 'is_rechat' => in_array($requestType, ['rechat', 'narration'], true), + 'is_npc_mode' => !empty($GLOBALS["IS_NPC"]), + 'is_rolemastered' => !empty($GLOBALS["is_rolemastered"]) || $metadataRolemaster, + 'npc_master' => $lookup['npc_master'], + 'npc_data' => $lookup['npc_data'], + 'npc_metadata' => $metadata, + 'npc_extended' => $extended, + 'activity_status' => $activityStatus, + ]; + + return $cachedContext; +} + +function herikaActionCatalogGetConfigListValues($definition) +{ + $configKey = ''; + $fallbackCsv = ''; + $fallbackValues = []; + + if (is_string($definition)) { + $configKey = trim($definition); + } elseif (is_array($definition)) { + $configKey = trim(strval($definition['config_key'] ?? '')); + $fallbackCsv = trim(strval($definition['fallback_csv'] ?? '')); + $fallbackValues = $definition['fallback_values'] ?? []; + } + + $rawValues = ''; + if ($configKey !== '' && isset($GLOBALS[$configKey])) { + $rawValues = trim(strval($GLOBALS[$configKey])); + } + if ($rawValues === '') { + $rawValues = $fallbackCsv; + } + + $values = herikaActionCatalogNormalizeRequirementStringList($rawValues); + if (count($fallbackValues) > 0) { + $values = array_values(array_unique(array_merge( + $values, + herikaActionCatalogNormalizeRequirementStringList($fallbackValues) + ))); + } + + return $values; +} + +function herikaActionCatalogGetActionConfigListValues($config, $definition) +{ + $config = is_array($config) ? $config : []; + $configKey = ''; + $fallbackCsv = ''; + $fallbackValues = []; + + if (is_string($definition)) { + $configKey = trim($definition); + } elseif (is_array($definition)) { + $configKey = trim(strval($definition['config_key'] ?? '')); + $fallbackCsv = trim(strval($definition['fallback_csv'] ?? '')); + $fallbackValues = $definition['fallback_values'] ?? []; + } + + $rawValues = ''; + if ($configKey !== '' && array_key_exists($configKey, $config)) { + $rawValues = strval($config[$configKey]); + } + if (trim($rawValues) === '') { + $rawValues = $fallbackCsv; + } + + $values = herikaActionCatalogNormalizeRequirementStringList(preg_split('/[\r\n,]+/', $rawValues) ?: []); + if (count($fallbackValues) > 0) { + $values = array_values(array_unique(array_merge( + $values, + herikaActionCatalogNormalizeRequirementStringList($fallbackValues) + ))); + } + + return $values; +} + +function herikaActionCatalogNpcMatchesFactionRequirement($npcMaster, $npcData, $factionIds, $requireAll = false) +{ + $factionIds = herikaActionCatalogNormalizeRequirementStringList($factionIds); + if (count($factionIds) === 0) { + return true; + } + + if (!$npcMaster || !is_array($npcData) || count($npcData) === 0) { + return false; + } + + $npcFactions = $npcMaster->getNpcFactions($npcData, true); + + foreach ($factionIds as $factionId) { + $matches = false; + $stableReference = chimParseStableFormReference($factionId); + + if ($stableReference) { + foreach ($npcFactions as $npcFaction) { + if (chimFactionEntryMatchesStableFormReference($npcFaction, $stableReference['stable_key'])) { + $matches = true; + break; + } + } + + if (!$matches) { + $runtimeFormId = chimResolveStableFormReferenceToRuntimeFormId($stableReference['stable_key']); + if ($runtimeFormId !== null) { + $matches = $npcMaster->isNpcInFaction($npcData, $runtimeFormId); + } + } + } else { + $matches = $npcMaster->isNpcInFaction($npcData, strtoupper($factionId)); + } + + if ($requireAll && !$matches) { + return false; + } + if (!$requireAll && $matches) { + return true; + } + } + + return $requireAll; +} + +function herikaActionCatalogMatchesActivityRequirements($requirements, $status) +{ + $requirements = herikaActionCatalogDecodeJson($requirements, []); + if (!is_array($requirements) || count($requirements) === 0) { + return true; + } + + $status = is_array($status) ? $status : []; + $available = !empty($status['available']); + $fresh = !empty($status['fresh']); + + if (!empty($requirements['require_available']) && !$available) { + return false; + } + if (!empty($requirements['require_fresh']) && !$fresh) { + return false; + } + + $boolKeys = [ + 'is_in_combat', + 'is_attacking', + 'is_moving', + 'is_running', + 'is_sneaking', + 'is_sitting', + 'is_sleeping', + 'is_unconscious', + 'is_dead', + 'is_weapon_drawn', + ]; + + foreach ($boolKeys as $boolKey) { + if (!array_key_exists($boolKey, $requirements)) { + continue; + } + + $expected = herikaActionCatalogToBool($requirements[$boolKey]); + if (!$available) { + if ($expected) { + return false; + } + continue; + } + + if (herikaActionCatalogToBool($status[$boolKey] ?? false) !== $expected) { + return false; + } + } + + $currentAction = strtolower(trim(strval($status['current_action'] ?? ''))); + $useType = strtolower(trim(strval($status['use_type'] ?? ''))); + + if (isset($requirements['current_action'])) { + $expectedAction = strtolower(trim(strval($requirements['current_action']))); + if ($expectedAction !== '' && $currentAction !== $expectedAction) { + return false; + } + } + + $currentActionIn = herikaActionCatalogNormalizeRequirementStringList($requirements['current_action_in'] ?? []); + if (count($currentActionIn) > 0) { + if ($currentAction === '' || !in_array($currentAction, $currentActionIn, true)) { + return false; + } + } + + $currentActionNotIn = herikaActionCatalogNormalizeRequirementStringList($requirements['current_action_not_in'] ?? []); + if ($currentAction !== '' && in_array($currentAction, $currentActionNotIn, true)) { + return false; + } + + if (isset($requirements['use_type'])) { + $expectedUseType = strtolower(trim(strval($requirements['use_type']))); + if ($expectedUseType !== '' && $useType !== $expectedUseType) { + return false; + } + } + + $useTypeIn = herikaActionCatalogNormalizeRequirementStringList($requirements['use_type_in'] ?? []); + if (count($useTypeIn) > 0) { + if ($useType === '' || !in_array($useType, $useTypeIn, true)) { + return false; + } + } + + $useTypeNotIn = herikaActionCatalogNormalizeRequirementStringList($requirements['use_type_not_in'] ?? []); + if ($useType !== '' && in_array($useType, $useTypeNotIn, true)) { + return false; + } + + return true; +} + +function herikaActionCatalogRequirementsMatch($requirements, $context) +{ + $requirements = herikaActionCatalogDecodeJson($requirements, []); + if (!is_array($requirements) || count($requirements) === 0) { + return true; + } + + $context = is_array($context) ? $context : herikaActionCatalogGetRuntimeRequirementContext(); + + if (isset($requirements['requires_rolemaster'])) { + $expectedRolemaster = herikaActionCatalogToBool($requirements['requires_rolemaster']); + if (herikaActionCatalogToBool($context['is_rolemastered'] ?? false) !== $expectedRolemaster) { + return false; + } + } + + if (isset($requirements['requires_training_service'])) { + $hasTrainingService = !empty($context['npc_extended']['class']['teaches']); + if ($hasTrainingService !== herikaActionCatalogToBool($requirements['requires_training_service'])) { + return false; + } + } + + if (!empty($requirements['hide_in_rechat']) && !empty($context['is_rechat'])) { + return false; + } + if (!empty($requirements['show_only_in_rechat']) && empty($context['is_rechat'])) { + return false; + } + + $requestTypesAny = herikaActionCatalogNormalizeRequirementStringList($requirements['request_types_any'] ?? []); + if (count($requestTypesAny) > 0 && !in_array(strtolower(trim(strval($context['request_type'] ?? ''))), $requestTypesAny, true)) { + return false; + } + + $requestTypesNone = herikaActionCatalogNormalizeRequirementStringList($requirements['request_types_none'] ?? []); + if (count($requestTypesNone) > 0 && in_array(strtolower(trim(strval($context['request_type'] ?? ''))), $requestTypesNone, true)) { + return false; + } + + $npcNamesAny = herikaActionCatalogNormalizeRequirementStringList($requirements['npc_names_any'] ?? []); + if (count($npcNamesAny) > 0 && !in_array(strtolower(trim(strval($context['npc_name'] ?? ''))), $npcNamesAny, true)) { + return false; + } + + if (isset($requirements['npc_name_in_config_list'])) { + $allowedNpcNames = herikaActionCatalogGetConfigListValues($requirements['npc_name_in_config_list']); + if (count($allowedNpcNames) > 0 && !in_array(strtolower(trim(strval($context['npc_name'] ?? ''))), $allowedNpcNames, true)) { + return false; + } + } + + if (isset($requirements['npc_name_in_action_config_list'])) { + $allowedNpcNames = herikaActionCatalogGetActionConfigListValues( + $context['action_config'] ?? [], + $requirements['npc_name_in_action_config_list'] + ); + if (count($allowedNpcNames) > 0 && !in_array(strtolower(trim(strval($context['npc_name'] ?? ''))), $allowedNpcNames, true)) { + return false; + } + } + + if (!herikaActionCatalogNpcMatchesFactionRequirement( + $context['npc_master'] ?? null, + $context['npc_data'] ?? [], + $requirements['npc_factions_any'] ?? [], + false + )) { + return false; + } + + if (!herikaActionCatalogNpcMatchesFactionRequirement( + $context['npc_master'] ?? null, + $context['npc_data'] ?? [], + $requirements['npc_factions_all'] ?? [], + true + )) { + return false; + } + + if (!herikaActionCatalogMatchesActivityRequirements($requirements['activity'] ?? [], $context['activity_status'] ?? [])) { + return false; + } + + return true; +} + +function herikaActionCatalogGetLastActionsIssuedMap() +{ + static $cachedKey = null; + static $cachedRows = null; + + if (!isset($GLOBALS["db"]) || !($GLOBALS["db"] instanceof sql)) { + return []; + } + + $localActorName = trim(strval($GLOBALS["HERIKA_NAME"] ?? '')); + if ($localActorName === '') { + return []; + } + + if ($cachedKey === $localActorName && is_array($cachedRows)) { + return $cachedRows; + } + + $escapedActorName = $GLOBALS["db"]->escape($localActorName); + $rows = $GLOBALS["db"]->fetchAll( + "SELECT * FROM ( + SELECT DISTINCT ON (action) * + FROM actions_issued + WHERE (actorname = '$escapedActorName' or actorname like '%$escapedActorName,%' or actorname='*') + ORDER BY action, gamets DESC, ts DESC + ) AS sub + ORDER BY gamets DESC, ts DESC" + ); + + $cachedKey = $localActorName; + $cachedRows = []; + foreach ($rows as $row) { + $actionCode = trim(strval($row['action'] ?? '')); + if ($actionCode === '') { + continue; + } + + $cachedRows[$actionCode] = $row; + } + + return $cachedRows; +} + +function herikaActionCatalogIsActionOnCooldown($codeName, $cooldownSeconds) +{ + $codeName = trim(strval($codeName)); + $cooldownSeconds = intval($cooldownSeconds); + if ($codeName === '' || $cooldownSeconds <= 0 || empty($GLOBALS["gameRequest"][2])) { + return false; + } + + require_once __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'utils_game_timestamp.php'; + + $lastActionsIssuedMap = herikaActionCatalogGetLastActionsIssuedMap(); + if (!isset($lastActionsIssuedMap[$codeName])) { + return false; + } + + $ingameNow = convert_gamets2seconds($GLOBALS["gameRequest"][2]); + $lastTriggered = convert_gamets2seconds($lastActionsIssuedMap[$codeName]["gamets"] ?? 0); + if ($ingameNow <= 0 || $lastTriggered <= 0) { + return false; + } + + return ($ingameNow - $lastTriggered) < $cooldownSeconds; +} + +function herikaActionCatalogRowMatchesRequirements($row, $context = null) +{ + if (!is_array($row)) { + return true; + } + + $metadata = herikaActionCatalogDecodeJson($row['metadata'] ?? [], []); + $context = is_array($context) ? $context : herikaActionCatalogGetRuntimeRequirementContext(); + $context['action_config'] = function_exists('herikaActionCatalogGetResolvedCustomConfig') + ? herikaActionCatalogGetResolvedCustomConfig($row['code_name'] ?? '', $row) + : []; + + if (!herikaActionCatalogRequirementsMatch($metadata['requirements'] ?? [], $context)) { + return false; + } + + $cooldownSeconds = intval($metadata['cooldown_seconds'] ?? 0); + if ($cooldownSeconds > 0 && herikaActionCatalogIsActionOnCooldown($row['code_name'] ?? '', $cooldownSeconds)) { + return false; + } + + return true; +} + +function herikaActionCatalogResetCache() +{ + unset($GLOBALS["HERIKA_ACTION_CATALOG_DB_READY"]); + unset($GLOBALS["HERIKA_ACTION_CATALOG_ROWS_BY_CODE"]); +} + +function herikaActionCatalogDbReady() +{ + if (isset($GLOBALS["HERIKA_ACTION_CATALOG_DB_READY"])) { + return $GLOBALS["HERIKA_ACTION_CATALOG_DB_READY"]; + } + + if (($GLOBALS["DBDRIVER"] ?? '') !== 'postgresql') { + $GLOBALS["HERIKA_ACTION_CATALOG_DB_READY"] = false; + return false; + } + + if (!isset($GLOBALS["db"]) || !($GLOBALS["db"] instanceof sql)) { + $GLOBALS["HERIKA_ACTION_CATALOG_DB_READY"] = false; + return false; + } + + $coreAction = $GLOBALS["db"]->fetchOne(" + SELECT 1 AS exists + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'core_action' + "); + $coreActionCustom = $GLOBALS["db"]->fetchOne(" + SELECT 1 AS exists + FROM information_schema.tables + WHERE table_schema = 'public' AND table_name = 'core_action_custom' + "); + $combinedView = $GLOBALS["db"]->fetchOne(" + SELECT 1 AS exists + FROM information_schema.views + WHERE table_schema = 'public' AND table_name = 'combined_core_action' + "); + + $ready = isset($coreAction["exists"]) && isset($coreActionCustom["exists"]) && isset($combinedView["exists"]); + $GLOBALS["HERIKA_ACTION_CATALOG_DB_READY"] = $ready; + return $ready; +} + +function herikaActionCatalogGetExistingCustomImportVersion($codeName) +{ + $codeName = trim(strval($codeName)); + if ($codeName === '' || !herikaActionCatalogDbReady()) { + return null; + } + + $row = $GLOBALS["db"]->fetchOne(" + SELECT import_version + FROM public.core_action_custom + WHERE LOWER(code_name) = LOWER(" . herikaActionCatalogSqlText($codeName) . ") + LIMIT 1 + "); + + if (!is_array($row) || !array_key_exists('import_version', $row)) { + return null; + } + + return herikaActionCatalogNormalizeImportVersion($row['import_version']); +} + +function herikaBuildActionCatalogFunctionDefinitionsByCode($runtimeFunctions = null) +{ + $definitions = []; + $runtimeFunctions = is_array($runtimeFunctions) ? $runtimeFunctions : ($GLOBALS["FUNCTIONS"] ?? []); + $retiredCodes = array_fill_keys(herikaGetRetiredActionCodes(), true); + + foreach ($runtimeFunctions as $functionEntry) { + if (!is_array($functionEntry) || empty($functionEntry['name'])) { + continue; + } + + $codeName = function_exists('getFunctionCodeName') ? getFunctionCodeName($functionEntry['name']) : false; + if ($codeName === false || isset($retiredCodes[$codeName])) { + continue; + } + + $definitions[$codeName] = $functionEntry; + } + + return $definitions; +} + +function herikaBuildActionCatalogSeedRows($actionNames, $descriptions, $returnMessages, $currentEnabledCodes = [], $defaultEnabledCodes = [], $functionDefinitionsByCode = []) +{ + $npcDefaults = herikaGetNpcDefaultActionCodes(); + $followerDefaults = herikaGetFollowerDefaultActionCodes(); + $activationDefaults = count($defaultEnabledCodes) > 0 ? $defaultEnabledCodes : array_unique(array_merge($npcDefaults, $followerDefaults)); + $allCodeNames = array_unique(array_merge( + array_keys(is_array($actionNames) ? $actionNames : []), + array_keys(is_array($descriptions) ? $descriptions : []), + array_keys(is_array($returnMessages) ? $returnMessages : []), + is_array($currentEnabledCodes) ? $currentEnabledCodes : [], + $activationDefaults, + $npcDefaults, + $followerDefaults, + array_keys(is_array($functionDefinitionsByCode) ? $functionDefinitionsByCode : []) + )); + + natcasesort($allCodeNames); + + $retiredCodes = array_fill_keys(herikaGetRetiredActionCodes(), true); + $scriptProxyPrograms = herikaActionCatalogGetBaseScriptProxyPrograms(); + $rows = []; + + foreach ($allCodeNames as $codeName) { + $codeName = trim(strval($codeName)); + if ($codeName === '' || isset($retiredCodes[$codeName])) { + continue; + } + + $availableToNpc = in_array($codeName, $npcDefaults, true); + $availableToFollowers = in_array($codeName, $followerDefaults, true); + $isActivated = in_array($codeName, $activationDefaults, true) || in_array($codeName, $currentEnabledCodes, true); + $functionDefinition = is_array($functionDefinitionsByCode[$codeName] ?? null) ? $functionDefinitionsByCode[$codeName] : []; + $parameters = herikaActionCatalogNormalizeParameterSchema($functionDefinition['parameters'] ?? null); + $scriptProxyProgram = $scriptProxyPrograms[$codeName] ?? null; + $metadata = herikaActionCatalogBuildBaseMetadata($codeName, $scriptProxyProgram); + + $rows[$codeName] = [ + 'code_name' => $codeName, + 'action_name' => isset($actionNames[$codeName]) && trim(strval($actionNames[$codeName])) !== '' + ? herikaNormalizeActionCatalogDisplayActionName($actionNames[$codeName]) + : $codeName, + 'description' => isset($descriptions[$codeName]) ? herikaNormalizeActionCatalogDisplayText($descriptions[$codeName]) : '', + 'return_message' => isset($returnMessages[$codeName]) ? herikaNormalizeActionCatalogDisplayText($returnMessages[$codeName]) : '', + 'available_to_npc' => $availableToNpc, + 'available_to_followers' => $availableToFollowers, + 'is_activated' => $isActivated, + 'parameters_json' => $parameters, + 'metadata' => $metadata, + 'game_function' => herikaActionCatalogIsGameFunction($metadata), + 'import_version' => 0, + 'script_proxy_program' => $scriptProxyProgram, + ]; + } + + return $rows; +} + +function herikaDeleteRetiredActionCatalogRows() +{ + if (!herikaActionCatalogDbReady()) { + return; + } + + $retiredCodes = herikaGetRetiredActionCodes(); + if (count($retiredCodes) === 0) { + return; + } + + $literals = []; + foreach ($retiredCodes as $retiredCode) { + $literals[] = herikaActionCatalogSqlText($retiredCode); + } + + $inList = implode(',', $literals); + $GLOBALS["db"]->execQuery("DELETE FROM public.core_action_custom WHERE code_name IN ({$inList})"); + $GLOBALS["db"]->execQuery("DELETE FROM public.core_action WHERE code_name IN ({$inList})"); +} + +function herikaSyncActionCatalogBaseRows($rowsByCode) +{ + if (!herikaActionCatalogDbReady()) { + return; + } + + herikaDeleteRetiredActionCatalogRows(); + herikaDeleteUnexpectedBaseActionCatalogRows($rowsByCode); + + $existingCustomMetadataByCode = []; + $existingCustomRows = $GLOBALS["db"]->fetchAll(" + SELECT code_name, metadata + FROM public.core_action_custom + "); + foreach ($existingCustomRows as $existingCustomRow) { + $existingCodeName = strtolower(trim(strval($existingCustomRow['code_name'] ?? ''))); + if ($existingCodeName === '') { + continue; + } + + $existingCustomMetadataByCode[$existingCodeName] = herikaActionCatalogDecodeJson($existingCustomRow['metadata'] ?? [], []); + } + + foreach ($rowsByCode as $row) { + if (!is_array($row) || empty($row['code_name'])) { + continue; + } + + $preservedCustomMetadata = herikaActionCatalogMergePreservedCustomMetadata( + $row['metadata'] ?? [], + $existingCustomMetadataByCode[strtolower(trim(strval($row['code_name'])))] ?? [] + ); + + $GLOBALS["db"]->execQuery(" + INSERT INTO public.core_action ( + code_name, + action_name, + description, + return_message, + available_to_npc, + available_to_followers, + is_activated, + parameters_json, + metadata, + game_function, + import_version, + script_proxy_program + ) VALUES ( + " . herikaActionCatalogSqlText($row['code_name']) . ", + " . herikaActionCatalogSqlText($row['action_name'] ?? '') . ", + " . herikaActionCatalogSqlText($row['description'] ?? '') . ", + " . herikaActionCatalogSqlText($row['return_message'] ?? '') . ", + " . herikaActionCatalogSqlBool(!empty($row['available_to_npc'])) . ", + " . herikaActionCatalogSqlBool(!empty($row['available_to_followers'])) . ", + " . herikaActionCatalogSqlBool(!empty($row['is_activated'])) . ", + " . herikaActionCatalogSqlJson($row['parameters_json'] ?? []) . ", + " . herikaActionCatalogSqlJson($row['metadata'] ?? []) . ", + " . herikaActionCatalogSqlBool(!empty($row['game_function'])) . ", + " . herikaActionCatalogNormalizeImportVersion($row['import_version'] ?? 0) . ", + " . herikaActionCatalogSqlJson($row['script_proxy_program'] ?? null, true) . " + ) + ON CONFLICT (code_name) DO UPDATE SET + action_name = EXCLUDED.action_name, + description = EXCLUDED.description, + return_message = EXCLUDED.return_message, + available_to_npc = EXCLUDED.available_to_npc, + available_to_followers = EXCLUDED.available_to_followers, + is_activated = EXCLUDED.is_activated, + parameters_json = EXCLUDED.parameters_json, + metadata = EXCLUDED.metadata, + game_function = EXCLUDED.game_function, + import_version = EXCLUDED.import_version, + script_proxy_program = EXCLUDED.script_proxy_program, + updated_at = NOW() + "); + + $GLOBALS["db"]->execQuery(" + UPDATE public.core_action_custom + SET + action_name = " . herikaActionCatalogSqlText($row['action_name'] ?? '') . ", + description = " . herikaActionCatalogSqlText($row['description'] ?? '') . ", + return_message = " . herikaActionCatalogSqlText($row['return_message'] ?? '') . ", + available_to_npc = " . herikaActionCatalogSqlBool(!empty($row['available_to_npc'])) . ", + available_to_followers = " . herikaActionCatalogSqlBool(!empty($row['available_to_followers'])) . ", + parameters_json = " . herikaActionCatalogSqlJson($row['parameters_json'] ?? []) . ", + metadata = " . herikaActionCatalogSqlJson($preservedCustomMetadata) . ", + game_function = " . herikaActionCatalogSqlBool(!empty($row['game_function'])) . ", + import_version = " . herikaActionCatalogNormalizeImportVersion($row['import_version'] ?? 0) . ", + script_proxy_program = " . herikaActionCatalogSqlJson($row['script_proxy_program'] ?? null, true) . ", + updated_at = NOW() + WHERE LOWER(code_name) = LOWER(" . herikaActionCatalogSqlText($row['code_name']) . ") + "); + } + + herikaActionCatalogResetCache(); +} + +function herikaDeleteUnexpectedBaseActionCatalogRows($rowsByCode) +{ + if (!herikaActionCatalogDbReady()) { + return; + } + + $seedCodeLiterals = []; + foreach ($rowsByCode as $row) { + if (!is_array($row) || empty($row['code_name'])) { + continue; + } + + $seedCodeLiterals[] = herikaActionCatalogSqlText(strtolower(trim(strval($row['code_name'])))); + } + + if (count($seedCodeLiterals) === 0) { + return; + } + + $seedCodeList = implode(',', array_unique($seedCodeLiterals)); + $builtinFilter = "metadata @> '{\"source\":\"functions.php\",\"builtin\":true}'::jsonb"; + + $GLOBALS["db"]->execQuery(" + DELETE FROM public.core_action + WHERE {$builtinFilter} + AND LOWER(code_name) NOT IN ({$seedCodeList}) + "); + + $GLOBALS["db"]->execQuery(" + DELETE FROM public.core_action_custom + WHERE {$builtinFilter} + AND LOWER(code_name) NOT IN ({$seedCodeList}) + "); +} + +function herikaMarkLegacyActionPreferencesImported() +{ + if (!isset($GLOBALS["db"]) || !($GLOBALS["db"] instanceof sql)) { + return; + } + + $GLOBALS["db"]->execQuery(" + INSERT INTO public.conf_opts (id, value) + VALUES ('core_action_legacy_user_pref_imported', '1') + ON CONFLICT (id) DO UPDATE SET value = EXCLUDED.value + "); +} + +function herikaLegacyActionPreferencesImported() +{ + if (!isset($GLOBALS["db"]) || !($GLOBALS["db"] instanceof sql)) { + return false; + } + + $row = $GLOBALS["db"]->fetchOne(" + SELECT value + FROM public.conf_opts + WHERE id = 'core_action_legacy_user_pref_imported' + LIMIT 1 + "); + + return isset($row['value']) && trim(strval($row['value'])) === '1'; +} + +function herikaImportLegacyActionPreferences($rowsByCode) +{ + if (!herikaActionCatalogDbReady() || herikaLegacyActionPreferencesImported()) { + return; + } + + $userPrefPath = dirname(__DIR__, 2) . DIRECTORY_SEPARATOR . 'functions' . DIRECTORY_SEPARATOR . 'user_pref.json'; + if (!file_exists($userPrefPath)) { + herikaMarkLegacyActionPreferencesImported(); + return; + } + + $selectedCodes = json_decode(file_get_contents($userPrefPath), true); + if (!is_array($selectedCodes) || count($selectedCodes) === 0) { + herikaMarkLegacyActionPreferencesImported(); + return; + } + + $selectedMap = array_fill_keys(array_map('strval', $selectedCodes), true); + foreach ($rowsByCode as $row) { + if (!is_array($row) || empty($row['code_name'])) { + continue; + } + + $GLOBALS["db"]->execQuery(" + INSERT INTO public.core_action_custom ( + code_name, + action_name, + description, + return_message, + available_to_npc, + available_to_followers, + is_activated, + parameters_json, + metadata, + game_function, + import_version, + script_proxy_program + ) VALUES ( + " . herikaActionCatalogSqlText($row['code_name']) . ", + " . herikaActionCatalogSqlText($row['action_name'] ?? '') . ", + " . herikaActionCatalogSqlText($row['description'] ?? '') . ", + " . herikaActionCatalogSqlText($row['return_message'] ?? '') . ", + " . herikaActionCatalogSqlBool(!empty($row['available_to_npc'])) . ", + " . herikaActionCatalogSqlBool(!empty($row['available_to_followers'])) . ", + " . herikaActionCatalogSqlBool(isset($selectedMap[$row['code_name']])) . ", + " . herikaActionCatalogSqlJson($row['parameters_json'] ?? []) . ", + " . herikaActionCatalogSqlJson($row['metadata'] ?? []) . ", + " . herikaActionCatalogSqlBool(!empty($row['game_function'])) . ", + " . herikaActionCatalogNormalizeImportVersion($row['import_version'] ?? 0) . ", + " . herikaActionCatalogSqlJson($row['script_proxy_program'] ?? null, true) . " + ) + ON CONFLICT (code_name) DO UPDATE SET + action_name = EXCLUDED.action_name, + description = EXCLUDED.description, + return_message = EXCLUDED.return_message, + available_to_npc = EXCLUDED.available_to_npc, + available_to_followers = EXCLUDED.available_to_followers, + is_activated = EXCLUDED.is_activated, + parameters_json = EXCLUDED.parameters_json, + metadata = EXCLUDED.metadata, + game_function = EXCLUDED.game_function, + import_version = EXCLUDED.import_version, + script_proxy_program = EXCLUDED.script_proxy_program, + updated_at = NOW() + "); + } + + herikaMarkLegacyActionPreferencesImported(); + herikaActionCatalogResetCache(); +} + +function herikaGetActionCatalogRowsByCode() +{ + if (isset($GLOBALS["HERIKA_ACTION_CATALOG_ROWS_BY_CODE"])) { + return $GLOBALS["HERIKA_ACTION_CATALOG_ROWS_BY_CODE"]; + } + + $GLOBALS["HERIKA_ACTION_CATALOG_ROWS_BY_CODE"] = []; + if (!herikaActionCatalogDbReady()) { + return $GLOBALS["HERIKA_ACTION_CATALOG_ROWS_BY_CODE"]; + } + + $rows = $GLOBALS["db"]->fetchAll(" + SELECT + code_name, + action_name, + description, + return_message, + available_to_npc, + available_to_followers, + is_activated, + parameters_json, + metadata, + game_function, + script_proxy_program + FROM public.combined_core_action + "); + + foreach ($rows as $row) { + $codeName = trim(strval($row['code_name'] ?? '')); + if ($codeName === '') { + continue; + } + + $GLOBALS["HERIKA_ACTION_CATALOG_ROWS_BY_CODE"][$codeName] = [ + 'code_name' => $codeName, + 'action_name' => herikaNormalizeActionCatalogDisplayActionName(strval($row['action_name'] ?? $codeName)), + 'description' => strval($row['description'] ?? ''), + 'return_message' => strval($row['return_message'] ?? ''), + 'available_to_npc' => herikaActionCatalogToBool($row['available_to_npc'] ?? false), + 'available_to_followers' => herikaActionCatalogToBool($row['available_to_followers'] ?? false), + 'is_activated' => herikaActionCatalogToBool($row['is_activated'] ?? false), + 'parameters_json' => herikaActionCatalogNormalizeParameterSchema( + herikaActionCatalogDecodeJson($row['parameters_json'] ?? [], []) + ), + 'metadata' => herikaActionCatalogDecodeJson($row['metadata'] ?? [], []), + 'game_function' => herikaActionCatalogToBool($row['game_function'] ?? false), + 'import_version' => herikaActionCatalogNormalizeImportVersion($row['import_version'] ?? 0), + 'script_proxy_program' => herikaActionCatalogDecodeJson($row['script_proxy_program'] ?? null, []), + ]; + } + + return $GLOBALS["HERIKA_ACTION_CATALOG_ROWS_BY_CODE"]; +} + +function herikaGetActionCatalogRow($codeName) +{ + $codeName = trim(strval($codeName)); + if ($codeName === '') { + return null; + } + + $rowsByCode = herikaGetActionCatalogRowsByCode(); + return $rowsByCode[$codeName] ?? null; +} + +function herikaActionCatalogGetCustomConfigValue($codeName, $configKey, $default = null) +{ + $codeName = trim(strval($codeName)); + $configKey = trim(strval($configKey)); + if ($codeName === '' || $configKey === '') { + return $default; + } + + $row = herikaGetActionCatalogRow($codeName); + if (!is_array($row)) { + return $default; + } + + $config = herikaActionCatalogGetResolvedCustomConfig($codeName, $row); + if (!array_key_exists($configKey, $config)) { + return $default; + } + + return $config[$configKey]; +} + +function herikaLoadEnabledActionCodesForMode($isNpc, $applyRequirements = false) +{ + $rowsByCode = herikaGetActionCatalogRowsByCode(); + if (count($rowsByCode) === 0) { + return []; + } + + $enabledCodes = []; + foreach ($rowsByCode as $codeName => $row) { + if (!$row['is_activated']) { + continue; + } + + if ($applyRequirements && !herikaActionCatalogRowMatchesRequirements($row)) { + continue; + } + + if ($isNpc && !empty($row['available_to_npc'])) { + $enabledCodes[] = $codeName; + } elseif (!$isNpc && !empty($row['available_to_followers'])) { + $enabledCodes[] = $codeName; + } + } + + return array_values(array_unique($enabledCodes)); +} + +function herikaActionCatalogIsActionEnabled($codeName) +{ + $codeName = trim(strval($codeName)); + if ($codeName === '') { + return false; + } + + $rowsByCode = herikaGetActionCatalogRowsByCode(); + if (!isset($rowsByCode[$codeName])) { + return true; + } + + return !empty($rowsByCode[$codeName]['is_activated']); +} + +function herikaActionCatalogBuildFunctionEntryFromRow($row) +{ + if (!is_array($row) || empty($row['code_name']) || trim(strval($row['action_name'] ?? '')) === '') { + return null; + } + + return [ + 'name' => strval($row['action_name']), + 'description' => strval($row['description'] ?? ''), + 'parameters' => herikaActionCatalogNormalizeParameterSchema($row['parameters_json'] ?? null), + ]; +} + +function herikaActionCatalogRowIsAvailableInCurrentMode($row) +{ + $isNpcMode = !empty($GLOBALS["IS_NPC"]); + if ($isNpcMode) { + return !empty($row['available_to_npc']); + } + + return !empty($row['available_to_followers']); +} + +function herikaActionCatalogRowIsUsableInCurrentContext($row) +{ + if (!is_array($row) || empty($row['is_activated'])) { + return false; + } + + if (!herikaActionCatalogRowIsAvailableInCurrentMode($row)) { + return false; + } + + return herikaActionCatalogRowMatchesRequirements($row); +} + +function herikaActionCatalogShouldPreferRowForActionName($candidateRow, $currentRow) +{ + $candidateAvailable = herikaActionCatalogRowIsUsableInCurrentContext($candidateRow); + $currentAvailable = herikaActionCatalogRowIsUsableInCurrentContext($currentRow); + if ($candidateAvailable !== $currentAvailable) { + return $candidateAvailable; + } + + $candidateEnabled = !empty($candidateRow['is_activated']); + $currentEnabled = !empty($currentRow['is_activated']); + if ($candidateEnabled !== $currentEnabled) { + return $candidateEnabled; + } + + $candidateBuiltin = !empty(($candidateRow['metadata'] ?? [])['builtin']); + $currentBuiltin = !empty(($currentRow['metadata'] ?? [])['builtin']); + if ($candidateBuiltin !== $currentBuiltin) { + return !$candidateBuiltin; + } + + $candidateDispatch = strtolower(trim(strval(($candidateRow['metadata'] ?? [])['dispatch'] ?? ''))); + $currentDispatch = strtolower(trim(strval(($currentRow['metadata'] ?? [])['dispatch'] ?? ''))); + if ($candidateDispatch !== $currentDispatch) { + if ($candidateDispatch === 'script_proxy') { + return true; + } + if ($currentDispatch === 'script_proxy') { + return false; + } + } + + return false; +} + +function herikaActionCatalogApplyRowsToRuntimeFunctions() +{ + $rowsByCode = herikaGetActionCatalogRowsByCode(); + if (count($rowsByCode) === 0) { + return; + } + + $runtimeFunctionMap = []; + foreach ($GLOBALS["FUNCTIONS"] ?? [] as $functionEntry) { + if (!is_array($functionEntry) || empty($functionEntry['name'])) { + continue; + } + + $codeName = function_exists('getFunctionCodeName') ? getFunctionCodeName($functionEntry['name']) : false; + if ($codeName === false || in_array($codeName, herikaGetRetiredActionCodes(), true)) { + continue; + } + + $runtimeFunctionMap[$codeName] = $functionEntry; + } + + foreach ($rowsByCode as $codeName => $row) { + $rowMetadata = is_array($row['metadata'] ?? null) + ? $row['metadata'] + : herikaActionCatalogDecodeJson($row['metadata'] ?? [], []); + $isBuiltin = !empty($rowMetadata['builtin']); + $runtimeDescription = ''; + if ($isBuiltin && isset($GLOBALS["F_TRANSLATIONS_BASE"][$codeName])) { + $runtimeDescription = function_exists('herikaFormatActionPromptTemplate') + ? herikaFormatActionPromptTemplate($GLOBALS["F_TRANSLATIONS_BASE"][$codeName] ?? '') + : strval($GLOBALS["F_TRANSLATIONS_BASE"][$codeName] ?? ''); + } else { + $runtimeDescription = function_exists('herikaFormatActionPromptTemplate') + ? herikaFormatActionPromptTemplate($row['description'] ?? '') + : strval($row['description'] ?? ''); + } + + $GLOBALS["F_NAMES"][$codeName] = $row['action_name']; + $GLOBALS["F_TRANSLATIONS"][$codeName] = $runtimeDescription; + $GLOBALS["F_RETURNMESSAGES"][$codeName] = $row['return_message']; + + $catalogFunctionEntry = herikaActionCatalogBuildFunctionEntryFromRow($row); + if ($catalogFunctionEntry === null) { + continue; + } + + $catalogFunctionEntry['description'] = $runtimeDescription; + + if (isset($runtimeFunctionMap[$codeName])) { + $runtimeFunctionMap[$codeName]['name'] = $catalogFunctionEntry['name']; + $runtimeFunctionMap[$codeName]['description'] = $runtimeDescription; + $runtimeFunctionMap[$codeName]['parameters'] = $catalogFunctionEntry['parameters']; + } else { + $runtimeFunctionMap[$codeName] = $catalogFunctionEntry; + } + } + + $preferredCodeByActionName = []; + foreach ($runtimeFunctionMap as $codeName => $functionEntry) { + $actionName = trim(strval($functionEntry['name'] ?? '')); + if ($actionName === '') { + continue; + } + + if (!isset($preferredCodeByActionName[$actionName])) { + $preferredCodeByActionName[$actionName] = $codeName; + continue; + } + + $currentCode = $preferredCodeByActionName[$actionName]; + $candidateRow = $rowsByCode[$codeName] ?? ['metadata' => ['builtin' => true], 'is_activated' => true]; + $currentRow = $rowsByCode[$currentCode] ?? ['metadata' => ['builtin' => true], 'is_activated' => true]; + if (herikaActionCatalogShouldPreferRowForActionName($candidateRow, $currentRow)) { + $preferredCodeByActionName[$actionName] = $codeName; + } + } + + $dedupedRuntimeFunctionMap = []; + foreach ($preferredCodeByActionName as $actionName => $codeName) { + if (isset($runtimeFunctionMap[$codeName])) { + $dedupedRuntimeFunctionMap[$codeName] = $runtimeFunctionMap[$codeName]; + } + } + + $GLOBALS["HERIKA_ACTION_NAME_PREFERRED_CODE"] = $preferredCodeByActionName; + $GLOBALS["BASE_FUNCTIONS"] = $dedupedRuntimeFunctionMap; + $GLOBALS["FUNCTIONS"] = array_values($dedupedRuntimeFunctionMap); +} + +function herikaActionCatalogUpsertCustomToggle($codeName, $enabled) +{ + $codeName = trim(strval($codeName)); + if ($codeName === '' || !herikaActionCatalogDbReady()) { + return false; + } + + $literalCode = herikaActionCatalogSqlText($codeName); + $row = $GLOBALS["db"]->fetchOne(" + SELECT + code_name, + action_name, + description, + return_message, + available_to_npc, + available_to_followers, + parameters_json, + metadata, + game_function, + import_version, + script_proxy_program + FROM public.combined_core_action + WHERE code_name = {$literalCode} + LIMIT 1 + "); + + if (!$row) { + return false; + } + + $actionName = herikaNormalizeActionCatalogDisplayActionName(strval($row['action_name'] ?? '')); + + $result = $GLOBALS["db"]->execQuery(" + INSERT INTO public.core_action_custom ( + code_name, + action_name, + description, + return_message, + available_to_npc, + available_to_followers, + is_activated, + parameters_json, + metadata, + game_function, + import_version, + script_proxy_program + ) VALUES ( + " . herikaActionCatalogSqlText($row['code_name'] ?? $codeName) . ", + " . herikaActionCatalogSqlText($actionName) . ", + " . herikaActionCatalogSqlText($row['description'] ?? '') . ", + " . herikaActionCatalogSqlText($row['return_message'] ?? '') . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['available_to_npc'] ?? false)) . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['available_to_followers'] ?? false)) . ", + " . herikaActionCatalogSqlBool((bool) $enabled) . ", + " . herikaActionCatalogSqlJson($row['parameters_json'] ?? []) . ", + " . herikaActionCatalogSqlJson($row['metadata'] ?? []) . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['game_function'] ?? false)) . ", + " . herikaActionCatalogNormalizeImportVersion($row['import_version'] ?? 0) . ", + " . herikaActionCatalogSqlJson($row['script_proxy_program'] ?? null, true) . " + ) + ON CONFLICT (code_name) DO UPDATE SET + action_name = EXCLUDED.action_name, + description = EXCLUDED.description, + return_message = EXCLUDED.return_message, + available_to_npc = EXCLUDED.available_to_npc, + available_to_followers = EXCLUDED.available_to_followers, + is_activated = EXCLUDED.is_activated, + parameters_json = EXCLUDED.parameters_json, + metadata = EXCLUDED.metadata, + game_function = EXCLUDED.game_function, + import_version = EXCLUDED.import_version, + script_proxy_program = EXCLUDED.script_proxy_program, + updated_at = NOW() + "); + + herikaActionCatalogResetCache(); + return $result !== false; +} + +function herikaActionCatalogUpsertCustomConfigValue($codeName, $configKey, $value) +{ + $codeName = trim(strval($codeName)); + $configKey = trim(strval($configKey)); + if ($codeName === '' || $configKey === '' || !herikaActionCatalogDbReady()) { + return false; + } + + $literalCode = herikaActionCatalogSqlText($codeName); + $row = $GLOBALS["db"]->fetchOne(" + SELECT + code_name, + action_name, + description, + return_message, + available_to_npc, + available_to_followers, + is_activated, + parameters_json, + metadata, + game_function, + import_version, + script_proxy_program + FROM public.combined_core_action + WHERE code_name = {$literalCode} + LIMIT 1 + "); + + if (!$row) { + return false; + } + + return herikaActionCatalogUpsertCustomConfig($codeName, [$configKey => $value]); +} + +function herikaActionCatalogUpsertCustomConfig($codeName, $configValues) +{ + $codeName = trim(strval($codeName)); + if ($codeName === '' || !is_array($configValues) || !herikaActionCatalogDbReady()) { + return false; + } + + $literalCode = herikaActionCatalogSqlText($codeName); + $row = $GLOBALS["db"]->fetchOne(" + SELECT + code_name, + action_name, + description, + return_message, + available_to_npc, + available_to_followers, + is_activated, + parameters_json, + metadata, + game_function, + import_version, + script_proxy_program + FROM public.combined_core_action + WHERE code_name = {$literalCode} + LIMIT 1 + "); + + if (!$row) { + return false; + } + + $metadata = herikaActionCatalogDecodeJson($row['metadata'] ?? [], []); + if (!isset($metadata['custom_config']) || !is_array($metadata['custom_config'])) { + $metadata['custom_config'] = []; + } + + foreach ($configValues as $configKey => $configValue) { + $configKey = trim(strval($configKey)); + if ($configKey === '') { + continue; + } + $metadata['custom_config'][$configKey] = $configValue; + } + + $actionName = herikaNormalizeActionCatalogDisplayActionName(strval($row['action_name'] ?? '')); + + $result = $GLOBALS["db"]->execQuery(" + INSERT INTO public.core_action_custom ( + code_name, + action_name, + description, + return_message, + available_to_npc, + available_to_followers, + is_activated, + parameters_json, + metadata, + game_function, + import_version, + script_proxy_program + ) VALUES ( + " . herikaActionCatalogSqlText($row['code_name'] ?? $codeName) . ", + " . herikaActionCatalogSqlText($actionName) . ", + " . herikaActionCatalogSqlText($row['description'] ?? '') . ", + " . herikaActionCatalogSqlText($row['return_message'] ?? '') . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['available_to_npc'] ?? false)) . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['available_to_followers'] ?? false)) . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['is_activated'] ?? false)) . ", + " . herikaActionCatalogSqlJson($row['parameters_json'] ?? []) . ", + " . herikaActionCatalogSqlJson($metadata) . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['game_function'] ?? false)) . ", + " . herikaActionCatalogNormalizeImportVersion($row['import_version'] ?? 0) . ", + " . herikaActionCatalogSqlJson($row['script_proxy_program'] ?? null, true) . " + ) + ON CONFLICT (code_name) DO UPDATE SET + action_name = EXCLUDED.action_name, + description = EXCLUDED.description, + return_message = EXCLUDED.return_message, + available_to_npc = EXCLUDED.available_to_npc, + available_to_followers = EXCLUDED.available_to_followers, + is_activated = EXCLUDED.is_activated, + parameters_json = EXCLUDED.parameters_json, + metadata = EXCLUDED.metadata, + game_function = EXCLUDED.game_function, + import_version = EXCLUDED.import_version, + script_proxy_program = EXCLUDED.script_proxy_program, + updated_at = NOW() + "); + + herikaActionCatalogResetCache(); + return $result !== false; +} + +function herikaActionCatalogUpsertCustomParameters($codeName, $parameters) +{ + $codeName = trim(strval($codeName)); + if ($codeName === '' || !herikaActionCatalogDbReady()) { + return false; + } + + $literalCode = herikaActionCatalogSqlText($codeName); + $row = $GLOBALS["db"]->fetchOne(" + SELECT + code_name, + action_name, + description, + return_message, + available_to_npc, + available_to_followers, + is_activated, + parameters_json, + metadata, + game_function, + import_version, + script_proxy_program + FROM public.combined_core_action + WHERE code_name = {$literalCode} + LIMIT 1 + "); + + if (!$row) { + return false; + } + + $normalizedParameters = herikaActionCatalogNormalizeParameterSchema( + herikaActionCatalogDecodeJson($parameters, []) + ); + $actionName = herikaNormalizeActionCatalogDisplayActionName(strval($row['action_name'] ?? '')); + + $result = $GLOBALS["db"]->execQuery(" + INSERT INTO public.core_action_custom ( + code_name, + action_name, + description, + return_message, + available_to_npc, + available_to_followers, + is_activated, + parameters_json, + metadata, + game_function, + import_version, + script_proxy_program + ) VALUES ( + " . herikaActionCatalogSqlText($row['code_name'] ?? $codeName) . ", + " . herikaActionCatalogSqlText($actionName) . ", + " . herikaActionCatalogSqlText($row['description'] ?? '') . ", + " . herikaActionCatalogSqlText($row['return_message'] ?? '') . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['available_to_npc'] ?? false)) . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['available_to_followers'] ?? false)) . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['is_activated'] ?? false)) . ", + " . herikaActionCatalogSqlJson($normalizedParameters) . ", + " . herikaActionCatalogSqlJson($row['metadata'] ?? []) . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['game_function'] ?? false)) . ", + " . herikaActionCatalogNormalizeImportVersion($row['import_version'] ?? 0) . ", + " . herikaActionCatalogSqlJson($row['script_proxy_program'] ?? null, true) . " + ) + ON CONFLICT (code_name) DO UPDATE SET + action_name = EXCLUDED.action_name, + description = EXCLUDED.description, + return_message = EXCLUDED.return_message, + available_to_npc = EXCLUDED.available_to_npc, + available_to_followers = EXCLUDED.available_to_followers, + is_activated = EXCLUDED.is_activated, + parameters_json = EXCLUDED.parameters_json, + metadata = EXCLUDED.metadata, + game_function = EXCLUDED.game_function, + import_version = EXCLUDED.import_version, + script_proxy_program = EXCLUDED.script_proxy_program, + updated_at = NOW() + "); + + herikaActionCatalogResetCache(); + return $result !== false; +} + +function herikaActionCatalogUpsertCustomRow($row) +{ + if (!is_array($row) || !herikaActionCatalogDbReady()) { + return false; + } + + $codeName = trim(strval($row['code_name'] ?? '')); + $actionName = herikaNormalizeActionCatalogDisplayActionName(trim(strval($row['action_name'] ?? ''))); + if ($codeName === '' || $actionName === '') { + return false; + } + + $parameters = herikaActionCatalogNormalizeParameterSchema( + herikaActionCatalogDecodeJson($row['parameters_json'] ?? [], []) + ); + + $metadata = herikaActionCatalogDecodeJson($row['metadata'] ?? [], []); + if (!isset($metadata['dispatch']) || trim(strval($metadata['dispatch'])) === '') { + $metadata['dispatch'] = !empty($row['script_proxy_program']) ? 'script_proxy' : 'plugin_command'; + } + if (!array_key_exists('builtin', $metadata)) { + $metadata['builtin'] = false; + } + if (!isset($metadata['status']) || trim(strval($metadata['status'])) === '') { + $metadata['status'] = 'active'; + } + if (!isset($metadata['source']) || trim(strval($metadata['source'])) === '') { + $metadata['source'] = 'core_action_custom'; + } + + $scriptProxyProgram = $row['script_proxy_program'] ?? null; + if ($scriptProxyProgram !== null) { + $scriptProxyProgram = herikaActionCatalogDecodeJson($scriptProxyProgram, []); + } + + $gameFunction = array_key_exists('game_function', $row) + ? herikaActionCatalogToBool($row['game_function']) + : herikaActionCatalogIsGameFunction($metadata); + $importVersion = herikaActionCatalogNormalizeImportVersion($row['import_version'] ?? 0); + + $result = $GLOBALS["db"]->execQuery(" + INSERT INTO public.core_action_custom ( + code_name, + action_name, + description, + return_message, + available_to_npc, + available_to_followers, + is_activated, + parameters_json, + metadata, + game_function, + import_version, + script_proxy_program + ) VALUES ( + " . herikaActionCatalogSqlText($codeName) . ", + " . herikaActionCatalogSqlText($actionName) . ", + " . herikaActionCatalogSqlText($row['description'] ?? '') . ", + " . herikaActionCatalogSqlText($row['return_message'] ?? '') . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['available_to_npc'] ?? false)) . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['available_to_followers'] ?? false)) . ", + " . herikaActionCatalogSqlBool(herikaActionCatalogToBool($row['is_activated'] ?? true)) . ", + " . herikaActionCatalogSqlJson($parameters) . ", + " . herikaActionCatalogSqlJson($metadata) . ", + " . herikaActionCatalogSqlBool($gameFunction) . ", + " . $importVersion . ", + " . herikaActionCatalogSqlJson($scriptProxyProgram, true) . " + ) + ON CONFLICT (code_name) DO UPDATE SET + action_name = EXCLUDED.action_name, + description = EXCLUDED.description, + return_message = EXCLUDED.return_message, + available_to_npc = EXCLUDED.available_to_npc, + available_to_followers = EXCLUDED.available_to_followers, + is_activated = EXCLUDED.is_activated, + parameters_json = EXCLUDED.parameters_json, + metadata = EXCLUDED.metadata, + game_function = EXCLUDED.game_function, + import_version = EXCLUDED.import_version, + script_proxy_program = EXCLUDED.script_proxy_program, + updated_at = NOW() + "); + + herikaActionCatalogResetCache(); + return $result !== false; +} + +function herikaActionCatalogNormalizeRefId($value) +{ + $text = trim(strval($value)); + if ($text === '') { + return ''; + } + + return stripos($text, '0x') === 0 ? $text : ('0x' . $text); +} + +function herikaActionCatalogGetBufferCharacterCount() +{ + $totalChars = 0; + if (!isset($GLOBALS["DEBUG"]["BUFFER"]) || !is_array($GLOBALS["DEBUG"]["BUFFER"])) { + return 0; + } + + foreach ($GLOBALS["DEBUG"]["BUFFER"] as $item) { + $text = is_string($item) ? $item : strval($item); + if (function_exists('mb_strlen')) { + $totalChars += mb_strlen($text, 'UTF-8'); + } else { + $totalChars += strlen($text); + } + } + + return $totalChars; +} + +function herikaActionCatalogResolveContextPath($context, $path) +{ + if (!is_array($context)) { + return null; + } + + $currentValue = $context; + foreach (explode('.', strval($path)) as $segment) { + if ($segment === '') { + continue; + } + + if (!is_array($currentValue) || !array_key_exists($segment, $currentValue)) { + return null; + } + + $currentValue = $currentValue[$segment]; + } + + return $currentValue; +} + +function herikaActionCatalogResolveTemplateString($value, $context) +{ + if (!is_string($value) || strpos($value, '{{') === false) { + return $value; + } + + if (preg_match('/^\{\{\s*([^}]+)\s*\}\}$/', $value, $matches)) { + $resolved = herikaActionCatalogResolveContextPath($context, trim($matches[1])); + if (is_array($resolved)) { + return herikaActionCatalogJsonEncode($resolved); + } + return $resolved; + } + + return preg_replace_callback('/\{\{\s*([^}]+)\s*\}\}/', function ($matches) use ($context) { + $resolved = herikaActionCatalogResolveContextPath($context, trim($matches[1])); + if (is_array($resolved)) { + return herikaActionCatalogJsonEncode($resolved); + } + return strval($resolved ?? ''); + }, $value); +} + +function herikaActionCatalogResolveTemplateValue($value, $context) +{ + if (is_array($value)) { + $resolved = []; + foreach ($value as $key => $item) { + $resolved[$key] = herikaActionCatalogResolveTemplateValue($item, $context); + } + return $resolved; + } + + return herikaActionCatalogResolveTemplateString($value, $context); +} + +function herikaActionCatalogBuildScriptProxyContext($actionParts, $actionParts2) +{ + $actionCodeName = trim(strval($actionParts2[0] ?? '')); + $rawParameter = strval($actionParts2[1] ?? ''); + $parameterData = []; + $trimmedParameter = trim($rawParameter); + if ($trimmedParameter !== '' && in_array(substr($trimmedParameter, 0, 1), ['{', '['], true)) { + $parameterData = herikaActionCatalogDecodeJson($trimmedParameter, []); + } + + if (!is_array($parameterData)) { + $parameterData = []; + } + + $npcData = []; + $npcMetadata = []; + if (class_exists('NpcMaster')) { + $npcMaster = new NpcMaster(); + $npcData = $npcMaster->getByName($actionParts[0]) ?: []; + $npcMetadata = is_array($npcData) ? ($npcMaster->getMetadata($npcData) ?: []) : []; + } + + $bufferCharacters = herikaActionCatalogGetBufferCharacterCount(); + $parameterTarget = strval($parameterData['target'] ?? $rawParameter); + $actionRow = $actionCodeName !== '' ? herikaGetActionCatalogRow($actionCodeName) : null; + $resolvedConfig = $actionCodeName !== '' ? herikaActionCatalogGetResolvedCustomConfig($actionCodeName, $actionRow) : []; + + return [ + 'actor_name' => strval($actionParts[0] ?? ''), + 'actor_refid' => herikaActionCatalogNormalizeRefId($npcData['refid'] ?? ''), + 'actor_furniture' => strval($npcMetadata['furniture'] ?? ''), + 'action_name' => $actionCodeName, + 'full_call' => implode('|', $actionParts), + 'parameter_raw' => $rawParameter, + 'parameter_target' => $parameterTarget, + 'parameters' => $parameterData, + 'config' => $resolvedConfig, + 'request_ts' => $GLOBALS["gameRequest"][1] ?? time(), + 'game_ts' => $GLOBALS["gameRequest"][2] ?? 0, + 'local_ts' => time(), + 'player_name' => strval($GLOBALS["PLAYER_NAME"] ?? 'Player'), + 'player_refid' => defined('PLAYER_REFID') ? strval(PLAYER_REFID) : '0x00000014', + 'cache_people_limited' => strval($GLOBALS["CACHE_PEOPLE_LIMITED"] ?? ''), + 'cache_location' => strval($GLOBALS["CACHE_LOCATION"] ?? ''), + 'cache_party' => strval($GLOBALS["CACHE_PARTY"] ?? ''), + 'toast_delay_seconds' => intval(ceil($bufferCharacters / 12)), + 'local_ts_ms' => (int) round(microtime(true) * 1000), + ]; +} + +function herikaActionCatalogExecuteScriptProxyCommands($commands, $context) +{ + if (!is_array($commands) || count($commands) === 0) { + return false; + } + + $skyrimCommandBuilder = new SkyrimCommandBuilder(); + $executed = false; + + foreach ($commands as $command) { + if (!is_array($command) || !isset($command['cmd_id'])) { + continue; + } + + $args = herikaActionCatalogResolveTemplateValue($command['args'] ?? [], $context); + if (!is_array($args)) { + $args = []; + } + + $delaySeconds = herikaActionCatalogResolveTemplateValue($command['delay_seconds'] ?? 0, $context); + $localTs = null; + if (is_numeric($delaySeconds) && floatval($delaySeconds) > 0) { + $localTs = time() + intval(ceil(floatval($delaySeconds))); + } + + $json = $skyrimCommandBuilder->build(intval($command['cmd_id']), $args); + $skyrimCommandBuilder->send($json, $localTs); + $executed = true; + } + + return $executed; +} + +function herikaActionCatalogExecuteScriptProxyDbInserts($dbInserts, $context) +{ + if (!is_array($dbInserts) || count($dbInserts) === 0) { + return false; + } + + $executed = false; + foreach ($dbInserts as $dbInsert) { + if (!is_array($dbInsert) || empty($dbInsert['table']) || !is_array($dbInsert['data'] ?? null)) { + continue; + } + + $data = herikaActionCatalogResolveTemplateValue($dbInsert['data'], $context); + if (!is_array($data)) { + continue; + } + + $GLOBALS["db"]->insert($dbInsert['table'], $data); + $executed = true; + } + + return $executed; +} + +function herikaActionCatalogExecuteScriptProxyNpcMetadataUpdates($npcMetadataUpdates, $context) +{ + if (!is_array($npcMetadataUpdates) || count($npcMetadataUpdates) === 0) { + return false; + } + + $resolvedUpdates = herikaActionCatalogResolveTemplateValue($npcMetadataUpdates, $context); + if (!is_array($resolvedUpdates) || count($resolvedUpdates) === 0) { + return false; + } + + require_once __DIR__ . DIRECTORY_SEPARATOR . 'activity_status.php'; + return chimApplyNpcMetadataUpdatesByName( + trim(strval($context['actor_name'] ?? '')), + $resolvedUpdates + ); +} + +function herikaActionCatalogRunScriptProxyProgram($program, $context) +{ + if (!is_array($program) || count($program) === 0) { + return false; + } + + $executed = false; + + if (isset($program['switch_on']) && is_array($program['cases'] ?? null)) { + $switchValue = strval(herikaActionCatalogResolveContextPath($context, $program['switch_on']) ?? ''); + $selectedProgram = $program['cases'][$switchValue] ?? ($program['cases']['__default'] ?? null); + if (is_array($selectedProgram)) { + $executed = herikaActionCatalogRunScriptProxyProgram($selectedProgram, $context) || $executed; + } + } + + $executed = herikaActionCatalogExecuteScriptProxyCommands($program['commands'] ?? [], $context) || $executed; + $executed = herikaActionCatalogExecuteScriptProxyDbInserts($program['db_inserts'] ?? [], $context) || $executed; + $executed = herikaActionCatalogExecuteScriptProxyNpcMetadataUpdates($program['npc_metadata_updates'] ?? [], $context) || $executed; + + return $executed; +} + +function herikaActionCatalogExecuteScriptProxyAction($action) +{ + if (!herikaActionCatalogDbReady()) { + return false; + } + + $actionParts = explode('|', strval($action), 3); + if (count($actionParts) < 3) { + return false; + } + + $actionParts2 = explode('@', $actionParts[2], 2); + $codeName = trim(strval($actionParts2[0] ?? '')); + if ($codeName === '') { + return false; + } + + $row = herikaGetActionCatalogRow($codeName); + if (!is_array($row) || empty($row['script_proxy_program'])) { + return false; + } + + $context = herikaActionCatalogBuildScriptProxyContext($actionParts, $actionParts2); + $executed = herikaActionCatalogRunScriptProxyProgram($row['script_proxy_program'], $context); + if ($executed) { + error_log("[ACTION CATALOG {$codeName}] Executed server-side via ScriptProxy"); + } + + return $executed; +} diff --git a/lib/core/activity_status.php b/lib/core/activity_status.php index 9610f4620..fa7890155 100644 --- a/lib/core/activity_status.php +++ b/lib/core/activity_status.php @@ -184,6 +184,97 @@ function chimUpsertActivityStatusMetadata(array $metadata, array $payload): arra return $metadata; } +function chimClearActivityStatusMetadata(array $metadata): array +{ + unset($metadata['activity_status']); + unset($metadata['current_action']); + unset($metadata['furniture']); + unset($metadata['use_type']); + unset($metadata['activity_status_timestamp']); + + return $metadata; +} + +function chimBuildActivityStatusMetadataUpdates(array $payload): array +{ + $status = chimSanitizeActivityStatusPayload($payload); + + $setValues = [ + 'activity_status' => $status, + 'current_action' => $status['current_action'], + 'furniture' => $status['furniture_name'], + 'activity_status_timestamp' => $status['timestamp'], + ]; + $unsetKeys = []; + + if ($status['use_type'] !== '') { + $setValues['use_type'] = $status['use_type']; + } else { + $unsetKeys[] = 'use_type'; + } + + return [ + 'set' => $setValues, + 'unset' => $unsetKeys, + ]; +} + +function chimApplyNpcMetadataUpdatesByName(string $npcName, array $updates): bool +{ + $npcName = trim($npcName); + if ($npcName === '' || count($updates) === 0) { + return false; + } + + require_once __DIR__ . DIRECTORY_SEPARATOR . 'npc_master.class.php'; + if (!class_exists('NpcMaster')) { + return false; + } + + $npcMaster = new NpcMaster(); + $npcData = $npcMaster->getByName($npcName); + if (!is_array($npcData) || count($npcData) === 0) { + return false; + } + + $setValues = []; + $unsetKeys = []; + + if (array_key_exists('activity_status', $updates)) { + $activityPayload = $updates['activity_status']; + unset($updates['activity_status']); + + if (is_array($activityPayload)) { + $activityUpdates = chimBuildActivityStatusMetadataUpdates($activityPayload); + $setValues = array_merge($setValues, $activityUpdates['set']); + $unsetKeys = array_merge($unsetKeys, $activityUpdates['unset']); + } elseif ($activityPayload === null) { + $unsetKeys = array_merge($unsetKeys, [ + 'activity_status', + 'current_action', + 'furniture', + 'use_type', + 'activity_status_timestamp', + ]); + } + } + + foreach ($updates as $key => $value) { + $metadataKey = trim((string) $key); + if ($metadataKey === '') { + continue; + } + + if ($value === null) { + $unsetKeys[] = $metadataKey; + } else { + $setValues[$metadataKey] = $value; + } + } + + return $npcMaster->updateMetadataKeysByName($npcName, $setValues, $unsetKeys); +} + function chimActivityStatusIsFresh(array $status, int $maxAgeMs = 45000): bool { $timestamp = (int) ($status['timestamp'] ?? 0); @@ -252,6 +343,13 @@ function chimBuildActivityStatusSummary(array $status): string if ($action === 'combat') { return $target !== '' ? "in combat with {$target}" : 'in combat'; } + if ($action === 'ritual') { + $ritualType = strtolower(trim((string) ($status['current_use'] ?? ''))); + if ($ritualType !== '' && $ritualType !== 'ritual') { + return "performing a {$ritualType} ritual"; + } + return 'performing a ritual'; + } if ($useType !== '' && $action === 'using') { return 'using ' . chimHumanizeActivityUseType($useType, $status['furniture_name']); } diff --git a/lib/core/database_schema/core_action.sql b/lib/core/database_schema/core_action.sql new file mode 100644 index 000000000..c3f7db060 --- /dev/null +++ b/lib/core/database_schema/core_action.sql @@ -0,0 +1,88 @@ +CREATE TABLE IF NOT EXISTS public.core_action ( + id SERIAL PRIMARY KEY, + code_name VARCHAR(128) UNIQUE NOT NULL, + action_name VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + return_message TEXT NOT NULL DEFAULT '', + available_to_npc BOOLEAN NOT NULL DEFAULT FALSE, + available_to_followers BOOLEAN NOT NULL DEFAULT FALSE, + is_activated BOOLEAN NOT NULL DEFAULT TRUE, + parameters_json JSONB NOT NULL DEFAULT '{}'::jsonb, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + game_function BOOLEAN NOT NULL DEFAULT TRUE, + import_version BIGINT NOT NULL DEFAULT 0, + script_proxy_program JSONB, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS public.core_action_custom ( + id SERIAL PRIMARY KEY, + code_name VARCHAR(128) UNIQUE NOT NULL, + action_name VARCHAR(255) NOT NULL, + description TEXT NOT NULL DEFAULT '', + return_message TEXT NOT NULL DEFAULT '', + available_to_npc BOOLEAN NOT NULL DEFAULT FALSE, + available_to_followers BOOLEAN NOT NULL DEFAULT FALSE, + is_activated BOOLEAN NOT NULL DEFAULT TRUE, + parameters_json JSONB NOT NULL DEFAULT '{}'::jsonb, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + game_function BOOLEAN NOT NULL DEFAULT TRUE, + import_version BIGINT NOT NULL DEFAULT 0, + script_proxy_program JSONB, + created_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_core_action_code_name_lower ON public.core_action (LOWER(code_name)); +CREATE INDEX IF NOT EXISTS idx_core_action_action_name_lower ON public.core_action (LOWER(action_name)); +CREATE INDEX IF NOT EXISTS idx_core_action_is_activated ON public.core_action (is_activated); +CREATE INDEX IF NOT EXISTS idx_core_action_available_to_npc ON public.core_action (available_to_npc); +CREATE INDEX IF NOT EXISTS idx_core_action_available_to_followers ON public.core_action (available_to_followers); +CREATE INDEX IF NOT EXISTS idx_core_action_game_function ON public.core_action (game_function); + +CREATE INDEX IF NOT EXISTS idx_core_action_custom_code_name_lower ON public.core_action_custom (LOWER(code_name)); +CREATE INDEX IF NOT EXISTS idx_core_action_custom_action_name_lower ON public.core_action_custom (LOWER(action_name)); +CREATE INDEX IF NOT EXISTS idx_core_action_custom_is_activated ON public.core_action_custom (is_activated); +CREATE INDEX IF NOT EXISTS idx_core_action_custom_available_to_npc ON public.core_action_custom (available_to_npc); +CREATE INDEX IF NOT EXISTS idx_core_action_custom_available_to_followers ON public.core_action_custom (available_to_followers); +CREATE INDEX IF NOT EXISTS idx_core_action_custom_game_function ON public.core_action_custom (game_function); + +CREATE OR REPLACE VIEW public.combined_core_action AS +SELECT + c.id, + c.code_name, + c.action_name, + c.description, + c.return_message, + c.available_to_npc, + c.available_to_followers, + c.is_activated, + c.parameters_json, + c.metadata, + c.game_function, + c.import_version, + c.script_proxy_program, + c.created_at, + c.updated_at +FROM public.core_action_custom c +UNION ALL +SELECT + b.id, + b.code_name, + b.action_name, + b.description, + b.return_message, + b.available_to_npc, + b.available_to_followers, + b.is_activated, + b.parameters_json, + b.metadata, + b.game_function, + b.import_version, + b.script_proxy_program, + b.created_at, + b.updated_at +FROM public.core_action b +LEFT JOIN public.core_action_custom c ON LOWER(b.code_name) = LOWER(c.code_name) +WHERE c.code_name IS NULL; diff --git a/lib/core/game_plugins.php b/lib/core/game_plugins.php new file mode 100644 index 000000000..51017d021 --- /dev/null +++ b/lib/core/game_plugins.php @@ -0,0 +1,457 @@ + 0 && strlen($value) < $padLength) { + $value = str_pad($value, $padLength, '0', STR_PAD_LEFT); + } + + return $value; + } +} + +if (!function_exists('chimNormalizeRuntimeFormId')) { + function chimNormalizeRuntimeFormId($value): string + { + return chimNormalizeHexIdentifier($value, 8); + } +} + +if (!function_exists('chimNormalizeLocalFormId')) { + function chimNormalizeLocalFormId($value): string + { + return chimNormalizeHexIdentifier($value, 8); + } +} + +if (!function_exists('chimNormalizeFormIdPrefix')) { + function chimNormalizeFormIdPrefix($value): string + { + $prefix = chimNormalizeHexIdentifier($value); + if ($prefix === '') { + return ''; + } + + if (strlen($prefix) <= 2) { + return str_pad(substr($prefix, -2), 2, '0', STR_PAD_LEFT); + } + + return str_pad(substr($prefix, -5), 5, '0', STR_PAD_LEFT); + } +} + +if (!function_exists('chimBuildStableFormReference')) { + function chimBuildStableFormReference($pluginName, $localFormId): string + { + $pluginName = chimNormalizePluginName($pluginName); + $localFormId = chimNormalizeLocalFormId($localFormId); + if ($pluginName === '' || $localFormId === '') { + return ''; + } + + return $pluginName . '|' . $localFormId; + } +} + +if (!function_exists('chimParseStableFormReference')) { + function chimParseStableFormReference($value): ?array + { + $value = trim((string) $value); + if ($value === '' || strpos($value, '|') === false) { + return null; + } + + $parts = explode('|', $value, 2); + $pluginName = chimNormalizePluginName($parts[0] ?? ''); + $localFormId = chimNormalizeLocalFormId($parts[1] ?? ''); + if ($pluginName === '' || $localFormId === '') { + return null; + } + + return [ + 'plugin_name' => $pluginName, + 'local_formid' => $localFormId, + 'stable_key' => chimBuildStableFormReference($pluginName, $localFormId), + ]; + } +} + +if (!function_exists('chimStableFormReferenceEquals')) { + function chimStableFormReferenceEquals($left, $right): bool + { + $leftParsed = chimParseStableFormReference($left); + $rightParsed = chimParseStableFormReference($right); + if (!$leftParsed || !$rightParsed) { + return false; + } + + return strcasecmp($leftParsed['plugin_name'], $rightParsed['plugin_name']) === 0 + && $leftParsed['local_formid'] === $rightParsed['local_formid']; + } +} + +if (!function_exists('chimFactionEntryMatchesStableFormReference')) { + function chimFactionEntryMatchesStableFormReference(array $factionEntry, $stableReference): bool + { + $parsedReference = chimParseStableFormReference($stableReference); + if (!$parsedReference) { + return false; + } + + if (!empty($factionEntry['stable_key']) && chimStableFormReferenceEquals($factionEntry['stable_key'], $parsedReference['stable_key'])) { + return true; + } + + $pluginName = chimNormalizePluginName($factionEntry['plugin'] ?? ''); + $localFormId = chimNormalizeLocalFormId($factionEntry['local_formid'] ?? ''); + if ($pluginName === '' || $localFormId === '') { + return false; + } + + return strcasecmp($pluginName, $parsedReference['plugin_name']) === 0 + && $localFormId === $parsedReference['local_formid']; + } +} + +if (!function_exists('chimComputeRuntimeFormIdFromPrefix')) { + function chimComputeRuntimeFormIdFromPrefix($formIdPrefix, $localFormId): ?string + { + $formIdPrefix = chimNormalizeFormIdPrefix($formIdPrefix); + $localFormId = chimNormalizeLocalFormId($localFormId); + if ($formIdPrefix === '' || $localFormId === '') { + return null; + } + + $localValue = hexdec($localFormId); + + if (strlen($formIdPrefix) === 2) { + $runtimeValue = ((hexdec($formIdPrefix) & 0xFF) << 24) | ($localValue & 0x00FFFFFF); + return sprintf('%08X', $runtimeValue & 0xFFFFFFFF); + } + + if (strlen($formIdPrefix) === 5 && strpos($formIdPrefix, 'FE') === 0) { + $lightIndex = hexdec(substr($formIdPrefix, 2)); + $runtimeValue = 0xFE000000 | (($lightIndex & 0x0FFF) << 12) | ($localValue & 0x00000FFF); + return sprintf('%08X', $runtimeValue & 0xFFFFFFFF); + } + + return null; + } +} + +if (!function_exists('chimGetLoadedGamePluginByName')) { + function chimGetLoadedGamePluginByName($pluginName): ?array + { + static $pluginCache = []; + + $pluginName = chimNormalizePluginName($pluginName); + if ($pluginName === '') { + return null; + } + + $cacheKey = strtolower($pluginName); + if (array_key_exists($cacheKey, $pluginCache)) { + return $pluginCache[$cacheKey]; + } + + global $db; + if (!isset($db)) { + return null; + } + + $escapedPluginName = $db->escape($pluginName); + $pluginRow = $db->fetchOne(" + SELECT plugin_name, is_light, compile_index, small_file_compile_index, partial_index, formid_prefix, updated_at + FROM public.game_plugins + WHERE LOWER(plugin_name) = LOWER('{$escapedPluginName}') + LIMIT 1 + "); + + $pluginCache[$cacheKey] = is_array($pluginRow) ? $pluginRow : null; + return $pluginCache[$cacheKey]; + } +} + +if (!function_exists('chimGetLoadedGamePluginByFormIdPrefix')) { + function chimGetLoadedGamePluginByFormIdPrefix($formIdPrefix): ?array + { + static $pluginCache = []; + + $formIdPrefix = chimNormalizeFormIdPrefix($formIdPrefix); + if ($formIdPrefix === '') { + return null; + } + + $cacheKey = strtoupper($formIdPrefix); + if (array_key_exists($cacheKey, $pluginCache)) { + return $pluginCache[$cacheKey]; + } + + global $db; + if (!isset($db)) { + return null; + } + + $escapedFormIdPrefix = $db->escape($formIdPrefix); + $pluginRow = $db->fetchOne(" + SELECT plugin_name, is_light, compile_index, small_file_compile_index, partial_index, formid_prefix, updated_at + FROM public.game_plugins + WHERE formid_prefix = '{$escapedFormIdPrefix}' + LIMIT 1 + "); + + $pluginCache[$cacheKey] = is_array($pluginRow) ? $pluginRow : null; + return $pluginCache[$cacheKey]; + } +} + +if (!function_exists('chimGetLoadedGamePluginByRuntimeFormId')) { + function chimGetLoadedGamePluginByRuntimeFormId($runtimeFormId): ?array + { + $runtimeFormId = chimNormalizeRuntimeFormId($runtimeFormId); + if ($runtimeFormId === '') { + return null; + } + + $formIdPrefix = (strpos($runtimeFormId, 'FE') === 0) + ? substr($runtimeFormId, 0, 5) + : substr($runtimeFormId, 0, 2); + + return chimGetLoadedGamePluginByFormIdPrefix($formIdPrefix); + } +} + +if (!function_exists('chimExtractLocalFormIdFromRuntimeFormId')) { + function chimExtractLocalFormIdFromRuntimeFormId($runtimeFormId): string + { + $runtimeFormId = chimNormalizeRuntimeFormId($runtimeFormId); + if ($runtimeFormId === '') { + return ''; + } + + $runtimeValue = hexdec($runtimeFormId); + if (strpos($runtimeFormId, 'FE') === 0) { + return sprintf('%08X', $runtimeValue & 0x00000FFF); + } + + return sprintf('%08X', $runtimeValue & 0x00FFFFFF); + } +} + +if (!function_exists('chimBuildLegacyDescriptionBaseId')) { + function chimBuildLegacyDescriptionBaseId($runtimeFormId, ?array $pluginRow = null): string + { + $runtimeFormId = chimNormalizeRuntimeFormId($runtimeFormId); + if ($runtimeFormId === '') { + return ''; + } + + if (strpos($runtimeFormId, 'FE') === 0) { + return 'FEXXX' . substr($runtimeFormId, -3); + } + + if ($pluginRow === null) { + $pluginRow = chimGetLoadedGamePluginByRuntimeFormId($runtimeFormId); + } + + if (is_array($pluginRow) && !empty($pluginRow['plugin_name'])) { + if (strcasecmp((string) $pluginRow['plugin_name'], 'Skyrim.esm') === 0) { + return ''; + } + } elseif (substr($runtimeFormId, 0, 2) === '00') { + return ''; + } + + return 'XX' . substr($runtimeFormId, -6); + } +} + +if (!function_exists('chimBuildDescriptionBaseIdCandidates')) { + function chimBuildDescriptionBaseIdCandidates($value): array + { + $candidates = []; + $pushCandidate = function ($candidate) use (&$candidates): void { + $candidate = trim((string) $candidate); + if ($candidate === '') { + return; + } + + if (strpos($candidate, '|') !== false) { + $parsedStable = chimParseStableFormReference($candidate); + if ($parsedStable) { + $candidate = $parsedStable['stable_key']; + } + } else { + $candidate = strtoupper($candidate); + } + + if (!in_array($candidate, $candidates, true)) { + $candidates[] = $candidate; + } + }; + + $value = trim((string) $value); + if ($value === '') { + return []; + } + + $parsedStableReference = chimParseStableFormReference($value); + if ($parsedStableReference) { + $pushCandidate($parsedStableReference['stable_key']); + + $pluginRow = chimGetLoadedGamePluginByName($parsedStableReference['plugin_name']); + $runtimeFormId = null; + if ($pluginRow && !empty($pluginRow['formid_prefix'])) { + $runtimeFormId = chimComputeRuntimeFormIdFromPrefix( + $pluginRow['formid_prefix'], + $parsedStableReference['local_formid'] + ); + } + + if ($runtimeFormId) { + $pushCandidate($runtimeFormId); + $pushCandidate(chimBuildLegacyDescriptionBaseId($runtimeFormId, $pluginRow)); + } + + return $candidates; + } + + $upperValue = strtoupper($value); + if (strpos($upperValue, 'XX') === 0 || strpos($upperValue, 'FEXXX') === 0) { + $pushCandidate($upperValue); + return $candidates; + } + + $runtimeFormId = chimNormalizeRuntimeFormId($value); + if ($runtimeFormId === '') { + return []; + } + + $pushCandidate($runtimeFormId); + + $pluginRow = chimGetLoadedGamePluginByRuntimeFormId($runtimeFormId); + $localFormId = chimExtractLocalFormIdFromRuntimeFormId($runtimeFormId); + if ($pluginRow && !empty($pluginRow['plugin_name']) && $localFormId !== '') { + $pushCandidate(chimBuildStableFormReference($pluginRow['plugin_name'], $localFormId)); + } + + $pushCandidate(chimBuildLegacyDescriptionBaseId($runtimeFormId, $pluginRow)); + + return $candidates; + } +} + +if (!function_exists('chimResolveStableFormReferenceToRuntimeFormId')) { + function chimResolveStableFormReferenceToRuntimeFormId($stableReference): ?string + { + $parsedReference = chimParseStableFormReference($stableReference); + if (!$parsedReference) { + return null; + } + + $pluginRow = chimGetLoadedGamePluginByName($parsedReference['plugin_name']); + if (!$pluginRow || empty($pluginRow['formid_prefix'])) { + return null; + } + + return chimComputeRuntimeFormIdFromPrefix($pluginRow['formid_prefix'], $parsedReference['local_formid']); + } +} + +if (!function_exists('chimReplaceLoadedGamePlugins')) { + function chimReplaceLoadedGamePlugins(array $plugins): int + { + global $db; + if (!isset($db)) { + return 0; + } + + $normalizedPlugins = []; + foreach ($plugins as $pluginRow) { + if (!is_array($pluginRow)) { + continue; + } + + $pluginName = chimNormalizePluginName($pluginRow['plugin_name'] ?? ''); + $formIdPrefix = chimNormalizeFormIdPrefix($pluginRow['formid_prefix'] ?? ''); + if ($pluginName === '' || $formIdPrefix === '') { + continue; + } + + $normalizedPlugins[strtolower($pluginName)] = [ + 'plugin_name' => $pluginName, + 'is_light' => !empty($pluginRow['is_light']), + 'compile_index' => isset($pluginRow['compile_index']) ? intval($pluginRow['compile_index']) : 0, + 'small_file_compile_index' => isset($pluginRow['small_file_compile_index']) ? intval($pluginRow['small_file_compile_index']) : 0, + 'partial_index' => isset($pluginRow['partial_index']) ? intval($pluginRow['partial_index']) : 0, + 'formid_prefix' => $formIdPrefix, + ]; + } + + if (count($normalizedPlugins) === 0) { + return 0; + } + + $db->execQuery('DELETE FROM public.game_plugins'); + + foreach ($normalizedPlugins as $pluginRow) { + $escapedPluginName = $db->escape($pluginRow['plugin_name']); + $escapedFormIdPrefix = $db->escape($pluginRow['formid_prefix']); + $isLight = $pluginRow['is_light'] ? 'TRUE' : 'FALSE'; + $compileIndex = intval($pluginRow['compile_index']); + $smallFileCompileIndex = intval($pluginRow['small_file_compile_index']); + $partialIndex = intval($pluginRow['partial_index']); + + $db->execQuery(" + INSERT INTO public.game_plugins ( + plugin_name, + is_light, + compile_index, + small_file_compile_index, + partial_index, + formid_prefix, + updated_at + ) VALUES ( + '{$escapedPluginName}', + {$isLight}, + {$compileIndex}, + {$smallFileCompileIndex}, + {$partialIndex}, + '{$escapedFormIdPrefix}', + CURRENT_TIMESTAMP + ) + ON CONFLICT (plugin_name) DO UPDATE SET + is_light = EXCLUDED.is_light, + compile_index = EXCLUDED.compile_index, + small_file_compile_index = EXCLUDED.small_file_compile_index, + partial_index = EXCLUDED.partial_index, + formid_prefix = EXCLUDED.formid_prefix, + updated_at = CURRENT_TIMESTAMP + "); + } + + return count($normalizedPlugins); + } +} diff --git a/lib/core/npc_master.class.php b/lib/core/npc_master.class.php index aa8df2573..ea0607f10 100644 --- a/lib/core/npc_master.class.php +++ b/lib/core/npc_master.class.php @@ -1,5 +1,9 @@ $value) { + $metadataKey = trim((string) $key); + if ($metadataKey === '') { + continue; + } + + if ($value === null) { + $unsetKeys[] = $metadataKey; + continue; + } + + $normalizedSetValues[$metadataKey] = $value; + } + + $normalizedUnsetKeys = []; + foreach ($unsetKeys as $key) { + $metadataKey = trim((string) $key); + if ($metadataKey === '') { + continue; + } + $normalizedUnsetKeys[$metadataKey] = true; + } + + if (count($normalizedSetValues) === 0 && count($normalizedUnsetKeys) === 0) { + return false; + } + + $metadataExpr = "COALESCE(metadata, '{}'::jsonb)"; + + foreach (array_keys($normalizedUnsetKeys) as $metadataKey) { + $escapedKey = $this->db->escape($metadataKey); + $metadataExpr = "({$metadataExpr} - '{$escapedKey}')"; + } + + foreach ($normalizedSetValues as $metadataKey => $value) { + $escapedKey = $this->db->escape($metadataKey); + $encodedValue = json_encode($value, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); + if ($encodedValue === false) { + continue; + } + + $escapedValue = $this->db->escape($encodedValue); + $metadataExpr = "jsonb_set({$metadataExpr}, '{\"{$escapedKey}\"}', '{$escapedValue}'::jsonb, true)"; + } + + $escapedNpcName = $this->db->escape($npcName); + $query = " + UPDATE {$this->table} + SET metadata = {$metadataExpr} + WHERE npc_name = '{$escapedNpcName}' + "; + + return $this->db->execQuery($query) !== false; + } + public function backupNpcById($id) { $id = (int) $id; @@ -965,6 +1032,23 @@ public function isNpcInFaction($npcData, $factionFormId) return false; } + $stableReference = chimParseStableFormReference($factionFormId); + if ($stableReference) { + foreach ($extendedData['factions'] as $faction) { + if ( + isset($faction['rank']) && $faction['rank'] > -1 && + chimFactionEntryMatchesStableFormReference($faction, $stableReference['stable_key']) + ) { + return true; + } + } + + $resolvedRuntimeFormId = chimResolveStableFormReferenceToRuntimeFormId($stableReference['stable_key']); + if ($resolvedRuntimeFormId !== null) { + $factionFormId = $resolvedRuntimeFormId; + } + } + // Normalize formid for comparison (handle case-insensitive comparison) $normalizedSearchFormId = strtoupper($factionFormId); diff --git a/lib/data_functions.php b/lib/data_functions.php index 630ae6201..a34773087 100755 --- a/lib/data_functions.php +++ b/lib/data_functions.php @@ -8,6 +8,7 @@ require_once(__DIR__."/model_dynmodel.php"); require_once(__DIR__."/emote_moods.php"); require_once(__DIR__."/core/activity_status.php"); +require_once(__DIR__."/core/game_plugins.php"); require_once(__DIR__."/core/npc_master.class.php"); @@ -215,44 +216,107 @@ function isItemBlacklisted($itemName) { } /** - * Lookup description from descriptions table, supporting mod FormIDs (XX prefix) - * Tries exact FormID first, then falls back to XX-prefixed version for mod items + * Lookup description from the merged descriptions view using runtime, legacy, or stable keys. + * Supports: + * - exact runtime FormIDs (e.g. 020098A0) + * - legacy wildcard keys (e.g. XX0098A0, FEXXX822) + * - stable plugin-aware keys (e.g. Dawnguard.esm|000098A0) * - * @param string $formId The FormID to lookup (hex format, e.g., "0303572F") - * @return array|null Array with 'name' and 'description' keys, or null if not found + * @param string $formId The identifier to lookup + * @return array|null Array with 'baseid', 'name', and 'description' keys, or null if not found */ function lookupDescriptionByFormID(string $formId): ?array { global $db; - - // Ensure FormID is properly formatted (8 hex digits, uppercase) - $formId = strtoupper(str_replace('0x', '', $formId)); - $formId = str_pad($formId, 8, '0', STR_PAD_LEFT); - - // Try exact FormID first - $escapedFormId = $db->escape($formId); - $record = $db->fetchOne( - "SELECT name, description FROM descriptions WHERE baseid = '{$escapedFormId}' LIMIT 1" - ); - - if ($record && !empty($record['name'])) { - return $record; - } - - // If not found and FormID starts with a mod index (first 2 digits not 00-03), try XX prefix - $modIndex = substr($formId, 0, 2); - if ($modIndex !== '00' && $modIndex !== '01' && $modIndex !== '02' && $modIndex !== '03') { - // Replace first 2 digits with XX for mod item lookup - $xxFormId = 'XX' . substr($formId, 2); - $escapedXXFormId = $db->escape($xxFormId); + + foreach (chimBuildDescriptionBaseIdCandidates($formId) as $candidateBaseId) { + $escapedBaseId = $db->escape($candidateBaseId); $record = $db->fetchOne( - "SELECT name, description FROM descriptions WHERE baseid = '{$escapedXXFormId}' LIMIT 1" + "SELECT baseid, name, description FROM combined_descriptions WHERE baseid = '{$escapedBaseId}' LIMIT 1" ); - + if ($record && !empty($record['name'])) { return $record; } } - + + return null; +} + +/** + * Lookup description only by exact runtime FormID or stable plugin-aware key. + * This deliberately skips legacy wildcard keys and name fallback to avoid + * cross-matching unrelated item descriptions for spells. + * + * @param string $formId The runtime or stable identifier to lookup + * @return array|null Array with 'baseid', 'name', and 'description' keys, or null if not found + */ +function lookupStrictDescriptionByFormID(string $formId): ?array { + global $db; + + $candidates = []; + $pushCandidate = function ($candidate) use (&$candidates): void { + $candidate = trim((string) $candidate); + if ($candidate === '') { + return; + } + + if (strpos($candidate, '|') !== false) { + $parsedStable = chimParseStableFormReference($candidate); + if ($parsedStable) { + $candidate = $parsedStable['stable_key']; + } + } else { + $candidate = strtoupper($candidate); + } + + if (!in_array($candidate, $candidates, true)) { + $candidates[] = $candidate; + } + }; + + $formId = trim($formId); + if ($formId === '') { + return null; + } + + $parsedStableReference = chimParseStableFormReference($formId); + if ($parsedStableReference) { + $pushCandidate($parsedStableReference['stable_key']); + + $pluginRow = chimGetLoadedGamePluginByName($parsedStableReference['plugin_name']); + if ($pluginRow && !empty($pluginRow['formid_prefix'])) { + $runtimeFormId = chimComputeRuntimeFormIdFromPrefix( + $pluginRow['formid_prefix'], + $parsedStableReference['local_formid'] + ); + if ($runtimeFormId) { + $pushCandidate($runtimeFormId); + } + } + } else { + $runtimeFormId = chimNormalizeRuntimeFormId($formId); + if ($runtimeFormId !== '') { + $pushCandidate($runtimeFormId); + + $pluginRow = chimGetLoadedGamePluginByRuntimeFormId($runtimeFormId); + $localFormId = chimExtractLocalFormIdFromRuntimeFormId($runtimeFormId); + if ($pluginRow && !empty($pluginRow['plugin_name']) && $localFormId !== '') { + $pushCandidate(chimBuildStableFormReference($pluginRow['plugin_name'], $localFormId)); + } + } + } + + foreach ($candidates as $candidateBaseId) { + $escapedBaseId = $db->escape($candidateBaseId); + $record = $db->fetchOne( + "SELECT baseid, name, description FROM combined_descriptions WHERE baseid = '{$escapedBaseId}' LIMIT 1" + ); + + if ($record && !empty($record['description'])) { + return $record; + } + } + return null; } @@ -748,11 +812,8 @@ function DataLastInfoFor($actorBeingCalled, $lastNelements = -2,$addNPCDescripti if (!in_array($baseID, $seenBaseIDs)) { $seenBaseIDs[] = $baseID; - // Look up description from descriptions table - $baseIDDec = hexdec(str_replace('0x', '', $baseID)); - $descRecord = $GLOBALS["db"]->fetchOne( - "SELECT description FROM descriptions WHERE baseid = '{$baseIDDec}' LIMIT 1" - ); + // Look up description through the shared runtime/stable/legacy resolver + $descRecord = lookupDescriptionByFormID($baseID); if ($descRecord && !empty($descRecord['description'])) { // Store description under clean name (without STEALING tag) @@ -4918,7 +4979,7 @@ function call_llm_internal() { if (isset($actionParts2[1])) { // Parameter part - if ($actionParts2[0]=="Attack"||$actionParts2[0]=="AttackHunt") { + if ($actionParts2[0]=="Attack") { // Lets polish the parameters $localtarget=$actionParts2[1]; $mang1=explode(",",$localtarget); @@ -4934,22 +4995,6 @@ function call_llm_internal() { $actions[$n]="{$actionParts[0]}|{$actionParts[1]}|Attack@{$mang3[0]}"; error_log("[ACTION POSTFILTER Attack] $localtarget => {$mang3[0]} => $mang4"); - } else if ($actionParts2[0]=="Inspect") { - // Lets polish the parammeters - $localtarget=$actionParts2[1]; - $mang1=explode(",",$localtarget); - $mang2=explode(" and ",$mang1[0]); - $mang3=explode("(",$mang2[0]); - $mang4=FindClosestActorName($mang3[0]); - - if ($mang4) - $actions[$n]="{$actionParts[0]}|{$actionParts[1]}|Inspect@{$mang4}"; - else - $actions[$n]="{$actionParts[0]}|{$actionParts[1]}|Inspect@{$mang3[0]}"; - - error_log("[ACTION POSTFILTER GiveItemTo] $localtarget => {$mang3[0]} => $mang4"); - - } else if ($actionParts2[0]=="GiveItemTo") { // Check if parameter is JSON (multi-param) - skip post-filtering for JSON if (isset($actionParts2[1]) && substr(trim($actionParts2[1]), 0, 1) === '{') { @@ -5227,19 +5272,6 @@ function call_llm_internal() { } else $actions[$n]="{$actionParts[0]}|{$actionParts[1]}|TakeGoldFromPlayer@$mang4"; - } else if ($actionParts2[0]=="SetCurrentTask") { - // Lets polish the parammeters - if (empty(trim($actionParts2[1]))) { - //$speech=implode(" ".$talkedSoFar); typo? if not, what does this do - //trying - $speech=implode(" ", $talkedSoFar); - $actions[$n]="{$actionParts[0]}|{$actionParts[1]}|SetCurrentTask@$speech"; - error_log("[ACTION POSTFILTER SetCurrentTask, using speech as parameter $speech] "); - - } else { - error_log("[ACTION POSTFILTER SetCurrentTask, using target as parameter {$actionParts2[1]}] "); - } - } else if ($actionParts2[0]=="PickupItem") { // Parse item parameter - can be JSON or plain string $itemParam = trim($actionParts2[1]); @@ -6091,12 +6123,11 @@ function buildDynamicBiography(array $FOLLOWER_CONF, bool $forLetter = false, bo $getItemDescription = function($itemName, $baseid = null) { global $db; - // Try by baseid first if provided + // Try the shared runtime/stable/legacy baseid resolver first if provided if (!empty($baseid)) { - $escapedBaseid = $db->escape($baseid); - $result = $db->fetchAll("SELECT description FROM combined_descriptions WHERE baseid='{$escapedBaseid}' LIMIT 1"); - if (!empty($result) && !empty($result[0]['description'])) { - return $result[0]['description']; + $record = lookupDescriptionByFormID((string) $baseid); + if (!empty($record['description'])) { + return $record['description']; } } @@ -6379,11 +6410,14 @@ function buildDynamicBiography(array $FOLLOWER_CONF, bool $forLetter = false, bo continue; } - // Only add spells that have descriptions in the database + // Only add spells that have exact/stable spell descriptions in the database. + // Do not use legacy wildcard or name fallback here; those can collide with + // unrelated item descriptions after mod-aware FormID resolution. $description = null; if (!empty($baseid) && !in_array($baseid, $describedBaseids)) { - $description = $getItemDescription($spellName, $baseid); - if ($description) { + $record = lookupStrictDescriptionByFormID((string) $baseid); + if (!empty($record['description'])) { + $description = $record['description']; $describedBaseids[] = $baseid; } } diff --git a/lib/quest_reference_data.php b/lib/quest_reference_data.php index 393c216f2..80900ee7d 100644 --- a/lib/quest_reference_data.php +++ b/lib/quest_reference_data.php @@ -1,5 +1,9 @@ escape($hex); - return "'{$hexCn}'"; + $canonicalCn = $GLOBALS["db"]->escape($canonical); + return "'{$canonicalCn}'"; } $normalized = quest_reference_normalize_formid($formId); diff --git a/main.php b/main.php index dc03abeb5..eb89f3782 100755 --- a/main.php +++ b/main.php @@ -2199,49 +2199,11 @@ function maybeQueueNpcVoiceRefresh($currentNpcData, $npcMaster) $GLOBALS["COMMAND_PROMPT_ENFORCE_ACTIONS"]="(If {$GLOBALS["HERIKA_NAME"]} is just speaking, use action \"Talk\". If another action is even remotely contextually appropriate, use it, even if in doubt)"; } -// Cooldown definitions -$COOLDOWNMAP["ComeCloser"]=120/0.00864; -$COOLDOWNMAP["WaitHere"]=300/0.00864; -$COOLDOWNMAP["UseSoulGaze"]=300/0.00864; -$COOLDOWNMAP["InspectSurroundings"]=100/0.00864; -$COOLDOWNMAP["Inspect"]=300/0.00864; -$COOLDOWNMAP["Relax"]=180/0.00864; -$COOLDOWNMAP["MakeAToast"]=60/0.00864; -$COOLDOWNMAP["Toast"]=60/0.00864; -$COOLDOWNMAP["StartRitualCeremony"]=60/0.00864; -$COOLDOWNMAP["Follow"]=60/0.00864; -$COOLDOWNMAP["FollowPlayer"]=60/0.00864; - -if ($GLOBALS["FUNCTIONS_ARE_ENABLED"]) { - $localActorName=$GLOBALS["db"]->escape($GLOBALS["HERIKA_NAME"]); - $lastActionsIssuedMap=$GLOBALS["db"]->fetchAll("SELECT * FROM (SELECT DISTINCT ON (action) * FROM actions_issued WHERE (actorname = '$localActorName' or actorname like '%$localActorName,%' or actorname='*') ORDER BY action, gamets DESC, ts DESC) AS sub ORDER BY gamets DESC, ts DESC"); - if (isset($lastActionsIssuedMap[0])) { - foreach ($lastActionsIssuedMap as $lastActionsIssued) { - - $ingamenow=convert_gamets2seconds($gameRequest[2]); - $lasttriggered=convert_gamets2seconds($lastActionsIssued["gamets"]); - $elapsedSecs=gamets2seconds_between($gameRequest[2],$lastActionsIssued["gamets"]); - - if (isset($COOLDOWNMAP[$lastActionsIssued["action"]])) { - if (($ingamenow-$lasttriggered)<$COOLDOWNMAP[$lastActionsIssued["action"]]) { // COnsider here use gamets and ts and id001 time functions - error_log("{$lastActionsIssued["action"]} in cooldown for $localActorName, {$COOLDOWNMAP[$lastActionsIssued["action"]]} $ingamenow-$lasttriggered $elapsedSecs"); - unsetFunction($lastActionsIssued["action"]); - } else { - error_log("{$lastActionsIssued["action"]} NOT in cooldown for $localActorName {$COOLDOWNMAP[$lastActionsIssued["action"]]} $ingamenow-$lasttriggered $elapsedSecs"); - } - } - } - } -} - // Rolemaster stuff if (isset($GLOBALS["is_rolemastered"])) { - // ReturnBackHome is initially disabled. Les restore it from copy here. Only applies to rolemastered NPCs $GLOBALS["NPC_ROLEMASTERED"]=true; - $GLOBALS["ENABLED_FUNCTIONS"][]="ReturnBackHome"; - $GLOBALS["FUNCTIONS"][]=$GLOBALS["BASE_FUNCTIONS"]["ReturnBackHome"]; error_log("{$GLOBALS["HERIKA_NAME"]} is_rolemastered"); if ((rand(0,5)!==0)){ // Remember goal from time to time $GLOBALS["PATCH_PROMPT_ENFORCE_ACTIONS"]=true; diff --git a/processor/comm.php b/processor/comm.php index 3a1f1bc29..0bc2bf2c5 100755 --- a/processor/comm.php +++ b/processor/comm.php @@ -2,6 +2,7 @@ require_once($GLOBALS["ENGINE_PATH"]."/lib/dynamic_update_util.php"); require_once($GLOBALS["ENGINE_PATH"]."/lib/utils_game_timestamp.php"); require_once($GLOBALS["ENGINE_PATH"]."/lib/playthrough_snapshot.php"); +require_once($GLOBALS["ENGINE_PATH"]."/lib/core/game_plugins.php"); $MUST_END=false; @@ -1542,7 +1543,7 @@ function emitPlayerMenuScriptQueueLine($line) $meta["mods"]=isset($splitNameBase[41]) ?explode("#",$splitNameBase[41]):null; - // NPC factions - format: formID1:rank1#formID2:rank2#... + // NPC factions - format: formID1:rank1[:PluginName.esp|LocalFormId]#formID2:rank2[:...] $factionString = isset($splitNameBase[42]) ? $splitNameBase[42] : ''; $factionList = []; $formIds=[]; @@ -1552,21 +1553,36 @@ function emitPlayerMenuScriptQueueLine($line) foreach ($factionPairs as $pair) { $parts = explode(":", $pair); if (count($parts) >= 2) { - $formId = $parts[0]; + $formId = chimNormalizeRuntimeFormId($parts[0]); + if ($formId === '') { + continue; + } $formIds[]=$formId;// Collect form IDs to fetch names later $rank = intval($parts[1]); - $factionList[] = [ + $factionEntry = [ 'formid' => $formId, 'rank' => $rank, 'name'=>'' // Placeholder, will be filled after fetching faction names from DB ]; + + $stableReference = isset($parts[2]) ? chimParseStableFormReference($parts[2]) : null; + if ($stableReference) { + $factionEntry['plugin'] = $stableReference['plugin_name']; + $factionEntry['local_formid'] = $stableReference['local_formid']; + $factionEntry['stable_key'] = $stableReference['stable_key']; + } + + $factionList[] = $factionEntry; } } } // Fetch only the faction names we need in a single query to avoid multiple DB hits $arrFormIdNames=[]; if (sizeof($formIds)>0) { - $arrFormIdNames=$factionNames=$db->fetchAll("select formid,name from factions where formid in ('".implode("','", $formIds)."')"); + $escapedFormIds = array_map(function ($formId) use ($db) { + return $db->escape($formId); + }, array_values(array_unique($formIds))); + $arrFormIdNames = $db->fetchAll("select formid,name from factions where formid in ('".implode("','", $escapedFormIds)."')"); } // Now map the arrFormIdNames to mapFormIdNames diff --git a/processor/funcret.php b/processor/funcret.php index 009c933b2..a2c8d2358 100755 --- a/processor/funcret.php +++ b/processor/funcret.php @@ -1,4 +1,36 @@ 'assistant', 'content' => null, 'tool_calls' => [array("id" => $lastCallId, "function"=>["name"=>$functionLocaleName,"arguments" => "{\"$argName\":\"{$returnFunction[2]}\"}"])]); $functionCalled[] = array('role' => 'assistant', 'content' => null, 'tool_calls' => [array("id" => $lastCallId, "function" => ["name" => $functionLocaleName, "arguments" => "{\"$argName\":\"\"}"])]); // $returnFunction[2] is not set here +$debugNotificationText = chimSanitizeDebugNotificationText($returnFunction[3] ?? ''); +if ($debugNotificationText !== '' && chimActionShouldEmitDebugNotification($functionCodeName)) { + $notificationSpeaker = trim(strval($GLOBALS["HERIKA_NAME"] ?? '')); + if ($notificationSpeaker === '') { + $notificationSpeaker = 'The Narrator'; + } + + echo $notificationSpeaker . "|rolecommand|DebugNotification@" . $debugNotificationText . PHP_EOL; +} + $returnFunctionArray[] = array('role' => 'tool', 'content' => "{$returnFunction[3]}", 'tool_call_id' => "$lastCallId"); if ($forceAttackingText) @@ -242,4 +263,4 @@ $GLOBALS["FUNCTIONS_ARE_ENABLED"] = false; } -?> \ No newline at end of file +?> diff --git a/processor/import_files.php b/processor/import_files.php index 246ca739d..b7d9accb3 100644 --- a/processor/import_files.php +++ b/processor/import_files.php @@ -1,16 +1,20 @@ $colName) { + $headerMap[strtolower(trim($colName))] = $i; + } + + while (($data = fgetcsv($handle, 0, ',')) !== false) { + if (empty($data)) { + continue; + } + + $codeName = customActionImportCsvGetValue($headerMap, $data, 'code_name'); + $actionName = customActionImportCsvGetValue($headerMap, $data, 'action_name'); + if ($codeName === '' || $actionName === '') { + Logger::warn("Custom Action Import: Skipping row with missing code_name or action_name"); + $errorCount++; + continue; + } + + $errorMessage = ''; + $parameters = customActionImportDecodeJsonField( + customActionImportCsvGetValue($headerMap, $data, 'parameters_json', ''), + ['type' => 'object', 'properties' => [], 'required' => []], + $errorMessage, + 'parameters_json' + ); + if ($parameters === null) { + Logger::error("Custom Action Import: {$codeName} - {$errorMessage}"); + $errorCount++; + continue; + } + + $metadata = customActionImportDecodeJsonField( + customActionImportCsvGetValue($headerMap, $data, 'metadata', ''), + [], + $errorMessage, + 'metadata' + ); + if ($metadata === null) { + Logger::error("Custom Action Import: {$codeName} - {$errorMessage}"); + $errorCount++; + continue; + } + + $scriptProxyRaw = customActionImportCsvGetValue($headerMap, $data, 'script_proxy_program', ''); + $scriptProxyProgram = customActionImportDecodeJsonField( + $scriptProxyRaw, + null, + $errorMessage, + 'script_proxy_program' + ); + if ($scriptProxyProgram === null && trim($scriptProxyRaw) !== '') { + Logger::error("Custom Action Import: {$codeName} - {$errorMessage}"); + $errorCount++; + continue; + } + + $incomingImportVersion = herikaActionCatalogNormalizeImportVersion( + customActionImportCsvGetValue($headerMap, $data, 'import_version', '0') + ); + + $metadataSource = trim(strval($metadata['source'] ?? '')); + if ($metadataSource === '') { + $metadataSource = trim(strval($metadata['bridge_script'] ?? '')); + } + if ($metadataSource === '' && $filename !== '') { + $metadataSource = pathinfo($filename, PATHINFO_FILENAME); + } + if ($metadataSource === '') { + $metadataSource = 'custom_action_import'; + } + $metadata['source'] = $metadataSource; + $metadata['import_type'] = 'custom_action_import'; + $metadata['import_version'] = $incomingImportVersion; + if ($filename !== '') { + $metadata['import_filename'] = $filename; + } + if (!isset($metadata['dispatch']) || trim(strval($metadata['dispatch'])) === '') { + $metadata['dispatch'] = $scriptProxyProgram !== null ? 'script_proxy' : 'plugin_command'; + } + if (!array_key_exists('builtin', $metadata)) { + $metadata['builtin'] = false; + } + if (!isset($metadata['status']) || trim(strval($metadata['status'])) === '') { + $metadata['status'] = 'active'; + } + + $row = [ + 'code_name' => $codeName, + 'action_name' => $actionName, + 'description' => customActionImportCsvGetValue($headerMap, $data, 'description', ''), + 'return_message' => customActionImportCsvGetValue($headerMap, $data, 'return_message', ''), + 'available_to_npc' => customActionImportCsvToBool( + customActionImportCsvGetValue($headerMap, $data, 'available_to_npc', '0'), + false + ), + 'available_to_followers' => customActionImportCsvToBool( + customActionImportCsvGetValue($headerMap, $data, 'available_to_followers', '0'), + false + ), + 'is_activated' => customActionImportCsvToBool( + customActionImportCsvGetValue($headerMap, $data, 'is_activated', '1'), + true + ), + 'game_function' => customActionImportCsvToBool( + customActionImportCsvGetValue($headerMap, $data, 'game_function', '1'), + true + ), + 'parameters_json' => $parameters, + 'metadata' => $metadata, + 'import_version' => $incomingImportVersion, + 'script_proxy_program' => $scriptProxyProgram, + ]; + + $existingImportVersion = herikaActionCatalogGetExistingCustomImportVersion($codeName); + if ($existingImportVersion !== null + && !herikaActionCatalogShouldOverwriteImportVersion($incomingImportVersion, $existingImportVersion) + ) { + Logger::info( + "Custom Action Import: Skipping '{$codeName}' because import_version {$incomingImportVersion} is not newer than existing {$existingImportVersion}" + ); + $skippedCount++; + $processedCodeNames[] = $codeName; + continue; + } + + if (herikaActionCatalogUpsertCustomRow($row)) { + $processedCount++; + $processedCodeNames[] = $codeName; + } else { + Logger::error("Custom Action Import: Failed to upsert action '{$codeName}'"); + $errorCount++; + } + } + + if ($filename !== '' && $errorCount === 0) { + $literalFilename = herikaActionCatalogSqlText($filename); + $staleFilter = ''; + if (count($processedCodeNames) > 0) { + $literalCodes = array_map('herikaActionCatalogSqlText', array_values(array_unique($processedCodeNames))); + $staleFilter = ' AND code_name NOT IN (' . implode(',', $literalCodes) . ')'; + } + + $db->execQuery(" + DELETE FROM public.core_action_custom + WHERE COALESCE(metadata->>'import_type', '') = 'custom_action_import' + AND COALESCE(metadata->>'import_filename', '') = {$literalFilename} + {$staleFilter} + "); + } + + fclose($handle); + unlink($tempFile); + + Logger::info("Custom Action Import: Processing complete. $processedCount records processed, $skippedCount skipped, $errorCount errors"); + + $db->insert( + 'eventlog', + array( + 'ts' => $timestamp, + 'gamets' => $game_timestamp, + 'type' => 'custom_action_import', + 'data' => "CSV upload ($filename): $processedCount records processed, $skippedCount skipped, $errorCount errors", + 'sess' => 'web', + 'localts' => time(), + 'people' => '', + 'location' => '', + 'party' => '' + ) + ); + } catch (Exception $e) { + $hadFatalError = true; + Logger::error("Custom Action Import: Fatal error processing CSV: " . $e->getMessage()); + if (isset($tempFile) && file_exists($tempFile)) { + unlink($tempFile); + } + + $db->insert( + 'eventlog', + array( + 'ts' => $timestamp, + 'gamets' => $game_timestamp, + 'type' => 'custom_action_import', + 'data' => "CSV upload failed: " . $e->getMessage(), + 'sess' => 'web', + 'localts' => time(), + 'people' => '', + 'location' => '', + 'party' => '' + ) + ); + } + + return !$hadFatalError; +} + ?> diff --git a/processor/request.php b/processor/request.php index 27343499f..4046a57da 100755 --- a/processor/request.php +++ b/processor/request.php @@ -23,67 +23,7 @@ So here we will override the result (which probably will be nothing) */ - if ($functionCodeName == "ReadQuestJournal") { - $returnFunction[3] = DataQuestJournal($returnFunction[2]); // Overwrite funrect content with info from database - $gameRequest[3] .= $returnFunction[3]; // Add also to $gameRequest - - if (!isset($GLOBALS["CACHE_PEOPLE"])) { - $GLOBALS["CACHE_PEOPLE"]=DataBeingsInRange(); - } - - if (!isset($GLOBALS["CACHE_LOCATION"])) { - $GLOBALS["CACHE_LOCATION"]=DataLastKnownLocation(); - } - - if (!isset($GLOBALS["CACHE_PARTY"])) { - $GLOBALS["CACHE_PARTY"]=DataGetCurrentPartyConf(); - } - - // Store info. - $db->insert( - 'eventlog', - array( - 'ts' => $gameRequest[1], - 'gamets' => $gameRequest[2], - 'type' => 'chat', - 'data' => "The Narrator. {$GLOBALS["HERIKA_NAME"]} reads in quest journal:".prettyPrintJson($returnFunction[3]), - 'sess' => 'pending', - 'localts' => time(), - 'people'=> $GLOBALS["CACHE_PEOPLE"], - 'location'=>$GLOBALS["CACHE_LOCATION"], - 'party'=>$GLOBALS["CACHE_PARTY"] - ) - ); - - - } else if ($functionCodeName == "SearchDiary") { - - $returnFunction[3] = DataDiaryLogIndex($returnFunction[2]); // Overwrite funrect content with info from database - $gameRequest[3] .= $returnFunction[3]; // Add also to $gameRequest - - - } else if ($functionCodeName == "ReadDiaryPage") { - - $returnFunction[3] = DataDiaryLog($returnFunction[2]); // Overwrite funrect content with info from database - $gameRequest[3] .= $returnFunction[3]; // Add also to $gameRequest - - } else if ($functionCodeName == "SetCurrentTask") { - // "Task" here is the last motto. "Let's take the hobbits to Isengard"->Current task should be "Travel to Isengard" - $returnFunction[3] .= "ok"; // This is always ok - $gameRequest[3].="done"; - // This table is a stack whithout pop. - $db->insert( - 'currentmission', - array( - 'ts' => $gameRequest[1], - 'gamets' => $gameRequest[2], - 'description' => $db->escape($returnFunction[2]), - 'sess' => 'pending', - 'localts' => time() - ) - ); - die(); - } else if ($functionCodeName == "Attack") { + if ($functionCodeName == "Attack") { if (strpos($returnFunction[3],"Error")!==false) { $GLOBALS["FUNCTIONS_ARE_ENABLED"]=false; // RE-Enable functions // Endless loop if enabled $request="Specify a valid target:(available targets: ".implode(",",$GLOBALS["FUNCTION_PARM_INSPECT"]).")"; diff --git a/prompt.includes.php b/prompt.includes.php index 82bd468bf..ce515b326 100755 --- a/prompt.includes.php +++ b/prompt.includes.php @@ -65,15 +65,4 @@ } -if (file_exists(__DIR__."/functions/user_pref.json")) { - $currentOnes=json_decode(file_get_contents(__DIR__."/functions/user_pref.json"),true); - if (isset($currentOnes) && is_array($currentOnes) && (count($currentOnes) > 0)) { - - $GLOBALS["ENABLED_FUNCTIONS"]=$currentOnes; // add functions from plugins to existing selection - - error_log("JSON: " . implode('|', $GLOBALS["ENABLED_FUNCTIONS"]) ); //debug - } -} - - ?> diff --git a/prompts/prompts.php b/prompts/prompts.php index 9b810f00e..9e8aa4505 100644 --- a/prompts/prompts.php +++ b/prompts/prompts.php @@ -187,10 +187,8 @@ function shouldTriggerRPGComment($eventType) { "GetDateTime"=>"({$GLOBALS["HERIKA_NAME"]} answers with the current date and time in short sentence){$GLOBALS["TEMPLATE_DIALOG"]}", "MoveTo"=>"({$GLOBALS["HERIKA_NAME"]} talks, eg: makes a comment about movement to the destination){$GLOBALS["TEMPLATE_DIALOG"]}", "CheckInventory"=>"({$GLOBALS["HERIKA_NAME"]} talks about inventory and backpack items){$GLOBALS["TEMPLATE_DIALOG"]}", - "Inspect"=>"({$GLOBALS["HERIKA_NAME"]} talks about items inspected, short speech){$GLOBALS["TEMPLATE_DIALOG"]}", "ReadQuestJournal"=>"({$GLOBALS["HERIKA_NAME"]} talks about quests they have read in the quest journal){$GLOBALS["TEMPLATE_DIALOG"]}", "TravelTo"=>"({$GLOBALS["HERIKA_NAME"]} talks about the journey){$GLOBALS["TEMPLATE_DIALOG"]}", - "InspectSurroundings"=>"({$GLOBALS["HERIKA_NAME"]} talks about seen actors, or to the actor its looking for){$GLOBALS["TEMPLATE_DIALOG"]}", "GiveGoldTo"=>"({$GLOBALS["HERIKA_NAME"]} Talks about coins or gold given.{$GLOBALS["TEMPLATE_DIALOG"]}", "Brawl"=>"({$GLOBALS["HERIKA_NAME"]} {$GLOBALS["TEMPLATE_DIALOG"]}" @@ -308,7 +306,7 @@ function shouldTriggerRPGComment($eventType) { ], // Database Prompt (Welcome) "welcome"=>[ - "cue"=>["{$gameRequest[3]}. {$GLOBALS["HERIKA_NAME"]} should Inspect surroundings to see who is in scene. Write {$GLOBALS["HERIKA_NAME"]}'s prose/narration."], + "cue"=>["{$gameRequest[3]}. {$GLOBALS["HERIKA_NAME"]} should identify who is in the scene and write {$GLOBALS["HERIKA_NAME"]}'s prose/narration."], "player_request"=>["The Narrator: {$gameRequest[3]}"], ], "cheatmode"=>[ diff --git a/service/processors/rolemaster/cmd/instruction.php b/service/processors/rolemaster/cmd/instruction.php index 9b11b3330..34f5c746b 100644 --- a/service/processors/rolemaster/cmd/instruction.php +++ b/service/processors/rolemaster/cmd/instruction.php @@ -75,8 +75,10 @@ // Function stuff require($enginePath . "functions/functions_instruction.php"); - $GLOBALS["ENABLED_FUNCTIONS"][]="ReturnBackHome"; - $GLOBALS["FUNCTIONS"][]=$GLOBALS["BASE_FUNCTIONS"]["ReturnBackHome"]; + if (!function_exists('herikaActionCatalogIsActionEnabled') || herikaActionCatalogIsActionEnabled("ReturnBackHome")) { + $GLOBALS["ENABLED_FUNCTIONS"][]="ReturnBackHome"; + $GLOBALS["FUNCTIONS"][]=$GLOBALS["BASE_FUNCTIONS"]["ReturnBackHome"]; + } $fnames=[]; foreach ($GLOBALS["F_NAMES"] as $functionCode=>$functionName) { @@ -356,4 +358,4 @@ function parseSceneNote($response) { Logger::info("Successfully logged instruction command to responselog"); -?> \ No newline at end of file +?> diff --git a/service/processors/rolemaster/cmd/suggestion.php b/service/processors/rolemaster/cmd/suggestion.php index ec367f452..f9731994b 100644 --- a/service/processors/rolemaster/cmd/suggestion.php +++ b/service/processors/rolemaster/cmd/suggestion.php @@ -34,8 +34,10 @@ // Function stuff require($enginePath . "functions/functions_instruction.php"); - $GLOBALS["ENABLED_FUNCTIONS"][]="ReturnBackHome"; - $GLOBALS["FUNCTIONS"][]=$GLOBALS["BASE_FUNCTIONS"]["ReturnBackHome"]; + if (!function_exists('herikaActionCatalogIsActionEnabled') || herikaActionCatalogIsActionEnabled("ReturnBackHome")) { + $GLOBALS["ENABLED_FUNCTIONS"][]="ReturnBackHome"; + $GLOBALS["FUNCTIONS"][]=$GLOBALS["BASE_FUNCTIONS"]["ReturnBackHome"]; + } $fnames=[]; foreach ($GLOBALS["F_NAMES"] as $functionCode=>$functionName) { diff --git a/ui/addons/snqe/quest_reference_table.php b/ui/addons/snqe/quest_reference_table.php index dfe933874..74b93f1d2 100644 --- a/ui/addons/snqe/quest_reference_table.php +++ b/ui/addons/snqe/quest_reference_table.php @@ -60,21 +60,17 @@ function quest_ref_parse_formids_input($rawInput) continue; } - $normalized = quest_reference_normalize_formid($tokenCn); - if ($normalized === null || $normalized < 0) { + $canonical = quest_reference_canonicalize_formid_for_text_storage($tokenCn); + if ($canonical === null || $canonical === '') { $invalid[] = $tokenCn; continue; } - $hex = strtolower(quest_reference_formid_to_hex($normalized)); - if ($hex === "") { - $invalid[] = $tokenCn; - continue; - } + $dedupeKey = strtolower($canonical); - if (!isset($seen[$hex])) { - $seen[$hex] = true; - $valid[] = $hex; + if (!isset($seen[$dedupeKey])) { + $seen[$dedupeKey] = true; + $valid[] = $canonical; } } @@ -104,19 +100,15 @@ function quest_ref_decode_formids_json($value) continue; } - $formId = quest_reference_normalize_formid($itemCn); - if ($formId === null || $formId < 0) { - continue; - } - - $hex = strtolower(quest_reference_formid_to_hex($formId)); - if ($hex === "") { + $canonical = quest_reference_canonicalize_formid_for_text_storage($itemCn); + if ($canonical === null || $canonical === '') { continue; } - if (!isset($seen[$hex])) { - $seen[$hex] = true; - $normalized[] = $hex; + $dedupeKey = strtolower($canonical); + if (!isset($seen[$dedupeKey])) { + $seen[$dedupeKey] = true; + $normalized[] = $canonical; } } @@ -729,7 +721,7 @@ function quest_ref_csv_cell($row, $index)
@@ -748,8 +740,8 @@ function quest_ref_csv_cell($row, $index) - -

Accepts JSON array or comma/newline-separated values. Decimal values are converted to canonical hex.

+ +

Accepts JSON array or comma/newline-separated values. Decimal values are converted to canonical hex, and stable Plugin.esp|LocalFormId values are preserved.

@@ -783,7 +775,7 @@ class="action-button download-csv" CSV columns: , formids_json, active, note.

- Quote JSON arrays in CSV, for example "[""0x0006762e"",""0x00058b3f""]". Empty active defaults to true. + Quote JSON arrays in CSV, for example "[""0x0006762e"",""MyMod.esp|000058B3""]". Empty active defaults to true.

Current rows: (active: , form IDs: ). diff --git a/ui/description_upload.php b/ui/description_upload.php index 669c33a5f..689713a32 100644 --- a/ui/description_upload.php +++ b/ui/description_upload.php @@ -729,6 +729,10 @@ Export Custom Descriptions

CSV format: baseid, name, description

+

+ Base IDs can use exact runtime FormIDs like 020098A0, legacy wildcard keys like XX0098A0 or FEXXX822, + or stable plugin-aware keys like Dawnguard.esm|000098A0. +

@@ -908,7 +912,7 @@ class="btn-danger" {$locationsWidgetContent} + {$pluginsWidgetContent} "; echo render_widget('CHIM Stats', $chimStatsHtml); echo $locationsModal; // Output modal HTML globally + echo $pluginsModal; // Output detected mods modal HTML globally echo $eventTypesModal; // Output event types modal HTML globally // Latest Diary Entry Widget diff --git a/ui/tmpl/navbar.php b/ui/tmpl/navbar.php index 5665434c0..156a3ccf1 100755 --- a/ui/tmpl/navbar.php +++ b/ui/tmpl/navbar.php @@ -397,7 +397,7 @@ function isValidPluginVersion($version) {
  • - AI Action Editor + Action Editor
  • diff --git a/unittests/tests/ActionCatalogTest.php b/unittests/tests/ActionCatalogTest.php new file mode 100644 index 000000000..4cec044a2 --- /dev/null +++ b/unittests/tests/ActionCatalogTest.php @@ -0,0 +1,201 @@ + 'MoveTo', + 'Drink' => 'Drink', + 'AttackHunt' => 'Hunt', + ], + [], + [], + [], + [], + [ + 'MoveTo' => [ + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'target' => ['type' => 'string'], + ], + 'required' => ['target'], + ], + ], + 'Drink' => [ + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'target' => ['type' => 'string'], + ], + 'required' => [], + ], + ], + ] + ); + + $this->assertArrayNotHasKey('AttackHunt', $rows); + $this->assertArrayNotHasKey('Surrender', $rows); + + $this->assertTrue($rows['MoveTo']['available_to_npc']); + $this->assertFalse($rows['MoveTo']['available_to_followers']); + $this->assertTrue($rows['MoveTo']['is_activated']); + + $this->assertTrue($rows['Drink']['available_to_npc']); + $this->assertTrue($rows['Drink']['available_to_followers']); + $this->assertTrue($rows['Drink']['is_activated']); + } + + public function testBuildActionCatalogSeedRows_SeedsParametersMetadataAndScriptProxyProgram(): void + { + $rows = herikaBuildActionCatalogSeedRows( + [ + 'MoveTo' => 'MoveTo', + 'Drink' => 'Drink', + ], + [], + [], + [], + [], + [ + 'MoveTo' => [ + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'target' => ['type' => 'string'], + ], + 'required' => ['target'], + ], + ], + 'Drink' => [ + 'parameters' => [ + 'type' => 'object', + 'properties' => [ + 'target' => ['type' => 'string'], + ], + 'required' => [], + ], + ], + ] + ); + + $this->assertSame('object', $rows['MoveTo']['parameters_json']['type']); + $this->assertSame(['target'], $rows['MoveTo']['parameters_json']['required']); + $this->assertSame('plugin_command', $rows['MoveTo']['metadata']['dispatch']); + $this->assertTrue($rows['MoveTo']['game_function']); + $this->assertNull($rows['MoveTo']['script_proxy_program']); + + $this->assertSame('script_proxy', $rows['Drink']['metadata']['dispatch']); + $this->assertTrue($rows['Drink']['game_function']); + $this->assertIsArray($rows['Drink']['script_proxy_program']); + $this->assertNotEmpty($rows['Drink']['script_proxy_program']['cases']); + } + + public function testBuildActionCatalogSeedRows_SeedsBuiltinRequirementsAndCooldownMetadata(): void + { + $rows = herikaBuildActionCatalogSeedRows( + [ + 'RentRoom' => 'RentRoom', + 'WaitHere' => 'WaitHere', + 'SheatheWeapon' => 'SheatheWeapon', + 'Training' => 'Training', + 'HireCarriage' => 'HireCarriage', + 'HireFerry' => 'HireFerry', + ], + [], + [], + [], + [], + [ + 'RentRoom' => ['parameters' => ['type' => 'object', 'properties' => [], 'required' => []]], + 'WaitHere' => ['parameters' => ['type' => 'object', 'properties' => [], 'required' => []]], + 'SheatheWeapon' => ['parameters' => ['type' => 'object', 'properties' => [], 'required' => []]], + 'Training' => ['parameters' => ['type' => 'object', 'properties' => [], 'required' => []]], + 'HireCarriage' => ['parameters' => ['type' => 'object', 'properties' => [], 'required' => []]], + 'HireFerry' => ['parameters' => ['type' => 'object', 'properties' => [], 'required' => []]], + ] + ); + + $this->assertSame(['0005091B'], $rows['RentRoom']['metadata']['requirements']['npc_factions_any']); + $this->assertSame(300, $rows['WaitHere']['metadata']['cooldown_seconds']); + $this->assertTrue($rows['SheatheWeapon']['metadata']['requirements']['activity']['is_weapon_drawn']); + $this->assertTrue($rows['Training']['metadata']['requirements']['requires_training_service']); + $this->assertSame( + 'allowed_npc_names', + $rows['HireCarriage']['metadata']['requirements']['npc_name_in_action_config_list']['config_key'] + ); + $this->assertSame( + "Bjorlam\nAlfarinn\nKibell\nSigaar\nThaer\nEngar\nGunjar\nMarkus", + $rows['HireCarriage']['metadata']['editor_fields'][1]['default'] + ); + $this->assertSame( + "Gort\nHarlaug\nJolf", + $rows['HireFerry']['metadata']['editor_fields'][1]['default'] + ); + } + + public function testBuildActionCatalogSeedRows_NormalizesDisplayTextToGenericNpcAndPlayerLabels(): void + { + $hadHerikaName = array_key_exists('HERIKA_NAME', $GLOBALS); + $hadPlayerName = array_key_exists('PLAYER_NAME', $GLOBALS); + $originalHerikaName = $GLOBALS['HERIKA_NAME'] ?? null; + $originalPlayerName = $GLOBALS['PLAYER_NAME'] ?? null; + + $GLOBALS['HERIKA_NAME'] = 'Narrator'; + $GLOBALS['PLAYER_NAME'] = 'RANGROO'; + + try { + $rows = herikaBuildActionCatalogSeedRows( + [ + 'TakeGoldFromPlayer' => 'TakeGoldFromRANGROO', + 'MakeFollower' => 'JoinRANGROOParty', + ], + [ + 'TakeGoldFromPlayer' => 'The Narrator takes amount (property target) of gold from RANGROO, once RANGROO is agree. infer amount from context.', + 'MakeFollower' => 'The Narrator joins RANGROO party and travels with RANGROO as an ally.', + ], + [ + 'TakeGoldFromPlayer' => 'RANGROO gave #TARGET# coins to The Narrator. If this a transaction, maybe GiveItemToPlayer is needed.', + 'MakeFollower' => 'The Narrator is now part of RANGROO party.', + ] + ); + + $this->assertSame('Take_Gold_From_Player', $rows['TakeGoldFromPlayer']['action_name']); + $this->assertSame( + 'NPC takes amount (property target) of gold from PLAYER, once PLAYER is agree. infer amount from context.', + $rows['TakeGoldFromPlayer']['description'] + ); + $this->assertSame( + 'PLAYER gave #TARGET# coins to NPC. If this a transaction, maybe GiveItemToPlayer is needed.', + $rows['TakeGoldFromPlayer']['return_message'] + ); + $this->assertSame('Join_Player_Party', $rows['MakeFollower']['action_name']); + $this->assertSame( + 'NPC joins PLAYER party and travels with PLAYER as an ally.', + $rows['MakeFollower']['description'] + ); + $this->assertSame( + 'NPC is now part of PLAYER party.', + $rows['MakeFollower']['return_message'] + ); + } finally { + if ($hadHerikaName) { + $GLOBALS['HERIKA_NAME'] = $originalHerikaName; + } else { + unset($GLOBALS['HERIKA_NAME']); + } + + if ($hadPlayerName) { + $GLOBALS['PLAYER_NAME'] = $originalPlayerName; + } else { + unset($GLOBALS['PLAYER_NAME']); + } + } + } +} diff --git a/unittests/tests/FormReferenceSupportTest.php b/unittests/tests/FormReferenceSupportTest.php new file mode 100644 index 000000000..e0c21fb75 --- /dev/null +++ b/unittests/tests/FormReferenceSupportTest.php @@ -0,0 +1,93 @@ + 'MyMod.esp', + 'is_light' => false, + 'compile_index' => 2, + 'small_file_compile_index' => 0, + 'partial_index' => 0, + 'formid_prefix' => '02', + 'updated_at' => '2026-04-27 00:00:00', + ]; + } + + if (stripos($query, "where lower(plugin_name) = lower('somelight.esl')") !== false) { + return [ + 'plugin_name' => 'SomeLight.esl', + 'is_light' => true, + 'compile_index' => 254, + 'small_file_compile_index' => 0x123, + 'partial_index' => 0x123, + 'formid_prefix' => 'FE123', + 'updated_at' => '2026-04-27 00:00:00', + ]; + } + + return null; + } + }; + } + + public function testQuestReferenceHelpersSupportStableReferences(): void + { + $this->assertSame( + 'MyMod.esp|000086EE', + quest_reference_canonicalize_formid_for_text_storage('MyMod.esp|86ee') + ); + $this->assertSame( + hexdec('020086EE'), + quest_reference_normalize_formid('MyMod.esp|000086EE') + ); + + $this->assertSame( + 'SomeLight.esl|00000822', + quest_reference_canonicalize_formid_for_text_storage('SomeLight.esl|822') + ); + $this->assertSame( + hexdec('FE123822'), + quest_reference_normalize_formid('SomeLight.esl|00000822') + ); + } + + public function testNpcMasterSupportsStableFactionDetection(): void + { + $npcData = [ + 'extended_data' => json_encode([ + 'factions' => [ + [ + 'formid' => '020086EE', + 'rank' => 0, + 'plugin' => 'MyMod.esp', + 'local_formid' => '000086EE', + 'stable_key' => 'MyMod.esp|000086EE', + ], + ], + ]), + ]; + + $npcMaster = new NpcMaster(); + + $this->assertTrue($npcMaster->isNpcInFaction($npcData, 'MyMod.esp|000086EE')); + $this->assertTrue($npcMaster->isNpcInFaction($npcData, '020086EE')); + $this->assertFalse($npcMaster->isNpcInFaction($npcData, 'MyMod.esp|00001234')); + } +}