ff_u_dyn_enc

Dynamic encounters: replace the creature list of an encounter on trigger.

On module start, every encounter blueprint (.ute) of the module’s custom palette is read through ResMan, and stored in the module’s SQLite database. Base game and hak encounters are ignored.

Encounters are stored in the dyn_enc table:

Column

Value

resref

ResRef of the encounter blueprint.

min

Minimum number of creatures (RecCreatures).

max

Maximum number of creatures (MaxCreatures).

creatures

Creature list, as a JSON array of NWNX_Encounter_CreatureListEntry: {"resref": "", "challengeRating": 0.0, "unique": 0}.

When a player triggers an encounter whose tag is the name of an existing 2da (<tag>.2da):

  1. <tag>.2da is loaded as a SQL table of the same name, unless it was already loaded. Encounters whose tag is not a 2da are left unchanged.

  2. A random row is picked from the table.

  3. The creature list of the RESREF blueprint of dyn_enc is copied to the triggered encounter. The MIN and MAX columns are read, but not applied yet: NWNX cannot set them on an encounter.

The encounter difficulty is never changed.

The creature list of the triggered encounter is resized to the size of the copied list.

Encounter tables

One row per encounter that can be picked:

Column

Value

RESREF

ResRef of a module encounter blueprint.

MIN

Minimum number of spawned creatures.

MAX

Maximum number of spawned creatures.

Variables

ENABLE__FF_U_DYN_ENC: (string) (opt-in) Set to "Y" on module to enable the unit.

Source code

// @code

#include "ff_i_core"


const string ENABLE__FF_U_DYN_ENC = "ENABLE__FF_U_DYN_ENC";

const string TABLE_DYN_ENC = "dyn_enc";

// Columns of the encounter tables
const string COLUMN_RESREF = "RESREF";
const string COLUMN_MIN    = "MIN";
const string COLUMN_MAX    = "MAX";

//! @brief A row of an encounter table.
struct DynEncRow
{
  string resref;  ///< ResRef of a dyn_enc encounter, "" if none was found.
  int minSpawned; ///< Minimum number of spawned creatures.
  int maxSpawned; ///< Maximum number of spawned creatures.
};


// -----------------------------------------------------------------------------

//! @brief Create the dynamic encounter table.
void CreateTable();
void CreateTable()
{
  SqlPlainExec("CREATE TABLE " + TABLE_DYN_ENC + " ("
    + "resref TEXT NOT NULL PRIMARY KEY, "
    + "min INTEGER NOT NULL, "
    + "max INTEGER NOT NULL, "
    + "creatures TEXT NOT NULL);");
}


// -----------------------------------------------------------------------------

//! @brief Return the creature list of an encounter template as a JSON array.
//! Entries have the fields of struct NWNX_Encounter_CreatureListEntry.
//! @param jTemplate Encounter template, as returned by TemplateToJson().
json GetCreatureList(json jTemplate);
json GetCreatureList(json jTemplate)
{
  json jResult = JsonArray();
  json jList = JsonPointer(jTemplate, "/CreatureList/value");
  int nCount = JsonGetLength(jList);
  int i;
  for (i = 0; i < nCount; i++)
  {
    json jEntry = JsonArrayGet(jList, i);
    json jCreature = JsonObject();
    jCreature = JsonObjectSet(jCreature, "resref", JsonPointer(jEntry, "/ResRef/value"));
    jCreature = JsonObjectSet(jCreature, "challengeRating", JsonPointer(jEntry, "/CR/value"));
    jCreature = JsonObjectSet(jCreature, "unique", JsonPointer(jEntry, "/SingleSpawn/value"));
    jResult = JsonArrayInsert(jResult, jCreature);
  }
  return jResult;
}


// -----------------------------------------------------------------------------

//! @brief Store one encounter blueprint in the dynamic encounter table.
//! @param sResRef ResRef of the encounter blueprint.
void StoreEncounter(string sResRef);
void StoreEncounter(string sResRef)
{
  json jTemplate = TemplateToJson(sResRef, RESTYPE_UTE);

  sqlquery sqlQuery = SqlPrepareQueryObject(GetModule(),
    "INSERT INTO " + TABLE_DYN_ENC + " (resref, min, max, creatures)"
    + " VALUES (@resref, @min, @max, @creatures);");
  SqlBindString(sqlQuery, "@resref", sResRef);
  SqlBindInt(sqlQuery, "@min", JsonGetInt(JsonPointer(jTemplate, "/RecCreatures/value")));
  SqlBindInt(sqlQuery, "@max", JsonGetInt(JsonPointer(jTemplate, "/MaxCreatures/value")));
  SqlBindString(sqlQuery, "@creatures", JsonDump(GetCreatureList(jTemplate)));
  SqlStep(sqlQuery);
  LogDebug(__FILE__, "Saved encounter '" + sResRef + "' in " + TABLE_DYN_ENC);
}


// -----------------------------------------------------------------------------

//! @brief Index every encounter blueprint of the module.
void OnModuleCreated();
void OnModuleCreated()
{
  CreateTable();

  int nCount = 0;
  // Module resources only: base game and hak encounters are skipped
  string sResRef = NWNX_Util_GetFirstResRef(NWNX_UTIL_RESREF_TYPE_ENCOUNTER, "", TRUE);
  while (sResRef != "")
  {
    sResRef = RemoveExtension(sResRef, "ute");
    StoreEncounter(sResRef);
    nCount++;
    sResRef = NWNX_Util_GetNextResRef();
  }

  LogNotice(__FILE__ + ": Indexed " + IntToString(nCount) + " encounter blueprint(s)");
}


// -----------------------------------------------------------------------------

