Page 1 of 1

UOX3 Auction Board – Watchlists, Anti-Snipe, Buyout, Reserve, “Ending Soon” Pings

Posted: Sun Nov 02, 2025 4:42 am
by dragon slayer
Bring a modern, low-friction auction house to your shard. Players list items, bid, watch favorites, and get notified as auctions approach the finish line. Sellers get automatic credits and easy withdrawals. Staff gets sensible safety features (escrow, fingerprinting, cancel-with-fee).

Highlights
One Board Item = Whole System
Double-click the board to open a full Auction UI (Gump).
All data is stored on the board via tags (no core edits or DB needed).
Create Auctions (with Escrow)
Target an item in your backpack, set description, start price (0 for donation), duration, optional reserve and buyout.
Item is locked in the board’s container until auction ends or is claimed.
Bidding & Buyout
Classic incremental bidding.
Optional Buyout instantly closes the auction and transfers the item to the buyer.
Reserve Support
If reserve isn’t met, the item returns to the seller automatically.
Donations (Freebies)
Start price 0 = Donation. Anyone can Claim immediately while the auction is open.
Watchlists + Per-Auction Watchers
Players can Watch / Unwatch any auction.
A separate per-auction watcher index powers “ending soon” pings.
“Ending Soon” Notifications
Online watchers, bidders, and the owner get pings at 5 minutes and 1 minute remaining.
Includes support for anti-snipe extensions (see below), so pings re-arm when the timer extends.
Anti-Snipe Extensions
If a bid arrives inside the last 5 minutes (tunable), extend by 2 minutes (tunable), capped by a max extension count.
Sorting, Filters, and Search
Sort by Ending, Newest, Highest.
Filter to My Auctions or Watching.

Search tokens:
owner:Jane item:sword desc:slayer min:100 max:1000 open ended reserve noreserve buyout nobuyout
Bare words match owner / item / name / description.
Seller Credits & Withdrawal
Gold from Buyout/Claim goes to the seller’s credit on the board.
Withdraw Credit issues gold coins back to the player.
Safer Escrow with Fingerprinting
Each listing stores a simple item fingerprint (id/hue/amount/mores/optional durability).
Claims/Buyouts verify the escrow item still matches the listing.
Cancel with Fee (Staff-Friendly)
Sellers can cancel open auctions for a configurable fee (flat + percentage, min).
Unique bidders are notified about the cancellation.
Crash/Restart Resistant
All auction data, watchlists, credits, and escrow indexes are stored in board tags.

Configuration (Tunables at top of the script)

Anti-snipe: ANTISNIPE_WINDOW_MIN, ANTISNIPE_EXTEND_MIN, ANTISNIPE_MAX_EXTENDS
Notify Marks: END_NOTIFY_MARKS (defaults: 5m and 1m)
Cancel Fee: CANCEL_FEE_FLAT, CANCEL_FEE_PCT, CANCEL_FEE_MIN
UI / Timer: ROWS_PER_PAGE, TICK_MS (timer tick), gump art IDs, etc.
By default the payment helper pulls gold from backpack first, then bank (same logic as StableMaster).
Fingerprinting prevents swapped/edited escrow items from being claimed.

1. Place the Script
Save as js/custom/auction_board.js (or your preferred path).

2. Associate the Script (jse_fileassociations.scp)
Add an entry so the engine loads the JS for your board item section.

3. Create a Board Item (DFN example)
[AuctionBoard]
{
get=base_item
NAME=Auction Board
ID=0x0EDD
SCRIPT=7520
DECAY=0
WEIGHT=255
}
Place it where you want players to access auctions. Double-click to open.
No core changes are required. The board starts its own repeating timer and keeps everything self-contained.

Player Guide (TL;DR)
Open the board → Browse, search, sort, filter.
Add Auction → You’ll be prompted to target an item in your backpack.
Bid → Enter your bid amount on the item’s page.
Buyout (when available) → Instantly purchase and receive the item.
Watch → Get notified when an auction is about to end.
Withdraw Credit → Convert your seller credit to gold coins.

Admin / Balance Notes
Escrow Limit: Script includes an example seller escrow cap (MAX_ESCROW) you can adjust.
Performance: One board = one timer (default every 5s). Most shards will just use a single public board.

Safety:
Fingerprint blocks claiming if the escrow item doesn’t match the listing.
Cancel requires a fee and notifies unique bidders.
Reserve Not Met: item returns to seller automatically.
Persistence: Everything (auctions JSON, credits, watchlists, escrow index) is stored in board tags.

Known Behaviors / Tips
Donations (start price 0) can be claimed immediately while open.
Anti-Snipe extensions re-arm the “ending soon” pings per extension cycle.
Multiple Boards are supported (each keeps its own data), but most shards just run one.

Changelog (Initial Public Release)
Auction board with full UI: listing, bidding, buyout, reserve, donation.
Watchlists + per-auction watcher index with 5m/1m pings.
Anti-snipe extensions with configurable window, extend, and cap.
Seller credits with backpack/bank payment and gold withdrawal.
Escrow fingerprinting + cancel-with-fee + robust notifications.

Changelog (Version: 2)
Added nice pictures at the top that change during the holidays.
Updated Auction Board Name to be center of gump when opened.

Re: UOX3 Auction Board – Watchlists, Anti-Snipe, Buyout, Reserve, “Ending Soon” Pings

Posted: Sun Nov 02, 2025 4:40 pm
by dragon slayer
// ========================= Auction Board (Readable Names) =========================
// UOX3 / SpiderMonkey 1.8.5 / ES5

// --------------------------- Button IDs (top-level) ---------------------------
var BID_SEARCH_APPLY = 8001;
var BID_SEARCH_CLEAR = 8002;

var GUMP_BG = 5054;
var G_W = 500, G_H = 900;
var BTN = 0xF7;
var ROWS_PER_PAGE = 10;
var TIMER_ID = 1;
var TICK_MS = 5000; // 5s tick; UI shows minutes anyway

var BID_TOGGLE_WATCH = 8102;

var BID_SORT_ENDING = 8201;
var BID_SORT_NEWEST = 8202;
var BID_SORT_HIGHEST = 8203;

var BID_FILTER_MINE = 8301;
var BID_FILTER_WATCH = 8302;

var BID_VIEW_BUYOUT = 6003;

// Anti-snipe defaults
var ANTISNIPE_WINDOW_MIN = 5;  // last 5 minutes
var ANTISNIPE_EXTEND_MIN = 2;  // extend by 2 minutes
var ANTISNIPE_MAX_EXTENDS = 5; // cap number of extensions

// Target IDs
var TID_ADD_AUCTION = 0;

// Row/View buttons
var BID_VIEW_BASE = 100000;    // per-auction view base id
var BID_VIEW_BLOCK = 100000;   // reserve 100k ids: [100000..199999)
var BID_PAGE_PREV = 2001;
var BID_PAGE_NEXT = 2002;
var BID_ADD_AUCTION = 3001;
var BID_WITHDRAW = 4001;

var BID_ADD_OK = 5001;
var BID_ADD_CANCEL = 5009;

var BID_VIEW_BID = 6001;     // for donations: acts as Claim
var BID_VIEW_CLAIM = 6002;   // winner claim after ended

// Money helpers
var GOLD_ID = 0x0EED;
var LAYER_BANK = 29;

// Cancellation fee rules
var CANCEL_FEE_FLAT = 0;
var CANCEL_FEE_PCT = 0.05;
var CANCEL_FEE_MIN = 100;

// Cancel confirm buttons
var BID_VIEW_CANCEL = 6101;
var BID_CANCEL_YES = 6102;
var BID_CANCEL_NO = 6103;

// Duration bounds
var DUR_MIN_MS = 60 * 1000;                 // 1 minute min
var DUR_MAX_MS = 365 * 24 * 60 * 60 * 1000; // 365 days max

// Ending-soon notify marks
var END_NOTIFY_MARKS = [{ ms: 300000, tag: "5m" }, { ms: 60000, tag: "1m" }];

// =============================== Naming Helpers ===============================
function getNowMillis() { return Date.now(); }
function daysToMilliseconds(n) { return (n | 0) * 86400000; }
function toIntOrDefault(v, d) { v = parseInt(v, 10); return isNaN(v) ? (d | 0) : v; }
function toNumberOrZero(v) { return Number(v) || 0; }
function toLowerTrimmed(s) { return ("" + (s || "")).toLowerCase(); }

function toHex4(v) { v |= 0; var s = v.toString(16).toUpperCase(); return "0x" + ("0000" + s).substr(-4); }
function toSerialHex8(v) { v |= 0; var s = v.toString(16).toUpperCase(); return "0x" + ("00000000" + s).substr(-8); }

