Script: forge_inc

Teleports to
jailed (0o Pit Prison o0)
// forge_inc.nss — shared helpers for the Forge of Wonders system.
//
// Pricing, per-forge caps, legality checks and the disenchant service all go
// through here. Do NOT #include "itemprocs" from this file (NWScript has no
// include guards; consumer scripts include both side by side).
//
// Reserved custom tokens: 6110-6117 disenchant slot names, 6118 picked name
// (forge already uses 100-103).

// Generated whitelist of legally-placed item variants (ForgeIsKnownLegalVariant,
// ForgeLegalFingerprint) — regenerate with bin/gen-forge-legal.py.
#include "forge_legal_inc"
// Appraise scaling (AppraiseBonusScaled) for the per-player value ceiling.
#include "appraise_inc"

const int FORGE_LEGAL_MAX_PROPS = 6;
const int FORGE_LEGAL_MAX_VALUE = 750000;

// Extra item value (gp) a maxed-Appraise player may lawfully forge above the
// default ceilings: +0 at no Appraise investment, +500,000 at an Appraise check
// of 65. Applied to each forge's per-tier cap, the global enchant backstop, and
// the contraband/jail ceiling alike. See appraise_inc.nss.
const int FORGE_APPRAISE_MAX_BONUS = 500000;

// Item-local int stamped by modifyitem when a forge lawfully enchants an item
// above the default global ceiling because the player's Appraise allowed it.
// Its value is the lawful per-item value ceiling that justified the work, so the
// contraband scan / Forge Warden honor it regardless of who later holds the item
// (legality stays intrinsic to the item — see FORGE_CLEAN note below).
const string FORGE_CEIL = "FORGE_CEIL";

// Appraise-extended value bonus for oPC (0..FORGE_APPRAISE_MAX_BONUS).
int ForgeAppraiseBonus(object oPC)
{
    return AppraiseBonusScaled(oPC, FORGE_APPRAISE_MAX_BONUS);
}
const string FORGE_VAULT_TAG = "FORGE_VAULT";
const int FORGE_DIS_SLOTS = 8;

// Contraband-scan "clean" stamp. An item verified legal by the login scan gets
// item-local int "FORGE_CLEAN" set to FORGE_CLEAN_VER; future logins skip it,
// so a repeat login does almost no work. The stamp persists with the item in
// the player's .bic. BUMP FORGE_CLEAN_VER whenever the legality rules or the
// forge_legal_inc whitelist change, so every item is re-scanned exactly once
// after a rules update. Forge masters clear the stamp when they modify an item
// (see modifyitem.nss / forge_dis_go.nss). Legality is intrinsic to the item,
// so a clean stamp is valid across owners — trading can't launder gear.
const int FORGE_CLEAN_VER = 2;

// Tri-state result of ForgeItemLegality.
const int FORGE_LEG_LEGAL         = 0;  // confirmed within the law
const int FORGE_LEG_ILLEGAL       = 1;  // tampered + over the ceiling
const int FORGE_LEG_INDETERMINATE = 2;  // valuation infra unavailable — don't judge

void ForgeLog(string sMsg)
{
    if (!GetLocalInt(GetModule(), "FORGE_DEBUG")) return;
    string sLine = "[FORGE] " + sMsg;
    WriteTimestampedLogEntry(sLine);
    SendMessageToAllDMs(sLine);
}

// Cosmetic properties are not enchantments: never counted, compared, listed
// or fingerprinted by the forge system. Covers weapon visual effects (Bree
// appearance station, see inc_emotewand AddItemPropertyVisualEffect) and
// Light of any color/brightness (Continual Flame, gems of power, the forge's
// own Light enchant — illumination only, no combat bonus). forge_legal_inc's
// ForgeLegalFingerprint mirrors this rule.
int ForgeIsCosmeticProp(itemproperty ip)
{
    int nType = GetItemPropertyType(ip);
    return nType == ITEM_PROPERTY_VISUALEFFECT
        || nType == ITEM_PROPERTY_LIGHT;
}

