Script: merit_db

// merit_db.nss — Merit Award System database helpers
//
// Campaign DB: "meritdb" (SQLite)
// Schema:  players(cdkey PK, name, last_login, bugs, exploits, features, merit_spent)
//
// Merit rates: defect=1pt  exploit=3pts  feature=2pts

const string MERIT_DB            = "meritdb";
const int    MERIT_BUG_VALUE     = 1;
const int    MERIT_EXPLOIT_VALUE = 3;
const int    MERIT_FEATURE_VALUE = 2;

const int MERIT_COST_1 = 5;
const int MERIT_COST_2 = 10;
const int MERIT_COST_3 = 20;
const int MERIT_COST_4 = 35;
const int MERIT_COST_5 = 50;

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

void Merit_InitDb()
{
    sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
        "CREATE TABLE IF NOT EXISTS players (" +
        "cdkey TEXT PRIMARY KEY," +
        "name TEXT," +
        "last_login TEXT," +
        "bugs INTEGER DEFAULT 0," +
        "exploits INTEGER DEFAULT 0," +
        "features INTEGER DEFAULT 0," +
        "merit_spent INTEGER DEFAULT 0)");
    SqlStep(q);

    // Redemption requests. Escrow model: cost is debited (added to
    // players.merit_spent) the moment a row is inserted as 'pending', and
    // refunded only if the row is 'cancelled'. 'fulfilled' rows keep the debit.
    sqlquery qr = SqlPrepareQueryCampaign(MERIT_DB,
        "CREATE TABLE IF NOT EXISTS redemptions (" +
        "id INTEGER PRIMARY KEY AUTOINCREMENT," +
        "cdkey TEXT NOT NULL," +
        "player_name TEXT," +
        "reward_id INTEGER NOT NULL," +
        "reward_label TEXT," +
        "cost INTEGER NOT NULL," +
        "needs_dm INTEGER NOT NULL DEFAULT 1," +
        "status TEXT NOT NULL DEFAULT 'pending'," +     // pending|fulfilled|cancelled
        "requested_at TEXT NOT NULL DEFAULT (datetime('now'))," +
        "resolved_by TEXT," +
        "resolved_at TEXT," +
        "item_tag TEXT)");                              // unique serial tag of a granted item
    SqlStep(qr);

    // Migration: older DBs created the redemptions table before item_tag
    // existed; CREATE TABLE IF NOT EXISTS won't alter it, so add the column
    // when missing. PRAGMA table_info is checked first to avoid logging a SQL
    // error every boot from re-running ALTER on a table that already has it.
    int bHasItemTag = FALSE;
    sqlquery qp = SqlPrepareQueryCampaign(MERIT_DB, "PRAGMA table_info(redemptions)");
    while (SqlStep(qp))
        if (SqlGetString(qp, 1) == "item_tag") { bHasItemTag = TRUE; break; }
    if (!bHasItemTag)
    {
        sqlquery qa = SqlPrepareQueryCampaign(MERIT_DB,
            "ALTER TABLE redemptions ADD COLUMN item_tag TEXT");
        SqlStep(qa);
    }

    // Transaction ledger — every merit movement (spend/refund/award) with the
    // resulting available balance, for audit and recovery. Never pruned.
    sqlquery ql = SqlPrepareQueryCampaign(MERIT_DB,
        "CREATE TABLE IF NOT EXISTS merit_ledger (" +
        "id INTEGER PRIMARY KEY AUTOINCREMENT," +
        "cdkey TEXT NOT NULL," +
        "player_name TEXT," +
        "delta INTEGER NOT NULL," +          // <0 spent, >0 refunded/awarded
        "balance_after INTEGER," +
        "reason TEXT," +
        "redemption_id INTEGER," +
        "created_at TEXT NOT NULL DEFAULT (datetime('now')))");
    SqlStep(ql);
}

// ------------------------------------------------------------
// Login tracking

void Merit_RecordLogin(object oPC)
{
    sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
        "INSERT INTO players(cdkey, name, last_login) VALUES(@k, @n, datetime('now'))" +
        " ON CONFLICT(cdkey) DO UPDATE SET name=excluded.name, last_login=excluded.last_login");
    SqlBindString(q, "@k", GetPCPublicCDKey(oPC));
    SqlBindString(q, "@n", GetPCPlayerName(oPC));
    SqlStep(q);
}