function parseDurationToMilliseconds(input)
{
    if (typeof input === "number") return Math.max(DUR_MIN_MS, Math.min(DUR_MAX_MS, input | 0));
    var s = ("" + (input || "")).trim().toLowerCase();
    if (s === "") return 0;
    if (/^\d+$/.test(s))
    { // plain int => days
        return Math.max(DUR_MIN_MS, Math.min(DUR_MAX_MS, parseInt(s, 10) * 86400000));
    }
    var re = /(\d+)\s*([smhd])/g, m, total = 0;
    while ((m = re.exec(s)))
    {
        var n = parseInt(m[1], 10);
        switch (m[2])
        {
            case "s": total += n * 1000; break;
            case "m": total += n * 60000; break;
            case "h": total += n * 3600000; break;
            case "d": total += n * 86400000; break;
        }
    }
    return Math.max(DUR_MIN_MS, Math.min(DUR_MAX_MS, total | 0));
}

function getCanonicalItemName(it)
{
    if (!ValidateObject(it)) return "-";
    var s = it.name || it.sectionID;
    if (!s || s === "") s = "0x" + (it.id | 0).toString(16).toUpperCase();
    return s;
}

// =============================== Fees & Bids ===============================
function getLastBidAmount(a) { return (a.bids && a.bids.length ? (a.bids[a.bids.length - 1].amount | 0) : 0); }
function getCurrentAuctionValue(a) { return Math.max((a.startPrice | 0), getLastBidAmount(a)); } // used for sorting
function getCurrentValueForFee(a) { return Math.max((a.startPrice | 0), getLastBidAmount(a)); } // used for fees

function calculateCancelFee(a)
{
    var fee = Math.floor(getCurrentValueForFee(a) * CANCEL_FEE_PCT) + (CANCEL_FEE_FLAT | 0);
    if (fee < CANCEL_FEE_MIN) fee = CANCEL_FEE_MIN;
    return fee;
}
function getUniqueBidderSerials(a)
{
    var seen = {}, out = [];
    if (!a.bids) return out;
    for (var i = 0; i < a.bids.length; i++)
    {
        var s = a.bids[i].char | 0;
        if (!seen[s]) { seen[s] = 1; out.push(s); }
    }
    return out;
}
function formatTimeLeftShort(ms)
{
    if (ms <= 0) return "ended";
    var s = Math.floor(ms / 1000);
    var d = Math.floor(s / 86400); s -= d * 86400;
    var h = Math.floor(s / 3600); s -= h * 3600;
    var m = Math.floor(s / 60);
    if (d > 0) return d + "d " + h + "h";
    if (h > 0) return h + "h " + m + "m";
    return m + "m";
}

// =============================== Fingerprints ===============================
function buildItemFingerprint(it)
{
    if (!ValidateObject(it)) return "missing";
    var parts = [
        it.id | 0, it.hue | 0, it.amount | 0,
        it.sectionID || "", it.name || "",
        it.more1 | 0, it.more2 | 0, it.morex | 0, it.morey | 0, it.morez | 0
    ];
    if (typeof it.hp !== "undefined") parts.push(it.hp | 0);
    if (typeof it.maxhp !== "undefined") parts.push(it.maxhp | 0);
    return parts.join("|");
}
function fingerprintMatchesEscrowItem(a, it)
{
    var expected = a.itemFP || a.fingerprint || "";
    if (!expected) return true; // allow pre-fingerprint listings
    return buildItemFingerprint(it) === expected;
}

// =============================== Gump helpers ===============================
function addItemArtToGump(g, y, x, itemID, hue)
{
    if (typeof g.AddPictureColor === "function") g.AddPictureColor(y, x, itemID | 0, hue | 0);
}

// =============================== Payment ===============================
function payFromBackpackOrBank(pUser, amount)
{
    amount |= 0;
    if (amount <= 0) return true;

    // 1) Backpack
    if (pUser.ResourceCount(GOLD_ID, 0) >= amount)
    {
        pUser.UseResource(amount, GOLD_ID);
        pUser.SysMessage(amount + " gold has been paid from your backpack.");
        return true;
    }

    // 2) Bank box
    var bankBox = pUser.FindItemLayer(LAYER_BANK);
    if (bankBox)
    {
        var bankItem, foundGold = false;
        for (bankItem = bankBox.FirstItem(); !bankBox.FinishedItems(); bankItem = bankBox.NextItem())
        {
            if (ValidateObject(bankItem) && bankItem.id == GOLD_ID && bankItem.amount >= amount)
            {
                bankBox.UseResource(amount, GOLD_ID);
                foundGold = true; break;
            }
        }
        if (foundGold)
        {
            pUser.SysMessage(amount + " gold has been paid from your bank account.");
            return true;
        }
    }
    return false;
}

// =============================== Storage (Auctions) ===============================
function loadAuctionsFromBoard(board)
{
    var raw = board.GetTag("auctions");
    if (!raw || raw === "") return [];
    try { return JSON.parse(raw); } catch (e) { return []; }
}
function saveAuctionsToBoard(board, list)
{
    board.SetTag("auctions", JSON.stringify(list));
}
function allocateNextAuctionId(board)
{
    var n = toIntOrDefault(board.GetTag("nextAuctionId"), 1);
    board.SetTag("nextAuctionId", (n + 1) + "");
    return n;
}
function findAuctionInListById(list, aid)
{
    for (var i = 0; i < list.length; i++) if ((list[i].id | 0) === (aid | 0)) return { idx: i, a: list[i] };
    return { idx: -1, a: null };
}

// =============================== Storage (Credit) ===============================
function creditTagKeyForChar(charSer)
{
    return "credit_" + (charSer | 0);
}
function addCreditForChar(board, charSer, amt)
{
    if ((amt | 0) <= 0) return;
    var key = creditTagKeyForChar(charSer);
    var cur = toIntOrDefault(board.GetTag(key), 0);
    board.SetTag(key, (cur + (amt | 0)) + "");
}
function withdrawCreditToGoldCoins(board, pUser)
{
    var key = creditTagKeyForChar(pUser.serial);
    var cur = toIntOrDefault(board.GetTag(key), 0);
    if (cur <= 0) return 0;

    var left = cur;
    while (left > 0)
    {
        var chunk = left > 60000 ? 60000 : left;
        var g = CreateDFNItem(pUser.socket, pUser, "0x0EED", chunk, "ITEM", true);
        left -= chunk;
    }
    board.SetTag(key, "0");
    return cur;
}
function getCreditForChar(board, who) { return toIntOrDefault(board.GetTag(creditTagKeyForChar(who.serial)), 0); }

// =============================== Watchlists (per-player) ===============================
var WATCHLIST_IDS_PER_TAG = 20;
function watchlistKeyForCharacterChunk(charSer, chunk) { return "widx_" + (charSer | 0) + "_" + (chunk | 0); }

function readWatchlistIdsForCharacter(board, whoSer)
{
    var out = [];
    for (var c = 0; ; c++)
    {
        var raw = board.GetTag(watchlistKeyForCharacterChunk(whoSer, c));
        if (!raw || raw === "") break;
        var parts = raw.split(",");
        for (var i = 0; i < parts.length; i++)
        {
            var v = toIntOrDefault(parts[i], 0);
            if (v > 0) out.push(v);
        }
    }
    return out;
}
function writeWatchlistIdsForCharacter(board, whoSer, arr)
{
    // clear existing
    for (var c = 0; ; c++)
    {
        var k = watchlistKeyForCharacterChunk(whoSer, c), raw = board.GetTag(k);
        if (!raw || raw === "") { if (c === 0) {} else break; }
        board.SetTag(k, "");
    }
    // write chunks
    var i = 0, ch = 0;
    while (i < arr.length)
    {
        var slice = arr.slice(i, i + WATCHLIST_IDS_PER_TAG);
        board.SetTag(watchlistKeyForCharacterChunk(whoSer, ch), slice.join(","));
        i += WATCHLIST_IDS_PER_TAG; ch++;
    }
}
function isCharacterWatchingAuction(board, whoSer, aid)
{
    var arr = readWatchlistIdsForCharacter(board, whoSer);
    return arr.indexOf(aid | 0) !== -1;
}
function toggleWatchForCharacter(board, whoSer, aid)
{
    var arr = readWatchlistIdsForCharacter(board, whoSer);
    var v = aid | 0, i = arr.indexOf(v), on;
    if (i === -1) { arr.push(v); on = true; } else { arr.splice(i, 1); on = false; }
    writeWatchlistIdsForCharacter(board, whoSer, arr);
    return on;
}
function buildWatchSetForCharacter(board, whoSer)
{
    var arr = readWatchlistIdsForCharacter(board, whoSer), s = {};
    for (var i = 0; i < arr.length; i++) s[arr[i] | 0] = true;
    return s;
}