// Creature items (natural weapons / hide) are equipped by the engine into the
// creature slots during polymorph — druid Dragon Shape, wild shape, shifter
// forms, etc. They carry the form's built-in enchantments, have no stock
// blueprint, and cannot be forged or disenchanted by a player, so they must
// never count as contraband. Base item types 69-73 (CSLASHWEAPON, CPIERCWEAPON,
// CBLUDGWEAPON, CSLSHPRCWEAP, CREATUREITEM).
int ForgeIsCreatureItem(object oItem)
{
    int nBase = GetBaseItemType(oItem);
    return nBase == BASE_ITEM_CSLASHWEAPON
        || nBase == BASE_ITEM_CPIERCWEAPON
        || nBase == BASE_ITEM_CBLUDGWEAPON
        || nBase == BASE_ITEM_CSLSHPRCWEAP
        || nBase == BASE_ITEM_CREATUREITEM;
}

// Hidden inventory placeable (in the prison) used to host temporary item
// copies so valuations never transit a player-visible inventory or fire
// OnAcquireItem hooks. Falls back to OBJECT_SELF when it has an inventory.
object ForgeHolder()
{
    object oVault = GetObjectByTag(FORGE_VAULT_TAG);
    if (GetIsObjectValid(oVault))
        return oVault;
    if (GetHasInventory(OBJECT_SELF))
        return OBJECT_SELF;
    ForgeLog("ForgeHolder: FORGE_VAULT missing and OBJECT_SELF has no inventory");
    return OBJECT_INVALID;
}

// True assessed value of an item: priced as an identified, non-plot copy so
// unidentified/plot status can never discount an enchantment or dodge a cap.
// Returns -1 when no valuation is possible (callers must refuse, not treat
// as free).
// bPerUnit: value a single unit, not the whole stack. GetGoldPieceValue returns
// the stack's total, so the contraband ceiling (a per-item cap) must pass TRUE —
// otherwise a tall stack of cheap legal items reads as one over-cap item. The
// forge pricing callers leave it FALSE (enchanting a stack enchants every unit).
int ForgeItemValue(object oItem, int bPerUnit = FALSE)
{
    if (!GetIsObjectValid(oItem))
        return -1;
    object oHolder = ForgeHolder();
    if (!GetIsObjectValid(oHolder))
        return -1;
    object oCopy = CopyItem(oItem, oHolder, TRUE);
    if (!GetIsObjectValid(oCopy))
        return -1;
    if (bPerUnit)
        SetItemStackSize(oCopy, 1);
    SetIdentified(oCopy, TRUE);
    SetPlotFlag(oCopy, FALSE);
    int nValue = GetGoldPieceValue(oCopy);
    DestroyObject(oCopy);
    return nValue;
}

int ForgeCountProps(object oItem)
{
    int nCount = 0;
    itemproperty ip = GetFirstItemProperty(oItem);
    while (GetIsItemPropertyValid(ip))
    {
        if (GetItemPropertyDurationType(ip) == DURATION_TYPE_PERMANENT
            && !ForgeIsCosmeticProp(ip))
            nCount++;
        ip = GetNextItemProperty(oItem);
    }
    return nCount;
}

// Same matcher CustomAddProperty (itemprocs) uses to replace-instead-of-add:
// a property "matches" when types are equal and the new property's subtype is
// -1 (wildcard) or equal. A matching enchant replaces and does not grow the
// property count.
int ForgePropMatchesExisting(object oItem, itemproperty ip)
{
    int iTyp = GetItemPropertyType(ip);
    int iSubTyp = GetItemPropertySubType(ip);
    itemproperty ipLoop = GetFirstItemProperty(oItem);
    while (GetIsItemPropertyValid(ipLoop))
    {
        if (GetItemPropertyType(ipLoop) == iTyp
            && (iSubTyp == -1 || GetItemPropertySubType(ipLoop) == iSubTyp))
            return TRUE;
        ipLoop = GetNextItemProperty(oItem);
    }
    return FALSE;
}

