Script: merit_redeem
// merit_redeem.nss — Merit redemption catalogue + request/approval engine.
//
// Earned merit is never decremented (it stays bugs*1 + exploits*3 + features*2,
// in merit_db.nss). Spending is tracked entirely through players.merit_spent via
// an ESCROW model:
// * Merit_RequestById debits the cost immediately and inserts a 'pending'
// redemptions row. The server-side affordability check there guarantees
// merit_spent can never exceed earned.
// * Cancelling a pending request refunds the escrow (Merit_Refund).
// * Fulfilling keeps the debit.
//
// This pass ships every documented option as a PLACEHOLDER (red prefix). Nothing
// is auto-granted; each selection creates a request a DM fulfils or cancels.
// See CLAUDE-merit.md for how to add an option or graduate one to automated.
//
// Custom token map (this file):
// 5037 Player: confirmation-step prompt (delivery type / affordability)
// 5038 DM: selected-request description (detail entry)
// 5040-5048 DM: pending-redemption list slot labels
// 5049 DM: pending-redemption list header
// 5050-5058 Player: own-pending list slot labels ([Cancel] rows)
// 5059 Player: own-pending list header
// 5060-5068 Player: category option slot labels (with red placeholder prefix)
// 5070-5078 Player: tournament-gear picker slot labels (item names)
// Shared per-speaker locals "merit_lslot_0".."merit_lslot_8" hold the row/reward
// id for the currently displayed list (0 = empty). DM list also stashes
// "merit_lslot_<i>_desc" for the detail entry. The confirmation step uses
// separate locals: merit_pick_id / merit_pick_dm / merit_pick_afford, and the
// tournament picker uses merit_tslot_<i> (resref) + merit_tpage_off/total — all
// distinct from merit_lslot_* so "Nevermind" returns to the option list intact.
#include "merit_db"
#include "boost_inc"
// Red "[PLACEHOLDER] " prefix. MERITREDOPEN is replaced post-write with the raw
// colour bytes "<c" + FF 01 01 + ">" (null-free bright red, repo convention).
const string MERIT_PH = "<c�>[PLACEHOLDER]</c> ";
// Categories (match the conversation sub-menus and Merit_CatReward below).
const int MERIT_CAT_TELE = 0;
const int MERIT_CAT_PREM = 1;
const int MERIT_CAT_VANITY = 2;
const int MERIT_CAT_HOUSESIZE = 3;
const int MERIT_CAT_HOUSEFEAT = 4;
struct merit_reward
{
int valid;
int cost;
int needs_dm;
string label;
};
// ------------------------------------------------------------
// Catalogue — single source of truth. To add a redemption: add a case here and
// register its id in Merit_CatReward / Merit_CatCount.
struct merit_reward Merit_GetReward(int nId)
{
struct merit_reward r;
r.valid = 1;
switch (nId)
{
// --- Teleport & travel (rest-menu features) ---
case 101: r.cost = 3; r.needs_dm = 0; r.label = "Teleport to your party leader"; break;
case 102: r.cost = 4; r.needs_dm = 0; r.label = "Teleport to your last Well-of-Eru save point"; break;
case 103: r.cost = 6; r.needs_dm = 0; r.label = "Save-slot teleport (Slot 1)"; break;
case 104: r.cost = 7; r.needs_dm = 0; r.label = "Save-slot teleport (Slot 2)"; break;
case 105: r.cost = 8; r.needs_dm = 0; r.label = "Save-slot teleport (Slot 3)"; break;
case 106: r.cost = 9; r.needs_dm = 0; r.label = "Save-slot teleport (Slot 4)"; break;
case 107: r.cost = 10; r.needs_dm = 0; r.label = "Save-slot teleport (Slot 5)"; break;
// --- Premium boosts (2x gold & XP; extra purchases queue, they never stack) ---
case 201: r.cost = 1; r.needs_dm = 0; r.label = "1 week of 2x gold & XP for you"; break;
case 202: r.cost = 3; r.needs_dm = 0; r.label = "1 month of 2x gold & XP for you"; break;
case 203: r.cost = 4; r.needs_dm = 0; r.label = "1 week of 2x gold & XP for the whole server (+ your name in login welcome)"; break;
case 204: r.cost = 8; r.needs_dm = 0; r.label = "1 month of 2x gold & XP for the whole server (+ your name in login welcome)"; break;
// --- Vanity & swag ---
case 301: r.cost = 5; r.needs_dm = 1; r.label = "Graffiti the Well of Eru with your name & description"; break;
case 302: r.cost = 10; r.needs_dm = 0; r.label = "Tournament gear - pick one piece"; break;
case 303: r.cost = 25; r.needs_dm = 1; r.label = "A hand-made duct-tape wallet mailed to you (3 colours + design)"; break;
case 304: r.cost = 100; r.needs_dm = 1; r.label = "Become a DM"; break;
// --- Player housing: area size (cost scales with Length x Width) ---
case 401: r.cost = 1; r.needs_dm = 1; r.label = "Player home - area size 2 (LxW)"; break;
case 402: r.cost = 2; r.needs_dm = 1; r.label = "Player home - area size 4 (LxW)"; break;
case 403: r.cost = 3; r.needs_dm = 1; r.label = "Player home - area size 8 (LxW)"; break;
case 404: r.cost = 4; r.needs_dm = 1; r.label = "Player home - area size 16 (LxW)"; break;
case 405: r.cost = 5; r.needs_dm = 1; r.label = "Player home - area size 32 (LxW)"; break;
case 406: r.cost = 6; r.needs_dm = 1; r.label = "Player home - area size 64 (LxW)"; break;
case 407: r.cost = 7; r.needs_dm = 1; r.label = "Player home - area size 81 (LxW)"; break;
case 408: r.cost = 8; r.needs_dm = 1; r.label = "Player home - area size 100 (LxW)"; break;
// --- Player housing: optional add-ons ---
case 501: r.cost = 2; r.needs_dm = 1; r.label = "Home add-on: persistent storage chest (shared across your characters)"; break;
case 502: r.cost = 2; r.needs_dm = 1; r.label = "Home add-on: a Well-of-Eru store (100K buy limit; you pick NPC name & look)"; break;
case 503: r.cost = 5; r.needs_dm = 1; r.label = "Home add-on: a Tagget-tier forge"; break;
case 504: r.cost = 10; r.needs_dm = 1; r.label = "Home add-on: a Rivendell-tier forge"; break;
case 505: r.cost = 15; r.needs_dm = 1; r.label = "Home add-on: a Balrog-tier forge"; break;
default: r.valid = 0; r.cost = 0; r.needs_dm = 1; r.label = ""; break;
}
return r;
}
int Merit_CatCount(int nCat)
{
switch (nCat)
{
case MERIT_CAT_TELE: return 7;
case MERIT_CAT_PREM: return 4;
case MERIT_CAT_VANITY: return 4;
case MERIT_CAT_HOUSESIZE: return 8;
case MERIT_CAT_HOUSEFEAT: return 5;
}
return 0;
}
int Merit_CatReward(int nCat, int nIdx)
{
switch (nCat)
{
case MERIT_CAT_TELE: return 101 + nIdx; // 101..107
case MERIT_CAT_PREM: return 201 + nIdx; // 201..204
case MERIT_CAT_VANITY: return 301 + nIdx; // 301..304
case MERIT_CAT_HOUSESIZE: return 401 + nIdx; // 401..408
case MERIT_CAT_HOUSEFEAT: return 501 + nIdx; // 501..505
}
return 0;
}
// ------------------------------------------------------------
// Teleport unlocks (ids 101-107). Ownership is recorded as a 'fulfilled'
// redemptions row for the CD Key, so unlocks are per-CD-Key (every character on
// that key gets them). They must be bought in order: 101 -> 102 -> ... -> 107.
int Merit_IsTeleReward(int nId) { return nId >= 101 && nId <= 107; }
// TRUE if this CD Key already owns the unlock for reward nId.
int Merit_TeleOwned(string sCdKey, int nId)
{
sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
"SELECT 1 FROM redemptions WHERE cdkey=@k AND reward_id=@r"
+ " AND status='fulfilled' LIMIT 1");
SqlBindString(q, "@k", sCdKey);
SqlBindInt(q, "@r", nId);
return SqlStep(q);
}
// TRUE if the predecessor (if any) is owned, so nId may be purchased next.
int Merit_TelePrereqMet(string sCdKey, int nId)
{
if (nId <= 101) return TRUE;
return Merit_TeleOwned(sCdKey, nId - 1);
}
// ------------------------------------------------------------
// Helpers
// Message the holder of sCdKey if they are currently online.
void Merit_NotifyOnline(string sCdKey, string sMsg)
{
object o = GetFirstPC();
while (GetIsObjectValid(o))
{
if (GetPCPublicCDKey(o) == sCdKey)
{
SendMessageToPC(o, sMsg);
return;
}
o = GetNextPC();
}
}
// ------------------------------------------------------------
// Request / cancel / fulfill
// Returns TRUE if a pending request was created (cost escrowed).
int Merit_RequestById(object oPC, int nId)
{
struct merit_reward r = Merit_GetReward(nId);
if (!r.valid)
{
SendMessageToPC(oPC, "[Merit] Unknown reward.");
return FALSE;
}
string sCdKey = GetPCPublicCDKey(oPC);
int nAvail = Merit_Available(sCdKey);
if (nAvail < r.cost)
{
SendMessageToPC(oPC, "[Merit] That costs " + IntToString(r.cost)
+ " merit, but you only have " + IntToString(nAvail) + " available.");
return FALSE;
}
Merit_Spend(sCdKey, r.cost); // escrow the cost
sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
"INSERT INTO redemptions(cdkey, player_name, reward_id, reward_label, cost, needs_dm)"
+ " VALUES(@k, @n, @r, @l, @c, @d)");
SqlBindString(q, "@k", sCdKey);
SqlBindString(q, "@n", GetPCPlayerName(oPC));
SqlBindInt(q, "@r", nId);
SqlBindString(q, "@l", r.label);
SqlBindInt(q, "@c", r.cost);
SqlBindInt(q, "@d", r.needs_dm);
SqlStep(q);
int nReqId = 0;
sqlquery qid = SqlPrepareQueryCampaign(MERIT_DB, "SELECT last_insert_rowid()");
if (SqlStep(qid)) nReqId = SqlGetInt(qid, 0);
Merit_Ledger(sCdKey, GetPCPlayerName(oPC), -r.cost,
"request #" + IntToString(nReqId) + ": " + r.label, nReqId);
Merit_SetNpcTokens(oPC); // refresh the displayed balance
SendMessageToPC(oPC, "[Merit] Request #" + IntToString(nReqId) + " submitted: "
+ r.label + ". " + IntToString(r.cost)
+ " merit is now held. A DM will fulfil it; cancel any time from "
+ "'My pending requests' for a full refund.");
SendMessageToAllDMs("[Merit] " + GetPCPlayerName(oPC) + " requested redemption #"
+ IntToString(nReqId) + ": " + r.label + " (" + IntToString(r.cost) + " merit"
+ (r.needs_dm ? ", DM approval" : "") + "). EmoteWand > [Admin] Merit Redemptions.");
return TRUE;
}
// bIsDm: a player may only cancel their own pending request; a DM may cancel any.
int Merit_CancelRedemption(int nReqId, string sActorCdKey, string sActorName, int bIsDm)
{
sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
"SELECT cdkey, player_name, reward_label, cost, status FROM redemptions WHERE id=@i");
SqlBindInt(q, "@i", nReqId);
if (!SqlStep(q)) return FALSE;
string sOwner = SqlGetString(q, 0);
string sPName = SqlGetString(q, 1);
string sLabel = SqlGetString(q, 2);
int nCost = SqlGetInt(q, 3);
string sStatus = SqlGetString(q, 4);
if (sStatus != "pending") return FALSE;
if (!bIsDm && sOwner != sActorCdKey) return FALSE;
Merit_Refund(sOwner, nCost);
Merit_Ledger(sOwner, sPName, nCost,
"cancel #" + IntToString(nReqId) + " refund: " + sLabel, nReqId);
sqlquery qu = SqlPrepareQueryCampaign(MERIT_DB,
"UPDATE redemptions SET status='cancelled', resolved_by=@by, resolved_at=datetime('now')"
+ " WHERE id=@i");
SqlBindString(qu, "@by", (bIsDm ? "DM:" : "") + sActorName);
SqlBindInt(qu, "@i", nReqId);
SqlStep(qu);
Merit_NotifyOnline(sOwner, "[Merit] Redemption #" + IntToString(nReqId) + " ("
+ sLabel + ") was cancelled. " + IntToString(nCost) + " merit refunded.");
SendMessageToAllDMs("[Merit] Redemption #" + IntToString(nReqId) + " (" + sLabel
+ ") cancelled by " + sActorName + "; " + IntToString(nCost) + " merit refunded to "
+ sPName + ".");
return TRUE;
}
int Merit_FulfillRedemption(int nReqId, string sDmName)
{
sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
"SELECT cdkey, player_name, reward_label, cost, status FROM redemptions WHERE id=@i");
SqlBindInt(q, "@i", nReqId);
if (!SqlStep(q)) return FALSE;
string sOwner = SqlGetString(q, 0);
string sPName = SqlGetString(q, 1);
string sLabel = SqlGetString(q, 2);
string sStatus = SqlGetString(q, 4);
if (sStatus != "pending") return FALSE;
sqlquery qu = SqlPrepareQueryCampaign(MERIT_DB,
"UPDATE redemptions SET status='fulfilled', resolved_by=@by, resolved_at=datetime('now')"
+ " WHERE id=@i");
SqlBindString(qu, "@by", "DM:" + sDmName);
SqlBindInt(qu, "@i", nReqId);
SqlStep(qu);
Merit_NotifyOnline(sOwner, "[Merit] Your redemption #" + IntToString(nReqId) + " ("
+ sLabel + ") has been fulfilled by a DM. Enjoy!");
SendMessageToAllDMs("[Merit] Redemption #" + IntToString(nReqId) + " (" + sLabel
+ ") for " + sPName + " marked fulfilled by " + sDmName + ".");
return TRUE;
}
// ------------------------------------------------------------
// List builders (set tokens + per-speaker slot locals before the entry renders)
// Player: options in a category -> tokens 5060-5068, locals merit_lslot_<i>.
void Merit_BuildCategory(object oPC, int nCat)
{
int i;
for (i = 0; i < 9; i++)
{
DeleteLocalInt(oPC, "merit_lslot_" + IntToString(i));
SetCustomToken(5060 + i, "");
}
string sCdKey = GetPCPublicCDKey(oPC);
int nCount = Merit_CatCount(nCat);
for (i = 0; i < nCount && i < 9; i++)
{
int nId = Merit_CatReward(nCat, i);
struct merit_reward r = Merit_GetReward(nId);
SetLocalInt(oPC, "merit_lslot_" + IntToString(i), nId);
// Teleport unlocks (101-107) are live rest-menu features bought in
// order: show their unlock status instead of the red placeholder.
if (Merit_IsTeleReward(nId))
{
if (Merit_TeleOwned(sCdKey, nId))
SetCustomToken(5060 + i, "[Owned] " + r.label);
else if (!Merit_TelePrereqMet(sCdKey, nId))
SetCustomToken(5060 + i, "[Locked - buy the previous one first] "
+ r.label + " [" + IntToString(r.cost) + " merit]");
else
SetCustomToken(5060 + i, r.label + " [" + IntToString(r.cost) + " merit]");
continue;
}
// Every option is now wired: DM-fulfilled options are delivered by a DM,
// tournament gear (302) and the premium 2x boosts (201-204) grant on the
// spot. Nothing carries the red [PLACEHOLDER] prefix anymore. MERIT_PH is
// kept for any future not-yet-wired instant reward.
string sPrefix = (r.needs_dm == 1 || nId == 302 || (nId >= 201 && nId <= 204))
? "" : MERIT_PH;
SetCustomToken(5060 + i, sPrefix + r.label + " [" + IntToString(r.cost) + " merit]");
}
}
// Player: own pending requests -> tokens 5050-5058 (+5059 header), locals merit_lslot_<i>.
void Merit_BuildMyPending(object oPC)
{
int i;
for (i = 0; i < 9; i++)
{
DeleteLocalInt(oPC, "merit_lslot_" + IntToString(i));
SetCustomToken(5050 + i, "");
}
string sCdKey = GetPCPublicCDKey(oPC);
sqlquery qc = SqlPrepareQueryCampaign(MERIT_DB,
"SELECT COUNT(*) FROM redemptions WHERE cdkey=@k AND status='pending'");
SqlBindString(qc, "@k", sCdKey);
int nCount = 0;
if (SqlStep(qc)) nCount = SqlGetInt(qc, 0);
SetCustomToken(5059, nCount
? ("You have " + IntToString(nCount) + " pending request(s)"
+ (nCount > 9 ? " (showing first 9)" : "") + ". Select one to cancel for a refund:")
: "You have no pending requests.");
sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
"SELECT id, reward_label, cost FROM redemptions WHERE cdkey=@k AND status='pending'"
+ " ORDER BY requested_at LIMIT 9");
SqlBindString(q, "@k", sCdKey);
i = 0;
while (SqlStep(q) && i < 9)
{
int nId = SqlGetInt(q, 0);
string sL = SqlGetString(q, 1);
int nCost = SqlGetInt(q, 2);
SetLocalInt(oPC, "merit_lslot_" + IntToString(i), nId);
SetCustomToken(5050 + i, "[Cancel] #" + IntToString(nId) + " " + sL
+ " (" + IntToString(nCost) + " merit)");
i++;
}
}
// DM: all pending requests, paged -> tokens 5040-5048 (+5049 header),
// locals merit_lslot_<i> and merit_lslot_<i>_desc.
void Merit_BuildPendingPage(object oDM)
{
int nOff = GetLocalInt(oDM, "merit_rpage_off");
sqlquery qc = SqlPrepareQueryCampaign(MERIT_DB,
"SELECT COUNT(*) FROM redemptions WHERE status='pending'");
int nTotal = 0;
if (SqlStep(qc)) nTotal = SqlGetInt(qc, 0);
SetLocalInt(oDM, "merit_rpage_total", nTotal);
int nPages = (nTotal + 8) / 9;
if (nPages == 0) nPages = 1;
int nPage = nOff / 9 + 1;
SetCustomToken(5049, IntToString(nTotal) + " pending (page "
+ IntToString(nPage) + " of " + IntToString(nPages) + ")");
int i;
for (i = 0; i < 9; i++)
{
DeleteLocalInt(oDM, "merit_lslot_" + IntToString(i));
DeleteLocalString(oDM, "merit_lslot_" + IntToString(i) + "_desc");
SetCustomToken(5040 + i, "(empty)");
}
sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
"SELECT id, player_name, reward_label, cost, needs_dm FROM redemptions"
+ " WHERE status='pending' ORDER BY requested_at LIMIT 9 OFFSET @o");
SqlBindInt(q, "@o", nOff);
i = 0;
while (SqlStep(q) && i < 9)
{
int nId = SqlGetInt(q, 0);
string sN = SqlGetString(q, 1);
string sL = SqlGetString(q, 2);
int nCost = SqlGetInt(q, 3);
int nDm = SqlGetInt(q, 4);
string sDesc = "#" + IntToString(nId) + " " + sN + ": " + sL
+ " (" + IntToString(nCost) + " merit" + (nDm ? ", DM approval" : "") + ")";
SetLocalInt(oDM, "merit_lslot_" + IntToString(i), nId);
SetLocalString(oDM, "merit_lslot_" + IntToString(i) + "_desc", sDesc);
SetCustomToken(5040 + i, sDesc);
i++;
}
}
// ------------------------------------------------------------
// Tournament gear (reward 302) — curated picker. One solid item per weapon
// type plus armour, tower shield, gauntlets, and bows (dupes/mislabeled
// blueprints dropped). The chosen piece is delivered instantly.
int Merit_TournCount() { return 23; }
string Merit_TournResref(int i)
{
switch (i)
{
case 0: return "tournamaentlongs"; // Longsword (sic: blueprint resref)
case 1: return "tournamentshorts"; // Short Sword
case 2: return "tournamentgreats"; // Greatsword
case 3: return "item093"; // Bastard Sword
case 4: return "tournamentscimit"; // Scimitar
case 5: return "tournamentdagger"; // Dagger
case 6: return "tournamentkama"; // Kama
case 7: return "tournamentkuk001"; // Kukri
case 8: return "tournamentclub"; // Club
case 9: return "tournamentmace"; // Mace
case 10: return "tournamentmornin"; // Morningstar
case 11: return "tournamentwarham"; // Warhammer
case 12: return "tournamentbattle"; // Battleaxe
case 13: return "tournamentgreata"; // Greataxe
case 14: return "tournamenthalber"; // Halberd
case 15: return "tournamentscythe"; // Scythe
case 16: return "tournamentmaugdo"; // Maug Double Sword
case 17: return "tournamentlongbo"; // Longbow
case 18: return "tournamentshortb"; // Shortbow
case 19: return "tournamentdarts"; // Darts
case 20: return "tournamentarmour"; // Armour
case 21: return "tournamenttowers"; // Tower Shield
case 22: return "tournamentgauntl"; // Gauntlets
}
return "";
}
string Merit_TournName(int i)
{
switch (i)
{
case 0: return "Tournament Longsword";
case 1: return "Tournament Short Sword";
case 2: return "Tournament Greatsword";
case 3: return "Tournament Bastard Sword";
case 4: return "Tournament Scimitar";
case 5: return "Tournament Dagger";
case 6: return "Tournament Kama";
case 7: return "Tournament Kukri";
case 8: return "Tournament Club";
case 9: return "Tournament Mace";
case 10: return "Tournament Morningstar";
case 11: return "Tournament Warhammer";
case 12: return "Tournament Battleaxe";
case 13: return "Tournament Greataxe";
case 14: return "Tournament Halberd";
case 15: return "Tournament Scythe";
case 16: return "Tournament Maug Double Sword";
case 17: return "Tournament Longbow";
case 18: return "Tournament Shortbow";
case 19: return "Tournament Darts";
case 20: return "Tournament Armour";
case 21: return "Tournament Tower Shield";
case 22: return "Tournament Gauntlets";
}
return "";
}
// Build the tournament picker page -> tokens 5070-5078, locals merit_tslot_<i>.
void Merit_BuildTournament(object oPC)
{
int nOff = GetLocalInt(oPC, "merit_tpage_off");
int nTotal = Merit_TournCount();
SetLocalInt(oPC, "merit_tpage_total", nTotal);
int i;
for (i = 0; i < 9; i++)
{
DeleteLocalString(oPC, "merit_tslot_" + IntToString(i));
DeleteLocalString(oPC, "merit_tslot_" + IntToString(i) + "_name");
SetCustomToken(5070 + i, "");
}
for (i = 0; i < 9; i++)
{
int idx = nOff + i;
if (idx >= nTotal) break;
SetLocalString(oPC, "merit_tslot_" + IntToString(i), Merit_TournResref(idx));
SetLocalString(oPC, "merit_tslot_" + IntToString(i) + "_name", Merit_TournName(idx));
SetCustomToken(5070 + i, Merit_TournName(idx));
}
}
// ------------------------------------------------------------
// Confirmation step (token 5037 + merit_pick_* locals on the speaker)
void Merit_PrepConfirm(object oPC, int nId)
{
struct merit_reward r = Merit_GetReward(nId);
string sCdKey = GetPCPublicCDKey(oPC);
int nAvail = Merit_Available(sCdKey);
int bAfford = (nAvail >= r.cost);
SetLocalInt(oPC, "merit_pick_id", nId);
SetLocalInt(oPC, "merit_pick_dm", r.needs_dm);
SetLocalInt(oPC, "merit_pick_afford", bAfford);
if (!r.valid)
{
SetCustomToken(5037, "Hmm, I can't find that reward. Try again?");
SetLocalInt(oPC, "merit_pick_afford", FALSE);
return;
}
if (!bAfford)
{
SetCustomToken(5037, "I'm afraid '" + r.label + "' costs " + IntToString(r.cost)
+ " merit, and you've only " + IntToString(nAvail)
+ " available. Best earn a bit more first.");
return;
}
if (Merit_IsTeleReward(nId))
{
if (Merit_TeleOwned(sCdKey, nId))
{
SetCustomToken(5037, "You already own that teleport unlock - it is "
+ "available to all your characters from the rest menu.");
SetLocalInt(oPC, "merit_pick_afford", FALSE);
return;
}
if (!Merit_TelePrereqMet(sCdKey, nId))
{
struct merit_reward rPrev = Merit_GetReward(nId - 1);
SetCustomToken(5037, "These teleport unlocks must be bought in order. "
+ "Buy '" + rPrev.label + "' first before this one.");
SetLocalInt(oPC, "merit_pick_afford", FALSE);
return;
}
}
if (nId == 302)
{
SetLocalInt(oPC, "merit_tpage_off", 0);
Merit_BuildTournament(oPC);
SetCustomToken(5037, "A fine choice - " + IntToString(r.cost)
+ " merit for one piece of Tournament gear, yours on the spot (this cannot be undone). Pick one:");
return;
}
if (r.needs_dm == 0)
SetCustomToken(5037, "Reward of '" + r.label + "' to be granted instantly -- this cannot be undone. ("
+ IntToString(r.cost) + " merit)");
else
SetCustomToken(5037, "Reward of '" + r.label
+ "' to be fulfilled by a DM later, as time and bandwidth allow. ("
+ IntToString(r.cost) + " merit)");
}
// ------------------------------------------------------------
// Instant grant (needs_dm == 0): spend, record a fulfilled row, ledger, deliver.
int Merit_GrantInstant(object oPC, int nId)
{
struct merit_reward r = Merit_GetReward(nId);
if (!r.valid) return FALSE;
string sCdKey = GetPCPublicCDKey(oPC);
// Teleport unlocks: authoritative order/duplicate guard (the conversation
// gating above is only UX). Never spend when out of order or already owned.
if (Merit_IsTeleReward(nId))
{
if (Merit_TeleOwned(sCdKey, nId))
{
SendMessageToPC(oPC, "[Merit] You already own that teleport unlock.");
return FALSE;
}
if (!Merit_TelePrereqMet(sCdKey, nId))
{
SendMessageToPC(oPC, "[Merit] Teleport unlocks must be purchased in order.");
return FALSE;
}
}
if (Merit_Available(sCdKey) < r.cost)
{
SendMessageToPC(oPC, "[Merit] You can no longer afford that.");
return FALSE;
}
Merit_Spend(sCdKey, r.cost);
sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
"INSERT INTO redemptions(cdkey, player_name, reward_id, reward_label, cost, needs_dm, status, resolved_by, resolved_at)"
+ " VALUES(@k, @n, @r, @l, @c, 0, 'fulfilled', 'auto', datetime('now'))");
SqlBindString(q, "@k", sCdKey);
SqlBindString(q, "@n", GetPCPlayerName(oPC));
SqlBindInt(q, "@r", nId);
SqlBindString(q, "@l", r.label);
SqlBindInt(q, "@c", r.cost);
SqlStep(q);
int nReqId = 0;
sqlquery qid = SqlPrepareQueryCampaign(MERIT_DB, "SELECT last_insert_rowid()");
if (SqlStep(qid)) nReqId = SqlGetInt(qid, 0);
Merit_Ledger(sCdKey, GetPCPlayerName(oPC), -r.cost,
"instant #" + IntToString(nReqId) + ": " + r.label, nReqId);
Merit_SetNpcTokens(oPC);
SendMessageToPC(oPC, "[Merit] Redeemed instantly: " + r.label + " ("
+ IntToString(r.cost) + " merit). Logged as #" + IntToString(nReqId) + ".");
SendMessageToAllDMs("[Merit] " + GetPCPlayerName(oPC) + " redeemed (instant) #"
+ IntToString(nReqId) + ": " + r.label + " (" + IntToString(r.cost) + " merit).");
// Premium 2x gold/XP boosts (201-204): enqueue a subscription. Buying more of
// the same kind never runs two clocks at once — extra time queues and slow-
// burns (see boost_db.nss). Server buffs pause everyone's personal clock.
if (nId >= 201 && nId <= 204)
{
int nDays = (nId == 201 || nId == 203) ? 7 : 30;
string sScope = (nId >= 203) ? "server" : "personal";
Boost_Reconcile();
int nBefore = (sScope == "server")
? Boost_ChainRemaining("server", "")
: Boost_ChainRemaining("personal", sCdKey);
Boost_Enqueue(sScope, sCdKey, GetPCPlayerName(oPC), nDays);
if (nBefore > 0)
SendMessageToPC(oPC, "[Boost] Queued — it activates when the current "
+ sScope + " boost runs out.");
else
SendMessageToPC(oPC, "[Boost] Active now — enjoy 2x gold & XP for "
+ IntToString(nDays) + " days!");
}
return TRUE;
}
// Tournament instant grant — like Merit_GrantInstant but delivers a chosen item.
int Merit_GrantTournament(object oPC, string sResref, string sItemName)
{
struct merit_reward r = Merit_GetReward(302);
string sCdKey = GetPCPublicCDKey(oPC);
if (Merit_Available(sCdKey) < r.cost)
{
SendMessageToPC(oPC, "[Merit] You can no longer afford that.");
return FALSE;
}
// Deliver the item FIRST and confirm it exists before spending any merit.
// CreateItemOnObject only returns an invalid object when the item truly
// could not be made (bad blueprint); when the PC's inventory is full the
// item is still created but lands on the ground at their feet. Both count as
// "delivered" for billing purposes -- the player has the item either way --
// so we only abort (no charge) when nothing was created at all.
object oItem = CreateItemOnObject(sResref, oPC);
if (!GetIsObjectValid(oItem))
{
SendMessageToPC(oPC, "[Merit] Could not create " + sItemName + " (item '"
+ sResref + "') - no merit was spent. Please try again, or tell a DM "
+ "if it keeps failing.");
return FALSE;
}
int bOnGround = (GetItemPossessor(oItem) != oPC); // inventory was full
// Delivery confirmed (in inventory or on the ground); now charge and record.
Merit_Spend(sCdKey, r.cost);
string sLabel = "Tournament gear: " + sItemName;
sqlquery q = SqlPrepareQueryCampaign(MERIT_DB,
"INSERT INTO redemptions(cdkey, player_name, reward_id, reward_label, cost, needs_dm, status, resolved_by, resolved_at)"
+ " VALUES(@k, @n, 302, @l, @c, 0, 'fulfilled', 'auto', datetime('now'))");
SqlBindString(q, "@k", sCdKey);
SqlBindString(q, "@n", GetPCPlayerName(oPC));
SqlBindString(q, "@l", sLabel);
SqlBindInt(q, "@c", r.cost);
SqlStep(q);
int nReqId = 0;
sqlquery qid = SqlPrepareQueryCampaign(MERIT_DB, "SELECT last_insert_rowid()");
if (SqlStep(qid)) nReqId = SqlGetInt(qid, 0);
// Stamp the granted item with a unique, serialized tag derived from the
// redemption id (MTG = Merit Tournament Gear). The autoincrement id is
// globally unique and monotonic, so every grant gets a distinct serial.
// "MTG_" + a decimal id stays well under NWN's 32-char tag limit. The tag
// is recorded on the redemption row so a physical item can be traced back
// to the grant that produced it.
string sTag = "MTG_" + IntToString(nReqId);
SetTag(oItem, sTag);
sqlquery qt = SqlPrepareQueryCampaign(MERIT_DB,
"UPDATE redemptions SET item_tag=@t WHERE id=@id");
SqlBindString(qt, "@t", sTag);
SqlBindInt(qt, "@id", nReqId);
SqlStep(qt);
Merit_Ledger(sCdKey, GetPCPlayerName(oPC), -r.cost,
"instant #" + IntToString(nReqId) + " [" + sTag + "]: " + sLabel, nReqId);
Merit_SetNpcTokens(oPC);
if (bOnGround)
SendMessageToPC(oPC, "[Merit] Granted " + sItemName + " (" + IntToString(r.cost)
+ " merit, tag " + sTag + "), but your inventory was full so it dropped "
+ "at your feet - pick it up off the ground!");
else
SendMessageToPC(oPC, "[Merit] Granted " + sItemName + " ("
+ IntToString(r.cost) + " merit, tag " + sTag + "). Check your inventory!");
SendMessageToAllDMs("[Merit] " + GetPCPlayerName(oPC) + " redeemed Tournament gear #"
+ IntToString(nReqId) + " (tag " + sTag + "): " + sItemName + ".");
return TRUE;
}