// Sent on login only when the player has at least one contribution.
void Merit_LoginMessage(object oPC)
{
    sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
        "SELECT bugs, exploits, features, merit_spent FROM players WHERE cdkey=@k");
    SqlBindString(q, "@k", GetPCPublicCDKey(oPC));
    if (!SqlStep(q)) return;

    int nBugs  = SqlGetInt(q, 0);
    int nExp   = SqlGetInt(q, 1);
    int nFtr   = SqlGetInt(q, 2);
    int nSpent = SqlGetInt(q, 3);

    if (nBugs + nExp + nFtr == 0) return;

    int nEarned = nBugs  * MERIT_BUG_VALUE
                + nExp   * MERIT_EXPLOIT_VALUE
                + nFtr   * MERIT_FEATURE_VALUE;
    int nAvail  = nEarned - nSpent;

    SendMessageToPC(oPC,
        "\n[Merit] Thank you for your contributions to this world!\n" +
        "  Defects reported:    " + IntToString(nBugs) + "\n" +
        "  Exploits reported:   " + IntToString(nExp)  + "\n" +
        "  Features implemented:" + IntToString(nFtr)  + "\n" +
        "Merit balance: " + IntToString(nAvail) + " pts available to spend.\n" +
        "Visit Barliman the barkeep in the Prancing Pony to redeem rewards.");
}

// ------------------------------------------------------------
// Awarding

void Merit_AwardBug(string sCdKey)
{
    sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
        "UPDATE players SET bugs=bugs+1 WHERE cdkey=@k");
    SqlBindString(q, "@k", sCdKey);
    SqlStep(q);
}

void Merit_AwardExploit(string sCdKey)
{
    sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
        "UPDATE players SET exploits=exploits+1 WHERE cdkey=@k");
    SqlBindString(q, "@k", sCdKey);
    SqlStep(q);
}

void Merit_AwardFeature(string sCdKey)
{
    sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
        "UPDATE players SET features=features+1 WHERE cdkey=@k");
    SqlBindString(q, "@k", sCdKey);
    SqlStep(q);
}

// ------------------------------------------------------------
// Balance

int Merit_Available(string sCdKey)
{
    sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
        "SELECT bugs, exploits, features, merit_spent FROM players WHERE cdkey=@k");
    SqlBindString(q, "@k", sCdKey);
    if (!SqlStep(q)) return 0;
    int nBugs  = SqlGetInt(q, 0);
    int nExp   = SqlGetInt(q, 1);
    int nFtr   = SqlGetInt(q, 2);
    int nSpent = SqlGetInt(q, 3);
    return nBugs  * MERIT_BUG_VALUE
         + nExp   * MERIT_EXPLOIT_VALUE
         + nFtr   * MERIT_FEATURE_VALUE
         - nSpent;
}

// Append a ledger row. nDelta < 0 = spent, > 0 = refunded/awarded. Records the
// available balance *after* the movement, so call this once the underlying
// counters (merit_spent / bugs / exploits / features) are already updated.
// Pass nRedemptionId = 0 when not tied to a redemption. If sName is "", the
// player's stored name is looked up.
void Merit_Ledger(string sCdKey, string sName, int nDelta, string sReason, int nRedemptionId)
{
    if (sName == "")
    {
        sqlquery qn = SqlPrepareQueryCampaign(MERIT_DB,
            "SELECT name FROM players WHERE cdkey=@k");
        SqlBindString(qn, "@k", sCdKey);
        if (SqlStep(qn)) sName = SqlGetString(qn, 0);
    }

    sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
        "INSERT INTO merit_ledger(cdkey, player_name, delta, balance_after, reason, redemption_id)"
        + " VALUES(@k, @n, @d, @b, @r, @i)");
    SqlBindString(q, "@k", sCdKey);
    SqlBindString(q, "@n", sName);
    SqlBindInt(q, "@d", nDelta);
    SqlBindInt(q, "@b", Merit_Available(sCdKey));
    SqlBindString(q, "@r", sReason);
    SqlBindInt(q, "@i", nRedemptionId);
    SqlStep(q);
}

void Merit_Spend(string sCdKey, int nCost)
{
    sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
        "UPDATE players SET merit_spent=merit_spent+@c WHERE cdkey=@k");
    SqlBindInt(q, "@c", nCost);
    SqlBindString(q, "@k", sCdKey);
    SqlStep(q);
}

