UNPKG

@trap_stevo/ventry

Version:

The universal engine for creating, tracking, and evolving interactive content — from posts, comments, and likes to offers, auctions, events, and beyond. Define, extend, and analyze content objects in real time. Turn anything into user-driven content.

491 lines (490 loc) 13.7 kB
"use strict"; const { now } = require("../HUDComponents/Time.cjs"); const { newID } = require("../HUDComponents/ID.cjs"); function computeNextIncrement(increment, current) { if (!increment) { return 1; } if (increment.fixed) { return increment.fixed; } if (increment.percent) { return Math.max(1, Math.floor(current * increment.percent)); } if (Array.isArray(increment.table)) { for (const row of increment.table) { if (row.upTo == null || current <= row.upTo) { return row.step; } } } return 1; } ; class AuctionsManager { constructor(vaultManager, metricsManager, eventsManager) { this.metrics = metricsManager; this.events = eventsManager; this.vault = vaultManager; } startAuction(itemID, ownerID, config = {}) { const it = this.vault.getItem(itemID); if (!it) { console.warn("Item not found."); return null; } if (it.data.ownerID !== ownerID) { console.warn("Unauthorized action."); return null; } const now = now(); const startsAt = config.schedule && config.schedule.startsAt || now; const endsAt = config.schedule && config.schedule.endsAt || now + 3 * 60 * 60 * 1000; const record = { leadingBidID: null, ownerID, itemID, id: newID(), status: startsAt <= now ? "live" : "scheduled", participants: config.participants || {}, visibility: config.visibility || {}, schedule: { startsAt, endsAt }, rules: config.rules || {}, currentPrice: config.pricing && config.pricing.startingBid || 0, pricing: config.pricing, bidsCount: 0, watchers: 0, version: 1 }; const saved = this.vault.createAuction(record); this.metrics.track("auction.started", 1, { auctionID: saved.id, itemID }); if (this.events) { this.events.emit("auction.started", { auctionID: saved.id, itemID, ownerID, schedule: record.schedule }); } return { auctionID: saved.id }; } cancelAuction(auctionID, ownerID, reason) { const auction = this.vault.getAuction(auctionID); if (!auction) { console.warn("Auction not found."); return null; } if (auction.data.ownerID !== ownerID) { console.warn("Unauthorized action."); return null; } if (auction.data.bidsCount > 0) { console.warn("Cancel prohibited after first bid."); return null; } this.vault.updateAuction(auctionID, { status: "canceled", reason: reason || null, version: (auction.data.version || 1) + 1 }); this.metrics.track("auction.canceled", 1, { auctionID, itemID: auction.data.itemID }); if (this.events) { this.events.emit("auction.canceled", { auctionID, itemID: auction.data.itemID, ownerID, reason }); } return { ok: true }; } getAuction(auctionID) { return this.vault.getAuction(auctionID); } listAuctions(filter = {}, page = {}) { const qb = this.vault.queryAuctions(filter, { "schedule.endsAt": 1 }, { limit: page.limit || 50 }); const auctions = qb.execute(true); return { auctions, nextCursor: undefined }; } watchAuction(auctionID, actorID) { const auction = this.vault.getAuction(auctionID); if (!auction) { console.warn("Auction not found."); return null; } this.vault.updateAuction(auctionID, { watchers: (auction.data.watchers || 0) + 1, version: (auction.data.version || 1) + 1 }); this.metrics.track("auction.watched", 1, { auctionID, itemID: auction.data.itemID, actorID }); if (this.events) { this.events.emit("auction.watched", { auctionID, itemID: auction.data.itemID, actorID }); } return { ok: true }; } unwatchAuction(auctionID, actorID) { const auction = this.vault.getAuction(auctionID); if (!auction) { console.warn("Auction not found."); return null; } this.vault.updateAuction(auctionID, { watchers: Math.max(0, (auction.data.watchers || 0) - 1), version: (auction.data.version || 1) + 1 }); this.metrics.track("auction.unwatched", 1, { auctionID, itemID: auction.data.itemID, actorID }); if (this.events) { this.events.emit("auction.unwatched", { auctionID, itemID: auction.data.itemID, actorID }); } return { ok: true }; } placeBid(auctionID, bidderID, input = {}) { let auction = this.vault.getAuction(auctionID); if (!auction) { console.warn("Auction not found."); return null; } const now = now(); if (auction.data.status === "scheduled" && auction.data.schedule && auction.data.schedule.startsAt <= now) { auction = this.vault.updateAuction(auctionID, { status: "live", version: (auction.data.version || 1) + 1 }); } if (auction.data.status !== "live") { console.warn("Auction not live."); return null; } if (auction.data.rules && auction.data.rules.softClose && auction.data.schedule.endsAt - now <= auction.data.rules.softClose.windowMs) { const newEnds = auction.data.schedule.endsAt + auction.data.rules.softClose.extendByMs; auction.data.schedule.endsAt = newEnds; auction = this.vault.updateAuction(auctionID, { schedule: auction.data.schedule, version: (auction.data.version || 1) + 1 }); this.metrics.track("auction.soft_extended", 1, { auctionID, endsAt: newEnds }); if (this.events) { this.events.emit("auction.soft_extended", { auctionID, endsAt: newEnds }); } } if (auction.data.rules && auction.data.rules.approveBidders && Array.isArray(auction.data.participants && auction.data.participants.approved)) { if (!auction.data.participants.approved.includes(bidderID)) { console.warn("Bidder not approved."); return null; } } const tick = computeNextIncrement(auction.data.pricing && auction.data.pricing.increment, auction.data.currentPrice || 0); const floor = (auction.data.leadingBidID ? auction.data.currentPrice : auction.data.pricing && auction.data.pricing.startingBid || 0) + tick; const amount = input.amount || floor; if (amount < floor) { console.warn("Bid too low."); return null; } const bid = this.vault.createBid({ id: newID(), auctionID, itemID: auction.data.itemID, bidderID, amount, currency: auction.data.pricing && auction.data.pricing.currency || "credits", status: "active", proxyMax: input.proxyMax || null }); const prevLeader = auction.data.leadingBidID ? this.vault.getBid(auction.data.leadingBidID) : null; let currentPrice = amount; let currentLeader = bid; let outbidID = null; if (auction.data.rules && auction.data.rules.proxyBidding && prevLeader && prevLeader.proxyMax) { const challengerMax = input.proxyMax || amount; const leaderMax = prevLeader.proxyMax; if (leaderMax >= challengerMax) { currentLeader = prevLeader; currentPrice = Math.min(leaderMax, challengerMax + tick); this.vault.updateBid(prevLeader.id, { status: "winning" }); this.vault.updateBid(bid.id, { status: "outbid" }); outbidId = bid.id; } else { currentLeader = bid; currentPrice = Math.min(challengerMax, leaderMax + tick); this.vault.updateBid(prevLeader.id, { status: "outbid" }); this.vault.updateBid(bid.id, { status: "winning" }); outbidID = prevLeader.id; } } else { if (prevLeader) { this.vault.updateBid(prevLeader.id, { status: "outbid" }); } this.vault.updateBid(bid.id, { status: "winning" }); outbidID = prevLeader && prevLeader.id; } const next = { leadingBidID: currentLeader.id, currentPrice: currentPrice, bidsCount: (auction.data.bidsCount || 0) + 1, version: (auction.data.version || 1) + 1 }; auction = this.vault.updateAuction(auctionID, next); if (outbidID) { this.metrics.track("bid.outbid", 1, { auctionID, bidID: outbidID }); if (this.events) { this.events.emit("bid.outbid", { auctionID, bidID: outbidID }); } } this.metrics.track("bid.placed", 1, { auctionID, bidID: bid.id, bidderID, amount: currentPrice }); if (this.events) { this.events.emit("bid.placed", { auctionID, bidID: bid.id, bidderID, amount: currentPrice, leading: currentLeader.id === bid.id }); } return { bidID: bid.id, leading: currentLeader.id === bid.id, currentPrice }; } retractBid(bidID, bidderID, reason) { const bid = this.vault.getBid(bidID); if (!bid) { console.warn("Bid not found."); return null; } const auction = this.vault.getAuction(bid.auctionID); if (!auction) { console.warn("Auction not found."); return null; } if (!(auction.data.rules && auction.data.rules.allowBidRetraction)) { console.warn("Retraction not allowed."); return null; } if (bid.data.bidderID !== bidderID) { console.warn("Unauthorized action."); return null; } this.vault.updateBid(bidID, { status: "retracted", reason: reason || null }); this.metrics.track("bid.retracted", 1, { auctionID: auction.id, bidID, bidderID }); if (this.events) { this.events.emit("bid.retracted", { auctionID: auction.id, bidID, bidderID, reason }); } return { ok: true }; } listBids(auctionID, page = {}) { const qb = this.vault.queryBids({ auctionID }, { timestamp: -1 }, { limit: page.limit || 100 }); const bids = qb.execute(true); return { bids, nextCursor: undefined }; } getLeadingBid(auctionID) { const auction = this.vault.getAuction(auctionID); if (!auction) { console.warn("Auction not found."); return null; } const bid = auction.data.leadingBidID ? this.vault.getBid(auction.data.leadingBidID) : null; const reserve = auction.data.pricing && auction.data.pricing.reserve; const reserveMet = reserve == null ? true : (auction.data.currentPrice || 0) >= reserve; return { bid, currentPrice: auction.data.currentPrice || 0, reserveMet }; } endAuction(auctionID, systemActor) { const auction = this.vault.getAuction(auctionID); if (!auction) { console.warn("Auction not found."); return null; } if (auction.data.status !== "live" && auction.data.status !== "scheduled") { console.warn("Already ended."); return null; } const now = now(); if (auction.data.status === "scheduled" && auction.data.schedule.startsAt > now) { this.vault.updateAuction(auctionID, { status: "canceled", version: (auction.data.version || 1) + 1 }); return { winner: undefined, reserveMet: false }; } const leading = auction.data.leadingBidID ? this.vault.getBid(auction.data.leadingBidID) : null; const reserve = auction.data.pricing && auction.data.pricing.reserve; const reserveMet = reserve == null ? true : (auction.data.currentPrice || 0) >= reserve; let winner = undefined; let status = "ended"; if (leading && reserveMet) { winner = { bidderID: leading.bidderID, amount: auction.data.currentPrice || leading.amount, bidID: leading.id }; } else if (leading && !reserveMet) { status = "reserve_unmet"; } this.vault.updateAuction(auctionID, { status, winner: winner || null, version: (auction.data.version || 1) + 1 }); this.metrics.track("auction.ended", 1, { auctionID, itemID: auction.data.itemID, reserveMet: !!reserveMet, winner: winner && winner.bidderID }); if (this.events) { this.events.emit("auction.ended", { auctionID, itemID: auction.data.itemID, winner, reserveMet: !!reserveMet }); } return { winner, reserveMet: !!reserveMet }; } settleAuction(auctionID, ownerID, settlement = {}) { const auction = this.vault.getAuction(auctionID); if (!auction) { console.warn("Auction not found."); return null; } if (auction.data.ownerID !== ownerID) { console.warn("Unauthorized action."); return null; } if (auction.data.status !== "ended" && auction.data.status !== "reserve_unmet") { console.warn("Auction ongoing."); return null; } this.vault.updateAuction(auctionID, { status: "settled", settlement, version: (auction.data.version || 1) + 1 }); this.metrics.track("auction.settled", 1, { auctionID, itemID: auction.data.itemID, ownerID }); if (this.events) { this.events.emit("auction.settled", { auctionID, itemID: auction.data.itemID, ownerID, settlement }); } return { ok: true }; } } ; module.exports = { AuctionsManager };