// =============================== Escrow index (per-seller) ===============================
var ESCROW_SERIALS_PER_TAG = 8;
function escrowIndexKeyForOwnerChunk(ownerSer, chunk) { return "aidx_" + (ownerSer | 0) + "_" + (chunk | 0); }

function readEscrowItemSerialsForOwner(board, ownerSer)
{
    var out = [];
    for (var c = 0; ; c++)
    {
        var raw = board.GetTag(escrowIndexKeyForOwnerChunk(ownerSer, c));
        if (!raw || raw === "") break;
        var parts = raw.split(",");
        for (var i = 0; i < parts.length; i++)
        {
            var s = toIntOrDefault(parts[i], 0);
            if (s > 0) out.push(s);
        }
    }
    return out;
}
function writeEscrowItemSerialsForOwner(board, ownerSer, arr)
{
    // clear
    for (var c = 0; ; c++)
    {
        var key = escrowIndexKeyForOwnerChunk(ownerSer, c);
        var raw = board.GetTag(key);
        if (!raw || raw === "") { if (c === 0) {} else break; }
        board.SetTag(key, "");
    }
    // write
    var idx = 0, chunk = 0;
    while (idx < arr.length)
    {
        var slice = arr.slice(idx, idx + ESCROW_SERIALS_PER_TAG);
        board.SetTag(escrowIndexKeyForOwnerChunk(ownerSer, chunk), slice.join(","));
        idx += ESCROW_SERIALS_PER_TAG; chunk++;
    }
}
function addEscrowItemForOwner(board, ownerSer, itemSer)
{
    var arr = readEscrowItemSerialsForOwner(board, ownerSer);
    var val = (itemSer | 0);
    if (arr.indexOf(val) === -1) { arr.push(val); writeEscrowItemSerialsForOwner(board, ownerSer, arr); }
}
function removeEscrowItemForOwner(board, ownerSer, itemSer)
{
    var arr = readEscrowItemSerialsForOwner(board, ownerSer);
    var val = (itemSer | 0);
    var i = arr.indexOf(val);
    if (i >= 0) { arr.splice(i, 1); writeEscrowItemSerialsForOwner(board, ownerSer, arr); }
}

// Date.getMonth() is 0-based: Jan=0 ... Oct=9 ... Dec=11
function getHolidayPictureIDs(now)
{
    if (!now) now = new Date();
    var month = now.getMonth(); // 0..11

    if (month === 9)  return { left: 0x4689, right: 0x4688 }; // October: Halloween
    if (month === 10) return { left: 0x46A0, right: 0x46A1 }; // November: Horn of Plenty
    if (month === 11) return { left: 0x9DC0, right: 0x9DBF }; // December: Christmas

    return { left: 0x18D9, right: 0x18DA };                // All other months
}

// =============================== Watchers (per-auction index) ===============================
function watcherIndexTagKeyForAuction(aid) { return "w_a_" + (aid | 0); }
function readWatcherSerialsForAuction(board, aid)
{
    var raw = board.GetTag(watcherIndexTagKeyForAuction(aid)) || "";
    if (!raw) return [];
    var out = [], parts = raw.split(",");
    for (var i = 0; i < parts.length; i++)
    {
        var v = toIntOrDefault(parts[i], 0);
        if (v > 0 && out.indexOf(v) === -1) out.push(v);
    }
    return out;
}
function writeWatcherSerialsForAuction(board, aid, arr)
{
    board.SetTag(watcherIndexTagKeyForAuction(aid), (arr || []).join(","));
}
function addWatcherToAuctionIndex(board, aid, whoSer)
{
    var arr = readWatcherSerialsForAuction(board, aid);
    var v = whoSer | 0;
    if (arr.indexOf(v) === -1) { arr.push(v); writeWatcherSerialsForAuction(board, aid, arr); }
}
function removeWatcherFromAuctionIndex(board, aid, whoSer)
{
    var arr = readWatcherSerialsForAuction(board, aid);
    var v = whoSer | 0, i = arr.indexOf(v);
    if (i !== -1) { arr.splice(i, 1); writeWatcherSerialsForAuction(board, aid, arr); }
}

// =============================== Notifications (ending soon) ===============================
function notificationTagKeyForMarkExt(aid, ser, tag, ext)
{
    return "anotif_" + (aid | 0) + "_" + (ser | 0) + "_" + tag + "_e" + ((ext | 0) + "");
}
function maybeSendEndingSoonPings(board, a, nowMs)
{
    if ((a.status || "open") !== "open") return;
    var end = toNumberOrZero(a.endAt);
    var remain = (end - nowMs) | 0;
    if (remain <= 0) return;

    // recipients = owner + watchers + unique bidders
    var recipients = {};
    var watchers = readWatcherSerialsForAuction(board, a.id | 0);
    for (var i = 0; i < watchers.length; i++) recipients[watchers[i] | 0] = 1;
    var uniq = getUniqueBidderSerials(a);
    for (var j = 0; j < uniq.length; j++) recipients[uniq[j] | 0] = 1;
    recipients[a.owner | 0] = 1;

    var ext = a.snipeExtends | 0;
    for (var k = 0; k < END_NOTIFY_MARKS.length; k++)
    {
        var mk = END_NOTIFY_MARKS[k];
        if (remain <= mk.ms)
        {
            for (var serStr in recipients)
            {
                var ser = serStr | 0;
                var key = notificationTagKeyForMarkExt(a.id | 0, ser, mk.tag, ext);
                if (toIntOrDefault(board.GetTag(key), 0)) continue;
                board.SetTag(key, "1");

                var ch = CalcCharFromSer(ser);
                if (ValidateObject(ch) && ch.socket != null)
                {
                    var mins = (mk.tag === "5m") ? "5 minutes" : "1 minute";
                    ch.SysMessage("Auction ending in " + mins + ": " + ((a.name || a.itemName) || ""));
                }
            }
        }
    }
}

// =============================== Sorting & Filtering ===============================
function sortAuctionsByMode(list, mode)
{
    var m = (mode || "ending");
    var L = list.slice(0);
    if (m === "ending")
    {
        L.sort(function(x, y) { return toNumberOrZero(x.endAt) - toNumberOrZero(y.endAt); });
    } else if (m === "newest")
    {
        L.sort(function(x, y) { return (toNumberOrZero(y.createdAt || y.id) - toNumberOrZero(x.createdAt || x.id)); });
    } else if (m === "highest")
    {
        L.sort(function(x, y) { return getCurrentAuctionValue(y) - getCurrentAuctionValue(x); });
    }
    return L;
}

/* Query tokens:
   owner:<text>  item:<text>  desc:<text>
   min:<n>       max:<n>      open | ended | donation | free | reserve | noreserve | buyout | nobuyout
   Bare words fuzzy match owner/item/name/desc
*/

function filterAuctionsBySearchQuery(list, q)
{
    q = toLowerTrimmed(q);
    if (!q) return list;
    var tokens = q.split(/\s+/);

    return list.filter(function(a)
    {
        var name = toLowerTrimmed(a.name || "");
        var item = toLowerTrimmed(a.itemName || "");
        var desc = toLowerTrimmed(a.desc || "");
        var owner = toLowerTrimmed(a.ownerName || "");
        var status = a.status || "open";
        var start = a.startPrice | 0;
        var last = getLastBidAmount(a);

        for (var i = 0; i < tokens.length; i++)
        {
            var t = tokens[i]; if (!t) continue;

            if (t.indexOf("owner:") === 0) { if (owner.indexOf(t.substr(6)) === -1) return false; continue; }
            if (t.indexOf("item:") === 0) { var n = t.substr(5); if (name.indexOf(n) === -1 && item.indexOf(n) === -1) return false; continue; }
            if (t.indexOf("desc:") === 0) { if (desc.indexOf(t.substr(5)) === -1) return false; continue; }

            if (t === "open") { if (status !== "open") return false; continue; }
            if (t === "ended" || t === "closed") { if (status !== "ended") return false; continue; }
            if (t === "donation" || t === "free") { if ((start | 0) > 0) return false; continue; }

            if (t.indexOf("min:") === 0) { var v = toIntOrDefault(t.substr(4), 0); if (Math.max(start, last) < v) return false; continue; }
            if (t.indexOf("max:") === 0) { var v = toIntOrDefault(t.substr(4), 0); if (Math.max(start, last) > v) return false; continue; }
            if (t === "reserve") { if ((a.reservePrice | 0) <= 0) return false; continue; }
            if (t === "noreserve") { if ((a.reservePrice | 0) > 0) return false; continue; }
            if (t === "buyout") { if ((a.buyoutPrice | 0) <= 0) return false; continue; }
            if (t === "nobuyout") { if ((a.buyoutPrice | 0) > 0) return false; continue; }

            if (name.indexOf(t) === -1 && item.indexOf(t) === -1 && desc.indexOf(t) === -1 && owner.indexOf(t) === -1) return false;
        }
        return true;
    });
}