// Refund escrowed merit (e.g. a cancelled redemption). Clamps merit_spent at 0
// so a double-cancel or bookkeeping slip can never drive it negative.
void Merit_Refund(string sCdKey, int nCost)
{
    if (nCost <= 0) return;
    sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
        "UPDATE players SET merit_spent=MAX(0, merit_spent-@c) WHERE cdkey=@k");
    SqlBindInt(q, "@c", nCost);
    SqlBindString(q, "@k", sCdKey);
    SqlStep(q);
}

// ------------------------------------------------------------
// NPC conversation tokens (5020-5027)
// Call from reply action scripts; tokens are set before the next entry renders.

void Merit_SetNpcTokens(object oPC)
{
    sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
        "SELECT bugs, exploits, features, merit_spent FROM players WHERE cdkey=@k");
    SqlBindString(q, "@k", GetPCPublicCDKey(oPC));

    int nBugs  = 0;
    int nExp   = 0;
    int nFtr   = 0;
    int nSpent = 0;
    if (SqlStep(q))
    {
        nBugs  = SqlGetInt(q, 0);
        nExp   = SqlGetInt(q, 1);
        nFtr   = SqlGetInt(q, 2);
        nSpent = SqlGetInt(q, 3);
    }

    int nBugPts = nBugs  * MERIT_BUG_VALUE;
    int nExpPts = nExp   * MERIT_EXPLOIT_VALUE;
    int nFtrPts = nFtr   * MERIT_FEATURE_VALUE;
    int nEarned = nBugPts + nExpPts + nFtrPts;
    int nAvail  = nEarned - nSpent;

    SetCustomToken(5020, IntToString(nBugs));
    SetCustomToken(5021, IntToString(nExp));
    SetCustomToken(5022, IntToString(nFtr));
    SetCustomToken(5023, IntToString(nBugPts));
    SetCustomToken(5024, IntToString(nExpPts));
    SetCustomToken(5025, IntToString(nFtrPts));
    SetCustomToken(5026, IntToString(nEarned));
    SetCustomToken(5027, IntToString(nAvail));
}

// ------------------------------------------------------------
// DM emote-wand player list (tokens 5001-5010)
// Call from reply action scripts; tokens are set before E_PLAYER_LIST renders.

void Merit_BuildPage(object oDM)
{
    int nOff = GetLocalInt(oDM, "merit_page_off");

    sqlquery qCount = SqlPrepareQueryCampaign(MERIT_DB, "SELECT COUNT(*) FROM players");
    int nTotal = 0;
    if (SqlStep(qCount)) nTotal = SqlGetInt(qCount, 0);
    SetLocalInt(oDM, "merit_page_total", nTotal);

    int nPages = (nTotal + 8) / 9;
    if (nPages == 0) nPages = 1;
    int nPage = nOff / 9 + 1;
    SetCustomToken(5010, "Page " + IntToString(nPage) + " of " + IntToString(nPages));

    int i;
    for (i = 0; i < 9; i++)
    {
        DeleteLocalString(oDM, "merit_slot_" + IntToString(i) + "_cdkey");
        DeleteLocalString(oDM, "merit_slot_" + IntToString(i) + "_name");
        SetCustomToken(5001 + i, "(empty)");
    }

    sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
        "SELECT cdkey, name, bugs, exploits, features, merit_spent" +
        " FROM players ORDER BY last_login DESC LIMIT 9 OFFSET @off");
    SqlBindInt(q, "@off", nOff);

    i = 0;
    while (SqlStep(q) && i < 9)
    {
        string sCdKey  = SqlGetString(q, 0);
        string sName   = SqlGetString(q, 1);
        int nBugs      = SqlGetInt(q, 2);
        int nExp       = SqlGetInt(q, 3);
        int nFtr       = SqlGetInt(q, 4);
        int nSpent     = SqlGetInt(q, 5);
        int nAvail     = nBugs  * MERIT_BUG_VALUE
                       + nExp   * MERIT_EXPLOIT_VALUE
                       + nFtr   * MERIT_FEATURE_VALUE
                       - nSpent;

        SetLocalString(oDM, "merit_slot_" + IntToString(i) + "_cdkey", sCdKey);
        SetLocalString(oDM, "merit_slot_" + IntToString(i) + "_name",  sName);

        string sLabel = sName
            + " [D:" + IntToString(nBugs)
            + " E:" + IntToString(nExp)
            + " F:" + IntToString(nFtr)
            + " bal:" + IntToString(nAvail) + "]";
        SetCustomToken(5001 + i, sLabel);
        i++;
    }
}