Script: bst_db

// bst_db.nss — Bestiary / creature-kill-tracking database helpers
//
// Campaign DB: "bestiarydb" (SQLite, file database/bestiary.sqlite3)
//
// Tables:
//   kills        (uuid, cdkey, char_name, resref, solo_kills, party_kills, last_kill)
//                  PRIMARY KEY (uuid, resref) — per-character per-creature totals.
//                  Character identity is GetObjectUUID (persists in the .bic);
//                  cdkey kept so per-(character,cdkey) aggregation is possible.
//   server_first (resref PK, cr, first_uuid, first_name, first_cdkey, first_player_name, first_at)
//                  one row per hard creature (CR >= BST_SF_CR) first slain server-wide.
//   catalogue    (resref PK, name, cr) — every creature type, seeded by nwn-wiki.
//   resref_alias (resref PK, canonical) — maps blueprint/variant resrefs to the
//                  canonical resref used everywhere else, also seeded by nwn-wiki.
//
// Kills are recorded by CANONICAL resref (see Bst_Canonical) so the in-game
// bestiary, the per-creature confirmation, and the wiki stats all agree.

const string BST_DB    = "bestiarydb";
const float  BST_SF_CR = 60.0;   // "Server First" threshold (Challenge Rating)

// ------------------------------------------------------------
// Schema

void Bst_InitDb()
{
    sqlquery q;

    q = SqlPrepareQueryCampaign(BST_DB,
        "CREATE TABLE IF NOT EXISTS kills (" +
        "uuid TEXT NOT NULL," +
        "cdkey TEXT NOT NULL," +
        "char_name TEXT," +
        "resref TEXT NOT NULL," +
        "solo_kills INTEGER NOT NULL DEFAULT 0," +
        "party_kills INTEGER NOT NULL DEFAULT 0," +
        "last_kill TEXT," +
        "PRIMARY KEY (uuid, resref))");
    SqlStep(q);

    q = SqlPrepareQueryCampaign(BST_DB,
        "CREATE INDEX IF NOT EXISTS idx_kills_resref ON kills(resref)");
    SqlStep(q);

    q = SqlPrepareQueryCampaign(BST_DB,
        "CREATE TABLE IF NOT EXISTS server_first (" +
        "resref TEXT PRIMARY KEY," +
        "cr REAL," +
        "first_uuid TEXT," +
        "first_name TEXT," +
        "first_cdkey TEXT," +
        "first_player_name TEXT," +
        "first_at TEXT NOT NULL DEFAULT (datetime('now')))");
    SqlStep(q);
    // Migration: add first_player_name to pre-existing DBs. Guard with PRAGMA so the
    // ALTER is skipped on logins after the column already exists.
    q = SqlPrepareQueryCampaign(BST_DB,
        "SELECT 1 FROM pragma_table_info('server_first') WHERE name='first_player_name'");
    if (!SqlStep(q))
    {
        q = SqlPrepareQueryCampaign(BST_DB,
            "ALTER TABLE server_first ADD COLUMN first_player_name TEXT");
        SqlStep(q);
    }

    q = SqlPrepareQueryCampaign(BST_DB,
        "CREATE TABLE IF NOT EXISTS catalogue (" +
        "resref TEXT PRIMARY KEY, name TEXT, cr REAL)");
    SqlStep(q);

    q = SqlPrepareQueryCampaign(BST_DB,
        "CREATE TABLE IF NOT EXISTS resref_alias (" +
        "resref TEXT PRIMARY KEY, canonical TEXT NOT NULL)");
    SqlStep(q);
}

// ------------------------------------------------------------
// Recording

// Resolve an instance/blueprint resref to its canonical resref. Falls back to
// the input when no alias row exists (e.g. catalogue not yet seeded).
string Bst_Canonical(string sResref)
{
    sqlquery q = SqlPrepareQueryCampaign(BST_DB,
        "SELECT canonical FROM resref_alias WHERE resref=@r");
    SqlBindString(q, "@r", sResref);
    if (SqlStep(q)) return SqlGetString(q, 0);
    return sResref;
}

// Add one kill of sResref (already canonical) to a character's record.
// bParty TRUE -> party kill, FALSE -> solo kill.
void Bst_RecordKill(string sUuid, string sCdkey, string sName, string sResref, int bParty)
{
    sqlquery q = SqlPrepareQueryCampaign(BST_DB,
        "INSERT INTO kills(uuid,cdkey,char_name,resref,solo_kills,party_kills,last_kill)" +
        " VALUES(@u,@k,@n,@r,@s,@p,datetime('now'))" +
        " ON CONFLICT(uuid,resref) DO UPDATE SET" +
        " solo_kills=solo_kills+@s," +
        " party_kills=party_kills+@p," +
        " cdkey=excluded.cdkey," +
        " char_name=excluded.char_name," +
        " last_kill=excluded.last_kill");
    SqlBindString(q, "@u", sUuid);
    SqlBindString(q, "@k", sCdkey);
    SqlBindString(q, "@n", sName);
    SqlBindString(q, "@r", sResref);
    SqlBindInt(q, "@s", bParty ? 0 : 1);
    SqlBindInt(q, "@p", bParty ? 1 : 0);
    SqlStep(q);
}

// TRUE when sResref exists in the seeded creature catalogue. A kill of a
// creature NOT in the catalogue (script-spawned, or added since the last wiki
// refresh) is still recorded, but bst_ondeath logs it for DM review.
int Bst_InCatalogue(string sResref)
{
    sqlquery q = SqlPrepareQueryCampaign(BST_DB,
        "SELECT 1 FROM catalogue WHERE resref=@r");
    SqlBindString(q, "@r", sResref);
    return SqlStep(q);
}