// =============================== Gumps ===============================
function openBoardGumpUI(pUser, board, page)
{
    var pSock = pUser.socket;
    var listAll = loadAuctionsFromBoard(board);

    // search & filters
    var q = pUser.GetTempTag("auction_q") || "";
    var list = filterAuctionsBySearchQuery(listAll, q);

    var mine = (toIntOrDefault(pUser.GetTempTag("auction_filter_mine"), 0) === 1);
    var watchOnly = (toIntOrDefault(pUser.GetTempTag("auction_filter_watch"), 0) === 1);
    if (mine) list = list.filter(function(a) { return (a.owner | 0) === (pUser.serial | 0); });
    if (watchOnly)
    {
        var ws = buildWatchSetForCharacter(board, pUser.serial | 0);
        list = list.filter(function(a) { return !!ws[a.id | 0]; });
    }
    var sortMode = pUser.GetTempTag("auction_sort") || "ending";
    list = sortAuctionsByMode(list, sortMode);

    page = toIntOrDefault(page, 1);
    var pages = Math.max(1, Math.ceil(list.length / ROWS_PER_PAGE));
    if (page < 1) page = 1; if (page > pages) page = pages;

    var g = new Gump;
    g.AddBackground(0, 0, G_H, G_W, GUMP_BG);
    g.AddCheckerTrans(0, 0, G_H, G_W);
   
    //g.AddPicture( G_H - 430, 20, 0x46A0 ); // horn of plenty
    //g.AddPicture( G_H - 580, 20, 0x46A1 ); // horn of plenty

    var art = getHolidayPictureIDs();

    g.AddPicture( G_H - 430, 20, art.left );   // seasonal left
    g.AddPicture( G_H - 580, 20, art.right );  // seasonal right

    // header
    g.AddHTMLGump(G_H - 525, 20, 300, 74, false, false, "<BASEFONT color=#ffffff><H3>Auction Board</H3></BASEFONT>");
    g.AddHTMLGump(G_H - 200, 20, 300, 74, false, false, "<BASEFONT color=#ffffff><H3>Page " + page + " / " + pages + "</H3></BASEFONT>");

    if (q && q.length)
    {
        g.AddHTMLGump(56, 20, 300, 74, false, false, "<BASEFONT color=#ffffff><H3>Results: " + list.length + " / " + listAll.length + "</H3></BASEFONT>");
    }

    g.AddHTMLGump(100, 70, 100, 74, false, false, "<BASEFONT color=#ffffff><H3>Owner</H3></BASEFONT>");
    g.AddHTMLGump(300, 70, 100, 74, false, false, "<BASEFONT color=#ffffff><H3>Item</H3></BASEFONT>");
    g.AddHTMLGump(430, 70, 100, 74, false, false, "<BASEFONT color=#ffffff><H3>Price/Last</H3></BASEFONT>");
    g.AddHTMLGump(540, 70, 100, 74, false, false, "<BASEFONT color=#ffffff><H3>Ends</H3></BASEFONT>");
    g.AddHTMLGump(820, 70, 100, 74, false, false, "<BASEFONT color=#ffffff><H3>Status</H3></BASEFONT>");

    // rows
    var start = (page - 1) * ROWS_PER_PAGE;
    for (var i = 0; i < ROWS_PER_PAGE; i++)
    {
        var idx = start + i;
        if (idx >= list.length) break;

        var a = list[idx];
        var y = 90 + i * 28;

        g.AddButton(16, y, BTN, 1, 0, BID_VIEW_BASE + (a.id | 0));
        g.AddHTMLGump(100, y, 500, 74, false, false, "<BASEFONT color=#ffffff>" + (a.ownerName || "") + "</BASEFONT>");
        g.AddHTMLGump(300, y, 500, 74, false, false, "<BASEFONT color=#ffffff>" + (a.name || a.itemName || "") + "</BASEFONT>");

        var last = (a.startPrice <= 0) ? "DONATION"
            : (a.bids && a.bids.length ? (a.bids[a.bids.length - 1].amount + " by " + (a.bids[a.bids.length - 1].name || ""))
                : (a.startPrice + " (start)"));
        var left = formatTimeLeftShort(toNumberOrZero(a.endAt) - getNowMillis());
        g.AddHTMLGump(430, y, 200, 74, false, false, "<BASEFONT color=#ffffff>" + last + "</BASEFONT>");
        g.AddHTMLGump(540, y, 400, 74, false, false, "<BASEFONT color=#ffffff>" + new Date(a.endAt).toLocaleString() + " (" + left + ")</BASEFONT>");
        g.AddHTMLGump(820, y, 50, 74, false, false, "<BASEFONT color=#ffffff>" + (a.status || "open") + "</BASEFONT>");
    }

    // paging
    if (page > 1) g.AddButton(G_H - 90, 20, 0x9A2, 1, 0, BID_PAGE_PREV);
    if (page < pages) g.AddButton(G_H - 90, 20, 0x9A5, 1, 0, BID_PAGE_NEXT);

    // withdraw & add
    g.AddButton(760, 420, BTN, 1, 0, BID_WITHDRAW);
    g.AddHTMLGump(630, 420, 200, 74, false, false, "<BASEFONT color=#ffffff><H3>Withdraw Credit</H3></BASEFONT>");

    var myCred = getCreditForChar(board, pUser);
    g.AddHTMLGump(830, 420, 50, 74, false, false, "<BASEFONT color=#ffffff><H3>(" + myCred + ")</H3></BASEFONT>");
    g.AddButton(540, 420, BTN, 1, 0, BID_ADD_AUCTION);
    g.AddHTMLGump(450, 420, 100, 74, false, false, "<BASEFONT color=#ffffff><H3>Add Auction</H3></BASEFONT>");

    // filters + sort
    g.AddButton(20, 470, BTN, 1, 0, BID_FILTER_MINE);
    g.AddHTMLGump(90, 470, 100, 74, false, false, "<BASEFONT color=#ffffff><H3>Mine: " + (mine ? "ON" : "OFF") + "</H3></BASEFONT>");

    g.AddButton(200, 470, BTN, 1, 0, BID_FILTER_WATCH);
    g.AddHTMLGump(270, 470, 200, 74, false, false, "<BASEFONT color=#ffffff><H3>Watching: " + (watchOnly ? "ON" : "OFF") + "</H3></BASEFONT>");

    g.AddHTMLGump(400, 470, 200, 74, false, false, "<BASEFONT color=#ffffff><H3>Sort:</H3></BASEFONT>");
    g.AddButton(440, 470, BTN, 1, 0, BID_SORT_ENDING);
    g.AddHTMLGump(510, 470, 200, 74, false, false, "<BASEFONT color=#ffffff><H3>Ending</H3></BASEFONT>");
    g.AddButton(560, 470, BTN, 1, 0, BID_SORT_NEWEST);
    g.AddHTMLGump(630, 470, 200, 74, false, false, "<BASEFONT color=#ffffff><H3>Newest</H3></BASEFONT>");
    g.AddButton(690, 470, BTN, 1, 0, BID_SORT_HIGHEST);
    g.AddHTMLGump(760, 470, 200, 74, false, false, "<BASEFONT color=#ffffff><H3>Highest</H3></BASEFONT>");

    // search
    g.AddHTMLGump(20, 420, 50, 74, false, false, "<BASEFONT color=#ffffff><H3>Search:</H3></BASEFONT>");
    g.AddGump(77, 425, 0x5f); g.AddGump(88, 436, 0x60); g.AddGump(270, 425, 0x61);
    g.AddButton(285, 420, 0xEE, 1, 0, BID_SEARCH_APPLY);
    g.AddButton(350, 420, 0xF4, 1, 0, BID_SEARCH_CLEAR);
    g.AddHTMLGump(20, 450, 500, 74, false, false, "<BASEFONT color=#ffffff>Search Tips: owner:Jane item:sword min:100 max:1000 open ended donation</BASEFONT>");
    g.AddTextEntry(88, 420, 170, 16, 0x0481, 1, 200, q || "search");

    // context
    pUser.SetTempTag("auction_board", board.serial + "");
    pUser.SetTempTag("auction_page", page + "");

    g.Send(pSock);
    g.Free();
}