// Nth (0-based) permanent property, in GetFirstItemProperty order.
itemproperty ForgeGetPermPropByIndex(object oItem, int nIndex)
{
    int nCount = 0;
    itemproperty ip = GetFirstItemProperty(oItem);
    while (GetIsItemPropertyValid(ip))
    {
        if (GetItemPropertyDurationType(ip) == DURATION_TYPE_PERMANENT
            && !ForgeIsCosmeticProp(ip))
        {
            if (nCount == nIndex)
                return ip;
            nCount++;
        }
        ip = GetNextItemProperty(oItem);
    }
    itemproperty ipInvalid;
    return ipInvalid;
}

// Human-readable property label from the 2das, e.g.
// "Enhancement Bonus: +3" or "Damage Bonus: Fire / 2d6".
// Falls back to raw numbers when a 2da lookup comes back empty.
string ForgePropName(itemproperty ip)
{
    int iTyp = GetItemPropertyType(ip);
    string sName;
    string sRef = Get2DAString("itempropdef", "GameStrRef", iTyp);
    if (sRef != "")
        sName = GetStringByStrRef(StringToInt(sRef));
    if (sName == "")
        sName = "Property #" + IntToString(iTyp);

    int iSubTyp = GetItemPropertySubType(ip);
    if (iSubTyp >= 0)
    {
        string sSubRes = Get2DAString("itempropdef", "SubTypeResRef", iTyp);
        if (sSubRes != "")
        {
            string sSubRef = Get2DAString(sSubRes, "Name", iSubTyp);
            string sSub = "";
            if (sSubRef != "")
                sSub = GetStringByStrRef(StringToInt(sSubRef));
            if (sSub == "")
                sSub = IntToString(iSubTyp);
            sName += ": " + sSub;
        }
    }

    int iCostTab = GetItemPropertyCostTable(ip);
    if (iCostTab >= 0)
    {
        string sCostRes = Get2DAString("iprp_costtable", "Name", iCostTab);
        if (sCostRes != "")
        {
            string sCostRef = Get2DAString(sCostRes, "Name",
                GetItemPropertyCostTableValue(ip));
            string sCost = "";
            if (sCostRef != "")
                sCost = GetStringByStrRef(StringToInt(sCostRef));
            if (sCost != "")
                sName += " " + sCost;
        }
    }
    return sName;
}

// Compare oItem's permanent properties against its pristine blueprint
// (instantiated by resref). Order-insensitive multiset compare keyed on
// (type, subtype, costtablevalue, param1value). Unknown resref counts as
// deviating — conservative by design.
int ForgeItemDeviatesFromBlueprint(object oItem)
{
    object oHolder = ForgeHolder();
    if (!GetIsObjectValid(oHolder))
        return FALSE; // can't judge — never jail on infrastructure failure

    string sResRef = GetResRef(oItem);
    object oStock = CreateItemOnObject(sResRef, oHolder);
    if (!GetIsObjectValid(oStock))
    {
        ForgeLog("DeviatesFromBlueprint: no blueprint for resref '" + sResRef
            + "' (item '" + GetName(oItem) + "') — treating as deviant");
        return TRUE;
    }

    int bDeviates = FALSE;
    if (ForgeCountProps(oItem) != ForgeCountProps(oStock))
        bDeviates = TRUE;
    else
    {
        // Every property on oItem must consume a distinct match on oStock.
        // Track consumed stock properties by index in a flag string.
        int nStock = ForgeCountProps(oStock);
        string sUsed; // one char per stock prop: "0" free, "1" consumed
        int i;
        for (i = 0; i < nStock; i++)
            sUsed += "0";

        itemproperty ip = GetFirstItemProperty(oItem);
        while (!bDeviates && GetIsItemPropertyValid(ip))
        {
            if (GetItemPropertyDurationType(ip) == DURATION_TYPE_PERMANENT
                && !ForgeIsCosmeticProp(ip))
            {
                int bMatched = FALSE;
                for (i = 0; i < nStock && !bMatched; i++)
                {
                    if (GetSubString(sUsed, i, 1) == "0")
                    {
                        itemproperty ipS = ForgeGetPermPropByIndex(oStock, i);
                        if (GetItemPropertyType(ipS) == GetItemPropertyType(ip)
                            && GetItemPropertySubType(ipS) == GetItemPropertySubType(ip)
                            && GetItemPropertyCostTableValue(ipS) == GetItemPropertyCostTableValue(ip)
                            && GetItemPropertyParam1Value(ipS) == GetItemPropertyParam1Value(ip))
                        {
                            sUsed = GetStringLeft(sUsed, i) + "1"
                                + GetSubString(sUsed, i + 1, nStock - i - 1);
                            bMatched = TRUE;
                        }
                    }
                }
                if (!bMatched)
                    bDeviates = TRUE;
            }
            ip = GetNextItemProperty(oItem);
        }
    }
    DestroyObject(oStock);
    return bDeviates;
}

