import type { TradeSession, TradeParticipant } from "./trade.types"; import { DrizzleClient } from "@/lib/DrizzleClient"; import { withTransaction } from "@/lib/db"; import { UserError } from "@/lib/errors"; import { economyService } from "@/modules/economy/economy.service"; import { inventoryService } from "@/modules/inventory/inventory.service"; import { itemTransactions } from "@/db/schema"; import type { Transaction } from "@/lib/types"; export class TradeService { private static sessions = new Map(); /** * Creates a new trade session */ static createSession(threadId: string, userA: { id: string, username: string }, userB: { id: string, username: string }): TradeSession { const session: TradeSession = { threadId, userA: { id: userA.id, username: userA.username, locked: false, offer: { money: 0n, items: [] } }, userB: { id: userB.id, username: userB.username, locked: false, offer: { money: 0n, items: [] } }, state: 'NEGOTIATING', lastInteraction: Date.now() }; this.sessions.set(threadId, session); return session; } static getSession(threadId: string): TradeSession | undefined { return this.sessions.get(threadId); } static endSession(threadId: string) { this.sessions.delete(threadId); } /** * Updates an offer. If allowed, validation checks should be done BEFORE calling this. * unlocking logic is handled here (if offer changes, unlock both). */ static updateMoney(threadId: string, userId: string, amount: bigint) { const session = this.getSession(threadId); if (!session) throw new Error("Session not found"); if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active"); const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null; if (!participant) throw new Error("User not in trade"); participant.offer.money = amount; this.unlockAll(session); session.lastInteraction = Date.now(); } static addItem(threadId: string, userId: string, item: { id: number, name: string }, quantity: bigint) { const session = this.getSession(threadId); if (!session) throw new Error("Session not found"); if (session.state !== 'NEGOTIATING') throw new Error("Trade is not active"); const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null; if (!participant) throw new Error("User not in trade"); const existing = participant.offer.items.find(i => i.id === item.id); if (existing) { existing.quantity += quantity; } else { participant.offer.items.push({ id: item.id, name: item.name, quantity }); } this.unlockAll(session); session.lastInteraction = Date.now(); } static removeItem(threadId: string, userId: string, itemId: number) { const session = this.getSession(threadId); if (!session) throw new Error("Session not found"); const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null; if (!participant) throw new Error("User not in trade"); participant.offer.items = participant.offer.items.filter(i => i.id !== itemId); this.unlockAll(session); session.lastInteraction = Date.now(); } static toggleLock(threadId: string, userId: string): boolean { const session = this.getSession(threadId); if (!session) throw new Error("Session not found"); const participant = session.userA.id === userId ? session.userA : session.userB.id === userId ? session.userB : null; if (!participant) throw new Error("User not in trade"); participant.locked = !participant.locked; session.lastInteraction = Date.now(); return participant.locked; } private static unlockAll(session: TradeSession) { session.userA.locked = false; session.userB.locked = false; } /** * Executes the trade atomically. * 1. Validates balances/inventory for both users. * 2. Swaps money. * 3. Swaps items. * 4. Logs transactions. */ static async executeTrade(threadId: string): Promise { const session = this.getSession(threadId); if (!session) throw new Error("Session not found"); if (!session.userA.locked || !session.userB.locked) { throw new Error("Both players must accept the trade first."); } session.state = 'COMPLETED'; // Prevent double execution await DrizzleClient.transaction(async (tx) => { // -- Validate & Execute User A -> User B -- await this.processTransfer(tx, session.userA, session.userB, session.threadId); // -- Validate & Execute User B -> User A -- await this.processTransfer(tx, session.userB, session.userA, session.threadId); }); this.endSession(threadId); } private static async processTransfer(tx: Transaction, from: TradeParticipant, to: TradeParticipant, threadId: string) { // 1. Money if (from.offer.money > 0n) { await economyService.modifyUserBalance( from.id, -from.offer.money, 'TRADE_OUT', `Trade with ${to.username} (Thread: ${threadId})`, to.id, tx ); await economyService.modifyUserBalance( to.id, from.offer.money, 'TRADE_IN', `Trade with ${from.username} (Thread: ${threadId})`, from.id, tx ); } // 2. Items for (const item of from.offer.items) { // Remove from sender await inventoryService.removeItem(from.id, item.id, item.quantity, tx); // Add to receiver await inventoryService.addItem(to.id, item.id, item.quantity, tx); // Log Item Transaction (Sender) await tx.insert(itemTransactions).values({ userId: BigInt(from.id), relatedUserId: BigInt(to.id), itemId: item.id, quantity: -item.quantity, type: 'TRADE_OUT', description: `Traded to ${to.username}`, }); // Log Item Transaction (Receiver) await tx.insert(itemTransactions).values({ userId: BigInt(to.id), relatedUserId: BigInt(from.id), itemId: item.id, quantity: item.quantity, type: 'TRADE_IN', description: `Received from ${from.username}`, }); } } }