function openAddAuctionGumpUI(pUser, board, item, prev)
{
    var pSock = pUser.socket;
    var g = new Gump;

    g.AddBackground(0, 0, 520, 240, GUMP_BG);
    g.AddCheckerTrans(0, 0, 520, 240);

    var lockedName = getCanonicalItemName(item);
    g.AddText(50, 16, 5, "Auction Item");
    g.AddText(20, 50, 3, "Item:");
    g.AddHTMLGump(230, 50, 220, 18, false, false, "<BASEFONT color=#FFFFFF>" + lockedName + "</BASEFONT>");
    g.AddText(20, 80, 3, "Description:");
    g.AddText(20, 110, 3, "Start Price (0 = donation):");
    g.AddText(20, 140, 3, "Duration (e.g., 30m, 2h, 1d):");
    g.AddText(20, 170, 3, "Reserve (0 = none):");
    g.AddText(20, 200, 3, "Buyout (0 = none):");

    g.AddButton(140, 16, BTN, 1, 0, BID_ADD_OK);
    g.AddText(208, 16, 5, "Start Auction");
    g.AddButton(300, 16, 0xF1, 1, 0, BID_ADD_CANCEL);

    g.AddTextEntry(230, 80, 220, 18, 0x0481, 1, 9, (prev && prev[0]) || (item.name || ""));      // desc
    g.AddTextEntry(230, 110, 170, 18, 0x0481, 1, 10, (prev && prev[1]) || item.buyvalue || "0");   // start
    g.AddTextEntry(230, 140, 170, 18, 0x0481, 1, 11, (prev && prev[2]) || "7d");                  // duration
    g.AddTextEntry(230, 170, 170, 18, 0x0481, 1, 12, (prev && prev[3]) || "0");                   // reserve
    g.AddTextEntry(230, 200, 170, 18, 0x0481, 1, 13, (prev && prev[4]) || "0");                   // buyout

    pUser.SetTempTag("auction_board", board.serial + "");
    pUser.SetTempTag("auction_item", item.serial + "");

    g.Send(pSock);
    g.Free();
}

function openViewAuctionGumpUI(pUser, board, a)
{
    var pSock = pUser.socket;
    var g = new Gump;

    g.AddBackground(0, 0, 350, 440, GUMP_BG);
    g.AddCheckerTrans(0, 0, 350, 440);

    g.AddHTMLGump(100, 16, 180, 22, false, false, "<BASEFONT color=#ffffff><H3>View Auction Item</H3></BASEFONT>");

    var LBLX = 18, VALX = 80, LBLW = 58, VALW = 210, ROWH = 22, DY = 25;
    var y = 44;
    function addRow(lbl, val, vW)
    {
        g.AddHTMLGump(LBLX, y, LBLW, ROWH, false, false, "<BASEFONT color=#ffffff>" + lbl + "</BASEFONT>");
        g.AddHTMLGump(VALX, y, (vW || VALW), ROWH, true, false, "<BASEFONT color=#ffffff>" + val + "</BASEFONT>");
        y += DY;
    }

    addRow("Owner:", (a.ownerName || ""));
    addRow("Desc:", (a.desc || "-"));

    var isDonation = ((a.startPrice | 0) <= 0);
    var minPrice = (a.bids && a.bids.length) ? (a.bids[a.bids.length - 1].amount + 1) : (a.startPrice | 0);
    if (isDonation) minPrice = 0;
    addRow(isDonation ? "Donation:" : "Min Bid", isDonation ? "FREE" : (minPrice + ""));

    if ((a.reservePrice | 0) > 0)
    {
        var curVal = Math.max((a.startPrice | 0), getLastBidAmount(a));
        var met = curVal >= (a.reservePrice | 0) ? " (met)" : " (not met)";
        addRow("Reserve:", (a.reservePrice + "") + met);
    }
    if ((a.buyoutPrice | 0) > 0) addRow("Buyout:", (a.buyoutPrice + ""));
    addRow("Ends:", new Date(a.endAt).toLocaleString(), 260);
    addRow("Status:", (a.status || "open"));

    g.AddButton(40, 260, BTN, 1, 0, BID_VIEW_BID);
    g.AddHTMLGump(60, 240, 150, 22, false, false, "<BASEFONT color=#ffffff>" + (isDonation ? "Claim" : "Bid") + "</BASEFONT>");

    var watching = isCharacterWatchingAuction(board, pUser.serial | 0, a.id | 0);
    g.AddButton(140, 260, BTN, 1, 0, BID_TOGGLE_WATCH);
    g.AddHTMLGump(150, 240, 150, 22, false, false, "<BASEFONT color=#ffffff>" + (watching ? "Unwatch" : "Watch") + "</BASEFONT>");

    g.AddButton(235, 260, BTN, 1, 0, BID_WITHDRAW);
    g.AddHTMLGump(215, 240, 150, 22, false, false, "<BASEFONT color=#ffffff>Withdraw Credit</BASEFONT>");

    if (((a.status || "open") === "open") && ((pUser.serial | 0) === (a.owner | 0)))
    {
        g.AddButton(140, 320, BTN, 1, 0, BID_VIEW_CANCEL);
        g.AddHTMLGump(150, 300, 150, 22, false, false, "<BASEFONT color=#ffffff>Cancel</BASEFONT>");
    }

    // Right preview panel
    var PX = 360, PY = 16, PW = 100;
    g.AddHTMLGump(PY, PX, PW, 22, false, false, "<BASEFONT color=#FFFF00><B>Preview</B></BASEFONT>");
    var it = CalcItemFromSer(a.itemSerial | 0);
    var hasIt = ValidateObject(it);
    if (hasIt)
    {
        addItemArtToGump(g, PY, PX + 20, (a.itemID || it.id | 0), (a.itemHue || it.hue | 0));
        var yy = PY + 100, step = 100;
        function kv(lbl, val)
        {
            g.AddHTMLGump(yy - 25, PX, 400, 18, false, false, "<BASEFONT color=#AAAAAA>" + lbl + "</BASEFONT>");
            g.AddHTMLGump(yy - 20, PX + 20, 400, 18, false, false, "<BASEFONT color=#FFFFFF>" + val + "</BASEFONT>");
            yy += step;
        }
        if (it.lodamage > 0 && it.hidamage > 0) kv("Lo/Hi Dam", (it.lodamage + "/" + it.hidamage));

        // durability (support hp/health and maxhp/maxhealth variations)
        var curDur = (typeof it.hp !== "undefined") ? (it.hp | 0)
            : (typeof it.health !== "undefined") ? (it.health | 0) : 0;
        var maxDur = (typeof it.maxhp !== "undefined") ? (it.maxhp | 0)
            : (typeof it.maxhealth !== "undefined") ? (it.maxhealth | 0) : 0;
        if (curDur > 0 && maxDur > 0) kv("Durability", curDur + " / " + maxDur);

        if (it.speed > 0) kv("Speed", (it.speed));
    } else
    {
        g.AddHTMLGump(PY + 30, PX, PW, 60, true, false, "<BASEFONT color=#ff8080>Item missing from board escrow.</BASEFONT>");
    }

    if (((a.status || "open") === "open") && ((a.buyoutPrice | 0) > 0))
    {
        g.AddButton(40, 320, BTN, 1, 0, BID_VIEW_BUYOUT);
        g.AddHTMLGump(50, 300, 60, 22, false, false, "<BASEFONT color=#ffffff>Buyout</BASEFONT>");
    }

    if ((a.status || "open") === "open")
    {
        g.AddHTMLGump(LBLX, y, LBLW, ROWH, false, false, "<BASEFONT color=#ffffff>Your Bid:</BASEFONT>");
        g.AddTextEntry(VALX, y, 120, 19, 0x0481, 1, 40, (minPrice > 0 ? (minPrice + "") : "0"));
    } else if ((a.status || "open") === "ended")
    {
        g.AddButton(140, 320, BTN, 1, 0, BID_VIEW_CLAIM);
        g.AddHTMLGump(140, 300, 220, 22, false, false, "<BASEFONT color=#ffffff>Claim (Winner)</BASEFONT>");
    }

    pUser.SetTempTag("auction_board", board.serial + "");
    pUser.SetTempTag("auction_aid", a.id + "");

    g.Send(pSock);
    g.Free();
}