// Tri-state legality verdict. Illegal = exceeds the global legal ceiling
// (6 properties / 750,000 gp) AND was tampered with (deviates from its stock
// blueprint). Stock drops above the ceiling stay legal. Returns INDETERMINATE
// when no valuation is possible — callers must neither jail nor mark such items
// clean. The single source of truth for legality (ForgeIsItemIllegal and the
// login scan both go through here).
int ForgeItemLegality(object oItem)
{
    if (!GetIsObjectValid(oItem))
        return FORGE_LEG_LEGAL;
    if (ForgeIsCreatureItem(oItem))
        return FORGE_LEG_LEGAL; // polymorph natural weapon/hide — engine-managed, never forged
    int nValue = ForgeItemValue(oItem, TRUE); // per-unit: the cap is per item, not per stack
    if (nValue < 0)
        return FORGE_LEG_INDETERMINATE; // no valuation infrastructure — never judge blind
    // A forge stamps FORGE_CEIL on an item it lawfully enchanted above the
    // default ceiling because the forger's Appraise allowed it; honor that
    // higher per-item ceiling for everyone (legality is intrinsic to the item).
    int nValueCeil = FORGE_LEGAL_MAX_VALUE;
    int nStamp = GetLocalInt(oItem, FORGE_CEIL);
    if (nStamp > nValueCeil)
        nValueCeil = nStamp;
    if (ForgeCountProps(oItem) <= FORGE_LEGAL_MAX_PROPS
        && nValue <= nValueCeil)
        return FORGE_LEG_LEGAL;
    if (!ForgeItemDeviatesFromBlueprint(oItem))
        return FORGE_LEG_LEGAL;
    // Deviating from blueprint is fine when the deviation matches an item the
    // module itself places (embedded store stock, creature loot, container
    // contents) — those are legally obtainable, not player-forged.
    if (ForgeIsKnownLegalVariant(oItem))
    {
        ForgeLog("ItemLegality: '" + GetName(oItem) + "' (" + GetResRef(oItem)
            + ") deviates but matches a known legal variant — allowed.");
        return FORGE_LEG_LEGAL;
    }
    return FORGE_LEG_ILLEGAL;
}

int ForgeIsItemIllegal(object oItem)
{
    return ForgeItemLegality(oItem) == FORGE_LEG_ILLEGAL;
}

// TRUE when oItem exists and sits in oPC's inventory or equipment (one
// container level deep — bags can't nest). Guards warden scripts against
// stale cached handles whose object id the engine may have recycled.
int ForgePCHolds(object oPC, object oItem)
{
    if (!GetIsObjectValid(oItem))
        return FALSE;
    object oPoss = GetItemPossessor(oItem);
    if (oPoss == oPC)
        return TRUE;
    return GetIsObjectValid(oPoss) && GetItemPossessor(oPoss) == oPC;
}

// TRUE when oItem was already handled by the current revert-all pass on oPC
// (item-local FORGE_RVT_SKIP carries the pass's run stamp). Stale stamps from
// earlier passes don't match, so nothing is skipped forever.
int ForgeRevertSeen(object oItem, object oPC)
{
    return GetLocalInt(oItem, "FORGE_RVT_SKIP")
        == GetLocalInt(oPC, "FORGE_RVT_RUN");
}

