@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
JavaScript
"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
};