function openCancelConfirmGumpUI(pUser, board, a)
{
    var pSock = pUser.socket;
    var g = new Gump;

    var fee = calculateCancelFee(a);
    var bidders = getUniqueBidderSerials(a).length;

    g.AddBackground(0, 0, 280, 180, GUMP_BG);
    g.AddCheckerTrans(0, 0, 280, 180);

    g.AddHTMLGump(16, 16, 228, 22, false, false, "<BASEFONT color=#ffffff><B>Cancel this auction?</B></BASEFONT>");
    g.AddHTMLGump(16, 46, 228, 22, false, false, "<BASEFONT color=#ffffff>Item: " + ((a.name || a.itemName) || "-") + "</BASEFONT>");
    g.AddHTMLGump(16, 70, 228, 22, false, false, "<BASEFONT color=#ffffff>Cancel fee: " + fee + " gold</BASEFONT>");
    g.AddHTMLGump(16, 94, 228, 44, false, false, "<BASEFONT color=#ffffff>All bidders will be notified/refunded.</BASEFONT>");

    g.AddButton(40, 130, 0x850, 1, 0, BID_CANCEL_YES);
    g.AddButton(140, 130, 0x847, 1, 0, BID_CANCEL_NO);

    pUser.SetTempTag("auction_board", board.serial + "");
    pUser.SetTempTag("auction_aid", a.id + "");

    g.Send(pSock);
    g.Free();
}

// =============================== Events ===============================
function onCreateDFN(objMade, objType)
{
    if (objType == 0)
    {
        objMade.StartTimer(TICK_MS, TIMER_ID, 7520);
    }
}

function onTimer(iBoard, timerID)
{
    if (timerID !== TIMER_ID) return;

    var list = loadAuctionsFromBoard(iBoard);
    var now = getNowMillis();
    var changed = false;

    for (var i = 0; i < list.length; i++)
    {
        var a = list[i];

        // ending-soon pings
        if ((a.status || "open") === "open") { maybeSendEndingSoonPings(iBoard, a, now); }

        if ((a.status || "open") === "open" && now >= toNumberOrZero(a.endAt))
        {
            if (a.bids && a.bids.length)
            {
                var w = a.bids[a.bids.length - 1];
                var topAmt = (w.amount | 0);
                var reserve = (a.reservePrice | 0);

                if (reserve > 0 && topAmt < reserve)
                {
                    // reserve not met -> return to seller, remove auction
                    var owner = CalcCharFromSer(a.owner | 0);
                    var item = CalcItemFromSer(a.itemSerial | 0);
                    if (ValidateObject(item))
                    {
                        item.movable = 1; item.decayable = true;
                        if (ValidateObject(owner)) item.SetCont(owner.pack);
                        else item.Teleport(iBoard.x, iBoard.y, iBoard.z);
                    }
                    removeEscrowItemForOwner(iBoard, a.owner | 0, a.itemSerial | 0);

                    // notify top bidder & seller
                    var topChar = CalcCharFromSer(w.char | 0);
                    if (ValidateObject(topChar) && topChar.socket != null)
                        topChar.SysMessage("Reserve not met on '" + (a.name || a.itemName || "") + "'. No winner.");
                    var seller = CalcCharFromSer(a.owner | 0);
                    if (ValidateObject(seller) && seller.socket != null)
                        seller.SysMessage("Your auction '" + (a.name || a.itemName || "") + "' ended: reserve not met.");

                    // clear watchers on this auction, then remove
                    writeWatcherSerialsForAuction(iBoard, a.id | 0, []);
                    list.splice(i, 1); i--;
                } else
                {
                    // reserve met or none -> ended with winner
                    a.status = "ended";
                    a.winner = w.char | 0;
                    a.winAmount = topAmt;

                    var seller2 = CalcCharFromSer(a.owner | 0);
                    if (ValidateObject(seller2) && seller2.socket != null)
                        seller2.SysMessage("Your auction '" + (a.name || a.itemName || "") + "' sold for " + topAmt + " gold.");
                    var winner = CalcCharFromSer(a.winner | 0);
                    if (ValidateObject(winner) && winner.socket != null)
                        winner.SysMessage("You won '" + (a.name || a.itemName || "") + "' for " + topAmt + " gold. Claim it on the board.");
                }
            } else
            {
                // no bids -> return to seller, remove OR mark ended if cannot
                var owner3 = CalcCharFromSer(a.owner | 0);
                var item3 = CalcItemFromSer(a.itemSerial | 0);
                if (ValidateObject(owner3) && ValidateObject(item3))
                {
                    item3.movable = 1; item3.decayable = true;
                    item3.SetCont(owner3.pack);
                    removeEscrowItemForOwner(iBoard, a.owner | 0, a.itemSerial | 0);
                    writeWatcherSerialsForAuction(iBoard, a.id | 0, []);
                    list.splice(i, 1); i--;
                } else
                {
                    a.status = "ended";
                    a.winner = -1;
                    a.winAmount = 0;
                }
            }
            changed = true;
        }
    }
    if (changed) saveAuctionsToBoard(iBoard, list);

    iBoard.StartTimer(TICK_MS, TIMER_ID, 7520);
}

function onUseChecked(pUser, iBoard)
{
    openBoardGumpUI(pUser, iBoard, 1);
    return false;
}