// Total kills (solo+party) of sResref by a character — for the combat-log line.
int Bst_GetTotal(string sUuid, string sResref)
{
    sqlquery q = SqlPrepareQueryCampaign(BST_DB,
        "SELECT solo_kills + party_kills FROM kills WHERE uuid=@u AND resref=@r");
    SqlBindString(q, "@u", sUuid);
    SqlBindString(q, "@r", sResref);
    if (SqlStep(q)) return SqlGetInt(q, 0);
    return 0;
}

// Register the first server-wide kill of a hard creature. Returns TRUE only when
// this call created the row (i.e. it really was the server first).
int Bst_RegisterServerFirst(string sResref, float fCR, string sUuid, string sName, string sCdkey, string sPlayerName)
{
    sqlquery qc = SqlPrepareQueryCampaign(BST_DB,
        "SELECT 1 FROM server_first WHERE resref=@r");
    SqlBindString(qc, "@r", sResref);
    if (SqlStep(qc)) return FALSE;       // already recorded

    sqlquery q = SqlPrepareQueryCampaign(BST_DB,
        "INSERT INTO server_first(resref,cr,first_uuid,first_name,first_cdkey,first_player_name)" +
        " VALUES(@r,@c,@u,@n,@k,@pn) ON CONFLICT(resref) DO NOTHING");
    SqlBindString(q, "@r", sResref);
    SqlBindFloat (q, "@c", fCR);
    SqlBindString(q, "@u", sUuid);
    SqlBindString(q, "@n", sName);
    SqlBindString(q, "@k", sCdkey);
    SqlBindString(q, "@pn", sPlayerName);
    SqlStep(q);
    return TRUE;
}

// ------------------------------------------------------------
// In-game bestiary menu (book conversation). Tokens 5030-5041.
//   local int    "bst_mode"       0 = Creatures Slain, 1 = Not Yet Slain
//   local int    "bst_page_off"   row offset (multiples of 9)
//   local int    "bst_page_total" total rows in the section (set here)
//   local string "bst_slot_N_resref" canonical resref shown in slot N
// Mirrors Merit_BuildPage in merit_db.nss.

void Bst_BuildPage(object oPC)
{
    int    nMode = GetLocalInt(oPC, "bst_mode");
    int    nOff  = GetLocalInt(oPC, "bst_page_off");
    string sUuid = GetObjectUUID(oPC);

    // Section row count (for pagination + [Next >>] visibility).
    sqlquery qc;
    if (nMode == 0)
        qc = SqlPrepareQueryCampaign(BST_DB,
            "SELECT COUNT(*) FROM catalogue c" +
            " JOIN kills k ON k.resref=c.resref WHERE k.uuid=@u");
    else
        qc = SqlPrepareQueryCampaign(BST_DB,
            "SELECT COUNT(*) FROM catalogue c" +
            " WHERE c.resref NOT IN (SELECT resref FROM kills WHERE uuid=@u)");
    SqlBindString(qc, "@u", sUuid);
    int nTotal = 0;
    if (SqlStep(qc)) nTotal = SqlGetInt(qc, 0);
    SetLocalInt(oPC, "bst_page_total", nTotal);

    int nPages = (nTotal + 8) / 9;
    if (nPages == 0) nPages = 1;
    int nPage = nOff / 9 + 1;
    SetCustomToken(5040, nMode == 0 ? "Creatures Slain" : "Not Yet Slain");
    SetCustomToken(5041, "Page " + IntToString(nPage) + " of " + IntToString(nPages));

    int i;
    for (i = 0; i < 9; i++)
    {
        DeleteLocalString(oPC, "bst_slot_" + IntToString(i) + "_resref");
        SetCustomToken(5030 + i, "");
    }

    sqlquery q;
    if (nMode == 0)
        q = SqlPrepareQueryCampaign(BST_DB,
            "SELECT c.resref, c.name, c.cr, k.solo_kills, k.party_kills" +
            " FROM catalogue c JOIN kills k ON k.resref=c.resref" +
            " WHERE k.uuid=@u ORDER BY c.cr DESC, c.name ASC LIMIT 9 OFFSET @off");
    else
        q = SqlPrepareQueryCampaign(BST_DB,
            "SELECT c.resref, c.name, c.cr" +
            " FROM catalogue c WHERE c.resref NOT IN (SELECT resref FROM kills WHERE uuid=@u)" +
            " ORDER BY c.cr DESC, c.name ASC LIMIT 9 OFFSET @off");
    SqlBindString(q, "@u", sUuid);
    SqlBindInt(q, "@off", nOff);

    i = 0;
    while (SqlStep(q) && i < 9)
    {
        string sResref = SqlGetString(q, 0);
        string sName   = SqlGetString(q, 1);
        int    nCR     = FloatToInt(SqlGetFloat(q, 2));

        SetLocalString(oPC, "bst_slot_" + IntToString(i) + "_resref", sResref);

        string sLabel;
        if (nMode == 0)
            sLabel = sName + "  (CR " + IntToString(nCR) + ")  [Solo:"
                   + IntToString(SqlGetInt(q, 3)) + " Party:"
                   + IntToString(SqlGetInt(q, 4)) + "]";
        else
            sLabel = sName + "  (CR " + IntToString(nCR) + ")";

        SetCustomToken(5030 + i, sLabel);
        i++;
    }
}