// First illegal item on the PC: equipped slots, then inventory, descending
// one level into containers (bags can't nest in NWN). bSkipMarked skips items
// already handled by the current revert-all pass (see ForgeRevertSeen) — they
// still count as illegal everywhere else.
object ForgeFindIllegalItem(object oPC, int bSkipMarked = FALSE)
{
    int nSlot;
    for (nSlot = 0; nSlot < NUM_INVENTORY_SLOTS; nSlot++)
    {
        object oItem = GetItemInSlot(nSlot, oPC);
        if (GetIsObjectValid(oItem) && ForgeIsItemIllegal(oItem)
            && !(bSkipMarked && ForgeRevertSeen(oItem, oPC)))
            return oItem;
    }
    object oItem = GetFirstItemInInventory(oPC);
    while (GetIsObjectValid(oItem))
    {
        if (ForgeIsItemIllegal(oItem)
            && !(bSkipMarked && ForgeRevertSeen(oItem, oPC)))
            return oItem;
        if (GetHasInventory(oItem))
        {
            object oInBag = GetFirstItemInInventory(oItem);
            while (GetIsObjectValid(oInBag))
            {
                if (ForgeIsItemIllegal(oInBag)
                    && !(bSkipMarked && ForgeRevertSeen(oInBag, oPC)))
                    return oInBag;
                oInBag = GetNextItemInInventory(oItem);
            }
        }
        oItem = GetNextItemInInventory(oPC);
    }
    return OBJECT_INVALID;
}

// Replace oItem with a pristine copy of its stock blueprint, created on oPC.
// New item is created first so a missing blueprint never costs the player
// the original. Returns the new item, or OBJECT_INVALID when the blueprint
// is unknown (item left untouched). Equipped originals land the replacement
// in the backpack.
object ForgeRevertToBlueprint(object oItem, object oPC)
{
    if (!GetIsObjectValid(oItem))
        return OBJECT_INVALID;
    string sResRef = GetResRef(oItem);
    object oNew = CreateItemOnObject(sResRef, oPC);
    if (!GetIsObjectValid(oNew))
    {
        ForgeLog("RevertToBlueprint: no blueprint for resref '" + sResRef
            + "' (item '" + GetName(oItem) + "')");
        return OBJECT_INVALID;
    }
    SetIdentified(oNew, TRUE);
    ForgeLog("RevertToBlueprint: '" + GetName(oItem) + "' -> stock '"
        + GetName(oNew) + "' (resref " + sResRef + ") for " + GetName(oPC));
    DestroyObject(oItem);
    return oNew;
}

// Revert every illegal item on the PC to its stock blueprint. Returns how
// many could NOT be reverted (no known blueprint); those stay illegal.
// Every visited item is stamped with this pass's run id BEFORE the revert,
// so the loop terminates even if DestroyObject is deferred past the rescan.
int ForgeRevertAllIllegal(object oPC)
{
    int nRun = GetLocalInt(oPC, "FORGE_RVT_RUN") + 1;
    SetLocalInt(oPC, "FORGE_RVT_RUN", nRun);
    int nFail = 0;
    int nGuard = 0;
    object oBad = ForgeFindIllegalItem(oPC, TRUE);
    while (GetIsObjectValid(oBad) && nGuard < 100)
    {
        nGuard++;
        SetLocalInt(oBad, "FORGE_RVT_SKIP", nRun);
        if (!GetIsObjectValid(ForgeRevertToBlueprint(oBad, oPC)))
            nFail++;
        oBad = ForgeFindIllegalItem(oPC, TRUE);
    }
    return nFail;
}