// =============================== Gump Press ===============================
function onGumpPress(pSock, pButton, gumpData)
{
    var pUser = pSock.currentChar;
    var bSer = toIntOrDefault(pUser.GetTempTag("auction_board"), 0);
    var board = CalcItemFromSer(bSer);
    if (!ValidateObject(board)) return true;

    // paging
    if (pButton === BID_PAGE_PREV)
    {
        var pg = toIntOrDefault(pUser.GetTempTag("auction_page"), 1);
        openBoardGumpUI(pUser, board, pg - 1);
        return true;
    }
    if (pButton === BID_PAGE_NEXT)
    {
        var pg2 = toIntOrDefault(pUser.GetTempTag("auction_page"), 1);
        openBoardGumpUI(pUser, board, pg2 + 1);
        return true;
    }

    // search
    if (pButton === BID_SEARCH_APPLY)
    {
        var qtxt = gumpData.getEdit(0) || "";
        if (qtxt.length > 120) qtxt = qtxt.substr(0, 120);
        pUser.SetTempTag("auction_q", qtxt);
        openBoardGumpUI(pUser, board, 1);
        return true;
    }
    if (pButton === BID_SEARCH_CLEAR)
    {
        pUser.SetTempTag("auction_q", "");
        openBoardGumpUI(pUser, board, 1);
        return true;
    }

    // withdraw
    if (pButton === BID_WITHDRAW)
    {
        var got = withdrawCreditToGoldCoins(board, pUser);
        pUser.SysMessage(got > 0 ? ("Withdrew " + got + " gold.") : "No credit to withdraw.");
        openBoardGumpUI(pUser, board, toIntOrDefault(pUser.GetTempTag("auction_page"), 1));
        return true;
    }

    // add auction flow
    if (pButton === BID_ADD_AUCTION)
    {
        var MAX_ESCROW = 50;
        if (readEscrowItemSerialsForOwner(board, pUser.serial | 0).length >= MAX_ESCROW)
        {
            pUser.TextMessage("You have reached the maximum number of active auctions.");
            return true;
        }
        pSock.CustomTarget(TID_ADD_AUCTION, "Select an item in your backpack to auction.");
        return true;
    }
    if (pButton === BID_ADD_CANCEL)
    {
        openBoardGumpUI(pUser, board, toIntOrDefault(pUser.GetTempTag("auction_page"), 1));
        return true;
    }
    if (pButton === BID_ADD_OK)
    {
        var desc = gumpData.getEdit(0);
        var price = gumpData.getEdit(1);
        var durStr = gumpData.getEdit(2);
        var reserve = gumpData.getEdit(3);
        var buyout = gumpData.getEdit(4);

        if ((desc || "").length < 3) { pUser.TextMessage("Invalid description"); reopenAddAuctionGumpUI(board, pUser, [desc, price + "", durStr + "", reserve + "", buyout + ""]); return true; }
        if (price < 0) { pUser.TextMessage("Invalid start price"); reopenAddAuctionGumpUI(board, pUser, [desc, price + "", durStr + "", reserve + "", buyout + ""]); return true; }

        var durMs = parseDurationToMilliseconds(durStr || "7d");
        if (durMs < DUR_MIN_MS || durMs > DUR_MAX_MS)
        {
            pUser.TextMessage("Duration must be between " + Math.round(DUR_MIN_MS / 60000) + " minutes and 365 days.");
            reopenAddAuctionGumpUI(board, pUser, [desc, price + "", durStr + "", reserve + "", buyout + ""]); return true;
        }

        if (reserve < 0 || buyout < 0) { pUser.TextMessage("Reserve/Buyout can't be negative."); reopenAddAuctionGumpUI(board, pUser, [desc, price + "", durStr + "", reserve + "", buyout + ""]); return true; }
        if (buyout > 0 && buyout < Math.max(price | 0, reserve | 0))
        {
            buyout = Math.max(price | 0, reserve | 0);
            pUser.SysMessage("Buyout was raised to " + buyout + " to be >= start/reserve.");
        }

        var item = CalcItemFromSer(toIntOrDefault(pUser.GetTempTag("auction_item"), 0));
        if (!ValidateObject(item) || item.container != pUser.pack)
        {
            pUser.TextMessage("The item must be in your pack to be auctioned.");
            openBoardGumpUI(pUser, board, toIntOrDefault(pUser.GetTempTag("auction_page"), 1));
            return true;
        }

        var list = loadAuctionsFromBoard(board);
        var aid = allocateNextAuctionId(board);
        var canonical = getCanonicalItemName(item);
        var fp = buildItemFingerprint(item);
        list.push({
            id: aid,
            owner: pUser.serial | 0,
            ownerName: pUser.name || ("0x" + (pUser.serial | 0).toString(16)),
            itemSerial: item.serial | 0,
            itemName: canonical,
            itemID: item.id | 0,
            itemHue: item.hue | 0,
            itemFP: fp,
            startPrice: price | 0,
            name: canonical,
            desc: desc,
            endAt: getNowMillis() + durMs,
            status: "open",
            bids: [],
            createdAt: getNowMillis(),
            reservePrice: reserve | 0,
            buyoutPrice: buyout | 0,
            snipeWinMs: (ANTISNIPE_WINDOW_MIN * 60000) | 0,
            snipeExtMs: (ANTISNIPE_EXTEND_MIN * 60000) | 0,
            snipeExtends: 0,
            snipeMax: ANTISNIPE_MAX_EXTENDS | 0
        });
        saveAuctionsToBoard(board, list);

        // Escrow: lock & move into board container
        item.movable = 0; item.decayable = false; item.SetCont(board);
        addEscrowItemForOwner(board, pUser.serial | 0, item.serial | 0);

        pUser.TextMessage("Auction started.");
        openBoardGumpUI(pUser, board, toIntOrDefault(pUser.GetTempTag("auction_page"), 1));
        return true;
    }

    // filters & sort
    if (pButton === BID_FILTER_MINE)
    {
        var cur = toIntOrDefault(pUser.GetTempTag("auction_filter_mine"), 0);
        pUser.SetTempTag("auction_filter_mine", (cur ? 0 : 1) + "");
        openBoardGumpUI(pUser, board, 1);
        return true;
    }
    if (pButton === BID_FILTER_WATCH)
    {
        var cur2 = toIntOrDefault(pUser.GetTempTag("auction_filter_watch"), 0);
        pUser.SetTempTag("auction_filter_watch", (cur2 ? 0 : 1) + "");
        openBoardGumpUI(pUser, board, 1);
        return true;
    }
    if (pButton === BID_SORT_ENDING) { pUser.SetTempTag("auction_sort", "ending"); openBoardGumpUI(pUser, board, 1); return true; }
    if (pButton === BID_SORT_NEWEST) { pUser.SetTempTag("auction_sort", "newest"); openBoardGumpUI(pUser, board, 1); return true; }
    if (pButton === BID_SORT_HIGHEST) { pUser.SetTempTag("auction_sort", "highest"); openBoardGumpUI(pUser, board, 1); return true; }

    // watch toggle
    if (pButton === BID_TOGGLE_WATCH)
    {
        var aidT = toIntOrDefault(pUser.GetTempTag("auction_aid"), 0);
        var listT = loadAuctionsFromBoard(board);
        var fT = findAuctionInListById(listT, aidT);
        if (!fT.a) { openBoardGumpUI(pUser, board, 1); return true; }
        var on = toggleWatchForCharacter(board, pUser.serial | 0, aidT);
        if (on) { addWatcherToAuctionIndex(board, aidT, pUser.serial | 0); }
        else { removeWatcherFromAuctionIndex(board, aidT, pUser.serial | 0); }
        pUser.SysMessage(on ? "Added to Watchlist." : "Removed from Watchlist.");
        openViewAuctionGumpUI(pUser, board, fT.a);
        return true;
    }

    // view row
    if (pButton >= BID_VIEW_BASE && pButton < BID_VIEW_BASE + BID_VIEW_BLOCK)
    {
        var aidV = pButton - BID_VIEW_BASE;
        var listV = loadAuctionsFromBoard(board);
        var fV = findAuctionInListById(listV, aidV);
        if (fV.a) openViewAuctionGumpUI(pUser, board, fV.a);
        else openBoardGumpUI(pUser, board, 1);
        return true;
    }

    // owner clicked cancel
    if (pButton === BID_VIEW_CANCEL)
    {
        var aidC = toIntOrDefault(pUser.GetTempTag("auction_aid"), 0);
        var listC = loadAuctionsFromBoard(board);
        var fC = findAuctionInListById(listC, aidC);
        if (!fC.a) { openBoardGumpUI(pUser, board, 1); return true; }
        var aC = fC.a;

        if ((aC.status || "open") !== "open" || (pUser.serial | 0) !== (aC.owner | 0))
        {
            pUser.SysMessage("You can only cancel your own open auction.");
            openViewAuctionGumpUI(pUser, board, aC); return true;
        }
        openCancelConfirmGumpUI(pUser, board, aC);
        return true;
    }

    // confirm YES
    if (pButton === BID_CANCEL_YES)
    {
        var aidY = toIntOrDefault(pUser.GetTempTag("auction_aid"), 0);
        var listY = loadAuctionsFromBoard(board);
        var fY = findAuctionInListById(listY, aidY);
        if (!fY.a) { openBoardGumpUI(pUser, board, 1); return true; }
        var aY = fY.a;

        if ((aY.status || "open") !== "open" || (pUser.serial | 0) !== (aY.owner | 0))
        {
            pUser.SysMessage("This auction can no longer be cancelled.");
            openViewAuctionGumpUI(pUser, board, aY); return true;
        }

        var fee = calculateCancelFee(aY);
        if (!payFromBackpackOrBank(pUser, fee))
        {
            pUser.SysMessage("You need " + fee + " gold in your backpack or bank to cancel.");
            openViewAuctionGumpUI(pUser, board, aY); return true;
        }

        var itemY = CalcItemFromSer(aY.itemSerial | 0);
        if (ValidateObject(itemY))
        {
            itemY.movable = 1; itemY.decayable = true;
            itemY.SetCont(pUser.pack);
            if (itemY.container != pUser.pack) itemY.Teleport(pUser.x, pUser.y, pUser.z);
        }

        // notify bidders
        var uniq = getUniqueBidderSerials(aY);
        for (var i = 0; i < uniq.length; i++)
        {
            var bc = CalcCharFromSer(uniq[i] | 0);
            if (ValidateObject(bc) && bc.socket != null)
                bc.SysMessage("Auction cancelled: '" + ((aY.name || aY.itemName) || "") + "'. Your bid has been voided.");
        }

        removeEscrowItemForOwner(board, aY.owner | 0, aY.itemSerial | 0);
        writeWatcherSerialsForAuction(board, aY.id | 0, []);
        listY.splice(fY.idx, 1);
        saveAuctionsToBoard(board, listY);

        pUser.SysMessage("Auction cancelled. Fee paid: " + fee + " gold.");
        openBoardGumpUI(pUser, board, Math.max(1, toIntOrDefault(pUser.GetTempTag("auction_page"), 1)));
        return true;
    }

    // confirm NO
    if (pButton === BID_CANCEL_NO)
    {
        var aidN = toIntOrDefault(pUser.GetTempTag("auction_aid"), 0);
        var listN = loadAuctionsFromBoard(board);
        var fN = findAuctionInListById(listN, aidN);
        if (fN.a) openViewAuctionGumpUI(pUser, board, fN.a);
        else openBoardGumpUI(pUser, board, 1);
        return true;
    }

    // bid / claim
    if (pButton === BID_VIEW_BID)
    {
        var aidB = toIntOrDefault(pUser.GetTempTag("auction_aid"), 0);
        var listB = loadAuctionsFromBoard(board);
        var fB = findAuctionInListById(listB, aidB);
        if (!fB.a) { openBoardGumpUI(pUser, board, 1); return true; }
        var aB = fB.a;

        var isDonation = ((aB.startPrice | 0) <= 0);
        if (isDonation)
        {
            var itemD = CalcItemFromSer(aB.itemSerial | 0);
            if (ValidateObject(itemD))
            {
                itemD.movable = 1; itemD.decayable = true;
                itemD.SetCont(pUser.pack);
                if (itemD.container != pUser.pack) itemD.Teleport(pUser.x, pUser.y, pUser.z);
            }
            removeEscrowItemForOwner(board, aB.owner | 0, aB.itemSerial | 0);
            writeWatcherSerialsForAuction(board, aB.id | 0, []);
            listB.splice(fB.idx, 1);
            saveAuctionsToBoard(board, listB);

            pUser.TextMessage("Donation claimed.");
            openBoardGumpUI(pUser, board, Math.max(1, toIntOrDefault(pUser.GetTempTag("auction_page"), 1)));
            return true;
        }

        if ((aB.status || "open") !== "open")
        {
            pUser.TextMessage("Auction is not open.");
            openViewAuctionGumpUI(pUser, board, aB); return true;
        }
        if ((pUser.serial | 0) === (aB.owner | 0))
        {
            pUser.TextMessage("You can't bid on your own auction.");
            openViewAuctionGumpUI(pUser, board, aB); return true;
        }

        var offer = gumpData.getEdit(0);
        var min = (aB.bids && aB.bids.length) ? (aB.bids[aB.bids.length - 1].amount + 1) : (aB.startPrice | 0);
        if (offer < min)
        {
            pUser.TextMessage("Your bid must be at least " + min + ".");
            openViewAuctionGumpUI(pUser, board, aB); return true;
        }

        var nowB = getNowMillis();
        var prevTop = (aB.bids && aB.bids.length) ? aB.bids[aB.bids.length - 1] : null;
        aB.bids = aB.bids || [];
        aB.bids.push({ char: pUser.serial | 0, name: pUser.name || "", amount: offer | 0 });

        // Anti-snipe
        if (toNumberOrZero(aB.snipeWinMs) > 0 && toNumberOrZero(aB.snipeExtMs) > 0 && ((aB.snipeExtends | 0) < (aB.snipeMax | 0)))
        {
            var remain = toNumberOrZero(aB.endAt) - nowB;
            if (remain <= toNumberOrZero(aB.snipeWinMs))
            {
                aB.endAt = toNumberOrZero(aB.endAt) + toNumberOrZero(aB.snipeExtMs);
                aB.snipeExtends = (aB.snipeExtends | 0) + 1;
                pUser.SysMessage("Auction extended due to late bid (+ " + Math.round(toNumberOrZero(aB.snipeExtMs) / 60000) + " min).");

                var seller = CalcCharFromSer(aB.owner | 0);
                if (ValidateObject(seller) && seller.socket != null)
                    seller.SysMessage("Your auction '" + (aB.name || aB.itemName || "") + "' was extended by a late bid.");
            }
        }
        saveAuctionsToBoard(board, listB);

        if (prevTop && (prevTop.char | 0) !== (pUser.serial | 0))
        {
            var prevChar = CalcCharFromSer(prevTop.char | 0);
            if (ValidateObject(prevChar) && prevChar.socket != null)
            {
                prevChar.SysMessage("You were outbid on '" + (aB.name || aB.itemName || "") + "'. New bid: " + (offer | 0) + " gold.");
            }
        }
        pUser.TextMessage("Bid placed.");
        openViewAuctionGumpUI(pUser, board, aB);
        return true;
    }

    // buyout
    if (pButton === BID_VIEW_BUYOUT)
    {
        var aidBO = toIntOrDefault(pUser.GetTempTag("auction_aid"), 0);
        var listBO = loadAuctionsFromBoard(board);
        var fBO = findAuctionInListById(listBO, aidBO);
        if (!fBO.a) { openBoardGumpUI(pUser, board, 1); return true; }
        var aBO = fBO.a;

        if ((aBO.status || "open") !== "open") { pUser.SysMessage("Auction is not open."); openViewAuctionGumpUI(pUser, board, aBO); return true; }
        if ((aBO.buyoutPrice | 0) <= 0) { pUser.SysMessage("No buyout available."); openViewAuctionGumpUI(pUser, board, aBO); return true; }
        if ((pUser.serial | 0) === (aBO.owner | 0)) { pUser.SysMessage("You can't buyout your own auction."); openViewAuctionGumpUI(pUser, board, aBO); return true; }

        var priceBO = aBO.buyoutPrice | 0;
        if (!payFromBackpackOrBank(pUser, priceBO))
        {
            pUser.SysMessage("You need " + priceBO + " gold in your backpack or bank to buyout.");
            openViewAuctionGumpUI(pUser, board, aBO); return true;
        }

        var itemBO = CalcItemFromSer(aBO.itemSerial | 0);
        if (!ValidateObject(itemBO) || !fingerprintMatchesEscrowItem(aBO, itemBO))
        {
            pUser.SysMessage("Listing is blocked: escrow item does not match its fingerprint. Contact staff.");
            openViewAuctionGumpUI(pUser, board, aBO); return true;
        }

        addCreditForChar(board, aBO.owner | 0, priceBO);

        if (ValidateObject(itemBO))
        {
            itemBO.movable = 1; itemBO.decayable = true;
            itemBO.SetCont(pUser.pack);
            if (itemBO.container != pUser.pack) itemBO.Teleport(pUser.x, pUser.y, pUser.z);
        }

        removeEscrowItemForOwner(board, aBO.owner | 0, aBO.itemSerial | 0);
        writeWatcherSerialsForAuction(board, aBO.id | 0, []);
        listBO.splice(fBO.idx, 1);
        saveAuctionsToBoard(board, listBO);

        pUser.TextMessage("Buyout successful for " + priceBO + " gold.");
        openBoardGumpUI(pUser, board, Math.max(1, toIntOrDefault(pUser.GetTempTag("auction_page"), 1)));
        return true;
    }

    // winner claim
    if (pButton === BID_VIEW_CLAIM)
    {
        var aidCL = toIntOrDefault(pUser.GetTempTag("auction_aid"), 0);
        var listCL = loadAuctionsFromBoard(board);
        var fCL = findAuctionInListById(listCL, aidCL);
        if (!fCL.a) { openBoardGumpUI(pUser, board, 1); return true; }
        var aCL = fCL.a;

        if ((aCL.status || "open") !== "ended") { pUser.TextMessage("Not ready to claim."); openViewAuctionGumpUI(pUser, board, aCL); return true; }
        if ((pUser.serial | 0) !== toIntOrDefault(aCL.winner, -1)) { pUser.TextMessage("You are not the winner."); openViewAuctionGumpUI(pUser, board, aCL); return true; }

        var priceCL = toIntOrDefault(aCL.winAmount, 0);
        if (priceCL > 0)
        {
            if (!payFromBackpackOrBank(pUser, priceCL))
            {
                pUser.TextMessage("You do not have enough gold in your backpack or bank to pay " + priceCL + ".");
                openViewAuctionGumpUI(pUser, board, aCL); return true;
            }
            addCreditForChar(board, aCL.owner | 0, priceCL);
        }

        var itemCL = CalcItemFromSer(aCL.itemSerial | 0);
        if (!ValidateObject(itemCL) || !fingerprintMatchesEscrowItem(aCL, itemCL))
        {
            pUser.SysMessage("Listing is blocked: escrow item does not match its fingerprint. Contact staff.");
            openViewAuctionGumpUI(pUser, board, aCL); return true;
        }
        if (ValidateObject(itemCL))
        {
            itemCL.movable = 1; itemCL.decayable = true;
            itemCL.SetCont(pUser.pack);
        }
        removeEscrowItemForOwner(board, aCL.owner | 0, aCL.itemSerial | 0);
        writeWatcherSerialsForAuction(board, aCL.id | 0, []);
        listCL.splice(fCL.idx, 1);
        saveAuctionsToBoard(board, listCL);
        pUser.TextMessage("You claimed your auction item.");
        openBoardGumpUI(pUser, board, 1);
        return true;
    }
}