//! @brief Load s2da as a SQL table named s2da, unless it already exists.
//! @param s2da 2da name.
//! @return TRUE if the table exists or was created, FALSE otherwise.
int LoadEncounterTable(string s2da);
int LoadEncounterTable(string s2da)
{
  sqlquery sqlQuery = SqlPrepareQueryObject(GetModule(),
    "SELECT 1 FROM sqlite_master WHERE name = @name;");
  SqlBindString(sqlQuery, "@name", s2da);
  if (SqlStep(sqlQuery))
    return TRUE;

  return NWNX_NWSQLiteExtensions_CreateVirtual2DATable(s2da);
}


// -----------------------------------------------------------------------------

//! @brief Return a random row of s2da. The resref is "" if no row was found.
//! @param s2da Name of a loaded encounter table.
struct DynEncRow GetRandomEncounter(string s2da);
struct DynEncRow GetRandomEncounter(string s2da)
{
  struct DynEncRow stRow;
  sqlquery sqlQuery = SqlPrepareQueryObject(GetModule(),
    "SELECT " + COLUMN_RESREF + ", " + COLUMN_MIN + ", " + COLUMN_MAX + " FROM " + s2da
    + " WHERE " + COLUMN_RESREF + " IS NOT NULL AND " + COLUMN_RESREF + " != ''"
    + " ORDER BY RANDOM() LIMIT 1;");
  if (SqlStep(sqlQuery))
  {
    stRow.resref = SqlGetString(sqlQuery, 0);
    stRow.minSpawned = SqlGetInt(sqlQuery, 1);
    stRow.maxSpawned = SqlGetInt(sqlQuery, 2);
    return stRow;
  }

  string sError = SqlGetError(sqlQuery);
  LogError(__FILE__ + ": No encounter found in '" + s2da + ".2da'" + (sError != "" ? ": " + sError : ""));
  return stRow;
}


// -----------------------------------------------------------------------------

//! @brief Copy a creature list to oEncounter, resizing its list to the same size.
//! @param oEncounter The encounter to modify.
//! @param jCreatures JSON array of NWNX_Encounter_CreatureListEntry fields.
void SetCreatureList(object oEncounter, json jCreatures);
void SetCreatureList(object oEncounter, json jCreatures)
{
  int nCount = JsonGetLength(jCreatures);
  if (nCount == 0)
    return;

  NWNX_Encounter_SetNumberOfCreaturesInEncounterList(oEncounter, nCount);

  int i;
  for (i = 0; i < nCount; i++)
  {
    json jCreature = JsonArrayGet(jCreatures, i);
    struct NWNX_Encounter_CreatureListEntry stEntry;
    stEntry.resref = JsonGetString(JsonObjectGet(jCreature, "resref"));
    stEntry.challengeRating = JsonGetFloat(JsonObjectGet(jCreature, "challengeRating"));
    stEntry.unique = JsonGetInt(JsonObjectGet(jCreature, "unique"));
    stEntry.alreadyUsed = FALSE;
    NWNX_Encounter_SetEncounterCreatureByIndex(oEncounter, i, stEntry);
  }
}


// -----------------------------------------------------------------------------

//! @brief Replace the creature list of oEncounter from the 2da named by its tag.
//! @param oEncounter The triggered encounter.
void OnEncounterTrigger(object oEncounter);
void OnEncounterTrigger(object oEncounter)
{
  object oEntered = StringToObject(NWNX_Events_GetEventData("ENTERED"));
  if (!GetIsPC(oEntered))
    return;

  // Not a dynamic encounter: no 2da is named after the tag
  string s2da = GetStringLowerCase(GetTag(oEncounter));
  if (s2da == "" || ResManGetAliasFor(s2da, RESTYPE_2DA) == "")
    return;

  if (!LoadEncounterTable(s2da))
  {
    LogError(__FILE__ + ": Cannot load '" + s2da + ".2da' for encounter '" + GetResRef(oEncounter) + "'");
    return;
  }

  struct DynEncRow stRow = GetRandomEncounter(s2da);
  string sResRef = stRow.resref;
  if (sResRef == "")
    return;

  sqlquery sqlQuery = SqlPrepareQueryObject(GetModule(),
    "SELECT creatures FROM " + TABLE_DYN_ENC + " WHERE resref = @resref;");
  SqlBindString(sqlQuery, "@resref", sResRef);
  if (!SqlStep(sqlQuery))
  {
    LogError(__FILE__ + ": Encounter '" + sResRef + "' from '" + s2da + ".2da' is not in " + TABLE_DYN_ENC);
    return;
  }

  SetCreatureList(oEncounter, JsonParse(SqlGetString(sqlQuery, 0)));
  // TODO: Set stRow.minSpawned and stRow.maxSpawned once NWNX can set them on an encounter
  LogDebug(__FILE__, "Encounter '" + GetTag(oEncounter) + "' uses the creatures of '" + sResRef + "' from '" + s2da + ".2da'"
    + ", min=" + IntToString(stRow.minSpawned) + ", max=" + IntToString(stRow.maxSpawned));
}


// -----------------------------------------------------------------------------

void main()
{
  if (!GetModuleFlag(ENABLE__FF_U_DYN_ENC, FALSE))
    return;

  string sEvent = NWNX_Events_GetCurrentEvent();
  if (sEvent == ON_REGISTER)
  {
    SubscribeToEvent(ON_MODULE_CREATED, __FILE__);
    SubscribeToEvent(NWNX_ON_ENCOUNTER_TRIGGER_BEFORE, __FILE__);
  }
  else if (sEvent == ON_MODULE_CREATED)
    OnModuleCreated();
  else if (sEvent == NWNX_ON_ENCOUNTER_TRIGGER_BEFORE)
    OnEncounterTrigger(OBJECT_SELF);
}