// Player-facing summary of what still makes oItem unlawful (for the Forge
// Warden's dialog), or a confirmation that it is now within the law.
string ForgeLegalStatus(object oItem)
{
    if (!GetIsObjectValid(oItem))
        return "";
    int nProps = ForgeCountProps(oItem);
    int nValue = ForgeItemValue(oItem, TRUE); // per-unit: matches the ForgeIsItemIllegal gate
    // Honor any Appraise-extended ceiling stamped on the item (see FORGE_CEIL),
    // so a lawfully high-value piece reads as within the law, not contraband.
    int nValueCeil = FORGE_LEGAL_MAX_VALUE;
    int nStamp = GetLocalInt(oItem, FORGE_CEIL);
    if (nStamp > nValueCeil)
        nValueCeil = nStamp;
    int bProps = nProps > FORGE_LEGAL_MAX_PROPS;
    int bValue = nValue > nValueCeil;
    if (!bProps && !bValue)
        return "It is within the law now: " + IntToString(nProps)
            + " enchantments of the " + IntToString(FORGE_LEGAL_MAX_PROPS)
            + " allowed, and a worth of " + IntToString(nValue)
            + " gold under the lawful " + IntToString(nValueCeil) + ".";
    string s = "";
    if (bProps)
        s += "It still bears " + IntToString(nProps) + " enchantments where the "
            + "law allows but " + IntToString(FORGE_LEGAL_MAX_PROPS) + ".";
    if (bValue)
    {
        if (s != "") s += " And i";
        else s += "I";
        s += "ts worth, " + IntToString(nValue) + " gold, still exceeds the lawful "
            + IntToString(nValueCeil) + ".";
    }
    return s;
}

// Prime the disenchant sub-conversation: target item on the PC, property
// count, and one custom token per listed property slot.
void ForgeDisenchantSetup(object oPC, object oTarget)
{
    SetLocalObject(oPC, "FORGE_DIS_ITEM", oTarget);
    int nCount = 0;
    if (GetIsObjectValid(oTarget))
    {
        nCount = ForgeCountProps(oTarget);
        SetCustomToken(100, GetName(oTarget));
    }
    SetLocalInt(oPC, "FORGE_DIS_COUNT", nCount);
    int n;
    for (n = 0; n < FORGE_DIS_SLOTS; n++)
    {
        string sLabel = "";
        if (n < nCount)
            sLabel = ForgePropName(ForgeGetPermPropByIndex(oTarget, n));
        SetCustomToken(6110 + n, sLabel);
    }
}

// Sequester a contested item for DM review: stored in the craftdb quarantine
// queue (same keys welloferuenter.nss uses), so it lands in the DM-review
// chest (ZEP_CR_QUARANTINE, House of Homer) on next open. No refund — a DM
// returns the item if the player's claim holds.
void ForgeQuarantineDisputedItem(object oItem, object oPC)
{
    if (!GetIsObjectValid(oItem) || !GetIsObjectValid(oPC))
        return;
    string sName = GetName(oItem);
    // Value the item BEFORE StoreCampaignObject/DestroyObject invalidate it.
    string sLog = "[FORGE DISPUTE] Item '" + sName + "' (resref: "
        + GetResRef(oItem) + ", props " + IntToString(ForgeCountProps(oItem))
        + ", value " + IntToString(ForgeItemValue(oItem))
        + ") sequestered from " + GetName(oPC) + " (account: "
        + GetPCPlayerName(oPC) + ") pending DM review.";

    // Stamp provenance onto the item itself so it travels with the object into
    // the quarantine chest snapshot and survives until a DM physically removes
    // it from the chest. The parallel quarantine_*_info campaign string below
    // is cleared the moment the chest is first opened (zep_cr_qrestore.nss), so
    // this local var is the durable record of who submitted the item.
    SetLocalString(oItem, "QUARANTINE_INFO", sLog);

    int nQ = GetCampaignInt("craftdb", "quarantine_count");
    StoreCampaignObject("craftdb", "quarantine_" + IntToString(nQ), oItem);
    SetCampaignString("craftdb", "quarantine_" + IntToString(nQ) + "_info", sLog);
    SetCampaignInt("craftdb", "quarantine_count", nQ + 1);
    DestroyObject(oItem);

    WriteTimestampedLogEntry(sLog);
    SendMessageToAllDMs(sLog);
    SendMessageToPC(oPC, "[Forge Warden] Your '" + sName + "' has been "
        + "sequestered under seal in the House of Homer pending DM review. "
        + "If your claim of lawful provenance holds, it will be returned to "
        + "you; if not, it is forfeit. No compensation is owed while the "
        + "matter is under judgment.");
}