// =============================== Target Callback ===============================
function onCallback0(socket, targObj)
{
    var pUser = socket.currentChar;

    if (!ValidateObject(targObj) || !targObj.isItem || targObj.container != pUser.pack)
    {
        socket.SysMessage("Select an item in your backpack.");
        return;
    }
    var sec = ((targObj.sectionID + "") || "").toLowerCase();
    if (sec === "gold" || sec === "bankcheck" || targObj.isContainer)
    {
        socket.SysMessage("This is not a valid auctionable item.");
        return;
    }

    var bSer = toIntOrDefault(pUser.GetTempTag("auction_board"), 0);
    var board = CalcItemFromSer(bSer);
    if (!ValidateObject(board))
    {
        socket.SysMessage("No board context.");
        return;
    }

    openAddAuctionGumpUI(pUser, board, targObj, null);
    pUser.SetTempTag("auction_item", targObj.serial + "");
}

// =============================== Reopen Helper ===============================
function reopenAddAuctionGumpUI(board, pUser, prev)
{
    var item = CalcItemFromSer(toIntOrDefault(pUser.GetTempTag("auction_item"), 0));
    if (!ValidateObject(item))
    {
        pUser.SysMessage("Item context lost.");
        openBoardGumpUI(pUser, board, toIntOrDefault(pUser.GetTempTag("auction_page"), 1));
        return;
    }
    // prev = [desc, price, duration, reserve, buyout]
    openAddAuctionGumpUI(pUser, board, item, prev);
}