// Jail oPC in the Pit Prison for carrying the illegal item oBad. Shared by the
// synchronous ForgeJailIfIllegal and the chunked login scan (forge_scan_step).
void ForgeJailForItem(object oPC, object oBad)
{
    if (!GetIsObjectValid(oBad))
        return;
    SetLocalObject(oPC, "FORGE_ILLEGAL_ITEM", oBad);
    ForgeLog("Jailing " + GetName(oPC) + " for item '" + GetName(oBad)
        + "' (resref " + GetResRef(oBad) + ", props "
        + IntToString(ForgeCountProps(oBad)) + ", value "
        + IntToString(ForgeItemValue(oBad)) + ", fp="
        + ForgeLegalFingerprint(oBad) + ")");
    object oWP = GetWaypointByTag("jailed");
    if (!GetIsObjectValid(oWP))
    {
        ForgeLog("ForgeJailForItem: waypoint 'jailed' missing!");
        return;
    }
    FloatingTextStringOnCreature("The " + GetName(oBad) + " you carry bears "
        + "unlawful enchantment. The Valar take notice...", oPC, FALSE);
    location lJail = GetLocation(oWP);
    DelayCommand(1.0, AssignCommand(oPC, JumpToLocation(lJail)));
    // Spoken hint after arrival so the player knows which jailer applies.
    DelayCommand(3.0, AssignCommand(oPC, SpeakString("The forged enchantments "
        + "on my " + GetName(oBad) + " are beyond the law. I should speak with "
        + "a FORGE WARDEN to make my gear lawful again.")));
}

// Jail the PC in the Pit Prison if they carry illegally forged gear. Synchronous
// full scan — kept for callers that need an immediate verdict; the login path
// uses ForgeBeginScan instead to stay under the instruction cap.
void ForgeJailIfIllegal(object oPC)
{
    if (!GetIsPC(oPC) || GetIsDM(oPC))
        return;
    ForgeJailForItem(oPC, ForgeFindIllegalItem(oPC));
}

// Begin a chunked contraband scan of oPC. Cheaply enumerates equipment, then
// inventory and one level of bag contents into ordered item-handle locals
// (FORGE_SCAN_0..N-1, count FORGE_SCAN_N, cursor FORGE_SCAN_I), then kicks off
// forge_scan_step which evaluates ONE item per delayed step — each step gets a
// fresh NWScript instruction budget, so a full inventory can't overflow. This
// enumeration pass does no valuation/blueprint work, so it is cheap even for a
// large inventory.
void ForgeBeginScan(object oPC)
{
    if (!GetIsPC(oPC) || GetIsDM(oPC))
        return;
    int n = 0;
    int nSlot;
    for (nSlot = 0; nSlot < NUM_INVENTORY_SLOTS; nSlot++)
    {
        object oItem = GetItemInSlot(nSlot, oPC);
        if (GetIsObjectValid(oItem))
            SetLocalObject(oPC, "FORGE_SCAN_" + IntToString(n++), oItem);
    }
    object oItem = GetFirstItemInInventory(oPC);
    while (GetIsObjectValid(oItem))
    {
        SetLocalObject(oPC, "FORGE_SCAN_" + IntToString(n++), oItem);
        if (GetHasInventory(oItem))
        {
            object oInBag = GetFirstItemInInventory(oItem);
            while (GetIsObjectValid(oInBag))
            {
                SetLocalObject(oPC, "FORGE_SCAN_" + IntToString(n++), oInBag);
                oInBag = GetNextItemInInventory(oItem);
            }
        }
        oItem = GetNextItemInInventory(oPC);
    }
    SetLocalInt(oPC, "FORGE_SCAN_N", n);
    SetLocalInt(oPC, "FORGE_SCAN_I", 0);
    if (n > 0)
        DelayCommand(0.1, ExecuteScript("forge_scan_step", oPC));
}